gentle-pi 2.1.2 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +70 -12
- package/assets/agents/gentle-ai-worker.md +7 -3
- package/assets/agents/jd-fix-agent.md +1 -1
- package/assets/agents/jd-judge-a.md +3 -1
- package/assets/agents/jd-judge-b.md +3 -1
- package/assets/agents/review-readability.md +4 -1
- package/assets/agents/review-reliability.md +4 -1
- package/assets/agents/review-resilience.md +4 -1
- package/assets/agents/review-risk.md +4 -1
- package/assets/agents/sdd-apply.md +6 -1
- package/assets/agents/sdd-archive.md +6 -1
- package/assets/agents/sdd-design.md +6 -1
- package/assets/agents/sdd-explore.md +6 -2
- package/assets/agents/sdd-init.md +10 -2
- package/assets/agents/sdd-onboard.md +6 -1
- package/assets/agents/sdd-proposal.md +6 -1
- package/assets/agents/sdd-spec.md +6 -1
- package/assets/agents/sdd-status.md +6 -1
- package/assets/agents/sdd-sync.md +6 -1
- package/assets/agents/sdd-tasks.md +6 -1
- package/assets/agents/sdd-verify.md +6 -1
- package/assets/chains/4r-review.chain.md +2 -0
- package/assets/chains/sdd-full.chain.md +1 -1
- package/assets/chains/sdd-plan.chain.md +1 -1
- package/assets/chains/sdd-verify.chain.md +1 -1
- package/assets/orchestrator-delegation.md +246 -67
- package/assets/orchestrator.md +7 -14
- package/assets/sdd-orchestrator-workflow.md +154 -9
- package/assets/support/sdd-status-contract.md +19 -1
- package/contracts/review-integration/v1/fixtures/consent.fixture.json +3 -3
- package/contracts/review-integration/v1/fixtures/start-v2.fixture.json +19 -28
- package/contracts/review-integration/v1/fixtures/start.fixture.json +1 -10
- package/contracts/review-integration/v1/fixtures/status-v2.fixture.json +12 -21
- package/contracts/review-integration/v1/schemas/correction-plan-request.schema.json +49 -0
- package/contracts/review-integration/v1/schemas/operation.schema.json +76 -0
- package/contracts/review-integration/v1/schemas/repair.schema.json +39 -0
- package/contracts/review-integration/v1/schemas/status-v2.schema.json +4 -2
- package/contracts/review-integration/v1/schemas/status.schema.json +4 -2
- package/contracts/review-integration/v2/fixtures/consent.fixture.json +1 -1
- package/contracts/review-integration/v2/fixtures/start.fixture.json +1 -10
- package/contracts/review-integration/v2/fixtures/status.fixture.json +1 -10
- package/contracts/review-integration/v2/schemas/failure.schema.json +5 -1
- package/contracts/review-integration/v2/schemas/operation.schema.json +6 -1
- package/contracts/review-integration/v2/schemas/repair.schema.json +4 -2
- package/contracts/review-integration/v2/schemas/start.schema.json +5 -2
- package/contracts/review-integration/v2/schemas/status.schema.json +4 -2
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +30 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/README.md +12 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/manifest.json +65 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/lens.schema.json +16 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/refuter.schema.json +1 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/schemas/targeted-validator.schema.json +1 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/lens.json +1 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/refuter.json +1 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/bundle/vectors/targeted-validator.json +1 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-capabilities.baseline.json +15 -0
- package/contracts/review-provider-contract-mirror/v1.1.0/generated/provider-roles.baseline.json +42 -0
- package/docs/native-authority-architecture.md +5 -5
- package/docs/review-integration.md +22 -2
- package/extensions/gentle-ai.ts +1595 -201
- package/extensions/sdd-init.ts +19 -6
- package/extensions/skill-registry.ts +10 -2
- package/extensions/startup-banner.ts +10 -4
- package/lib/gentle-ai-binary.ts +173 -2
- package/lib/git-commit-transaction.ts +77 -17
- package/lib/native-review-cli.ts +528 -65
- package/lib/provider-contract-bundle.ts +704 -0
- package/lib/review-candidate-view.ts +527 -18
- package/lib/review-compact-contract.ts +59 -248
- package/lib/review-host-relay.ts +436 -0
- package/lib/review-integration-v2.ts +537 -36
- package/lib/review-relay-contract.ts +16 -0
- package/lib/sdd-preflight.ts +53 -1
- package/package.json +5 -2
- package/runtime/gentle-ai-binary.mjs +173 -2
- package/runtime/git-commit-transaction.mjs +75 -15
- package/runtime/native-review-cli.mjs +524 -61
- package/runtime/review-integration-v2.mjs +536 -35
- package/runtime/review-relay-contract.mjs +17 -0
- package/scripts/build-git-commit-transaction-runner.mjs +1 -0
- package/scripts/check-provider-contract.mjs +138 -0
- package/scripts/gentle-ai-installer.mjs +23 -13
- package/scripts/maintainer/provider-relay-matrix.mjs +219 -0
- package/scripts/mirror-provider-contract.mjs +143 -0
- package/scripts/test-packed-runner.mjs +16 -2
- package/scripts/verify-package-files.mjs +110 -33
- package/skills/_shared/review-ledger-contract.md +4 -6
- package/skills/gentle-ai/SKILL.md +4 -4
- package/skills/issue-creation/SKILL.md +94 -168
- package/skills/judgment-day/SKILL.md +7 -1
- package/skills/judgment-day/references/prompts-and-formats.md +2 -0
- package/skills/rdd-defect-workflow/SKILL.md +54 -0
- package/tests/background-subagents.test.ts +771 -0
- package/tests/crosslane/cross-lane.mjs +1169 -0
- package/tests/delegated-key-learnings-contract.test.ts +238 -0
- package/tests/fixtures/devbinary/capabilities-v2.1.derived.json +331 -0
- package/tests/fixtures/devbinary/capabilities-v2.2.captured.json +340 -0
- package/tests/fixtures/devbinary/consent-v3.captured.json +37 -0
- package/tests/fixtures/devbinary/failure-v2-capture-evidence.captured.json +16 -0
- package/tests/fixtures/devbinary/result-artifact-v2-path.captured.json +12 -0
- package/tests/fixtures/devbinary/result-artifact-v2.captured.json +12 -0
- package/tests/fixtures/devbinary/start-v3-consent-declined.captured.json +19 -0
- package/tests/fixtures/devbinary/start-v3-consent-granted.captured.json +109 -0
- package/tests/fixtures/devbinary/status-v5-capture-result-submission.captured.json +184 -0
- package/tests/fixtures/devbinary/status-v5-repository-context.captured.json +138 -0
- package/tests/fixtures/devbinary/status-v5.captured.json +88 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/README.md +12 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/manifest.json +65 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/lens.schema.json +16 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/refuter.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/schemas/targeted-validator.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/lens.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/refuter.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.1.0/vectors/targeted-validator.json +1 -0
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-dev-binary-surfacing.test.ts +195 -0
- package/tests/gentle-ai-dev-binary.test.ts +336 -0
- package/tests/gentle-ai-installer.test.ts +46 -46
- package/tests/git-commit-transaction.test.ts +229 -1
- package/tests/maintainer/provider-relay.maintest.ts +265 -0
- package/tests/native-review-capability-contract.test.ts +48 -2
- package/tests/native-review-cli.test.ts +56 -0
- package/tests/native-review-consent.test.ts +164 -3
- package/tests/native-review-parity-runtime.test.ts +37 -0
- package/tests/native-review-parity.test.ts +218 -15
- package/tests/native-sdd-attempt-authority.test.ts +235 -0
- package/tests/orchestrator-budget.test.ts +30 -5
- package/tests/package-manifest.test.ts +98 -72
- package/tests/provider-contract-bundle.test.ts +385 -0
- package/tests/provider-contract-mirror.test.ts +206 -0
- package/tests/provider-defect-handoff.test.ts +355 -0
- package/tests/review-actor-tool-deny.test.ts +12 -13
- package/tests/review-candidate-view.test.ts +489 -9
- package/tests/review-compact-contract.test.ts +52 -119
- package/tests/review-controller-native-recovery.test.ts +643 -47
- package/tests/review-controller-native-routing.test.ts +1667 -222
- package/tests/review-controller-workspace-root.test.ts +17 -2
- package/tests/review-corrected-finalize-binding.test.ts +175 -0
- package/tests/review-dispatch-hydration-gap.test.ts +197 -0
- package/tests/review-host-relay-routing.test.ts +317 -0
- package/tests/review-host-relay.test.ts +520 -0
- package/tests/review-integration-v2-forward.test.ts +631 -0
- package/tests/review-integration-v2.test.ts +114 -0
- package/tests/review-ledger-contract.test.ts +12 -28
- package/tests/review-recovered-lineage-routing.test.ts +246 -0
- package/tests/review-relay-transport-agent.test.ts +249 -0
- package/tests/runtime-harness.mjs +242 -14
- package/tests/sdd-agent-tools.test.ts +18 -33
- package/tests/skill-collision-prefixes.test.ts +1 -0
- package/tests/skill-registry.test.ts +50 -1
- package/tests/verify-package-files.test.ts +62 -0
- package/assets/agents/review-refuter.md +0 -40
- package/assets/agents/review-validator.md +0 -23
- package/lib/native-review-remediation.ts +0 -49
- package/lib/review-compact.ts +0 -947
- package/lib/review-refuter-adapter.ts +0 -129
- package/lib/review-runtime-contract.ts +0 -68
- package/prompts/gcl.md +0 -54
- package/prompts/gis.md +0 -25
- package/prompts/gpr.md +0 -41
- package/prompts/gwr.md +0 -31
- package/tests/fixtures/native-review-cli/v2.1.2/bind-sdd.json +0 -25
- package/tests/fixtures/native-review-cli/v2.1.2/finalize.json +0 -8
- package/tests/fixtures/native-review-cli/v2.1.2/sdd-status-engram.json +0 -139
- package/tests/fixtures/native-review-cli/v2.1.2/sdd-status.json +0 -200
- package/tests/fixtures/native-review-cli/v2.1.2/start.json +0 -12
- package/tests/fixtures/native-review-cli/v2.1.2/validate-allow.json +0 -24
- package/tests/fixtures/native-review-cli/v2.1.2/validate-deny-empty-context.json +0 -20
- package/tests/fixtures/native-review-cli/v2.1.2/validate-deny.json +0 -28
- package/tests/review-compact.test.ts +0 -243
- package/tests/review-refuter-adapter.test.ts +0 -89
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// Maintainer provider-relay matrix — behavior-first tests (gentle-pi#311).
|
|
2
|
+
//
|
|
3
|
+
// `pnpm test` never picks this up: it globs `tests/*.test.ts` only, and this
|
|
4
|
+
// file lives one directory deeper and ends in `.maintest.ts`. It runs only
|
|
5
|
+
// via `pnpm run test:maintainer`. Strict validation + no-production-resolution
|
|
6
|
+
// always run; real-binary tests run only when env-supplied maintainer binaries
|
|
7
|
+
// exist (self-skip; fail-loud under GENTLE_PI_REQUIRE_MAINTAINER=1). Never fakes
|
|
8
|
+
// green. Tests never hardcode machine-specific paths; the verifier supplies
|
|
9
|
+
// them via env. The baseline is described generically; external evidence
|
|
10
|
+
// establishes whether it is the immutable RC8 runtime.
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { chmodSync, existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import test from "node:test";
|
|
16
|
+
import { REVIEW_HOST_RELAY_FAILURE, ReviewHostRelayError, runReviewHostRelaySlot } from "../../lib/review-host-relay.ts";
|
|
17
|
+
import { ARM_POSITIVE_ENV, CASE_KINDS, DESCRIPTOR_SCHEMA, DescriptorValidationError, POSITIVE_JOURNEY_COMMAND, loadDescriptor, resolveDeclaredExecutable, runMatrix, validateDescriptor } from "../../scripts/maintainer/provider-relay-matrix.mjs";
|
|
18
|
+
const BASELINE_ENV = "GENTLE_PI_MAINTAINER_BASELINE_BINARY";
|
|
19
|
+
const CAPABLE_ENV = "GENTLE_PI_MAINTAINER_CAPABLE_BINARY";
|
|
20
|
+
const REQUIRE_ENV = "GENTLE_PI_REQUIRE_MAINTAINER";
|
|
21
|
+
// Every declared-but-nonexistent executable path these tests use lives inside
|
|
22
|
+
// one private sandbox. A predictable /tmp path is squattable: another user can
|
|
23
|
+
// pre-create it between runs, and a test that resolves it would then spawn a
|
|
24
|
+
// file it never wrote. mkdtemp's unpredictable name plus 0700 removes that.
|
|
25
|
+
const SANDBOX = mkdtempSync(join(tmpdir(), "gentle-pi-maintainer-sandbox-"));
|
|
26
|
+
chmodSync(SANDBOX, 0o700);
|
|
27
|
+
process.on("exit", () => rmSync(SANDBOX, { recursive: true, force: true }));
|
|
28
|
+
const sandboxPath = (name: string) => join(SANDBOX, name);
|
|
29
|
+
const PLACEHOLDER_BINARY = sandboxPath("not-a-real-gentle-ai-binary");
|
|
30
|
+
const baselineBinary = process.env[BASELINE_ENV];
|
|
31
|
+
const capableBinary = process.env[CAPABLE_ENV];
|
|
32
|
+
const armed = process.env[REQUIRE_ENV] === "1";
|
|
33
|
+
|
|
34
|
+
const BINDING_TOKENS = Object.freeze([
|
|
35
|
+
"--lineage=review-1d5aadacc600e167",
|
|
36
|
+
`--expected-revision=sha256:${"c".repeat(64)}`,
|
|
37
|
+
`--target=sha256:${"d".repeat(64)}`,
|
|
38
|
+
`--repository-context=rctx1_${"e".repeat(64)}`,
|
|
39
|
+
"--lens=review-reliability",
|
|
40
|
+
"--order=0",
|
|
41
|
+
`--subject-hash=sha256:${"a".repeat(64)}`,
|
|
42
|
+
]);
|
|
43
|
+
const CAPTURE_TOKENS = Object.freeze([...BINDING_TOKENS, "--agent=pi", "--materialize=true"]);
|
|
44
|
+
const SUBMISSION = Object.freeze({
|
|
45
|
+
operationToken: "capture-result",
|
|
46
|
+
argumentTokens: Object.freeze([...BINDING_TOKENS, "--input={{value}}"]),
|
|
47
|
+
values: Object.freeze([{ slot: "reviewer_result", domain: "artifact_path_or_stdin", substitutionLocation: BINDING_TOKENS.length }]),
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
function descriptor(overrides = {}) {
|
|
51
|
+
return {
|
|
52
|
+
schema: DESCRIPTOR_SCHEMA,
|
|
53
|
+
gentleAiExecutable: PLACEHOLDER_BINARY,
|
|
54
|
+
piExecutable: "pi",
|
|
55
|
+
cases: [{ name: "baseline-negative-control", kind: "relay-unavailable", captureArgumentTokens: [...CAPTURE_TOKENS], submission: structuredClone(SUBMISSION) }],
|
|
56
|
+
...overrides,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
function rejects(obj, fragment) {
|
|
60
|
+
assert.throws(() => validateDescriptor(obj), (error) => error instanceof DescriptorValidationError && error.message.includes(fragment));
|
|
61
|
+
}
|
|
62
|
+
function tempDescriptor(t, obj) {
|
|
63
|
+
const directory = mkdtempSync(join(tmpdir(), "gentle-pi-maintainer-"));
|
|
64
|
+
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
|
65
|
+
const path = join(directory, "d.json");
|
|
66
|
+
writeFileSync(path, JSON.stringify(obj));
|
|
67
|
+
return path;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// Strict descriptor validation — exact shape, no defaults, no production
|
|
72
|
+
// resolution. Pure/deterministic; always runs.
|
|
73
|
+
// ---------------------------------------------------------------------------
|
|
74
|
+
test("valid descriptor validates and returns a normalized copy", () => {
|
|
75
|
+
const d = validateDescriptor(descriptor());
|
|
76
|
+
assert.equal(d.schema, DESCRIPTOR_SCHEMA);
|
|
77
|
+
assert.deepEqual(d.cases[0]!.captureArgumentTokens, [...CAPTURE_TOKENS]);
|
|
78
|
+
assert.deepEqual(d.cases[0]!.submission.values, SUBMISSION.values);
|
|
79
|
+
});
|
|
80
|
+
test("rejects malformed descriptor fields with exact-shape errors (no defaults, no production resolution)", () => {
|
|
81
|
+
rejects({ ...descriptor(), schema: "gentle-pi.maintainer.provider-relay-descriptor/v2" }, "descriptor.schema must be exactly");
|
|
82
|
+
rejects({ ...descriptor(), gentleAiExecutable: "gentle-ai" }, "absolute path");
|
|
83
|
+
rejects({ ...descriptor(), gentleAiExecutable: undefined }, "absolute path");
|
|
84
|
+
rejects({ ...descriptor(), extra: 1 }, "descriptor.extra");
|
|
85
|
+
rejects({ ...descriptor(), cases: [{ ...descriptor().cases[0]!, extra: 1 }] }, "extra");
|
|
86
|
+
assert.deepEqual([...CASE_KINDS], ["relay-unavailable", "positive-lens"]);
|
|
87
|
+
rejects({ ...descriptor(), cases: [{ ...descriptor().cases[0]!, kind: "bogus" }] }, "kind must be one of");
|
|
88
|
+
const dup = { ...descriptor().cases[0]!, name: "dup" };
|
|
89
|
+
rejects({ ...descriptor(), cases: [structuredClone(dup), structuredClone(dup)] }, "duplicated");
|
|
90
|
+
// Every case must declare the real provider-issued materialize slot.
|
|
91
|
+
rejects({ ...descriptor(), cases: [{ ...descriptor().cases[0]!, captureArgumentTokens: [...CAPTURE_TOKENS].filter((t) => t !== "--agent=pi") }] }, "--agent=pi");
|
|
92
|
+
rejects({ ...descriptor(), cases: [{ ...descriptor().cases[0]!, captureArgumentTokens: [...CAPTURE_TOKENS].filter((t) => t !== "--materialize=true") }] }, "--materialize=true");
|
|
93
|
+
});
|
|
94
|
+
test("submission is validated through the real relay resolver before any process launches (exact shape)", () => {
|
|
95
|
+
const twoValues = structuredClone(SUBMISSION);
|
|
96
|
+
twoValues.values = [...SUBMISSION.values, { slot: "extra", domain: "artifact_path_or_stdin", substitutionLocation: 0 }];
|
|
97
|
+
rejects({ ...descriptor(), cases: [{ ...descriptor().cases[0]!, submission: twoValues }] }, "not a bindable provider form");
|
|
98
|
+
const noSlot = structuredClone(SUBMISSION);
|
|
99
|
+
noSlot.argumentTokens = [...BINDING_TOKENS, "--input=/no/value/slot"];
|
|
100
|
+
rejects({ ...descriptor(), cases: [{ ...descriptor().cases[0]!, submission: noSlot }] }, "not a bindable provider form");
|
|
101
|
+
rejects({ ...descriptor(), cases: [{ ...descriptor().cases[0]!, submission: { ...structuredClone(SUBMISSION), extra: 1 } }] }, "extra");
|
|
102
|
+
});
|
|
103
|
+
test("loadDescriptor reads and validates a descriptor file", (t) => {
|
|
104
|
+
const path = tempDescriptor(t, descriptor());
|
|
105
|
+
assert.equal(loadDescriptor(path).schema, DESCRIPTOR_SCHEMA);
|
|
106
|
+
assert.throws(() => loadDescriptor(join(dirname(path), "missing.json")), /could not read descriptor/);
|
|
107
|
+
writeFileSync(join(dirname(path), "bad.json"), "{not json");
|
|
108
|
+
assert.throws(() => loadDescriptor(join(dirname(path), "bad.json")), /not valid JSON/);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
// No-production-resolution — the runner uses only declared executables and
|
|
113
|
+
// never falls through to the package-local production binary.
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
test("runMatrix uses only the declared executable, never the production resolver", async () => {
|
|
116
|
+
const [verdict] = await runMatrix(validateDescriptor({ ...descriptor(), gentleAiExecutable: sandboxPath("not-a-gentle-ai-binary") }));
|
|
117
|
+
assert.equal(verdict!.verdict, "blocked");
|
|
118
|
+
assert.match(verdict!.reason, /gentleAiExecutable/);
|
|
119
|
+
assert.notEqual(verdict!.verdict, "pass");
|
|
120
|
+
});
|
|
121
|
+
test("resolveDeclaredExecutable checks absolute paths verbatim and bare names on PATH only", () => {
|
|
122
|
+
assert.equal(resolveDeclaredExecutable(sandboxPath("nonexistent-absolute-123")), null);
|
|
123
|
+
assert.equal(resolveDeclaredExecutable(""), null);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Mis-declared capable binary — a `relay-unavailable` case is a negative
|
|
128
|
+
// control, so the arm gate must not trust the maintainer-declared `kind`. A
|
|
129
|
+
// declaration-only gate pointed at a CAPABLE binary would materialize, launch
|
|
130
|
+
// a REAL pi model, and run a REAL `capture-result --input=...` submission
|
|
131
|
+
// before reporting `fail`: the mutation would already have happened. Driven by
|
|
132
|
+
// local stub executables (the repo's fake-executable idiom), so it is
|
|
133
|
+
// deterministic and needs no arming.
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
function capableStubHarness(t: test.TestContext) {
|
|
136
|
+
const directory = mkdtempSync(join(tmpdir(), "gentle-pi-maintainer-capable-stub-"));
|
|
137
|
+
chmodSync(directory, 0o700);
|
|
138
|
+
t.after(() => rmSync(directory, { recursive: true, force: true }));
|
|
139
|
+
const materializeLog = join(directory, "materialized");
|
|
140
|
+
const submitLog = join(directory, "submitted");
|
|
141
|
+
const piLog = join(directory, "pi-launched");
|
|
142
|
+
const gentleAi = join(directory, "gentle-ai");
|
|
143
|
+
// Deliberately CAPABLE: it honours --materialize=true and would accept a
|
|
144
|
+
// submission, exactly like a binary a maintainer mis-declared as incapable.
|
|
145
|
+
writeFileSync(
|
|
146
|
+
gentleAi,
|
|
147
|
+
`#!/usr/bin/env node
|
|
148
|
+
const fs = require("node:fs");
|
|
149
|
+
const argv = process.argv.slice(2);
|
|
150
|
+
if (argv.includes("--materialize=true")) {
|
|
151
|
+
fs.writeFileSync(${JSON.stringify(materializeLog)}, JSON.stringify(argv));
|
|
152
|
+
process.stdout.write("prompt-bytes");
|
|
153
|
+
process.exit(0);
|
|
154
|
+
}
|
|
155
|
+
fs.writeFileSync(${JSON.stringify(submitLog)}, JSON.stringify(argv));
|
|
156
|
+
process.stdout.write(JSON.stringify({ schema: "gentle-ai.review-result-artifact/v2", admission_decision: "completed" }));
|
|
157
|
+
process.exit(0);
|
|
158
|
+
`,
|
|
159
|
+
);
|
|
160
|
+
chmodSync(gentleAi, 0o755);
|
|
161
|
+
const pi = join(directory, "pi");
|
|
162
|
+
writeFileSync(
|
|
163
|
+
pi,
|
|
164
|
+
`#!/usr/bin/env node
|
|
165
|
+
const fs = require("node:fs");
|
|
166
|
+
fs.writeFileSync(${JSON.stringify(piLog)}, "launched");
|
|
167
|
+
const chunks = [];
|
|
168
|
+
process.stdin.on("data", (chunk) => chunks.push(chunk));
|
|
169
|
+
process.stdin.on("end", () => { process.stdout.write("pi-result-bytes"); process.exit(0); });
|
|
170
|
+
`,
|
|
171
|
+
);
|
|
172
|
+
chmodSync(pi, 0o755);
|
|
173
|
+
return { gentleAi, pi, materializeLog, submitLog, piLog };
|
|
174
|
+
}
|
|
175
|
+
test("a relay-unavailable case against a CAPABLE binary fails closed at pi: zero pi launch, zero submission", async (t) => {
|
|
176
|
+
const stub = capableStubHarness(t);
|
|
177
|
+
const [verdict] = await runMatrix(validateDescriptor({ ...descriptor(), gentleAiExecutable: stub.gentleAi, piExecutable: stub.pi }));
|
|
178
|
+
// The declared binary really is capable, so the negative control cannot
|
|
179
|
+
// pass — but it must fail WITHOUT mutating anything.
|
|
180
|
+
assert.equal(verdict!.verdict, "fail");
|
|
181
|
+
assert.match(verdict!.reason, /kind=pi-launch-failed stage=pi mutationOutcome=none/);
|
|
182
|
+
// The load-bearing assertions: the real pi never ran and the real
|
|
183
|
+
// submission never fired.
|
|
184
|
+
assert.equal(existsSync(stub.piLog), false, "pi must never launch for a relay-unavailable case");
|
|
185
|
+
assert.equal(existsSync(stub.submitLog), false, "a relay-unavailable case must never reach submit");
|
|
186
|
+
// Materialize still happens: it is the honest capability probe, and it is
|
|
187
|
+
// read-only (mutationOutcome stays "none" before submit).
|
|
188
|
+
assert.equal(existsSync(stub.materializeLog), true);
|
|
189
|
+
});
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// Negative control — a baseline runtime without --materialize fails closed as
|
|
192
|
+
// relay-unavailable with zero Pi launch and zero submission. The baseline is
|
|
193
|
+
// env-supplied (GENTLE_PI_MAINTAINER_BASELINE_BINARY); external evidence
|
|
194
|
+
// establishes whether it is the immutable RC8 runtime. Self-skip / fail-loud.
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
const baselineArmed = typeof baselineBinary === "string" && baselineBinary.length > 0 && baselineBinary.startsWith("/") && existsSync(baselineBinary);
|
|
197
|
+
const baselineReason = () => `${BASELINE_ENV} is unset or not an existing absolute path; supply the baseline gentle-ai binary (external evidence establishes whether it is RC8), or set ${REQUIRE_ENV}=1 to fail instead of skip.`;
|
|
198
|
+
if (!baselineArmed && armed) throw new Error(baselineReason());
|
|
199
|
+
if (!baselineArmed) console.log(`tests/maintainer/provider-relay.maintest.ts: ${baselineReason()}`);
|
|
200
|
+
function baselineDescriptor(kind = "relay-unavailable" as const) {
|
|
201
|
+
return validateDescriptor({ ...descriptor(), gentleAiExecutable: baselineBinary, cases: [{ name: "baseline-negative-control", kind, captureArgumentTokens: [...CAPTURE_TOKENS], submission: structuredClone(SUBMISSION) }] });
|
|
202
|
+
}
|
|
203
|
+
test("negative control: runMatrix proves the baseline fails closed as relay-unavailable with zero mutation", { skip: !baselineArmed }, async () => {
|
|
204
|
+
const [verdict] = await runMatrix(baselineDescriptor());
|
|
205
|
+
assert.equal(verdict!.kind, "relay-unavailable");
|
|
206
|
+
assert.equal(verdict!.verdict, "pass");
|
|
207
|
+
assert.equal(verdict!.stage, "materialize");
|
|
208
|
+
assert.equal(verdict!.mutationOutcome, "none");
|
|
209
|
+
assert.equal(verdict!.reason, REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE);
|
|
210
|
+
});
|
|
211
|
+
test("negative control: the real relay returns a typed relay-unavailable error before Pi launches", { skip: !baselineArmed }, async () => {
|
|
212
|
+
// The baseline lacks --materialize, so it fails at materialize before any
|
|
213
|
+
// pi launch; RELAY_UNAVAILABLE at materialize proves Pi never launched.
|
|
214
|
+
let caught: unknown;
|
|
215
|
+
try {
|
|
216
|
+
await runReviewHostRelaySlot({ captureArgumentTokens: [...CAPTURE_TOKENS], submission: structuredClone(SUBMISSION), gentleAiExecutable: baselineBinary!, piExecutable: "pi" });
|
|
217
|
+
} catch (error) {
|
|
218
|
+
caught = error;
|
|
219
|
+
}
|
|
220
|
+
assert.ok(caught instanceof ReviewHostRelayError);
|
|
221
|
+
const e = caught as ReviewHostRelayError;
|
|
222
|
+
assert.equal(e.kind, REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE);
|
|
223
|
+
assert.equal(e.stage, "materialize");
|
|
224
|
+
assert.equal(e.mutationOutcome, "none");
|
|
225
|
+
assert.match(e.stderr, /flag provided but not defined: -(?:materialize|agent)/);
|
|
226
|
+
});
|
|
227
|
+
test("negative control: an armed positive-lens against the baseline blocks because it lacks the surface", { skip: !baselineArmed }, async () => {
|
|
228
|
+
const [verdict] = await runMatrix(baselineDescriptor("positive-lens"), { armPositive: true });
|
|
229
|
+
assert.equal(verdict!.verdict, "blocked");
|
|
230
|
+
assert.match(verdict!.reason, /lacks the pi host relay surface/);
|
|
231
|
+
assert.equal(verdict!.command, POSITIVE_JOURNEY_COMMAND);
|
|
232
|
+
});
|
|
233
|
+
// ---------------------------------------------------------------------------
|
|
234
|
+
// Honest positive arming/skip — the organic journey needs a real capable
|
|
235
|
+
// binary, a real pi, and a real review session (lifecycle machinery beyond
|
|
236
|
+
// this work unit). The runner blocks the positive leg before materialize
|
|
237
|
+
// unless armed. These tests run WITHOUT the arm (the verifier arms it).
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
const capableArmed = typeof capableBinary === "string" && capableBinary.length > 0 && capableBinary.startsWith("/") && existsSync(capableBinary);
|
|
240
|
+
const capableReason = () => `${CAPABLE_ENV} is unset or not an existing absolute path; supply a capable gentle-ai binary (local build of origin/main with the pi host-relay surface), or set ${REQUIRE_ENV}=1 to fail instead of skip.`;
|
|
241
|
+
if (!capableArmed && armed) throw new Error(capableReason());
|
|
242
|
+
if (!capableArmed) console.log(`tests/maintainer/provider-relay.maintest.ts: ${capableReason()}`);
|
|
243
|
+
function positiveDescriptor(gentleAi = capableBinary, pi = "pi") {
|
|
244
|
+
return validateDescriptor({ ...descriptor(), gentleAiExecutable: gentleAi!, piExecutable: pi, cases: [{ name: "positive-lens-relay", kind: "positive-lens", captureArgumentTokens: [...CAPTURE_TOKENS], submission: structuredClone(SUBMISSION) }] });
|
|
245
|
+
}
|
|
246
|
+
test("positive arming/skip: an unarmed positive leg blocks loudly and never fakes green", { skip: !capableArmed }, async () => {
|
|
247
|
+
const [verdict] = await runMatrix(positiveDescriptor());
|
|
248
|
+
assert.equal(verdict!.kind, "positive-lens");
|
|
249
|
+
assert.equal(verdict!.verdict, "blocked");
|
|
250
|
+
assert.match(verdict!.reason, new RegExp(ARM_POSITIVE_ENV));
|
|
251
|
+
assert.equal(verdict!.command, POSITIVE_JOURNEY_COMMAND);
|
|
252
|
+
assert.notEqual(verdict!.verdict, "pass");
|
|
253
|
+
assert.notEqual(verdict!.verdict, "fail");
|
|
254
|
+
});
|
|
255
|
+
test("positive arming/skip: a missing pi blocks at the pi pre-arm before materialize", { skip: !capableArmed }, async () => {
|
|
256
|
+
const [verdict] = await runMatrix(positiveDescriptor(capableBinary, sandboxPath("not-a-real-pi")));
|
|
257
|
+
assert.equal(verdict!.verdict, "blocked");
|
|
258
|
+
assert.match(verdict!.reason, /piExecutable/);
|
|
259
|
+
assert.equal(verdict!.command, POSITIVE_JOURNEY_COMMAND);
|
|
260
|
+
});
|
|
261
|
+
test("positive arming/skip: the exact next evidence command is surfaced for the separate verifier", { skip: !capableArmed }, async () => {
|
|
262
|
+
const [verdict] = await runMatrix(positiveDescriptor());
|
|
263
|
+
assert.match(verdict!.command!, /node --experimental-strip-types scripts\/maintainer\/provider-relay-matrix.mjs --descriptor/);
|
|
264
|
+
assert.match(verdict!.command!, /GENTLE_PI_MAINTAINER_ARM_POSITIVE=1/);
|
|
265
|
+
});
|
|
@@ -80,8 +80,54 @@ test("2.2.2 repeats 2.2.1 because the lane Pi speaks still did not move", () =>
|
|
|
80
80
|
assert.deepEqual(contract, NATIVE_CLI_CONTRACTS["2.2.1"] as Record<string, boolean>);
|
|
81
81
|
});
|
|
82
82
|
|
|
83
|
+
test("2.2.3 repeats 2.2.2 because Pi's consumed capability columns did not move", () => {
|
|
84
|
+
// Ground-truthed against the released v2.2.3 binary: the v2 lane reports
|
|
85
|
+
// protocol 2.0, the same eight negotiated operations, and the same START
|
|
86
|
+
// fields represented by this table.
|
|
87
|
+
const contract = NATIVE_CLI_CONTRACTS["2.2.3"] as Record<string, boolean>;
|
|
88
|
+
assert.equal(contract.mode, true);
|
|
89
|
+
assert.equal(contract.delivery, true);
|
|
90
|
+
assert.equal(contract.riskEvidence, false);
|
|
91
|
+
assert.equal(contract.hint, false);
|
|
92
|
+
assert.deepEqual(contract, NATIVE_CLI_CONTRACTS["2.2.2"] as Record<string, boolean>);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("2.4.0 repeats 2.2.3 because the START envelope Pi consumes still omits risk_evidence and hint", () => {
|
|
96
|
+
// Ground-truthed against the published v2.4.0 linux_amd64 release binary
|
|
97
|
+
// (sha256:0be4467... , captured 2026-08-17), not against a main build:
|
|
98
|
+
//
|
|
99
|
+
// mode `gentle-ai review mode status --json` still answers
|
|
100
|
+
// `gentle-ai.rdd-mode-status/v1`. The RESOLVED value moved --
|
|
101
|
+
// an unconfigured install now reports effective "off" with
|
|
102
|
+
// source "default" instead of "on" -- but that is a decision
|
|
103
|
+
// the envelope carries, not a capability it lost.
|
|
104
|
+
// delivery `review validate --gate pre-commit` still answers
|
|
105
|
+
// `gentle-ai.review-gate-result/v1` with
|
|
106
|
+
// `delivery: "disabled/unmanaged"` at exit 0. Under opt-in RDD
|
|
107
|
+
// this is now the DEFAULT delivery answer for an install that
|
|
108
|
+
// never enabled review, so the column matters more, not less.
|
|
109
|
+
//
|
|
110
|
+
// riskEvidence and hint stay dark for exactly the reason the 2.2.0 comment
|
|
111
|
+
// gives, re-proven rather than inherited: the negotiated
|
|
112
|
+
// `gentle-ai.review-integration.start/v3` envelope captured from this
|
|
113
|
+
// binary carries `risk_reasons: [{ code: "non_executable_only" }]` and no
|
|
114
|
+
// `risk_evidence` and no `hint`. `risk_evidence` does appear in v2.4.0, but
|
|
115
|
+
// only on the `gentle-ai.review-integration.consent/v3` blocking envelope,
|
|
116
|
+
// which is a different envelope on a different branch of START and is not
|
|
117
|
+
// what these columns describe.
|
|
118
|
+
const contract = NATIVE_CLI_CONTRACTS["2.4.0"] as Record<string, boolean>;
|
|
119
|
+
assert.equal(contract.mode, true);
|
|
120
|
+
assert.equal(contract.delivery, true);
|
|
121
|
+
assert.equal(contract.riskEvidence, false);
|
|
122
|
+
assert.equal(contract.hint, false);
|
|
123
|
+
assert.deepEqual(contract, NATIVE_CLI_CONTRACTS["2.2.3"] as Record<string, boolean>);
|
|
124
|
+
});
|
|
125
|
+
|
|
83
126
|
test("no shipped version key was added beyond the pin bump", () => {
|
|
84
127
|
// Rows are promises to consumers, so a new key only ever appears in a
|
|
85
|
-
// dedicated commit alongside a pin bump, never as a side effect.
|
|
86
|
-
|
|
128
|
+
// dedicated commit alongside a pin bump, never as a side effect. v2.2.4 and
|
|
129
|
+
// v2.3.0 shipped upstream while Pi stayed on 2.2.3 and were never pinned,
|
|
130
|
+
// so they get no row: a row asserts ground truth measured against a binary
|
|
131
|
+
// Pi actually ran, and the table only has to be ascending, not gapless.
|
|
132
|
+
assert.deepEqual(Object.keys(NATIVE_CLI_CONTRACTS), [...DARK_VERSIONS, "2.2.0", "2.2.1", "2.2.2", "2.2.3", "2.4.0"]);
|
|
87
133
|
});
|
|
@@ -4,13 +4,19 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import test from "node:test";
|
|
6
6
|
import {
|
|
7
|
+
NATIVE_REVIEW_DEFAULT_MAX_BUFFER_BYTES,
|
|
7
8
|
NATIVE_REVIEW_ERROR_CODE,
|
|
8
9
|
NativeReviewCliError,
|
|
9
10
|
NativeReviewCliV213 as NativeReviewCliV213Production,
|
|
11
|
+
NativeReviewCliV216,
|
|
10
12
|
createNodeExecFileAdapter,
|
|
11
13
|
type ExecFileAdapter,
|
|
12
14
|
type NativeStartRequest,
|
|
13
15
|
} from "../lib/native-review-cli.ts";
|
|
16
|
+
import {
|
|
17
|
+
NativeReviewCliV214 as RuntimeNativeReviewCliV214,
|
|
18
|
+
NativeReviewCliV216 as RuntimeNativeReviewCliV216,
|
|
19
|
+
} from "../runtime/native-review-cli.mjs";
|
|
14
20
|
|
|
15
21
|
// The queued-adapter unit tests never execute a real process; default to a fixed
|
|
16
22
|
// absolute package-local path so they do not depend on an installed binary
|
|
@@ -260,6 +266,23 @@ test("version process failures retain their typed failure code", async () => {
|
|
|
260
266
|
}
|
|
261
267
|
});
|
|
262
268
|
|
|
269
|
+
test("native output limits dominate killed timeout signals and expose the bounded configuration guidance", async () => {
|
|
270
|
+
const assertOutputLimit = (error: unknown): boolean => {
|
|
271
|
+
const value = error as { name?: unknown; code?: unknown; diagnostics?: { timed_out?: unknown; output_limit_exceeded?: unknown; max_buffer_bytes?: unknown; configuration_hint?: unknown } };
|
|
272
|
+
const diagnostics = value.diagnostics;
|
|
273
|
+
return value.name === "NativeReviewCliError"
|
|
274
|
+
&& value.code === NATIVE_REVIEW_ERROR_CODE.OUTPUT_LIMIT
|
|
275
|
+
&& diagnostics?.timed_out === false
|
|
276
|
+
&& diagnostics?.output_limit_exceeded === true
|
|
277
|
+
&& diagnostics?.max_buffer_bytes === NATIVE_REVIEW_DEFAULT_MAX_BUFFER_BYTES
|
|
278
|
+
&& diagnostics?.configuration_hint === "Inspect native review state before any new START; GENTLE_PI_REVIEW_MAX_BUFFER_BYTES accepts a positive decimal up to 67108864.";
|
|
279
|
+
};
|
|
280
|
+
const queue = queuedAdapter([VERSION, { stdout: "", timedOut: true, outputLimitExceeded: true }]);
|
|
281
|
+
await assert.rejects(() => new NativeReviewCliV213(queue.adapter).start({ cwd: "/repo" }), assertOutputLimit);
|
|
282
|
+
const runtimeQueue = queuedAdapter([VERSION, { stdout: "", timedOut: true, outputLimitExceeded: true }]);
|
|
283
|
+
await assert.rejects(() => new RuntimeNativeReviewCliV214(runtimeQueue.adapter).start({ cwd: "/repo" }), assertOutputLimit);
|
|
284
|
+
});
|
|
285
|
+
|
|
263
286
|
test("native mutation uncertainty requires target status before any replay decision", async () => {
|
|
264
287
|
const queue = queuedAdapter([VERSION, { stdout: "", timedOut: true }]);
|
|
265
288
|
await assert.rejects(
|
|
@@ -271,6 +294,39 @@ test("native mutation uncertainty requires target status before any replay decis
|
|
|
271
294
|
);
|
|
272
295
|
});
|
|
273
296
|
|
|
297
|
+
test("native clients resolve a bounded review output buffer override at construction and propagate it to child processes", async () => {
|
|
298
|
+
const expectedDefault = 16 * 1024 * 1024;
|
|
299
|
+
const override = "2097152";
|
|
300
|
+
const invalidOverrides = ["", "0", "-1", "1.5", "not-a-number", "67108865"];
|
|
301
|
+
const withOverride = <T>(value: string | undefined, callback: () => T): T => {
|
|
302
|
+
const previous = process.env.GENTLE_PI_REVIEW_MAX_BUFFER_BYTES;
|
|
303
|
+
try {
|
|
304
|
+
if (value === undefined) delete process.env.GENTLE_PI_REVIEW_MAX_BUFFER_BYTES;
|
|
305
|
+
else process.env.GENTLE_PI_REVIEW_MAX_BUFFER_BYTES = value;
|
|
306
|
+
return callback();
|
|
307
|
+
} finally {
|
|
308
|
+
if (previous === undefined) delete process.env.GENTLE_PI_REVIEW_MAX_BUFFER_BYTES;
|
|
309
|
+
else process.env.GENTLE_PI_REVIEW_MAX_BUFFER_BYTES = previous;
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
for (const [name, overrideValue, expected] of [
|
|
313
|
+
["default", undefined, expectedDefault],
|
|
314
|
+
["valid override", override, Number(override)],
|
|
315
|
+
...invalidOverrides.map((value) => [`invalid override ${JSON.stringify(value)}`, value, expectedDefault] as const),
|
|
316
|
+
] as const) {
|
|
317
|
+
const queue = queuedAdapter([VERSION, START]);
|
|
318
|
+
const client = withOverride(overrideValue, () => new NativeReviewCliV213(queue.adapter));
|
|
319
|
+
const v216 = withOverride(overrideValue, () => new NativeReviewCliV216(queue.adapter, "/package/.gentle-ai/gentle-ai"));
|
|
320
|
+
const runtimeV214 = withOverride(overrideValue, () => new RuntimeNativeReviewCliV214(queue.adapter, "/package/.gentle-ai/gentle-ai"));
|
|
321
|
+
const runtimeV216 = withOverride(overrideValue, () => new RuntimeNativeReviewCliV216(queue.adapter, "/package/.gentle-ai/gentle-ai"));
|
|
322
|
+
assert.equal(Reflect.get(v216, "maxBufferBytes"), expected, name);
|
|
323
|
+
assert.equal(Reflect.get(runtimeV214, "maxBufferBytes"), expected, `${name} runtime v214`);
|
|
324
|
+
assert.equal(Reflect.get(runtimeV216, "maxBufferBytes"), expected, `${name} runtime v216`);
|
|
325
|
+
await client.start({ cwd: "/repo" });
|
|
326
|
+
assert.deepEqual(queue.calls.map((call) => call.maxBufferBytes), [expected, expected], name);
|
|
327
|
+
}
|
|
328
|
+
});
|
|
329
|
+
|
|
274
330
|
test("native mutating commands omit the automatic timeout while preserving output caps", async () => {
|
|
275
331
|
const queue = queuedAdapter([
|
|
276
332
|
VERSION,
|
|
@@ -10,6 +10,10 @@ import {
|
|
|
10
10
|
clearNativeReviewCapabilitiesCacheForTesting,
|
|
11
11
|
type ExecFileAdapter,
|
|
12
12
|
} from "../lib/native-review-cli.ts";
|
|
13
|
+
import {
|
|
14
|
+
NativeReviewCliV216 as RuntimeNativeReviewCliV216,
|
|
15
|
+
clearNativeReviewCapabilitiesCacheForTesting as clearRuntimeNativeReviewCapabilitiesCacheForTesting,
|
|
16
|
+
} from "../runtime/native-review-cli.mjs";
|
|
13
17
|
import type { ReviewConsentV2 } from "../lib/review-integration-v2.ts";
|
|
14
18
|
|
|
15
19
|
const fixtureRoot = join(process.cwd(), "contracts", "review-integration", "v2", "fixtures");
|
|
@@ -58,6 +62,11 @@ function client(adapter: ExecFileAdapter): NativeReviewCliV216 {
|
|
|
58
62
|
return new NativeReviewCliV216(adapter, "/package/.gentle-ai/gentle-ai", 30_000, 1024 * 1024, async () => undefined, () => executableDigest);
|
|
59
63
|
}
|
|
60
64
|
|
|
65
|
+
function runtimeClient(adapter: ExecFileAdapter): RuntimeNativeReviewCliV216 {
|
|
66
|
+
clearRuntimeNativeReviewCapabilitiesCacheForTesting();
|
|
67
|
+
return new RuntimeNativeReviewCliV216(adapter, "/package/.gentle-ai/gentle-ai", 30_000, 1024 * 1024, async () => undefined, () => executableDigest);
|
|
68
|
+
}
|
|
69
|
+
|
|
61
70
|
test("negotiated ordinary START declares relay and preserves the complete target-bound consent envelope", async () => {
|
|
62
71
|
const consent = fixture<Record<string, unknown>>("consent.fixture.json");
|
|
63
72
|
const target = String(consent.target_identity);
|
|
@@ -97,18 +106,40 @@ test("controller-prebound START target is used without projecting a second works
|
|
|
97
106
|
assert.equal(queue.calls.some((arguments_) => arguments_[1] === "status"), false, "a prebound START target must not be projected again");
|
|
98
107
|
});
|
|
99
108
|
|
|
100
|
-
test("consent follow-up executes the provider-named invocation exactly once and refuses
|
|
109
|
+
test("consent follow-up executes the provider-named invocation exactly once and refuses changed lineage or target bindings", async () => {
|
|
101
110
|
const consent = fixture<ReviewConsentV2 extends never ? never : Record<string, unknown>>("consent.fixture.json");
|
|
102
111
|
const decodedConsent = (await import("../lib/review-integration-v2.ts")).decodeReviewConsentV2(consent);
|
|
103
|
-
const
|
|
104
|
-
|
|
112
|
+
const preboundLineage = "Review.Lineage_42";
|
|
113
|
+
for (const choice of decodedConsent.choices) {
|
|
114
|
+
choice.invocation = choice.invocation.replace("review-consent-fixture", preboundLineage);
|
|
115
|
+
}
|
|
116
|
+
const started = fixture<Record<string, unknown>>("start.fixture.json");
|
|
117
|
+
started.lineage_id = preboundLineage;
|
|
118
|
+
started.artifact_subjects = [];
|
|
105
119
|
const queue = queuedAdapter([capabilities(), started]);
|
|
106
120
|
const native = client(queue.adapter);
|
|
107
121
|
const result = await native.answerConsent!({ cwd: "/repo", consent: decodedConsent, answer: "granted" });
|
|
108
122
|
assert.equal(result.kind, "started");
|
|
123
|
+
assert.ok(result.kind === "started");
|
|
124
|
+
assert.equal(result.start.lineageId, preboundLineage);
|
|
109
125
|
assert.deepEqual(queue.calls.at(-1), decodedConsent.choices[0].invocation.split(" ").slice(1));
|
|
110
126
|
assert.equal(queue.calls.filter((arguments_) => arguments_.includes("granted")).length, 1);
|
|
111
127
|
|
|
128
|
+
const runtimeQueue = queuedAdapter([capabilities(), structuredClone(started)]);
|
|
129
|
+
const runtimeResult = await runtimeClient(runtimeQueue.adapter).answerConsent({ cwd: "/repo", consent: decodedConsent, answer: "granted" });
|
|
130
|
+
assert.equal(runtimeResult.kind, "started");
|
|
131
|
+
assert.ok(runtimeResult.kind === "started");
|
|
132
|
+
assert.equal(runtimeResult.start.lineageId, preboundLineage);
|
|
133
|
+
assert.equal(runtimeQueue.calls.filter((arguments_) => arguments_.includes("granted")).length, 1);
|
|
134
|
+
|
|
135
|
+
const changedLineage = JSON.parse(JSON.stringify(started).replaceAll(preboundLineage, "different-lineage")) as Record<string, unknown>;
|
|
136
|
+
const lineageQueue = queuedAdapter([capabilities(), changedLineage]);
|
|
137
|
+
await assert.rejects(
|
|
138
|
+
() => client(lineageQueue.adapter).answerConsent!({ cwd: "/repo", consent: decodedConsent, answer: "granted" }),
|
|
139
|
+
/lineage mismatch/,
|
|
140
|
+
);
|
|
141
|
+
assert.equal(lineageQueue.calls.filter((arguments_) => arguments_[0] === "review" && arguments_[1] === "start").length, 1);
|
|
142
|
+
|
|
112
143
|
const changed = structuredClone(decodedConsent);
|
|
113
144
|
changed.targetIdentity = `sha256:${"b".repeat(64)}`;
|
|
114
145
|
await assert.rejects(
|
|
@@ -177,6 +208,68 @@ test("every consent invocation binding guard reports its own reason without laun
|
|
|
177
208
|
}
|
|
178
209
|
});
|
|
179
210
|
|
|
211
|
+
test("fresh consent START accepts the strictly decoded provider-created lineage and rejects a mismatched target", async () => {
|
|
212
|
+
const decoded = (await import("../lib/review-integration-v2.ts")).decodeReviewConsentV2(fixture<Record<string, unknown>>("consent.fixture.json"));
|
|
213
|
+
const freshConsent = structuredClone(decoded);
|
|
214
|
+
for (const choice of freshConsent.choices) {
|
|
215
|
+
choice.invocation = choice.invocation.replace(" --lineage review-consent-fixture", "");
|
|
216
|
+
}
|
|
217
|
+
const freshStart = JSON.parse(JSON.stringify(fixture<Record<string, unknown>>("start.fixture.json"))
|
|
218
|
+
.replaceAll("review-start-fixture", "provider-created-lineage")) as Record<string, unknown>;
|
|
219
|
+
const queue = queuedAdapter([capabilities(), freshStart]);
|
|
220
|
+
const result = await client(queue.adapter).answerConsent!({ cwd: "/repo", consent: freshConsent, answer: "granted" });
|
|
221
|
+
assert.equal(result.kind, "started");
|
|
222
|
+
assert.ok(result.kind === "started");
|
|
223
|
+
assert.equal(result.start.lineageId, "provider-created-lineage");
|
|
224
|
+
assert.equal(queue.calls.filter((arguments_) => arguments_[0] === "review" && arguments_[1] === "start").length, 1);
|
|
225
|
+
assert.equal(queue.calls.at(-1)?.includes("--lineage"), false);
|
|
226
|
+
|
|
227
|
+
const runtimeQueue = queuedAdapter([capabilities(), structuredClone(freshStart)]);
|
|
228
|
+
const runtimeResult = await runtimeClient(runtimeQueue.adapter).answerConsent({ cwd: "/repo", consent: freshConsent, answer: "granted" });
|
|
229
|
+
assert.equal(runtimeResult.kind, "started");
|
|
230
|
+
assert.ok(runtimeResult.kind === "started");
|
|
231
|
+
assert.equal(runtimeResult.start.lineageId, "provider-created-lineage");
|
|
232
|
+
assert.equal(runtimeQueue.calls.filter((arguments_) => arguments_[0] === "review" && arguments_[1] === "start").length, 1);
|
|
233
|
+
assert.equal(runtimeQueue.calls.at(-1)?.includes("--lineage"), false);
|
|
234
|
+
|
|
235
|
+
const mismatchedTarget = structuredClone(freshStart);
|
|
236
|
+
((mismatchedTarget.repository_context as Record<string, unknown>).target_identity as string) = `sha256:${"b".repeat(64)}`;
|
|
237
|
+
const mismatchQueue = queuedAdapter([capabilities(), mismatchedTarget]);
|
|
238
|
+
await assert.rejects(
|
|
239
|
+
() => client(mismatchQueue.adapter).answerConsent!({ cwd: "/repo", consent: freshConsent, answer: "granted" }),
|
|
240
|
+
/target mismatch/,
|
|
241
|
+
);
|
|
242
|
+
assert.equal(mismatchQueue.calls.filter((arguments_) => arguments_[0] === "review" && arguments_[1] === "start").length, 1);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("duplicate, empty, missing, and malformed consent lineages fail before provider launch", async () => {
|
|
246
|
+
const decoded = (await import("../lib/review-integration-v2.ts")).decodeReviewConsentV2(fixture<Record<string, unknown>>("consent.fixture.json"));
|
|
247
|
+
const drifted = (replace: (invocation: string) => string): ReviewConsentV2 => {
|
|
248
|
+
const consent = structuredClone(decoded);
|
|
249
|
+
const choice = consent.choices.find((candidate) => candidate.answer === "granted") as { invocation: string };
|
|
250
|
+
choice.invocation = replace(choice.invocation);
|
|
251
|
+
return consent;
|
|
252
|
+
};
|
|
253
|
+
const cases = [
|
|
254
|
+
{ label: "duplicate", consent: drifted((value) => `${value} --lineage duplicate-lineage`) },
|
|
255
|
+
{ label: "empty", consent: drifted((value) => value.replace("--lineage review-consent-fixture", "--lineage=")) },
|
|
256
|
+
{ label: "missing", consent: drifted((value) => value.replace("--lineage review-consent-fixture", "--lineage")) },
|
|
257
|
+
{ label: "malformed", consent: drifted((value) => value.replace("--lineage review-consent-fixture", "--lineage invalid/lineage")) },
|
|
258
|
+
] as const;
|
|
259
|
+
for (const scenario of cases) {
|
|
260
|
+
const queue = queuedAdapter([]);
|
|
261
|
+
await assert.rejects(
|
|
262
|
+
() => client(queue.adapter).answerConsent!({ cwd: "/repo", consent: scenario.consent, answer: "granted" }),
|
|
263
|
+
(error: unknown) => {
|
|
264
|
+
assert.ok(error instanceof NativeReviewConsentBindingError, `${scenario.label} lineage must be a typed binding error`);
|
|
265
|
+
assert.equal(error.reason, "consent-invocation-option-invalid");
|
|
266
|
+
return true;
|
|
267
|
+
},
|
|
268
|
+
);
|
|
269
|
+
assert.deepEqual(queue.calls, [], `${scenario.label} lineage must not launch the provider`);
|
|
270
|
+
}
|
|
271
|
+
});
|
|
272
|
+
|
|
180
273
|
test("declined consent decodes the provider's explicit empty authority fields without creating a lineage", async () => {
|
|
181
274
|
const rawConsent = fixture<Record<string, unknown>>("consent.fixture.json");
|
|
182
275
|
const consent = (await import("../lib/review-integration-v2.ts")).decodeReviewConsentV2(rawConsent);
|
|
@@ -203,3 +296,71 @@ test("declined consent decodes the provider's explicit empty authority fields wi
|
|
|
203
296
|
assert.equal("lineageId" in result, false);
|
|
204
297
|
assert.deepEqual(queue.calls.at(-1), consent.choices[1].invocation.split(" ").slice(1));
|
|
205
298
|
});
|
|
299
|
+
|
|
300
|
+
// --- consent/v3 (dev-binary field-test lane) ---
|
|
301
|
+
//
|
|
302
|
+
// Fixture provenance: consent-v3.captured.json, start-v3-consent-granted
|
|
303
|
+
// .captured.json, and start-v3-consent-declined.captured.json were captured
|
|
304
|
+
// 2026-08-16 from the real binary at /home/gentleman/.cargo/bin/gentle-ai
|
|
305
|
+
// reporting "gentle-ai 2.4.0-main.b1afef46"; full capture commands are
|
|
306
|
+
// documented in tests/review-integration-v2-forward.test.ts.
|
|
307
|
+
|
|
308
|
+
const devbinaryFixtureRoot = join(process.cwd(), "tests", "fixtures", "devbinary");
|
|
309
|
+
const devbinaryFixture = <T = Record<string, unknown>>(name: string): T => JSON.parse(readFileSync(join(devbinaryFixtureRoot, name), "utf8")) as T;
|
|
310
|
+
|
|
311
|
+
test("negotiated START surfaces the captured consent/v3 envelope instead of failing schema-incompatible", async () => {
|
|
312
|
+
const consent = devbinaryFixture<Record<string, unknown>>("consent-v3.captured.json");
|
|
313
|
+
const target = String(consent.target_identity);
|
|
314
|
+
const queue = queuedAdapter([capabilities(), unrelatedStatus(target), consent]);
|
|
315
|
+
await assert.rejects(
|
|
316
|
+
() => client(queue.adapter).start({ cwd: "/repo" }),
|
|
317
|
+
(error: unknown) => {
|
|
318
|
+
assert.ok(error instanceof NativeReviewConsentRequiredError, `expected NativeReviewConsentRequiredError, got ${String(error)}`);
|
|
319
|
+
assert.deepEqual(error.consent.raw, consent);
|
|
320
|
+
assert.equal(error.consent.schema, "gentle-ai.review-integration.consent/v3");
|
|
321
|
+
assert.equal(error.consent.targetIdentity, target);
|
|
322
|
+
return true;
|
|
323
|
+
},
|
|
324
|
+
);
|
|
325
|
+
assert.deepEqual(queue.calls.at(-1), [
|
|
326
|
+
"review", "start", "--contract", "gentle-ai.review-integration/v2", "--cwd", "/repo",
|
|
327
|
+
"--target", target, "--projection", "workspace", "--consent", "relay",
|
|
328
|
+
]);
|
|
329
|
+
|
|
330
|
+
const runtimeQueue = queuedAdapter([capabilities(), unrelatedStatus(target), structuredClone(consent)]);
|
|
331
|
+
await assert.rejects(
|
|
332
|
+
() => runtimeClient(runtimeQueue.adapter).start({ cwd: "/repo" }),
|
|
333
|
+
(error: unknown) => (error as Error).name === "NativeReviewConsentRequiredError",
|
|
334
|
+
);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("a granted consent/v3 answer decodes the captured start/v3 result with its event binding", async () => {
|
|
338
|
+
const decoded = (await import("../lib/review-integration-v2.ts")).decodeReviewConsentV3(devbinaryFixture<Record<string, unknown>>("consent-v3.captured.json"));
|
|
339
|
+
const cwd = decoded.choices[0].invocation.split(" --cwd ")[1]!.split(" ")[0]!;
|
|
340
|
+
const started = devbinaryFixture<Record<string, unknown>>("start-v3-consent-granted.captured.json");
|
|
341
|
+
const queue = queuedAdapter([capabilities(), started]);
|
|
342
|
+
const result = await client(queue.adapter).answerConsent!({ cwd, consent: decoded, answer: "granted" });
|
|
343
|
+
assert.equal(result.kind, "started");
|
|
344
|
+
assert.ok(result.kind === "started");
|
|
345
|
+
assert.equal(result.start.lineageId, "review-377c60e10b852cfc");
|
|
346
|
+
assert.equal(result.start.selectedLenses.length, 4);
|
|
347
|
+
// The follow-up runs the provider-owned invocation verbatim, exactly once.
|
|
348
|
+
assert.deepEqual(queue.calls.at(-1), decoded.choices[0].invocation.split(" ").slice(1));
|
|
349
|
+
assert.equal(queue.calls.filter((arguments_) => arguments_.includes("granted")).length, 1);
|
|
350
|
+
|
|
351
|
+
const runtimeQueue = queuedAdapter([capabilities(), structuredClone(started)]);
|
|
352
|
+
const runtimeResult = await runtimeClient(runtimeQueue.adapter).answerConsent({ cwd, consent: decoded, answer: "granted" });
|
|
353
|
+
assert.equal(runtimeResult.kind, "started");
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("a declined consent/v3 answer accepts the captured declined result and creates no authority", async () => {
|
|
357
|
+
const decoded = (await import("../lib/review-integration-v2.ts")).decodeReviewConsentV3(devbinaryFixture<Record<string, unknown>>("consent-v3.captured.json"));
|
|
358
|
+
const cwd = decoded.choices[1].invocation.split(" --cwd ")[1]!.split(" ")[0]!;
|
|
359
|
+
const declined = devbinaryFixture<Record<string, unknown>>("start-v3-consent-declined.captured.json");
|
|
360
|
+
const queue = queuedAdapter([capabilities(), declined]);
|
|
361
|
+
const result = await client(queue.adapter).answerConsent!({ cwd, consent: decoded, answer: "declined" });
|
|
362
|
+
assert.equal(result.kind, "declined");
|
|
363
|
+
assert.ok(result.kind === "declined");
|
|
364
|
+
assert.equal(result.consent, "declined_this_candidate");
|
|
365
|
+
assert.equal(result.targetIdentity, decoded.targetIdentity);
|
|
366
|
+
});
|