gentle-pi 2.2.0 → 2.3.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 +51 -80
- package/assets/agents/review-readability.md +0 -2
- package/assets/agents/review-reliability.md +0 -2
- package/assets/agents/review-resilience.md +0 -2
- package/assets/agents/review-risk.md +0 -2
- package/assets/agents/sdd-apply.md +5 -3
- package/assets/agents/sdd-proposal.md +2 -0
- package/assets/agents/sdd-research.md +54 -0
- package/assets/agents/sdd-status.md +4 -4
- package/assets/agents/sdd-tasks.md +2 -3
- package/assets/agents/sdd-verify.md +21 -1
- package/assets/chains/sdd-full.chain.md +1 -1
- package/assets/chains/sdd-verify.chain.md +1 -1
- package/assets/orchestrator-delegation.md +54 -248
- package/assets/orchestrator-memory.md +2 -0
- package/assets/orchestrator.md +22 -38
- package/assets/sdd-orchestrator-workflow.md +30 -37
- package/assets/support/sdd-status-contract.md +7 -7
- package/contracts/review-integration/v1/schemas/transition-execution.schema.json +42 -0
- package/contracts/review-integration/v2/schemas/last-event-closure.schema.json +66 -0
- package/contracts/review-integration/v2/schemas/opencode-provider-role.schema.json +14 -0
- package/docs/native-authority-architecture.md +9 -11
- package/docs/review-integration.md +27 -373
- package/extensions/ask-user-choice.ts +151 -0
- package/extensions/gentle-ai.ts +1778 -3673
- package/extensions/quiet-tools.ts +515 -32
- package/extensions/sdd-init.ts +4 -8
- package/lib/gentle-ai-renderer.ts +70 -0
- package/lib/model-routing-authority.ts +133 -0
- package/lib/native-review-cli.ts +372 -940
- package/lib/opaque-pi-reviewer-adapter.ts +284 -0
- package/lib/review-candidate-view.ts +341 -132
- package/lib/review-host-relay.ts +210 -68
- package/lib/review-integration-v2.ts +839 -307
- package/lib/review-last-event-controller.ts +35 -0
- package/lib/sdd-preflight.ts +177 -66
- package/lib/sdd-status.ts +66 -111
- package/lib/terminal-theme.ts +1 -1
- package/package.json +83 -82
- package/runtime/gentle-ai-binary.mjs +1 -1
- package/runtime/native-review-cli.mjs +322 -890
- package/runtime/review-integration-v2.mjs +789 -257
- package/runtime/review-relay-contract.mjs +1 -1
- package/scripts/{build-git-commit-transaction-runner.mjs → build-runtime-modules.mjs} +4 -5
- package/scripts/gentle-ai-installer.mjs +75 -22
- package/scripts/maintainer/provider-relay-matrix.mjs +195 -11
- package/scripts/test-packed-runner.mjs +4 -7
- package/scripts/verify-package-files.mjs +12 -13
- package/skills/_shared/review-ledger-contract.md +8 -14
- package/skills/chained-pr/SKILL.md +3 -0
- package/skills/cognitive-doc-design/SKILL.md +1 -1
- package/skills/comment-writer/SKILL.md +1 -1
- package/skills/gentle-ai/SKILL.md +7 -74
- package/skills/judgment-day/SKILL.md +5 -7
- package/skills/rdd-defect-workflow/SKILL.md +3 -3
- package/skills/release/SKILL.md +3 -3
- package/skills/skill-registry/SKILL.md +1 -1
- package/skills/work-unit-commits/SKILL.md +3 -1
- package/tests/artifact-language.test.ts +24 -11
- package/tests/ask-user-choice.test.ts +264 -0
- package/tests/codegraph-tools.test.ts +3 -3
- package/tests/crosslane/cross-lane.mjs +15 -1168
- package/tests/delegated-key-learnings-contract.test.ts +8 -6
- package/tests/devbinary/native-review-parity.devtest.ts +167 -250
- package/tests/devbinary/pi-host-relay.devtest.ts +867 -0
- package/tests/fixtures/devbinary/last-event-capture-correction-plan.captured.json +10 -0
- package/tests/fixtures/devbinary/last-event-capture-refuter-approved.captured.json +20 -0
- package/tests/fixtures/devbinary/last-event-capture-refuter-correction-required.captured.json +26 -0
- package/tests/fixtures/devbinary/last-event-capture-result-approved.captured.json +8 -0
- package/tests/fixtures/devbinary/last-event-capture-result-correction-required.captured.json +26 -0
- package/tests/fixtures/devbinary/last-event-capture-validation-approved.captured.json +8 -0
- package/tests/fixtures/devbinary/last-event-closure.provenance.md +13 -0
- package/tests/fixtures/devbinary/review-acknowledged-v1.captured.json +9 -0
- package/tests/fixtures/devbinary/review-acknowledged.provenance.md +31 -0
- package/tests/fixtures/devbinary/start-v3-zero-lens-closed.captured.json +21 -0
- package/tests/fixtures/native-review-cli/v2.5.0-rc.1/PROVENANCE.txt +15 -0
- package/tests/gentle-ai-binary.test.ts +83 -13
- package/tests/gentle-ai-dev-binary.test.ts +51 -228
- package/tests/gentle-ai-installer.test.ts +137 -47
- package/tests/gentle-ai.test.ts +820 -131
- package/tests/gentle-theme.test.ts +133 -0
- package/tests/maintainer/provider-relay.maintest.ts +340 -4
- package/tests/model-routing-authority.test.ts +257 -0
- package/tests/native-review-capability-contract.test.ts +28 -1
- package/tests/native-review-cli.test.ts +586 -962
- package/tests/native-review-consent.test.ts +150 -66
- package/tests/native-review-parity-runtime.test.ts +80 -359
- package/tests/native-review-parity.test.ts +674 -925
- package/tests/opaque-pi-reviewer-adapter.test.ts +266 -0
- package/tests/orchestrator-budget.test.ts +132 -88
- package/tests/orchestrator-rdd-ownership.test.ts +103 -0
- package/tests/package-manifest.test.ts +37 -35
- package/tests/provider-defect-handoff.test.ts +15 -118
- package/tests/quiet-tool-rendering.test.ts +1055 -28
- package/tests/review-authority-recovery-docs.test.ts +1 -2
- package/tests/review-candidate-view.test.ts +409 -3
- package/tests/review-compact-contract.test.ts +4 -30
- package/tests/review-controller-lock-status.test.ts +2 -2
- package/tests/review-controller-native-recovery.test.ts +363 -1450
- package/tests/review-controller-native-routing.test.ts +1251 -5642
- package/tests/review-controller-retired-ops.test.ts +1 -1
- package/tests/review-controller-workspace-root.test.ts +222 -71
- package/tests/review-controller.test.ts +26 -816
- package/tests/review-corrected-finalize-binding.test.ts +112 -153
- package/tests/review-dispatch-hydration-gap.test.ts +1 -53
- package/tests/review-gate.test.ts +0 -45
- package/tests/review-host-relay-restart-parity.test.ts +360 -0
- package/tests/review-host-relay-routing.test.ts +117 -82
- package/tests/review-host-relay.test.ts +242 -8
- package/tests/review-integration-v2-forward.test.ts +576 -553
- package/tests/review-integration-v2.test.ts +239 -165
- package/tests/review-last-event-closure.test.ts +408 -0
- package/tests/review-ledger-contract.test.ts +97 -35
- package/tests/review-recovered-lineage-routing.test.ts +0 -47
- package/tests/review-relay-transport-agent.test.ts +119 -46
- package/tests/review-snapshot.test.ts +3 -2
- package/tests/runtime-harness.mjs +361 -196
- package/tests/sdd-agent-tools.test.ts +36 -0
- package/tests/sdd-preflight.test.ts +81 -15
- package/tests/sdd-status.test.ts +109 -110
- package/tests/skill-collision-prefixes.test.ts +5 -8
- package/tests/writer-edit-surface-scope.test.ts +230 -0
- package/themes/Gentleman-Cute.json +94 -0
- package/themes/Gentleman-Sexy.json +92 -0
- package/lib/git-commit-transaction.ts +0 -861
- package/runtime/git-commit-transaction.mjs +0 -862
- package/scripts/run-git-commit-transaction.mjs +0 -35
- package/tests/fixtures/native-review-cli/v2.1.3/sdd-status-engram.json +0 -139
- package/tests/fixtures/native-review-cli/v2.1.3/sdd-status.json +0 -200
- package/tests/git-commit-transaction.test.ts +0 -530
|
@@ -117,6 +117,139 @@ test("package manifest exposes bundled themes to Pi discovery", () => {
|
|
|
117
117
|
);
|
|
118
118
|
});
|
|
119
119
|
|
|
120
|
+
test("bundled Gentleman-Sexy Pi theme is available under its exact name", () => {
|
|
121
|
+
const theme = readJson<GentleThemeJson>(
|
|
122
|
+
join(PACKAGE_ROOT, "themes", "Gentleman-Sexy.json"),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
assert.equal(theme.name, "Gentleman-Sexy");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("bundled Gentleman-Cute Pi theme is available under its exact name", () => {
|
|
129
|
+
const theme = readJson<GentleThemeJson>(
|
|
130
|
+
join(PACKAGE_ROOT, "themes", "Gentleman-Cute.json"),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
assert.equal(theme.name, "Gentleman-Cute");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("bundled Gentleman-Sexy Pi theme defines complete brand and semantic color mappings", () => {
|
|
137
|
+
const theme = readJson<GentleThemeJson>(
|
|
138
|
+
join(PACKAGE_ROOT, "themes", "Gentleman-Sexy.json"),
|
|
139
|
+
);
|
|
140
|
+
const colors = theme.colors ?? {};
|
|
141
|
+
|
|
142
|
+
assert.deepEqual(
|
|
143
|
+
REQUIRED_THEME_COLOR_KEYS.filter((key) => !(key in colors)),
|
|
144
|
+
[],
|
|
145
|
+
);
|
|
146
|
+
assert.deepEqual(
|
|
147
|
+
["scrollbarThumb", "searchMatchBg", "searchMatchText", "thinkingMax"].filter(
|
|
148
|
+
(key) => !(key in colors),
|
|
149
|
+
),
|
|
150
|
+
[],
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
assertResolvedThemeColor(theme, "accent", "#F43888");
|
|
154
|
+
assert.equal(colors.borderAccent, "activePink");
|
|
155
|
+
assertResolvedThemeColor(theme, "borderAccent", "#FF4F9A");
|
|
156
|
+
assertResolvedThemeColor(theme, "selectedBg", "#28121E");
|
|
157
|
+
assertResolvedThemeColor(theme, "text", "#F6EFF3");
|
|
158
|
+
assertResolvedThemeColor(theme, "success", "#D2CBD0");
|
|
159
|
+
assertResolvedThemeColor(theme, "warning", "#F2B86D");
|
|
160
|
+
assertResolvedThemeColor(theme, "error", "#FF718F");
|
|
161
|
+
assertResolvedThemeColor(theme, "muted", "#A78E9B");
|
|
162
|
+
assertResolvedThemeColor(theme, "dim", "#76616B");
|
|
163
|
+
assertResolvedThemeColor(theme, "toolPendingBg", "#100A0F");
|
|
164
|
+
assertResolvedThemeColor(theme, "toolSuccessBg", "#151316");
|
|
165
|
+
assertResolvedThemeColor(theme, "toolErrorBg", "#261019");
|
|
166
|
+
assertResolvedThemeColor(theme, "toolTitle", "#E0C27A");
|
|
167
|
+
assertResolvedThemeColor(theme, "customMessageLabel", "#E0C27A");
|
|
168
|
+
assert.equal(colors.mdCodeBlockBorder, "muted");
|
|
169
|
+
assertResolvedThemeColor(theme, "mdCodeBlockBorder", "#A78E9B");
|
|
170
|
+
assert.equal(colors.mdQuoteBorder, "deepPink");
|
|
171
|
+
assertResolvedThemeColor(theme, "mdQuoteBorder", "#BF0F50");
|
|
172
|
+
assert.equal(colors.mdLink, "activePink");
|
|
173
|
+
assertResolvedThemeColor(theme, "mdLink", "#FF4F9A");
|
|
174
|
+
assert.equal(colors.mdListBullet, "activePink");
|
|
175
|
+
assertResolvedThemeColor(theme, "mdListBullet", "#FF4F9A");
|
|
176
|
+
assertResolvedThemeColor(theme, "toolDiffAdded", "#A9C7EE");
|
|
177
|
+
assert.equal(colors.syntaxKeyword, "deepPink");
|
|
178
|
+
assertResolvedThemeColor(theme, "syntaxKeyword", "#BF0F50");
|
|
179
|
+
assertResolvedThemeColor(theme, "syntaxFunction", "#C49BFF");
|
|
180
|
+
assert.equal(colors.syntaxOperator, "activePink");
|
|
181
|
+
assertResolvedThemeColor(theme, "syntaxOperator", "#FF4F9A");
|
|
182
|
+
assertResolvedThemeColor(theme, "syntaxString", "#E0C27A");
|
|
183
|
+
assertResolvedThemeColor(theme, "syntaxNumber", "#D7A0B8");
|
|
184
|
+
assertResolvedThemeColor(theme, "syntaxType", "#A9C7EE");
|
|
185
|
+
assert.equal(colors.thinkingXhigh, "activePink");
|
|
186
|
+
assertResolvedThemeColor(theme, "thinkingXhigh", "#FF4F9A");
|
|
187
|
+
assert.equal(colors.thinkingMax, "accent");
|
|
188
|
+
assertResolvedThemeColor(theme, "thinkingMax", "#F43888");
|
|
189
|
+
assert.equal(theme.export?.pageBg, "bg");
|
|
190
|
+
assert.equal(theme.export?.cardBg, "bgElement");
|
|
191
|
+
assert.equal(theme.export?.infoBg, "infoBg");
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("bundled Gentleman-Cute Pi theme defines complete brand and restrained color mappings", () => {
|
|
195
|
+
const theme = readJson<GentleThemeJson>(
|
|
196
|
+
join(PACKAGE_ROOT, "themes", "Gentleman-Cute.json"),
|
|
197
|
+
);
|
|
198
|
+
const colors = theme.colors ?? {};
|
|
199
|
+
|
|
200
|
+
assert.deepEqual(
|
|
201
|
+
REQUIRED_THEME_COLOR_KEYS.filter((key) => !(key in colors)),
|
|
202
|
+
[],
|
|
203
|
+
);
|
|
204
|
+
assert.deepEqual(
|
|
205
|
+
["scrollbarThumb", "searchMatchBg", "searchMatchText", "thinkingMax"].filter(
|
|
206
|
+
(key) => !(key in colors),
|
|
207
|
+
),
|
|
208
|
+
[],
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
assertResolvedThemeColor(theme, "accent", "#F095C8");
|
|
212
|
+
assertResolvedThemeColor(theme, "searchMatchBg", "#C96AA2");
|
|
213
|
+
assert.equal(colors.borderAccent, "activePink");
|
|
214
|
+
assertResolvedThemeColor(theme, "borderAccent", "#FFB1DD");
|
|
215
|
+
assertResolvedThemeColor(theme, "selectedBg", "#28121E");
|
|
216
|
+
assertResolvedThemeColor(theme, "text", "#F6EFF3");
|
|
217
|
+
assertResolvedThemeColor(theme, "success", "#B4E7C7");
|
|
218
|
+
assertResolvedThemeColor(theme, "warning", "#F2B86D");
|
|
219
|
+
assertResolvedThemeColor(theme, "error", "#FF718F");
|
|
220
|
+
assertResolvedThemeColor(theme, "muted", "#A78E9B");
|
|
221
|
+
assertResolvedThemeColor(theme, "dim", "#76616B");
|
|
222
|
+
assertResolvedThemeColor(theme, "toolPendingBg", "#100A0F");
|
|
223
|
+
assertResolvedThemeColor(theme, "toolSuccessBg", "#151316");
|
|
224
|
+
assertResolvedThemeColor(theme, "toolErrorBg", "#261019");
|
|
225
|
+
assertResolvedThemeColor(theme, "toolTitle", "#E0C27A");
|
|
226
|
+
assertResolvedThemeColor(theme, "customMessageLabel", "#E0C27A");
|
|
227
|
+
assert.equal(colors.mdCodeBlockBorder, "muted");
|
|
228
|
+
assertResolvedThemeColor(theme, "mdCodeBlockBorder", "#A78E9B");
|
|
229
|
+
assert.equal(colors.mdQuoteBorder, "deepPink");
|
|
230
|
+
assertResolvedThemeColor(theme, "mdQuoteBorder", "#C96AA2");
|
|
231
|
+
assert.equal(colors.mdLink, "accent");
|
|
232
|
+
assertResolvedThemeColor(theme, "mdLink", "#F095C8");
|
|
233
|
+
assert.equal(colors.mdListBullet, "accent");
|
|
234
|
+
assertResolvedThemeColor(theme, "mdListBullet", "#F095C8");
|
|
235
|
+
assertResolvedThemeColor(theme, "toolDiffAdded", "#B4E7C7");
|
|
236
|
+
assert.equal(colors.syntaxKeyword, "accent");
|
|
237
|
+
assertResolvedThemeColor(theme, "syntaxKeyword", "#F095C8");
|
|
238
|
+
assertResolvedThemeColor(theme, "syntaxFunction", "#A9C7EE");
|
|
239
|
+
assert.equal(colors.syntaxOperator, "sky");
|
|
240
|
+
assertResolvedThemeColor(theme, "syntaxOperator", "#C4DAF6");
|
|
241
|
+
assertResolvedThemeColor(theme, "syntaxString", "#B4E7C7");
|
|
242
|
+
assertResolvedThemeColor(theme, "syntaxNumber", "#F2B86D");
|
|
243
|
+
assertResolvedThemeColor(theme, "syntaxType", "#E0C27A");
|
|
244
|
+
assert.equal(colors.thinkingXhigh, "accent");
|
|
245
|
+
assertResolvedThemeColor(theme, "thinkingXhigh", "#F095C8");
|
|
246
|
+
assert.equal(colors.thinkingMax, "activePink");
|
|
247
|
+
assertResolvedThemeColor(theme, "thinkingMax", "#FFB1DD");
|
|
248
|
+
assert.equal(theme.export?.pageBg, "bg");
|
|
249
|
+
assert.equal(theme.export?.cardBg, "bgElement");
|
|
250
|
+
assert.equal(theme.export?.infoBg, "infoBg");
|
|
251
|
+
});
|
|
252
|
+
|
|
120
253
|
test("bundled Pi theme is named exactly Gentle and defines all required colors", () => {
|
|
121
254
|
const theme = readJson<GentleThemeJson>(
|
|
122
255
|
join(PACKAGE_ROOT, "themes", "Gentle.json"),
|
|
@@ -9,12 +9,12 @@
|
|
|
9
9
|
// them via env. The baseline is described generically; external evidence
|
|
10
10
|
// establishes whether it is the immutable RC8 runtime.
|
|
11
11
|
import assert from "node:assert/strict";
|
|
12
|
-
import { chmodSync, existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
13
13
|
import { tmpdir } from "node:os";
|
|
14
|
-
import { dirname, join } from "node:path";
|
|
14
|
+
import { delimiter, dirname, isAbsolute, join, relative } from "node:path";
|
|
15
15
|
import test from "node:test";
|
|
16
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";
|
|
17
|
+
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";
|
|
18
18
|
const BASELINE_ENV = "GENTLE_PI_MAINTAINER_BASELINE_BINARY";
|
|
19
19
|
const CAPABLE_ENV = "GENTLE_PI_MAINTAINER_CAPABLE_BINARY";
|
|
20
20
|
const REQUIRE_ENV = "GENTLE_PI_REQUIRE_MAINTAINER";
|
|
@@ -47,6 +47,15 @@ const SUBMISSION = Object.freeze({
|
|
|
47
47
|
values: Object.freeze([{ slot: "reviewer_result", domain: "artifact_path_or_stdin", substitutionLocation: BINDING_TOKENS.length }]),
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
+
const ROLE_LINEAGE = "review-fixture-role-vector", ROLE_REVISION = `sha256:${"a".repeat(64)}`;
|
|
51
|
+
const ROLE_TARGET = `sha256:${"b".repeat(64)}`;
|
|
52
|
+
const ROLE_CONTEXT = `rctx1_${"c".repeat(64)}`;
|
|
53
|
+
const ROLE_REQUEST_HASH = `sha256:${"9".repeat(64)}`;
|
|
54
|
+
const REFUTER_TOKENS = Object.freeze([`--lineage=${ROLE_LINEAGE}`, `--expected-revision=${ROLE_REVISION}`, `--target=${ROLE_TARGET}`, `--repository-context=${ROLE_CONTEXT}`, "--agent=pi", "--execute=true"]);
|
|
55
|
+
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"]);
|
|
56
|
+
const VALIDATION_REQUEST = Object.freeze({ schema: "gentle-ai.review-targeted-validation-request/v1", requestHash: ROLE_REQUEST_HASH });
|
|
57
|
+
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 });
|
|
58
|
+
|
|
50
59
|
function descriptor(overrides = {}) {
|
|
51
60
|
return {
|
|
52
61
|
schema: DESCRIPTOR_SCHEMA,
|
|
@@ -83,7 +92,7 @@ test("rejects malformed descriptor fields with exact-shape errors (no defaults,
|
|
|
83
92
|
rejects({ ...descriptor(), gentleAiExecutable: undefined }, "absolute path");
|
|
84
93
|
rejects({ ...descriptor(), extra: 1 }, "descriptor.extra");
|
|
85
94
|
rejects({ ...descriptor(), cases: [{ ...descriptor().cases[0]!, extra: 1 }] }, "extra");
|
|
86
|
-
assert.deepEqual([...CASE_KINDS], ["relay-unavailable", "positive-lens"]);
|
|
95
|
+
assert.deepEqual([...CASE_KINDS], ["relay-unavailable", "positive-lens", ...PROVIDER_ROLE_VECTOR_KINDS]);
|
|
87
96
|
rejects({ ...descriptor(), cases: [{ ...descriptor().cases[0]!, kind: "bogus" }] }, "kind must be one of");
|
|
88
97
|
const dup = { ...descriptor().cases[0]!, name: "dup" };
|
|
89
98
|
rejects({ ...descriptor(), cases: [structuredClone(dup), structuredClone(dup)] }, "duplicated");
|
|
@@ -108,6 +117,214 @@ test("loadDescriptor reads and validates a descriptor file", (t) => {
|
|
|
108
117
|
assert.throws(() => loadDescriptor(join(dirname(path), "bad.json")), /not valid JSON/);
|
|
109
118
|
});
|
|
110
119
|
|
|
120
|
+
function roleDescriptor(kind: "provider-role-refuter" | "provider-role-validator", overrides: Record<string, unknown> = {}) {
|
|
121
|
+
const tokens = kind === "provider-role-refuter" ? REFUTER_TOKENS : VALIDATOR_TOKENS;
|
|
122
|
+
const entry: Record<string, unknown> = { name: `${kind}-case`, kind, argumentTokens: [...tokens] };
|
|
123
|
+
if (kind === "provider-role-validator") entry.validationRequest = structuredClone(VALIDATION_REQUEST);
|
|
124
|
+
return { schema: DESCRIPTOR_SCHEMA, gentleAiExecutable: PLACEHOLDER_BINARY, piExecutable: "pi", cases: [entry], ...overrides };
|
|
125
|
+
}
|
|
126
|
+
const ROLE_STUB_BINARY = sandboxPath("gentle-ai-role-stub");
|
|
127
|
+
writeFileSync(ROLE_STUB_BINARY, "stub");
|
|
128
|
+
test("role vector descriptors validate and return a normalized copy with exact provider tokens", () => {
|
|
129
|
+
assert.deepEqual([...PROVIDER_ROLE_VECTOR_KINDS], ["provider-role-refuter", "provider-role-validator"]);
|
|
130
|
+
assert.deepEqual(PROVIDER_ROLE_VECTOR_VERB, { "provider-role-refuter": "capture-refuter", "provider-role-validator": "capture-validation" });
|
|
131
|
+
assert.deepEqual(PROVIDER_ROLE_VECTOR_ROLE, { "provider-role-refuter": "refuter", "provider-role-validator": "targeted-validator" });
|
|
132
|
+
const refuter = validateDescriptor(roleDescriptor("provider-role-refuter")).cases[0]!;
|
|
133
|
+
assert.equal(refuter.kind, "provider-role-refuter");
|
|
134
|
+
assert.deepEqual(refuter.argumentTokens, [...REFUTER_TOKENS]);
|
|
135
|
+
assert.equal("validationRequest" in refuter, false);
|
|
136
|
+
assert.equal("captureArgumentTokens" in refuter, false);
|
|
137
|
+
assert.equal("submission" in refuter, false);
|
|
138
|
+
const validator = validateDescriptor(roleDescriptor("provider-role-validator")).cases[0]!;
|
|
139
|
+
assert.deepEqual(validator.argumentTokens, [...VALIDATOR_TOKENS]);
|
|
140
|
+
assert.deepEqual(validator.validationRequest, VALIDATION_REQUEST);
|
|
141
|
+
});
|
|
142
|
+
test("role vector descriptors require exactly one --agent=pi and --execute=true, never a lens slot", () => {
|
|
143
|
+
for (const [name, argumentTokens, expected] of [
|
|
144
|
+
["missing agent", REFUTER_TOKENS.filter((t) => t !== "--agent=pi"), "--agent=pi"], ["duplicate agent", [...REFUTER_TOKENS, "--agent=pi"], "--agent=pi"],
|
|
145
|
+
["conflicting agent", [...REFUTER_TOKENS, "--agent=other"], "--agent=pi"], ["missing execute", REFUTER_TOKENS.filter((t) => t !== "--execute=true"), "--execute=true"],
|
|
146
|
+
["duplicate execute", [...REFUTER_TOKENS, "--execute=true"], "--execute=true"], ["conflicting execute", [...REFUTER_TOKENS, "--execute=false"], "--execute=true"],
|
|
147
|
+
] as const) {
|
|
148
|
+
rejects(roleDescriptor("provider-role-refuter", { cases: [{ name, kind: "provider-role-refuter", argumentTokens: [...argumentTokens] }] }), expected);
|
|
149
|
+
}
|
|
150
|
+
rejects(roleDescriptor("provider-role-refuter", { cases: [{ name: "r", kind: "provider-role-refuter", argumentTokens: [...REFUTER_TOKENS, "--materialize=true"] }] }), "--materialize=true");
|
|
151
|
+
});
|
|
152
|
+
test("targeted-validator descriptor requires exactly one request hash matching validationRequest", () => {
|
|
153
|
+
const mismatched = { ...structuredClone(VALIDATION_REQUEST), requestHash: `sha256:${"0".repeat(64)}` };
|
|
154
|
+
rejects(roleDescriptor("provider-role-validator", { cases: [{ name: "v", kind: "provider-role-validator", argumentTokens: [...VALIDATOR_TOKENS], validationRequest: mismatched }] }), "must include exactly one");
|
|
155
|
+
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");
|
|
156
|
+
rejects(roleDescriptor("provider-role-validator", { cases: [{ name: "v", kind: "provider-role-validator", argumentTokens: [...VALIDATOR_TOKENS] }] }), "validationRequest");
|
|
157
|
+
rejects(roleDescriptor("provider-role-refuter", { cases: [{ name: "r", kind: "provider-role-refuter", argumentTokens: [...REFUTER_TOKENS], validationRequest: structuredClone(VALIDATION_REQUEST) }] }), "validationRequest");
|
|
158
|
+
const badHash = { ...structuredClone(VALIDATION_REQUEST), requestHash: "not-a-sha256" };
|
|
159
|
+
rejects(roleDescriptor("provider-role-validator", { cases: [{ name: "v", kind: "provider-role-validator", argumentTokens: [...VALIDATOR_TOKENS], validationRequest: badHash }] }), "sha256");
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("refuter vector: runMatrix executes exactly once through review.capture-refuter with exact provider tokens and returns the typed artifact", async () => {
|
|
163
|
+
const calls: Array<{ kind: string; argumentTokens: readonly string[]; gentleAiExecutable: string; validationRequest?: unknown }> = [];
|
|
164
|
+
const [verdict] = await runMatrix(validateDescriptor({ ...roleDescriptor("provider-role-refuter"), gentleAiExecutable: ROLE_STUB_BINARY }), {
|
|
165
|
+
armPositive: true,
|
|
166
|
+
roleRunner: async (request) => {
|
|
167
|
+
calls.push({ kind: request.kind, argumentTokens: request.argumentTokens, gentleAiExecutable: request.gentleAiExecutable, ...(request.validationRequest === undefined ? {} : { validationRequest: request.validationRequest }) });
|
|
168
|
+
return { schema: PROVIDER_ROLE_CAPTURE_ARTIFACT_SCHEMA, lineageId: ROLE_LINEAGE, targetIdentity: ROLE_TARGET, role: "refuter", captured: true };
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
assert.equal(calls.length, 1, "the refuter vector must execute exactly once");
|
|
172
|
+
assert.equal(calls[0]!.kind, "provider-role-refuter");
|
|
173
|
+
assert.equal("validationRequest" in calls[0]!, false, "the refuter vector must not carry a validation_request");
|
|
174
|
+
assert.deepEqual(calls[0]!.argumentTokens, [...REFUTER_TOKENS]);
|
|
175
|
+
assert.ok(calls[0]!.argumentTokens.includes("--execute=true"));
|
|
176
|
+
assert.ok(!calls[0]!.argumentTokens.includes("--materialize=true"));
|
|
177
|
+
assert.equal(calls[0]!.gentleAiExecutable, ROLE_STUB_BINARY);
|
|
178
|
+
assert.equal(verdict!.verdict, "pass");
|
|
179
|
+
assert.equal(verdict!.role, "refuter");
|
|
180
|
+
assert.equal(verdict!.captured, true);
|
|
181
|
+
assert.equal(verdict!.lineageId, ROLE_LINEAGE);
|
|
182
|
+
assert.equal(verdict!.targetIdentity, ROLE_TARGET);
|
|
183
|
+
});
|
|
184
|
+
test("validator vector: runMatrix executes exactly once through review.capture-validation and preserves the request-hash binding", async () => {
|
|
185
|
+
const calls: Array<{ kind: string; argumentTokens: readonly string[]; validationRequest?: unknown }> = [];
|
|
186
|
+
const [verdict] = await runMatrix(validateDescriptor({ ...roleDescriptor("provider-role-validator"), gentleAiExecutable: ROLE_STUB_BINARY }), {
|
|
187
|
+
armPositive: true,
|
|
188
|
+
roleRunner: async (request) => {
|
|
189
|
+
calls.push({ kind: request.kind, argumentTokens: request.argumentTokens, ...(request.validationRequest === undefined ? {} : { validationRequest: request.validationRequest }) });
|
|
190
|
+
return { schema: PROVIDER_ROLE_CAPTURE_ARTIFACT_SCHEMA, lineageId: ROLE_LINEAGE, targetIdentity: ROLE_TARGET, role: "targeted-validator", captured: true };
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
assert.equal(calls.length, 1, "the validator vector must execute exactly once");
|
|
194
|
+
assert.equal(calls[0]!.kind, "provider-role-validator");
|
|
195
|
+
assert.deepEqual(calls[0]!.argumentTokens, [...VALIDATOR_TOKENS]);
|
|
196
|
+
assert.ok(calls[0]!.argumentTokens.includes(`--request-hash=${ROLE_REQUEST_HASH}`));
|
|
197
|
+
assert.deepEqual(calls[0]!.validationRequest, VALIDATION_REQUEST);
|
|
198
|
+
assert.equal(verdict!.verdict, "pass");
|
|
199
|
+
assert.equal(verdict!.role, "targeted-validator");
|
|
200
|
+
assert.equal(verdict!.captured, true);
|
|
201
|
+
});
|
|
202
|
+
test("an unarmed role vector blocks loudly and never fakes green (no model launch)", async () => {
|
|
203
|
+
const calls: unknown[] = [];
|
|
204
|
+
const [verdict] = await runMatrix(validateDescriptor({ ...roleDescriptor("provider-role-refuter"), gentleAiExecutable: ROLE_STUB_BINARY }), {
|
|
205
|
+
roleRunner: async () => { calls.push("ran"); return ROLE_ARTIFACT; },
|
|
206
|
+
});
|
|
207
|
+
assert.equal(calls.length, 0, "an unarmed role vector must never reach the runner");
|
|
208
|
+
assert.equal(verdict!.verdict, "blocked");
|
|
209
|
+
assert.match(verdict!.reason, new RegExp(ARM_POSITIVE_ENV));
|
|
210
|
+
assert.equal(verdict!.command, POSITIVE_JOURNEY_COMMAND);
|
|
211
|
+
});
|
|
212
|
+
test("handshake refusal reports relay-contract negotiation, distinct from a missing role surface", async () => {
|
|
213
|
+
for (const [kind, message] of [[ROLE_VECTOR_FAILURE.HANDSHAKE_REFUSED, "relay handshake refused"], [ROLE_VECTOR_FAILURE.ROLE_SURFACE_UNAVAILABLE, "missing role surface"]] as const) {
|
|
214
|
+
const [verdict] = await runMatrix(validateDescriptor({ ...roleDescriptor("provider-role-refuter"), gentleAiExecutable: ROLE_STUB_BINARY }), { armPositive: true, roleRunner: async () => { throw new ProviderRoleVectorError(kind, "execute", message); } });
|
|
215
|
+
assert.equal(verdict!.verdict, "blocked"); assert.match(verdict!.reason, kind === ROLE_VECTOR_FAILURE.HANDSHAKE_REFUSED ? /relay-contract negotiation/ : /provider role capture surface/);
|
|
216
|
+
}
|
|
217
|
+
});
|
|
218
|
+
test("negative control: an unsupported role surface fails closed with zero role invocation and no mutation", async () => {
|
|
219
|
+
const calls: unknown[] = [];
|
|
220
|
+
const [verdict] = await runMatrix(validateDescriptor({ ...roleDescriptor("provider-role-refuter"), gentleAiExecutable: ROLE_STUB_BINARY }), {
|
|
221
|
+
armPositive: true,
|
|
222
|
+
roleRunner: async (request) => {
|
|
223
|
+
calls.push(request);
|
|
224
|
+
throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.ROLE_SURFACE_UNAVAILABLE, "execute", "the declared gentle-ai binary lacks the provider role capture surface");
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
assert.equal(calls.length, 1, "the runner was probed exactly once to detect the missing surface");
|
|
228
|
+
assert.equal(verdict!.verdict, "blocked");
|
|
229
|
+
assert.equal(verdict!.mutationOutcome, "none");
|
|
230
|
+
assert.match(verdict!.reason, /provider role capture surface/);
|
|
231
|
+
assert.equal(verdict!.command, POSITIVE_JOURNEY_COMMAND);
|
|
232
|
+
assert.notEqual(verdict!.verdict, "pass");
|
|
233
|
+
assert.notEqual(verdict!.verdict, "fail");
|
|
234
|
+
});
|
|
235
|
+
test("negative control: a role vector failure (not surface-unavailable) is reported as fail, never pass", async () => {
|
|
236
|
+
const [verdict] = await runMatrix(validateDescriptor({ ...roleDescriptor("provider-role-refuter"), gentleAiExecutable: ROLE_STUB_BINARY }), {
|
|
237
|
+
armPositive: true,
|
|
238
|
+
roleRunner: async () => {
|
|
239
|
+
throw new ProviderRoleVectorError(ROLE_VECTOR_FAILURE.ROLE_FAILED, "execute", "gentle-ai role vector failed");
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
assert.equal(verdict!.verdict, "fail");
|
|
243
|
+
assert.match(verdict!.reason, /role-failed/);
|
|
244
|
+
});
|
|
245
|
+
test("runProviderRoleVector treats a prelaunch abort as not-started with no mutation", async () => {
|
|
246
|
+
const controller = new AbortController(); controller.abort();
|
|
247
|
+
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");
|
|
248
|
+
});
|
|
249
|
+
function roleChild(t: test.TestContext, name: string, source: string) {
|
|
250
|
+
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}`);
|
|
251
|
+
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)}` } };
|
|
252
|
+
}
|
|
253
|
+
test("runProviderRoleVector executes both real stub-child verbs and decodes snake_case artifacts", async (t) => {
|
|
254
|
+
for (const kind of PROVIDER_ROLE_VECTOR_KINDS) {
|
|
255
|
+
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);`);
|
|
256
|
+
const artifact = await runProviderRoleVector({ kind, argumentTokens: kind === "provider-role-refuter" ? REFUTER_TOKENS : VALIDATOR_TOKENS, gentleAiExecutable: child.executable, env: child.env });
|
|
257
|
+
assert.deepEqual(JSON.parse(readFileSync(log, "utf8")), ["review", PROVIDER_ROLE_VECTOR_VERB[kind], ...(kind === "provider-role-refuter" ? REFUTER_TOKENS : VALIDATOR_TOKENS)]);
|
|
258
|
+
assert.deepEqual(artifact, { schema: PROVIDER_ROLE_CAPTURE_ARTIFACT_SCHEMA, lineageId: ROLE_LINEAGE, targetIdentity: ROLE_TARGET, role: PROVIDER_ROLE_VECTOR_ROLE[kind], captured: true });
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
test("runProviderRoleVector rejects malformed artifact binding shape before stale binding comparison", async (t) => {
|
|
262
|
+
for (const malformed of [{ lineage_id: "", target_identity: ROLE_TARGET }, { lineage_id: ROLE_LINEAGE, target_identity: "not-a-sha256" }]) {
|
|
263
|
+
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);`);
|
|
264
|
+
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));
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
test("runProviderRoleVector classifies an empty successful artifact as unknown and requires STATUS re-query", async (t) => {
|
|
268
|
+
const child = roleChild(t, "empty-artifact.cjs", "process.exit(0);");
|
|
269
|
+
await assert.rejects(runProviderRoleVector({ kind: "provider-role-refuter", argumentTokens: REFUTER_TOKENS, gentleAiExecutable: child.executable, env: child.env }), (caught) => {
|
|
270
|
+
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;
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
test("runProviderRoleVector bounds each output stream before JSON or exit handling", async (t) => {
|
|
274
|
+
for (const stream of ["stdout", "stderr"] as const) {
|
|
275
|
+
const child = roleChild(t, `overflow-${stream}.cjs`, `process.${stream}.write("x".repeat(${ROLE_STREAM_MAX_BYTES + 1})); setInterval(() => {}, 1_000);`);
|
|
276
|
+
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");
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
async function descendantPid(path: string) {
|
|
280
|
+
const deadline = Date.now() + 500;
|
|
281
|
+
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)); }
|
|
282
|
+
assert.fail("fixture did not report a positive descendant PID");
|
|
283
|
+
}
|
|
284
|
+
async function assertReaped(pid: number) {
|
|
285
|
+
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; }
|
|
286
|
+
assert.fail("role termination left a deterministic child-process descendant running");
|
|
287
|
+
}
|
|
288
|
+
test("role watchdog and abort reap descendants on every platform", async (t) => {
|
|
289
|
+
for (const mode of ["watchdog", "abort"] as const) await t.test(mode, async (t) => {
|
|
290
|
+
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();
|
|
291
|
+
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);
|
|
292
|
+
t.after(() => { if (pid > 0) try { process.kill(pid, "SIGKILL"); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; } });
|
|
293
|
+
assert.ok(DEFAULT_ROLE_VECTOR_TIMEOUT_MS > 600_000, "the outer 900s watchdog FIRES after the provider-owned 600s deadline with setup/cancellation margin");
|
|
294
|
+
if (mode === "abort") controller.abort();
|
|
295
|
+
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");
|
|
296
|
+
await assertReaped(pid);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
test("failed tree termination settles despite a surviving descendant retaining inherited pipes", async (t) => {
|
|
300
|
+
const grandchild = sandboxPath("role-pipe-holder.pid"), controller = new AbortController();
|
|
301
|
+
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);`);
|
|
302
|
+
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; } });
|
|
303
|
+
const pid = await descendantPid(grandchild); t.after(() => { try { process.kill(pid, "SIGKILL"); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; } });
|
|
304
|
+
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");
|
|
305
|
+
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");
|
|
306
|
+
});
|
|
307
|
+
test("stale-target fail-closed: missing or duplicate required binding tokens are rejected before runner invocation", () => {
|
|
308
|
+
const drop = (tokens: readonly string[], prefix: string) => tokens.filter((t) => !t.startsWith(prefix));
|
|
309
|
+
for (const prefix of ["--lineage=", "--target=", "--expected-revision=", "--repository-context="]) {
|
|
310
|
+
rejects(roleDescriptor("provider-role-refuter", { cases: [{ name: "r", kind: "provider-role-refuter", argumentTokens: drop(REFUTER_TOKENS, prefix) }] }), `exactly one "${prefix}`);
|
|
311
|
+
}
|
|
312
|
+
rejects(roleDescriptor("provider-role-refuter", { cases: [{ name: "r", kind: "provider-role-refuter", argumentTokens: [...REFUTER_TOKENS, `--lineage=${ROLE_LINEAGE}`] }] }), "exactly one \"--lineage=\"");
|
|
313
|
+
const badTarget = REFUTER_TOKENS.map((t) => t.startsWith("--target=") ? "--target=not-a-sha256" : t);
|
|
314
|
+
rejects(roleDescriptor("provider-role-refuter", { cases: [{ name: "r", kind: "provider-role-refuter", argumentTokens: badTarget }] }), "malformed");
|
|
315
|
+
});
|
|
316
|
+
test("stale-target fail-closed: a returned artifact with mismatched lineage or target fails, never passes", async () => {
|
|
317
|
+
const stale = `sha256:${"f".repeat(64)}`;
|
|
318
|
+
for (const [lineageId, targetIdentity] of [[stale, ROLE_TARGET], [ROLE_LINEAGE, stale]] as const) {
|
|
319
|
+
const [verdict] = await runMatrix(validateDescriptor({ ...roleDescriptor("provider-role-refuter"), gentleAiExecutable: ROLE_STUB_BINARY }), {
|
|
320
|
+
armPositive: true,
|
|
321
|
+
roleRunner: async () => ({ schema: PROVIDER_ROLE_CAPTURE_ARTIFACT_SCHEMA, lineageId, targetIdentity, role: "refuter", captured: true }),
|
|
322
|
+
});
|
|
323
|
+
assert.equal(verdict!.verdict, "fail");
|
|
324
|
+
assert.match(verdict!.reason, /stale artifact/);
|
|
325
|
+
}
|
|
326
|
+
});
|
|
327
|
+
|
|
111
328
|
// ---------------------------------------------------------------------------
|
|
112
329
|
// No-production-resolution — the runner uses only declared executables and
|
|
113
330
|
// never falls through to the package-local production binary.
|
|
@@ -187,6 +404,125 @@ test("a relay-unavailable case against a CAPABLE binary fails closed at pi: zero
|
|
|
187
404
|
// read-only (mutationOutcome stays "none" before submit).
|
|
188
405
|
assert.equal(existsSync(stub.materializeLog), true);
|
|
189
406
|
});
|
|
407
|
+
// ---------------------------------------------------------------------------
|
|
408
|
+
// Resolve-once (POSIX subprocess proof) — a bare Pi declaration is resolved
|
|
409
|
+
// exactly once at the precheck and never re-resolved between precheck and
|
|
410
|
+
// launch. Deterministic local stub scenario: the first PATH candidate is
|
|
411
|
+
// resolved at precheck, a capable gentle-ai removes it during materialization,
|
|
412
|
+
// and a second same-name PATH candidate remains. The fixed code must attempt
|
|
413
|
+
// only the first concrete path (now gone) and fail closed — never fall through
|
|
414
|
+
// to launch the second. This is a POSIX shebang/executable fixture: it spawns
|
|
415
|
+
// real subprocesses with `shell:false`, which cannot execute `.bat`/`.cmd`
|
|
416
|
+
// launchers on Windows. Windows native launcher execution is #311 P8 evidence,
|
|
417
|
+
// not silently claimed here.
|
|
418
|
+
// ---------------------------------------------------------------------------
|
|
419
|
+
test("a bare Pi declaration is resolved once: the relay never falls through to a second PATH candidate after materialization removes the first", { skip: process.platform === "win32" && "POSIX shebang/executable subprocess fixture; Windows .cmd launcher execution (shell:false) is #311 P8 evidence, not claimed here" }, async (t) => {
|
|
420
|
+
const root = mkdtempSync(join(tmpdir(), "gentle-pi-maintainer-resolve-once-"));
|
|
421
|
+
chmodSync(root, 0o700);
|
|
422
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
423
|
+
const dirA = join(root, "path-a");
|
|
424
|
+
const dirB = join(root, "path-b");
|
|
425
|
+
mkdirSync(dirA, { recursive: true });
|
|
426
|
+
mkdirSync(dirB, { recursive: true });
|
|
427
|
+
chmodSync(dirA, 0o700);
|
|
428
|
+
chmodSync(dirB, 0o700);
|
|
429
|
+
const firstPiLog = join(root, "pi-first-launched");
|
|
430
|
+
const secondPiLog = join(root, "pi-second-launched");
|
|
431
|
+
const firstPi = join(dirA, "pi");
|
|
432
|
+
const secondPi = join(dirB, "pi");
|
|
433
|
+
// First PATH candidate: the one the precheck resolves.
|
|
434
|
+
writeFileSync(firstPi, `#!/usr/bin/env node\nrequire("node:fs").writeFileSync(${JSON.stringify(firstPiLog)}, "launched");\nprocess.exit(0);\n`);
|
|
435
|
+
chmodSync(firstPi, 0o755);
|
|
436
|
+
// Second PATH candidate: must NEVER launch under the fix.
|
|
437
|
+
writeFileSync(secondPi, `#!/usr/bin/env node\nrequire("node:fs").writeFileSync(${JSON.stringify(secondPiLog)}, "launched");\nprocess.exit(0);\n`);
|
|
438
|
+
chmodSync(secondPi, 0o755);
|
|
439
|
+
// A capable gentle-ai that removes the FIRST pi candidate during
|
|
440
|
+
// materialization, simulating a provider that cleans up its own runtime
|
|
441
|
+
// while a same-name second candidate remains on PATH.
|
|
442
|
+
const gentleAi = join(root, "gentle-ai");
|
|
443
|
+
writeFileSync(gentleAi, `#!/usr/bin/env node\nconst fs = require("node:fs");\nconst argv = process.argv.slice(2);\nif (argv.includes("--materialize=true")) {\n\ttry { fs.rmSync(${JSON.stringify(firstPi)}, { force: true }); } catch {}\n\tprocess.stdout.write("prompt-bytes");\n\tprocess.exit(0);\n}\nprocess.stdout.write(JSON.stringify({ schema: "gentle-ai.review-result-artifact/v2", admission_decision: "completed" }));\nprocess.exit(0);\n`);
|
|
444
|
+
chmodSync(gentleAi, 0o755);
|
|
445
|
+
const savedPath = process.env.PATH;
|
|
446
|
+
process.env.PATH = [dirA, dirB, savedPath].join(delimiter);
|
|
447
|
+
t.after(() => { process.env.PATH = savedPath; });
|
|
448
|
+
const [verdict] = await runMatrix(validateDescriptor({
|
|
449
|
+
...descriptor(),
|
|
450
|
+
gentleAiExecutable: gentleAi,
|
|
451
|
+
piExecutable: "pi",
|
|
452
|
+
cases: [{ name: "resolve-once", kind: "positive-lens", captureArgumentTokens: [...CAPTURE_TOKENS], submission: structuredClone(SUBMISSION) }],
|
|
453
|
+
}), { armPositive: true });
|
|
454
|
+
// The first pi was removed during materialization, so the relay must
|
|
455
|
+
// fail closed at pi-launch — never fall through to the second candidate.
|
|
456
|
+
assert.equal(verdict!.verdict, "fail");
|
|
457
|
+
assert.match(verdict!.reason, /pi-launch-failed/);
|
|
458
|
+
// The load-bearing assertion: the second PATH candidate never launched.
|
|
459
|
+
assert.equal(existsSync(secondPiLog), false, "the relay must never fall through to a second PATH candidate; the bare declaration is resolved once at precheck");
|
|
460
|
+
assert.equal(existsSync(firstPiLog), false, "the first candidate was removed during materialization and must not launch");
|
|
461
|
+
});
|
|
462
|
+
// ---------------------------------------------------------------------------
|
|
463
|
+
// Resolve-once (platform-neutral boundary proof) — runs on Windows AND POSIX.
|
|
464
|
+
// Proves runMatrix passes the first resolved concrete Pi path into the relay
|
|
465
|
+
// boundary, not the bare declaration and not a second PATH candidate. Uses the
|
|
466
|
+
// smallest dependency-injection seam in the existing options object: an
|
|
467
|
+
// optional `relay` function defaulting to the real relay. The injected fake
|
|
468
|
+
// records request.piExecutable and never executes a real subprocess, so no
|
|
469
|
+
// shebang/chmod/.cmd execution is involved. No real model or network.
|
|
470
|
+
// ---------------------------------------------------------------------------
|
|
471
|
+
test("platform-neutral: runMatrix passes the first resolved concrete Pi path into the relay boundary (no second resolution)", async (t) => {
|
|
472
|
+
// Keep the fixture on the same Windows volume as process.cwd() so the
|
|
473
|
+
// first PATH component genuinely exercises relative-path normalization.
|
|
474
|
+
const root = mkdtempSync(join(process.cwd(), ".gentle-pi-maintainer-portable-"));
|
|
475
|
+
chmodSync(root, 0o700);
|
|
476
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
477
|
+
const dirA = join(root, "path-a");
|
|
478
|
+
const dirB = join(root, "path-b");
|
|
479
|
+
mkdirSync(dirA, { recursive: true });
|
|
480
|
+
mkdirSync(dirB, { recursive: true });
|
|
481
|
+
chmodSync(dirA, 0o700);
|
|
482
|
+
chmodSync(dirB, 0o700);
|
|
483
|
+
const firstPi = join(dirA, "pi");
|
|
484
|
+
const secondPi = join(dirB, "pi");
|
|
485
|
+
// Ordinary files (no shebang/chmod needed): the injected relay never
|
|
486
|
+
// executes them. Only the precheck's existsSync/statSync touches them.
|
|
487
|
+
writeFileSync(firstPi, "first");
|
|
488
|
+
writeFileSync(secondPi, "second");
|
|
489
|
+
const gentleAi = join(root, "gentle-ai");
|
|
490
|
+
writeFileSync(gentleAi, "gentle-ai");
|
|
491
|
+
// First PATH component is RELATIVE to process.cwd(): a relative component
|
|
492
|
+
// must still normalize to the same absolute firstPi the precheck resolved.
|
|
493
|
+
const dirARelative = relative(process.cwd(), dirA);
|
|
494
|
+
assert.equal(isAbsolute(dirARelative), false, "fixture must keep the first PATH component relative");
|
|
495
|
+
const savedPath = process.env.PATH;
|
|
496
|
+
process.env.PATH = [dirARelative, dirB, savedPath].join(delimiter);
|
|
497
|
+
t.after(() => { process.env.PATH = savedPath; });
|
|
498
|
+
let receivedPiExecutable: string | undefined;
|
|
499
|
+
const [verdict] = await runMatrix(validateDescriptor({
|
|
500
|
+
...descriptor(),
|
|
501
|
+
gentleAiExecutable: gentleAi,
|
|
502
|
+
piExecutable: "pi",
|
|
503
|
+
cases: [{ name: "portable-resolve-once", kind: "positive-lens", captureArgumentTokens: [...CAPTURE_TOKENS], submission: structuredClone(SUBMISSION) }],
|
|
504
|
+
}), {
|
|
505
|
+
armPositive: true,
|
|
506
|
+
relay: async (request) => {
|
|
507
|
+
receivedPiExecutable = request.piExecutable;
|
|
508
|
+
// Simulate materialization removing the first candidate; the
|
|
509
|
+
// path was captured at precheck and must not be re-resolved.
|
|
510
|
+
rmSync(firstPi, { force: true });
|
|
511
|
+
return { promptByteLength: 1, resultByteLength: 1, submission: "{}" };
|
|
512
|
+
},
|
|
513
|
+
});
|
|
514
|
+
// The relay received the EXACT first concrete resolved path — equality to
|
|
515
|
+
// the absolute fixture proves normalization held under a relative PATH.
|
|
516
|
+
assert.equal(receivedPiExecutable, firstPi);
|
|
517
|
+
assert.notEqual(receivedPiExecutable, secondPi);
|
|
518
|
+
assert.notEqual(receivedPiExecutable, "pi");
|
|
519
|
+
assert.equal(verdict!.verdict, "pass");
|
|
520
|
+
// The first candidate was removed during the relay call; the second is
|
|
521
|
+
// untouched, proving no fallthrough resolution occurred.
|
|
522
|
+
assert.equal(existsSync(firstPi), false);
|
|
523
|
+
assert.equal(existsSync(secondPi), true);
|
|
524
|
+
});
|
|
525
|
+
|
|
190
526
|
// ---------------------------------------------------------------------------
|
|
191
527
|
// Negative control — a baseline runtime without --materialize fails closed as
|
|
192
528
|
// relay-unavailable with zero Pi launch and zero submission. The baseline is
|