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,17 @@
|
|
|
1
|
+
// Generated by scripts/build-git-commit-transaction-runner.mjs. Do not edit.
|
|
2
|
+
// gentle-pi.review-relay/v1 — the Pi host relay handshake (gentle-pi#311 P4,
|
|
3
|
+
// provider side gentle-ai#3249/#3254).
|
|
4
|
+
//
|
|
5
|
+
// gentle-ai admits `pi` as a host-mediated runtime identity only when the
|
|
6
|
+
// launcher that relays the invocation declares this exact contract through
|
|
7
|
+
// this exact environment variable. Any other value (or none) keeps Pi
|
|
8
|
+
// fail-closed at admission, before any repository, target, or authority work.
|
|
9
|
+
// That refusal is correct provider behavior and is never worked around here;
|
|
10
|
+
// the single central gentle-ai process runner injects this declaration on
|
|
11
|
+
// every invocation the extension makes.
|
|
12
|
+
//
|
|
13
|
+
// Both values are compiled constants: the contract identity is versioned by
|
|
14
|
+
// release, never by configuration.
|
|
15
|
+
|
|
16
|
+
export const GENTLE_PI_REVIEW_RELAY_CONTRACT_ENV = "GENTLE_PI_REVIEW_RELAY_CONTRACT";
|
|
17
|
+
export const GENTLE_PI_REVIEW_RELAY_CONTRACT = "gentle-pi.review-relay/v1";
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Offline drift check for the mirrored gentle-ai provider contract bundle
|
|
3
|
+
// (gentle-pi#311 P2).
|
|
4
|
+
//
|
|
5
|
+
// Fails when the mirror, the lock record, the schemas, the vectors, or the
|
|
6
|
+
// generated consumer baselines disagree in any direction. Runs completely
|
|
7
|
+
// offline against `contracts/review-provider-contract-mirror/` and never
|
|
8
|
+
// touches the network or the Gentle AI release pin.
|
|
9
|
+
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
14
|
+
import {
|
|
15
|
+
generateProviderContractBaselines,
|
|
16
|
+
piRuntimeRegistration,
|
|
17
|
+
verifyProviderContractBundleTree,
|
|
18
|
+
} from "../lib/provider-contract-bundle.ts";
|
|
19
|
+
import {
|
|
20
|
+
PROVIDER_CONTRACT_ACQUISITION,
|
|
21
|
+
PROVIDER_CONTRACT_LOCK_FILE,
|
|
22
|
+
PROVIDER_CONTRACT_LOCK_SCHEMA,
|
|
23
|
+
PROVIDER_CONTRACT_MIRROR_ROOT,
|
|
24
|
+
} from "./mirror-provider-contract.mjs";
|
|
25
|
+
|
|
26
|
+
function sha256Hex(bytes) {
|
|
27
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function recordEquals(problems, label, expected, actual) {
|
|
31
|
+
const expectedKeys = Object.keys(expected).sort();
|
|
32
|
+
const actualKeys = Object.keys(actual).sort();
|
|
33
|
+
for (const key of expectedKeys) {
|
|
34
|
+
if (!(key in actual)) problems.push(`${label}: ${key} is recorded in the lock but absent from the mirror`);
|
|
35
|
+
else if (actual[key] !== expected[key]) problems.push(`${label}: ${key} drifted (lock ${expected[key]}, mirror ${actual[key]})`);
|
|
36
|
+
}
|
|
37
|
+
for (const key of actualKeys) {
|
|
38
|
+
if (!(key in expected)) problems.push(`${label}: ${key} exists in the mirror but is not recorded in the lock`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Returns a list of drift problems; an empty list means the mirror, lock,
|
|
43
|
+
// schemas, vectors, and generated baselines all agree.
|
|
44
|
+
export function checkProviderContractMirror(packageRoot) {
|
|
45
|
+
const problems = [];
|
|
46
|
+
const mirrorRoot = join(packageRoot, ...PROVIDER_CONTRACT_MIRROR_ROOT.split("/"));
|
|
47
|
+
const lockPath = join(mirrorRoot, PROVIDER_CONTRACT_LOCK_FILE);
|
|
48
|
+
if (!existsSync(lockPath)) {
|
|
49
|
+
return [`${PROVIDER_CONTRACT_MIRROR_ROOT}/${PROVIDER_CONTRACT_LOCK_FILE} is missing; run scripts/mirror-provider-contract.mjs against a verified local bundle`];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
let lock;
|
|
53
|
+
try {
|
|
54
|
+
lock = JSON.parse(readFileSync(lockPath, "utf8"));
|
|
55
|
+
} catch (error) {
|
|
56
|
+
return [`lock record is not valid JSON: ${error instanceof Error ? error.message : String(error)}`];
|
|
57
|
+
}
|
|
58
|
+
if (lock === null || typeof lock !== "object" || Array.isArray(lock)) return ["lock record is not a JSON object"];
|
|
59
|
+
if (lock.schema !== PROVIDER_CONTRACT_LOCK_SCHEMA) problems.push(`lock schema is ${JSON.stringify(lock.schema)}; expected ${JSON.stringify(PROVIDER_CONTRACT_LOCK_SCHEMA)}`);
|
|
60
|
+
if (lock.acquisition !== PROVIDER_CONTRACT_ACQUISITION) problems.push(`lock acquisition is ${JSON.stringify(lock.acquisition)}; expected ${JSON.stringify(PROVIDER_CONTRACT_ACQUISITION)}`);
|
|
61
|
+
if (typeof lock.contract_semver !== "string") return [...problems, "lock contract_semver is not a string"];
|
|
62
|
+
if (lock.pi_registered !== true) problems.push("lock does not record pi as a registered runtime identity");
|
|
63
|
+
|
|
64
|
+
// The mirror directory must contain exactly the lock and the locked version.
|
|
65
|
+
const expectedVersionDirectory = `v${lock.contract_semver}`;
|
|
66
|
+
for (const entry of readdirSync(mirrorRoot)) {
|
|
67
|
+
if (entry !== PROVIDER_CONTRACT_LOCK_FILE && entry !== expectedVersionDirectory) {
|
|
68
|
+
problems.push(`mirror has unexpected entry ${JSON.stringify(entry)}; only ${PROVIDER_CONTRACT_LOCK_FILE} and ${expectedVersionDirectory} belong there`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const versionRoot = join(mirrorRoot, expectedVersionDirectory);
|
|
73
|
+
let bundle;
|
|
74
|
+
try {
|
|
75
|
+
bundle = verifyProviderContractBundleTree(join(versionRoot, "bundle"));
|
|
76
|
+
} catch (error) {
|
|
77
|
+
return [...problems, `mirrored bundle failed verification: ${error instanceof Error ? error.message : String(error)}`];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
if (bundle.contractSemver !== lock.contract_semver) {
|
|
81
|
+
problems.push(`mirrored bundle declares contract ${bundle.contractSemver}; lock records ${lock.contract_semver}`);
|
|
82
|
+
}
|
|
83
|
+
if (bundle.treeSha256 !== lock.tree_sha256) {
|
|
84
|
+
problems.push(`mirrored bundle tree digest ${bundle.treeSha256} does not match lock tree_sha256 ${lock.tree_sha256}`);
|
|
85
|
+
}
|
|
86
|
+
recordEquals(problems, "bundle entries", lock.entries ?? {}, Object.fromEntries(bundle.entrySha256));
|
|
87
|
+
const lockRuntimes = Array.isArray(lock.runtimes) ? lock.runtimes : [];
|
|
88
|
+
const bundleRuntimes = [...(bundle.runtimes ?? [])];
|
|
89
|
+
if (JSON.stringify(lockRuntimes) !== JSON.stringify(bundleRuntimes)) {
|
|
90
|
+
problems.push(`lock runtimes ${JSON.stringify(lockRuntimes)} do not match mirrored manifest runtimes ${JSON.stringify(bundleRuntimes)}`);
|
|
91
|
+
}
|
|
92
|
+
if (!piRuntimeRegistration(bundle).registered) {
|
|
93
|
+
problems.push("mirrored bundle does not register the pi runtime identity");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Regenerate the consumer contract surface from the verified mirror and
|
|
97
|
+
// require byte-identical generated files plus matching lock digests.
|
|
98
|
+
const baselines = generateProviderContractBaselines(bundle);
|
|
99
|
+
const generatedRoot = join(versionRoot, "generated");
|
|
100
|
+
const onDisk = existsSync(generatedRoot) ? readdirSync(generatedRoot).sort() : [];
|
|
101
|
+
const expectedGenerated = [...baselines.keys()].sort();
|
|
102
|
+
for (const name of onDisk) {
|
|
103
|
+
if (!baselines.has(name)) problems.push(`generated/${name} exists on disk but is not a generated provider baseline`);
|
|
104
|
+
}
|
|
105
|
+
const actualGeneratedSha256 = {};
|
|
106
|
+
for (const name of expectedGenerated) {
|
|
107
|
+
const path = join(generatedRoot, name);
|
|
108
|
+
if (!existsSync(path)) {
|
|
109
|
+
problems.push(`generated/${name} is missing from the mirror`);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
const actual = readFileSync(path);
|
|
113
|
+
const expected = Buffer.from(baselines.get(name), "utf8");
|
|
114
|
+
if (!actual.equals(expected)) problems.push(`generated/${name} is not byte-identical to the baseline derived from the verified mirror`);
|
|
115
|
+
actualGeneratedSha256[`generated/${name}`] = sha256Hex(actual);
|
|
116
|
+
}
|
|
117
|
+
recordEquals(problems, "generated baselines", lock.generated ?? {}, actualGeneratedSha256);
|
|
118
|
+
|
|
119
|
+
return problems;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function main() {
|
|
123
|
+
const packageRoot = join(fileURLToPath(new URL("..", import.meta.url)));
|
|
124
|
+
const problems = checkProviderContractMirror(packageRoot);
|
|
125
|
+
if (problems.length > 0) {
|
|
126
|
+
console.error("gentle-pi provider contract mirror has drifted:");
|
|
127
|
+
for (const problem of problems) console.error(`- ${problem}`);
|
|
128
|
+
console.error("\nRe-run scripts/mirror-provider-contract.mjs against a verified local bundle, or restore the mirrored bytes.");
|
|
129
|
+
process.exit(1);
|
|
130
|
+
}
|
|
131
|
+
const lock = JSON.parse(readFileSync(join(packageRoot, ...PROVIDER_CONTRACT_MIRROR_ROOT.split("/"), PROVIDER_CONTRACT_LOCK_FILE), "utf8"));
|
|
132
|
+
console.log(`gentle-pi provider contract mirror check passed (contract ${lock.contract_semver}, ${Object.keys(lock.entries).length} bundle entries, ${Object.keys(lock.generated).length} generated baselines, acquisition ${lock.acquisition}).`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const isMainModule = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
136
|
+
if (isMainModule) {
|
|
137
|
+
await main();
|
|
138
|
+
}
|
|
@@ -19,24 +19,32 @@ import { execFile } from "node:child_process";
|
|
|
19
19
|
import { promisify } from "node:util";
|
|
20
20
|
|
|
21
21
|
const execFileAsync = promisify(execFile);
|
|
22
|
-
const RELEASE_BASE_URL = "https://github.com/Gentleman-Programming/gentle-ai/releases/download/v2.2.2/";
|
|
23
22
|
const MAX_DOWNLOAD_BYTES = 100 * 1024 * 1024;
|
|
24
23
|
const MAX_REDIRECTS = 3;
|
|
25
24
|
const DOWNLOAD_TIMEOUTS = { headers: 10_000, body: 30_000, attempts: 2, retryDelay: 100 };
|
|
26
25
|
const GO_COMMAND_TIMEOUT_MS = 120_000;
|
|
27
26
|
const GO_COMMAND_MAX_BUFFER = 1024 * 1024;
|
|
28
27
|
const WINDOWS_SYSTEM_ROOT = "C:\\Windows";
|
|
29
|
-
|
|
28
|
+
// The one authoritative pinned Gentle AI version. Every other location in this
|
|
29
|
+
// module (the release download URL, the Windows source tag, and the reported
|
|
30
|
+
// version check below) derives from this constant instead of repeating the
|
|
31
|
+
// literal, so a pin bump cannot leave a stale copy behind. See
|
|
32
|
+
// scripts/install-gentle-ai.mjs for the incident that motivated this.
|
|
33
|
+
export const INSTALLER_VERSION = "2.4.0";
|
|
34
|
+
export const RELEASE_BASE_URL = `https://github.com/Gentleman-Programming/gentle-ai/releases/download/v${INSTALLER_VERSION}/`;
|
|
30
35
|
export const GENTLE_AI_INSTALL_METHOD = Object.freeze({
|
|
31
36
|
SIGNED_RELEASE_ASSET: "signed-release-asset",
|
|
32
37
|
GO_SUMDB_SOURCE_BUILD: "go-sumdb-source-build",
|
|
33
38
|
});
|
|
34
39
|
export const GENTLE_AI_WINDOWS_SOURCE_PACKAGE_PATH = "github.com/gentleman-programming/gentle-ai/v2/cmd/gentle-ai";
|
|
35
40
|
export const GENTLE_AI_WINDOWS_SOURCE_MODULE = "github.com/gentleman-programming/gentle-ai/v2";
|
|
36
|
-
export const GENTLE_AI_WINDOWS_SOURCE_TAG =
|
|
37
|
-
// `go mod download -json github.com/gentleman-programming/gentle-ai/v2@v2.
|
|
38
|
-
// with GOSUMDB=sum.golang.org reports this exact module SumDB checksum.
|
|
39
|
-
|
|
41
|
+
export const GENTLE_AI_WINDOWS_SOURCE_TAG = `v${INSTALLER_VERSION}`;
|
|
42
|
+
// `go mod download -json github.com/gentleman-programming/gentle-ai/v2@v2.4.0`
|
|
43
|
+
// with GOSUMDB=sum.golang.org reports this exact module SumDB checksum. The
|
|
44
|
+
// same response resolves the tag to commit 301fb2ad, which is also the
|
|
45
|
+
// `vcs_revision` the published v2.4.0 binary self-reports in its capabilities
|
|
46
|
+
// envelope -- the two independent provenance paths agree.
|
|
47
|
+
export const GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:XKkcy+t76cyUKQnRwL1UnqHbhh8o416I8HQ1UsdvWSI=";
|
|
40
48
|
export const GENTLE_AI_WINDOWS_SOURCE_PACKAGE = `${GENTLE_AI_WINDOWS_SOURCE_PACKAGE_PATH}@${GENTLE_AI_WINDOWS_SOURCE_TAG}`;
|
|
41
49
|
export const GENTLE_AI_WINDOWS_MINIMUM_GO_VERSION = "1.25.10";
|
|
42
50
|
export const GENTLE_AI_GO_TOOLCHAIN_UNAVAILABLE_CODE = "GENTLE_AI_GO_TOOLCHAIN_UNAVAILABLE";
|
|
@@ -55,7 +63,7 @@ export class GentleAiInstallerError extends Error {
|
|
|
55
63
|
// Sentinel used while a re-pinned gentle-ai release is not yet published. A
|
|
56
64
|
// sentinel digest can never match a real SHA-256, so installation fails closed,
|
|
57
65
|
// and verify-package-files.mjs refuses to pack/publish while any digest below
|
|
58
|
-
// still holds it. The v2.
|
|
66
|
+
// still holds it. The v2.4.0 digests are pinned from the published release:
|
|
59
67
|
// archive sha256 values verified against the minisign-signed checksums.txt and
|
|
60
68
|
// freshly computed hashes; binary sha256 values computed from the extracted
|
|
61
69
|
// executables.
|
|
@@ -66,15 +74,15 @@ function asset(name, sha256, binarySha256, executable) {
|
|
|
66
74
|
}
|
|
67
75
|
|
|
68
76
|
// Windows is absent from signed release archives on purpose. gentle-ai stopped
|
|
69
|
-
// distributing unsigned Windows builds in c4b764d0, so v2.
|
|
77
|
+
// distributing unsigned Windows builds in c4b764d0, so v2.4.0 publishes signed
|
|
70
78
|
// Darwin/Linux archives only. Windows x64/arm64 uses the separately verified
|
|
71
79
|
// exact-tag Go SumDB source-build path below; restore archive rows only when
|
|
72
80
|
// upstream ships signed Windows assets.
|
|
73
81
|
export const GENTLE_AI_RELEASE_ASSETS = Object.freeze({
|
|
74
|
-
"darwin/amd64": asset("gentle-ai_2.
|
|
75
|
-
"darwin/arm64": asset("gentle-ai_2.
|
|
76
|
-
"linux/amd64": asset("gentle-ai_2.
|
|
77
|
-
"linux/arm64": asset("gentle-ai_2.
|
|
82
|
+
"darwin/amd64": asset("gentle-ai_2.4.0_darwin_amd64.tar.gz", "c3f5a4251e927c41864e7a9a5670ac3fce97c6282755ec8e9b2e7e064067f750", "c4d25b98bae0d06b33baa23ba997cb64ab63c2e93e97983bcb9c239139bceaba", "gentle-ai"),
|
|
83
|
+
"darwin/arm64": asset("gentle-ai_2.4.0_darwin_arm64.tar.gz", "2240ae797119dd1dc5a0dd2fd2711077ce6712e66ab0b67461344c983478f7f4", "54a8a5957ce4f10f36b46ef818c888f484a3e213a71faca6b7783af6c029eb77", "gentle-ai"),
|
|
84
|
+
"linux/amd64": asset("gentle-ai_2.4.0_linux_amd64.tar.gz", "517eb8420cf966b74be1413a7ea1036119a5ccbb376e0778aa9f4d4d283e2e9a", "0be446737f8f1a9458fd2891d488beb2d72786578afead9bbf2d6ddddc9ec113", "gentle-ai"),
|
|
85
|
+
"linux/arm64": asset("gentle-ai_2.4.0_linux_arm64.tar.gz", "1badec9cad1f2bbeb22b5c6804c3a88c601d844abae231264e416e8095f2a15f", "cdd8fa43c3417e844cc11d2e436d4667edb0a54f086e0eb1d6cd3f7493bfee5a", "gentle-ai"),
|
|
78
86
|
});
|
|
79
87
|
|
|
80
88
|
function upstreamArchitecture(architecture) {
|
|
@@ -330,11 +338,13 @@ async function verifyGoBuildMetadata(execute, goPath, binaryPath, environment, c
|
|
|
330
338
|
catch (error) { if (error instanceof GentleAiInstallerError) throw error; throw new GentleAiInstallerError(GENTLE_AI_GO_INSTALL_FAILED_CODE, "Gentle AI source build metadata could not be verified.", error); }
|
|
331
339
|
}
|
|
332
340
|
|
|
341
|
+
function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); }
|
|
342
|
+
|
|
333
343
|
async function assertExactGentleAiVersion(execute, binaryPath, environment, cwd) {
|
|
334
344
|
let result;
|
|
335
345
|
try { result = await runCommand(execute, binaryPath, ["version"], commandOptions(environment, cwd)); }
|
|
336
346
|
catch (error) { throw new GentleAiInstallerError(GENTLE_AI_VERSION_MISMATCH_CODE, `Gentle AI source build at ${binaryPath} could not report its version.`, error); }
|
|
337
|
-
if (
|
|
347
|
+
if (!new RegExp(`^gentle-ai ${escapeRegExp(INSTALLER_VERSION)}\\r?\\n?$`).test(commandOutput(result))) throw new GentleAiInstallerError(GENTLE_AI_VERSION_MISMATCH_CODE, `Gentle AI source build reported ${JSON.stringify(commandOutput(result).trim())}; expected gentle-ai ${INSTALLER_VERSION}.`);
|
|
338
348
|
}
|
|
339
349
|
|
|
340
350
|
function sameFile(before, after) { return before.dev === after.dev && before.ino === after.ino && before.size === after.size && before.mtimeMs === after.mtimeMs; }
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Maintainer provider-relay matrix (gentle-pi#311, first direct work unit).
|
|
3
|
+
//
|
|
4
|
+
// Read-only maintainer harness driving the REAL Pi host relay
|
|
5
|
+
// (lib/review-host-relay.ts#runReviewHostRelaySlot) through explicit
|
|
6
|
+
// maintainer runtime descriptors. Validates an EXACT descriptor shape, uses
|
|
7
|
+
// ONLY declared executables (never re-resolves the production binary), and
|
|
8
|
+
// emits one machine-readable NDJSON verdict per case. Not a production path:
|
|
9
|
+
// `pnpm test` never imports it; `pnpm run test:maintainer` runs the tests;
|
|
10
|
+
// the CLI is the entry point the separate verifier uses for the real organic
|
|
11
|
+
// positive journey after a user-visible forecast.
|
|
12
|
+
import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { isAbsolute, join } from "node:path";
|
|
15
|
+
import { REVIEW_HOST_RELAY_FAILURE, ReviewHostRelayError, resolveReviewHostRelaySubmission, runReviewHostRelaySlot } from "../../lib/review-host-relay.ts";
|
|
16
|
+
export const DESCRIPTOR_SCHEMA = "gentle-pi.maintainer.provider-relay-descriptor/v1";
|
|
17
|
+
export const CASE_KINDS = Object.freeze(["relay-unavailable", "positive-lens"]);
|
|
18
|
+
// The positive lens runs only when explicitly armed: the organic journey
|
|
19
|
+
// needs a real capable binary, a real pi, and a real review session. Without
|
|
20
|
+
// the arm the runner blocks the positive leg before materialize.
|
|
21
|
+
export const ARM_POSITIVE_ENV = "GENTLE_PI_MAINTAINER_ARM_POSITIVE";
|
|
22
|
+
export const POSITIVE_JOURNEY_COMMAND =
|
|
23
|
+
"GENTLE_PI_MAINTAINER_ARM_POSITIVE=1 node --experimental-strip-types scripts/maintainer/provider-relay-matrix.mjs --descriptor <descriptor.json> # needs a real capable gentle-ai binary + real pi on PATH + real binding tokens from a live review session (gentle-ai review status --next-transition)";
|
|
24
|
+
export class DescriptorValidationError extends Error {
|
|
25
|
+
constructor(message) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.name = "DescriptorValidationError";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
const isObj = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
31
|
+
const isStr = (value) => typeof value === "string" && value.length > 0;
|
|
32
|
+
const isStrArr = (value) => Array.isArray(value) && value.length > 0 && value.every(isStr);
|
|
33
|
+
const fail = (message) => {
|
|
34
|
+
throw new DescriptorValidationError(message);
|
|
35
|
+
};
|
|
36
|
+
// Reject any key outside an exact allowed set; keeps the descriptor shape
|
|
37
|
+
// strict so a maintainer cannot smuggle production overrides through it.
|
|
38
|
+
const exactKeys = (obj, allowed, label) => {
|
|
39
|
+
for (const key of Object.keys(obj)) {
|
|
40
|
+
if (!allowed.has(key)) fail(`${label}.${key} is not allowed; exact shape is {${[...allowed].join(",")}}`);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
// Strict, exact-shape descriptor validation: no defaults, no production
|
|
44
|
+
// resolution. The submission is validated through the REAL relay resolver so
|
|
45
|
+
// the completing form is provably bindable before any process launches.
|
|
46
|
+
export function validateDescriptor(value) {
|
|
47
|
+
if (!isObj(value)) fail("descriptor must be a JSON object");
|
|
48
|
+
exactKeys(value, new Set(["schema", "gentleAiExecutable", "piExecutable", "cases"]), "descriptor");
|
|
49
|
+
if (value.schema !== DESCRIPTOR_SCHEMA) fail(`descriptor.schema must be exactly "${DESCRIPTOR_SCHEMA}"`);
|
|
50
|
+
if (!isStr(value.gentleAiExecutable) || !isAbsolute(value.gentleAiExecutable)) fail("descriptor.gentleAiExecutable must be a non-empty absolute path; the runner never re-resolves the production binary");
|
|
51
|
+
if (!isStr(value.piExecutable)) fail("descriptor.piExecutable must be a non-empty string");
|
|
52
|
+
if (!Array.isArray(value.cases) || value.cases.length === 0) fail("descriptor.cases must be a non-empty array");
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
return { schema: value.schema, gentleAiExecutable: value.gentleAiExecutable, piExecutable: value.piExecutable, cases: value.cases.map((entry, index) => {
|
|
55
|
+
if (!isObj(entry)) fail(`descriptor.cases[${index}] must be an object`);
|
|
56
|
+
exactKeys(entry, new Set(["name", "kind", "captureArgumentTokens", "submission"]), `descriptor.cases[${index}]`);
|
|
57
|
+
if (!isStr(entry.name)) fail(`descriptor.cases[${index}].name must be a non-empty string`);
|
|
58
|
+
if (seen.has(entry.name)) fail(`descriptor.cases[${index}].name "${entry.name}" is duplicated`);
|
|
59
|
+
seen.add(entry.name);
|
|
60
|
+
if (!CASE_KINDS.includes(entry.kind)) fail(`descriptor.cases[${index}].kind must be one of ${JSON.stringify([...CASE_KINDS])}`);
|
|
61
|
+
if (!isStrArr(entry.captureArgumentTokens)) fail(`descriptor.cases[${index}].captureArgumentTokens must be a non-empty array of non-empty strings`);
|
|
62
|
+
// A real host-relay materialize slot is the provider-issued
|
|
63
|
+
// --agent=pi --materialize=true pair; the descriptor must declare
|
|
64
|
+
// exactly that so the negative control exercises the unknown-flag
|
|
65
|
+
// refusal and the positive leg exercises the real surface.
|
|
66
|
+
for (const required of ["--agent=pi", "--materialize=true"]) {
|
|
67
|
+
if (!entry.captureArgumentTokens.includes(required)) fail(`descriptor.cases[${index}].captureArgumentTokens must include the provider-issued "${required}" token`);
|
|
68
|
+
}
|
|
69
|
+
const submission = entry.submission;
|
|
70
|
+
if (!isObj(submission)) fail(`descriptor.cases[${index}].submission must be an object; a materialize slot without a provider submission is a contract mismatch, never synthesized`);
|
|
71
|
+
exactKeys(submission, new Set(["operationToken", "argumentTokens", "values"]), `descriptor.cases[${index}].submission`);
|
|
72
|
+
if (!isStr(submission.operationToken)) fail(`descriptor.cases[${index}].submission.operationToken must be a non-empty string`);
|
|
73
|
+
if (!isStrArr(submission.argumentTokens)) fail(`descriptor.cases[${index}].submission.argumentTokens must be a non-empty array of non-empty strings`);
|
|
74
|
+
if (!Array.isArray(submission.values) || submission.values.length === 0 || !submission.values.every(isObj)) {
|
|
75
|
+
fail(`descriptor.cases[${index}].submission.values must be a non-empty array of objects`);
|
|
76
|
+
}
|
|
77
|
+
for (const [vi, ve] of submission.values.entries()) {
|
|
78
|
+
exactKeys(ve, new Set(["slot", "domain", "substitutionLocation"]), `descriptor.cases[${index}].submission.values[${vi}]`);
|
|
79
|
+
if (!isStr(ve.slot) || !isStr(ve.domain)) fail(`descriptor.cases[${index}].submission.values[${vi}] must have non-empty string slot and domain`);
|
|
80
|
+
}
|
|
81
|
+
// Delegate binding semantics (count, substitution location, the
|
|
82
|
+
// {{value}} slot) to the REAL relay resolver so they never drift.
|
|
83
|
+
try {
|
|
84
|
+
resolveReviewHostRelaySubmission({
|
|
85
|
+
operationToken: submission.operationToken,
|
|
86
|
+
argumentTokens: submission.argumentTokens,
|
|
87
|
+
values: submission.values.map((ve) => ({ slot: ve.slot, domain: ve.domain, substitutionLocation: ve.substitutionLocation })),
|
|
88
|
+
});
|
|
89
|
+
} catch (error) {
|
|
90
|
+
fail(`descriptor.cases[${index}].submission is not a bindable provider form: ${error instanceof Error ? error.message : String(error)}`);
|
|
91
|
+
}
|
|
92
|
+
return { name: entry.name, kind: entry.kind, captureArgumentTokens: [...entry.captureArgumentTokens], submission: { operationToken: submission.operationToken, argumentTokens: [...submission.argumentTokens], values: submission.values.map((ve) => ({ ...ve })) } };
|
|
93
|
+
}),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
export function loadDescriptor(path) {
|
|
97
|
+
if (!isStr(path)) fail("a descriptor path is required");
|
|
98
|
+
let raw, parsed;
|
|
99
|
+
try {
|
|
100
|
+
raw = readFileSync(path, "utf8");
|
|
101
|
+
} catch (error) {
|
|
102
|
+
fail(`could not read descriptor at ${path}: ${error instanceof Error ? error.message : String(error)}`);
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
parsed = JSON.parse(raw);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
fail(`descriptor at ${path} is not valid JSON: ${error instanceof Error ? error.message : String(error)}`);
|
|
108
|
+
}
|
|
109
|
+
return validateDescriptor(parsed);
|
|
110
|
+
}
|
|
111
|
+
// Resolves a declared executable without a shell: absolute path checked
|
|
112
|
+
// verbatim, bare name searched on PATH. Never re-resolves production.
|
|
113
|
+
export function resolveDeclaredExecutable(executable) {
|
|
114
|
+
if (isAbsolute(executable)) return existsSync(executable) && statSync(executable).isFile() ? executable : null;
|
|
115
|
+
for (const directory of (process.env.PATH ?? "").split(process.platform === "win32" ? ";" : ":")) {
|
|
116
|
+
if (!directory) continue;
|
|
117
|
+
const candidate = join(directory, executable);
|
|
118
|
+
if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
|
|
119
|
+
}
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
const missingReason = (executable, label) => `${label} "${executable}" is not an existing executable; arm the descriptor with a real binary, or set GENTLE_PI_REQUIRE_MAINTAINER=1 to fail instead of block.`;
|
|
123
|
+
// A guaranteed-nonexistent pi path inside a fresh private directory. mkdtemp
|
|
124
|
+
// picks an unpredictable name and 0700 keeps it ours, so nothing can race a
|
|
125
|
+
// file into the slot between the mkdtemp and the spawn.
|
|
126
|
+
function unlaunchablePi() {
|
|
127
|
+
const directory = mkdtempSync(join(tmpdir(), "gentle-pi-maintainer-no-pi-"));
|
|
128
|
+
chmodSync(directory, 0o700);
|
|
129
|
+
return { directory, executable: join(directory, "pi-must-never-launch") };
|
|
130
|
+
}
|
|
131
|
+
// Runs the matrix against the REAL relay. A case that cannot run honestly is
|
|
132
|
+
// `blocked` (never `pass`); `fail` is an armed case whose outcome mismatched.
|
|
133
|
+
export async function runMatrix(descriptor, options = {}) {
|
|
134
|
+
const positiveArmed = options.armPositive ?? (process.env[ARM_POSITIVE_ENV] === "1");
|
|
135
|
+
const verdicts = [];
|
|
136
|
+
for (const caseEntry of descriptor.cases) {
|
|
137
|
+
const gentleAiPath = resolveDeclaredExecutable(descriptor.gentleAiExecutable);
|
|
138
|
+
if (gentleAiPath === null) {
|
|
139
|
+
verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "blocked", reason: missingReason(descriptor.gentleAiExecutable, "gentleAiExecutable") });
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
// The positive lens needs a real pi AND an explicit arm; the negative
|
|
143
|
+
// control never launches pi (fails closed at materialize).
|
|
144
|
+
if (caseEntry.kind === "positive-lens") {
|
|
145
|
+
if (resolveDeclaredExecutable(descriptor.piExecutable) === null) {
|
|
146
|
+
verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "blocked", reason: missingReason(descriptor.piExecutable, "piExecutable"), command: POSITIVE_JOURNEY_COMMAND });
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (!positiveArmed) {
|
|
150
|
+
verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "blocked", reason: `positive-lens case is not explicitly armed; set ${ARM_POSITIVE_ENV}=1 with a real capable gentle-ai binary, a real pi, and a real review session (real binding tokens from \`gentle-ai review status --next-transition\`) to run the organic journey. The runner never synthesizes a provider result.`, command: POSITIVE_JOURNEY_COMMAND });
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
// `relay-unavailable` is a NEGATIVE CONTROL: it must never launch pi and
|
|
155
|
+
// never submit. The maintainer-declared `kind` is not evidence that the
|
|
156
|
+
// declared binary is actually incapable, so the arm gate cannot key off
|
|
157
|
+
// the declaration alone. Pointed at a mis-declared CAPABLE binary, a
|
|
158
|
+
// declaration-only gate materializes, runs a REAL pi model, and executes
|
|
159
|
+
// a REAL `capture-result --input=...` submission, and only then reports
|
|
160
|
+
// `fail` — the mutation already happened. So give the negative control a
|
|
161
|
+
// pi that cannot exist: an incapable binary still classifies
|
|
162
|
+
// relay-unavailable at materialize (the control stays genuine, since it
|
|
163
|
+
// never reached pi anyway), while a capable one fails closed at the pi
|
|
164
|
+
// stage as kind=pi-launch-failed stage=pi mutationOutcome=none, with
|
|
165
|
+
// zero pi launch and zero submission.
|
|
166
|
+
const negativeControl = caseEntry.kind === "relay-unavailable" ? unlaunchablePi() : null;
|
|
167
|
+
const request = { captureArgumentTokens: caseEntry.captureArgumentTokens, submission: caseEntry.submission, gentleAiExecutable: gentleAiPath, piExecutable: negativeControl === null ? descriptor.piExecutable : negativeControl.executable, ...(options.signal === undefined ? {} : { signal: options.signal }) };
|
|
168
|
+
try {
|
|
169
|
+
const result = await runReviewHostRelaySlot(request);
|
|
170
|
+
if (caseEntry.kind === "relay-unavailable") {
|
|
171
|
+
verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "fail", reason: "expected relay-unavailable (runtime without --materialize) but the relay succeeded; the descriptor's binary is unexpectedly capable" });
|
|
172
|
+
} else {
|
|
173
|
+
verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "pass", promptByteLength: result.promptByteLength, resultByteLength: result.resultByteLength, submissionByteLength: result.submission.length });
|
|
174
|
+
}
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (error instanceof ReviewHostRelayError) {
|
|
177
|
+
if (caseEntry.kind === "relay-unavailable") {
|
|
178
|
+
if (error.kind === REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE && error.stage === "materialize" && error.mutationOutcome === "none") {
|
|
179
|
+
verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "pass", stage: error.stage, mutationOutcome: error.mutationOutcome, reason: REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE });
|
|
180
|
+
} else {
|
|
181
|
+
verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "fail", reason: `expected relay-unavailable at materialize with zero mutation, got kind=${error.kind} stage=${error.stage} mutationOutcome=${error.mutationOutcome}`, stderr: error.stderr });
|
|
182
|
+
}
|
|
183
|
+
} else if (error.kind === REVIEW_HOST_RELAY_FAILURE.RELAY_UNAVAILABLE) {
|
|
184
|
+
verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "blocked", stage: error.stage, reason: `the declared gentle-ai binary lacks the pi host relay surface: ${error.message}`, command: POSITIVE_JOURNEY_COMMAND });
|
|
185
|
+
} else {
|
|
186
|
+
verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "fail", reason: `relay error kind=${error.kind} stage=${error.stage} mutationOutcome=${error.mutationOutcome}: ${error.message}`, stderr: error.stderr });
|
|
187
|
+
}
|
|
188
|
+
} else {
|
|
189
|
+
verdicts.push({ name: caseEntry.name, kind: caseEntry.kind, verdict: "fail", reason: `unexpected non-relay error: ${error instanceof Error ? error.message : String(error)}` });
|
|
190
|
+
}
|
|
191
|
+
} finally {
|
|
192
|
+
if (negativeControl !== null) rmSync(negativeControl.directory, { recursive: true, force: true });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return verdicts;
|
|
196
|
+
}
|
|
197
|
+
async function main() {
|
|
198
|
+
const argv = process.argv.slice(2);
|
|
199
|
+
const index = argv.indexOf("--descriptor");
|
|
200
|
+
if (index === -1 || argv[index + 1] === undefined) {
|
|
201
|
+
process.stderr.write("usage: provider-relay-matrix.mjs --descriptor <path.json>\n");
|
|
202
|
+
process.exitCode = 2;
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
let descriptor;
|
|
206
|
+
try {
|
|
207
|
+
descriptor = loadDescriptor(argv[index + 1]);
|
|
208
|
+
} catch (error) {
|
|
209
|
+
process.stderr.write(`descriptor validation failed: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
210
|
+
process.exitCode = 2;
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const verdicts = await runMatrix(descriptor, { armPositive: process.env[ARM_POSITIVE_ENV] === "1" });
|
|
214
|
+
for (const verdict of verdicts) {
|
|
215
|
+
process.stdout.write(`${JSON.stringify(verdict)}\n`);
|
|
216
|
+
if (verdict.verdict !== "pass") process.exitCode = 1;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (process.argv[1]?.endsWith("provider-relay-matrix.mjs")) await main();
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Field-test acquisition of the gentle-ai provider contract bundle
|
|
3
|
+
// (gentle-pi#311 P1/P2).
|
|
4
|
+
//
|
|
5
|
+
// This script NEVER fetches anything from the network and NEVER touches the
|
|
6
|
+
// Gentle AI release pin (scripts/gentle-ai-installer.mjs INSTALLER_VERSION).
|
|
7
|
+
// RC and main-built bundles are field-test input only: you pass an explicit
|
|
8
|
+
// LOCAL path to a `gentle-ai-review-provider-contract-<semver>.tar.gz` archive
|
|
9
|
+
// or to an already-extracted bundle tree, the bundle is strictly verified,
|
|
10
|
+
// and only then is the verified mirror written under
|
|
11
|
+
// `contracts/review-provider-contract-mirror/` together with a lock record
|
|
12
|
+
// (contract semver, archive digest when mirroring an archive, tree digest,
|
|
13
|
+
// per-entry digests, generated baseline digests, runtime registry).
|
|
14
|
+
//
|
|
15
|
+
// Usage:
|
|
16
|
+
// node scripts/mirror-provider-contract.mjs --bundle <local archive or tree> [--root <package root>]
|
|
17
|
+
|
|
18
|
+
import { lstatSync, mkdirSync, rmSync, writeFileSync, chmodSync } from "node:fs";
|
|
19
|
+
import { createHash } from "node:crypto";
|
|
20
|
+
import { dirname, join, resolve } from "node:path";
|
|
21
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
22
|
+
import {
|
|
23
|
+
generateProviderContractBaselines,
|
|
24
|
+
piRuntimeRegistration,
|
|
25
|
+
verifyProviderContractBundleArchive,
|
|
26
|
+
verifyProviderContractBundleTree,
|
|
27
|
+
} from "../lib/provider-contract-bundle.ts";
|
|
28
|
+
|
|
29
|
+
export const PROVIDER_CONTRACT_MIRROR_ROOT = "contracts/review-provider-contract-mirror";
|
|
30
|
+
export const PROVIDER_CONTRACT_LOCK_FILE = "provider-contract.lock.json";
|
|
31
|
+
export const PROVIDER_CONTRACT_LOCK_SCHEMA = "gentle-pi.review-provider-contract-mirror-lock/v1";
|
|
32
|
+
// This script exists for field-test consumption only; the lock says so
|
|
33
|
+
// explicitly so nothing downstream mistakes it for a release pin.
|
|
34
|
+
export const PROVIDER_CONTRACT_ACQUISITION = "field-test-local";
|
|
35
|
+
|
|
36
|
+
function sha256Hex(bytes) {
|
|
37
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parseArguments(argv) {
|
|
41
|
+
const values = { bundle: undefined, root: undefined };
|
|
42
|
+
for (let index = 0; index < argv.length; index += 2) {
|
|
43
|
+
const flag = argv[index];
|
|
44
|
+
const value = argv[index + 1];
|
|
45
|
+
if ((flag !== "--bundle" && flag !== "--root") || value === undefined) {
|
|
46
|
+
throw new Error("usage: node scripts/mirror-provider-contract.mjs --bundle <local archive or tree> [--root <package root>]");
|
|
47
|
+
}
|
|
48
|
+
if (values[flag.slice(2)] !== undefined) throw new Error(`duplicate ${flag} argument`);
|
|
49
|
+
values[flag.slice(2)] = value;
|
|
50
|
+
}
|
|
51
|
+
if (values.bundle === undefined) {
|
|
52
|
+
throw new Error("usage: node scripts/mirror-provider-contract.mjs --bundle <local archive or tree> [--root <package root>]");
|
|
53
|
+
}
|
|
54
|
+
return values;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function verifyLocalBundle(bundlePath) {
|
|
58
|
+
const details = lstatSync(bundlePath);
|
|
59
|
+
if (details.isDirectory()) {
|
|
60
|
+
return { bundle: verifyProviderContractBundleTree(bundlePath), source: { kind: "tree" } };
|
|
61
|
+
}
|
|
62
|
+
const { bundle, archiveSha256 } = verifyProviderContractBundleArchive(bundlePath);
|
|
63
|
+
return { bundle, source: { kind: "archive", archive_sha256: archiveSha256 } };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function sortedRecord(map) {
|
|
67
|
+
return Object.fromEntries([...map.entries()].sort(([left], [right]) => (left < right ? -1 : 1)));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function buildLockRecord(bundle, source, generatedSha256) {
|
|
71
|
+
return {
|
|
72
|
+
schema: PROVIDER_CONTRACT_LOCK_SCHEMA,
|
|
73
|
+
acquisition: PROVIDER_CONTRACT_ACQUISITION,
|
|
74
|
+
contract_semver: bundle.contractSemver,
|
|
75
|
+
source,
|
|
76
|
+
tree_sha256: bundle.treeSha256,
|
|
77
|
+
entries: sortedRecord(bundle.entrySha256),
|
|
78
|
+
generated: sortedRecord(generatedSha256),
|
|
79
|
+
runtimes: [...(bundle.runtimes ?? [])],
|
|
80
|
+
pi_registered: piRuntimeRegistration(bundle).registered,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function mirrorProviderContractBundle(bundlePath, packageRoot) {
|
|
85
|
+
const { bundle, source } = verifyLocalBundle(bundlePath);
|
|
86
|
+
const registration = piRuntimeRegistration(bundle);
|
|
87
|
+
if (!registration.registered) {
|
|
88
|
+
throw new Error(
|
|
89
|
+
`refusing to mirror provider contract ${bundle.contractSemver}: ${registration.reason ?? "the pi runtime identity is not registered"}`,
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const mirrorRoot = join(resolve(packageRoot), ...PROVIDER_CONTRACT_MIRROR_ROOT.split("/"));
|
|
94
|
+
const versionRoot = join(mirrorRoot, `v${bundle.contractSemver}`);
|
|
95
|
+
const bundleRoot = join(versionRoot, "bundle");
|
|
96
|
+
const generatedRoot = join(versionRoot, "generated");
|
|
97
|
+
|
|
98
|
+
// Single locked version policy: the mirror directory holds exactly one
|
|
99
|
+
// verified bundle plus its lock, so rebuild it from scratch every time.
|
|
100
|
+
rmSync(mirrorRoot, { recursive: true, force: true });
|
|
101
|
+
|
|
102
|
+
// Confined materialization: entries come exclusively from the verified
|
|
103
|
+
// in-memory inventory whose paths already passed canonical-path checks, so
|
|
104
|
+
// no archive metadata ever steers a write location or a file mode.
|
|
105
|
+
for (const [name, payload] of bundle.entries) {
|
|
106
|
+
const destination = join(bundleRoot, ...name.split("/"));
|
|
107
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
108
|
+
writeFileSync(destination, payload, { flag: "wx" });
|
|
109
|
+
chmodSync(destination, 0o644);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const baselines = generateProviderContractBaselines(bundle);
|
|
113
|
+
const generatedSha256 = new Map();
|
|
114
|
+
for (const [name, payload] of baselines) {
|
|
115
|
+
const destination = join(generatedRoot, name);
|
|
116
|
+
mkdirSync(generatedRoot, { recursive: true });
|
|
117
|
+
writeFileSync(destination, payload, { flag: "wx" });
|
|
118
|
+
chmodSync(destination, 0o644);
|
|
119
|
+
generatedSha256.set(`generated/${name}`, sha256Hex(Buffer.from(payload, "utf8")));
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const lock = buildLockRecord(bundle, source, generatedSha256);
|
|
123
|
+
const lockPath = join(mirrorRoot, PROVIDER_CONTRACT_LOCK_FILE);
|
|
124
|
+
writeFileSync(lockPath, `${JSON.stringify(lock, null, 2)}\n`, { flag: "wx" });
|
|
125
|
+
chmodSync(lockPath, 0o644);
|
|
126
|
+
|
|
127
|
+
return { bundle, source, lock, mirrorRoot };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function main() {
|
|
131
|
+
const packageRoot = join(fileURLToPath(new URL("..", import.meta.url)));
|
|
132
|
+
const { bundle: bundlePath, root } = parseArguments(process.argv.slice(2));
|
|
133
|
+
const { bundle, source, mirrorRoot } = mirrorProviderContractBundle(resolve(bundlePath), root ?? packageRoot);
|
|
134
|
+
console.log(
|
|
135
|
+
`Mirrored provider contract ${bundle.contractSemver} (${source.kind}${source.kind === "archive" ? ` ${source.archive_sha256}` : ""}) into ${mirrorRoot}`,
|
|
136
|
+
);
|
|
137
|
+
console.log(`tree ${bundle.treeSha256}; runtimes ${(bundle.runtimes ?? []).join(", ")}; acquisition ${PROVIDER_CONTRACT_ACQUISITION}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const isMainModule = process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
141
|
+
if (isMainModule) {
|
|
142
|
+
await main();
|
|
143
|
+
}
|