gentle-pi 2.2.0 → 2.4.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 +76 -79
- 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-explore.md +1 -0
- 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 +56 -249
- package/assets/orchestrator-memory.md +2 -0
- package/assets/orchestrator.md +20 -40
- 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/contracts/review-provider-contract-mirror/provider-contract.lock.json +8 -7
- package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/README.md +10 -0
- package/contracts/review-provider-contract-mirror/v1.2.0/bundle/manifest.json +74 -0
- package/contracts/review-provider-contract-mirror/v1.2.0/bundle/orchestration/pi.md +53 -0
- package/contracts/review-provider-contract-mirror/v1.2.0/bundle/schemas/targeted-validator.schema.json +1 -0
- package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-capabilities.baseline.json +9 -2
- package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/generated/provider-roles.baseline.json +2 -2
- 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 +2349 -3785
- package/extensions/quiet-tools.ts +515 -32
- package/extensions/sdd-init.ts +4 -8
- package/lib/agent-home.ts +8 -0
- package/lib/gentle-ai-renderer.ts +70 -0
- package/lib/model-routing-authority.ts +133 -0
- package/lib/native-review-cli.ts +387 -940
- package/lib/opaque-pi-reviewer-adapter.ts +284 -0
- package/lib/provider-contract-bundle.ts +88 -6
- package/lib/review-candidate-view.ts +341 -132
- package/lib/review-host-relay.ts +324 -96
- package/lib/review-integration-v2.ts +868 -307
- package/lib/review-last-event-controller.ts +35 -0
- package/lib/sdd-preflight.ts +179 -68
- 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 +337 -890
- package/runtime/review-integration-v2.mjs +816 -255
- 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 +23 -23
- package/skills/_shared/review-ledger-contract.md +14 -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/issue-creation/SKILL.md +53 -93
- 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/fixtures/provider-contract-bundle/v1.2.0/README.md +22 -0
- package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/manifest.json +11 -2
- package/tests/fixtures/provider-contract-bundle/v1.2.0/orchestration/pi.md +97 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/lens.schema.json +16 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/schemas/refuter.schema.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/lens.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/refuter.json +1 -0
- package/tests/fixtures/provider-contract-bundle/v1.2.0/vectors/targeted-validator.json +1 -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 +821 -131
- package/tests/gentle-theme.test.ts +133 -0
- package/tests/issue-creation-skill.test.ts +103 -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 +45 -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 +152 -36
- package/tests/provider-contract-bundle.test.ts +76 -0
- package/tests/provider-contract-mirror.test.ts +19 -0
- package/tests/provider-defect-handoff.test.ts +15 -118
- package/tests/quiet-tool-rendering.test.ts +1055 -28
- package/tests/review-agent-end-preflight.test.ts +408 -0
- 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-contract-prompt.test.ts +139 -0
- 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 +328 -91
- package/tests/review-host-relay.test.ts +436 -14
- package/tests/review-integration-v2-forward.test.ts +623 -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 +362 -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 +366 -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
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/lens.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/schemas/refuter.schema.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/lens.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/refuter.json +0 -0
- /package/contracts/review-provider-contract-mirror/{v1.1.0 → v1.2.0}/bundle/vectors/targeted-validator.json +0 -0
- /package/{contracts/review-provider-contract-mirror/v1.1.0/bundle → tests/fixtures/provider-contract-bundle/v1.2.0}/schemas/targeted-validator.schema.json +0 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { chmod, mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join, posix, win32 } from "node:path";
|
|
5
|
+
|
|
6
|
+
export const OPAQUE_PI_REVIEWER_ARGV = Object.freeze([
|
|
7
|
+
"--print",
|
|
8
|
+
"--mode", "text",
|
|
9
|
+
"--no-session",
|
|
10
|
+
"--no-tools",
|
|
11
|
+
"--no-extensions",
|
|
12
|
+
"--no-skills",
|
|
13
|
+
"--no-prompt-templates",
|
|
14
|
+
"--no-themes",
|
|
15
|
+
"--no-context-files",
|
|
16
|
+
"--no-approve",
|
|
17
|
+
] as const);
|
|
18
|
+
|
|
19
|
+
export const OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE = {
|
|
20
|
+
SCRATCH_FAILED: "scratch-failed",
|
|
21
|
+
LAUNCH_FAILED: "launch-failed",
|
|
22
|
+
CANCELLED: "cancelled",
|
|
23
|
+
TIMED_OUT: "timed-out",
|
|
24
|
+
NONZERO_EXIT: "nonzero-exit",
|
|
25
|
+
EMPTY_OUTPUT: "empty-output",
|
|
26
|
+
CLEANUP_FAILED: "cleanup-failed",
|
|
27
|
+
} as const;
|
|
28
|
+
export type OpaquePiReviewerTransportFailureKind = (typeof OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE)[keyof typeof OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE];
|
|
29
|
+
|
|
30
|
+
export interface OpaquePiReviewerOptions {
|
|
31
|
+
readonly piExecutable?: string;
|
|
32
|
+
readonly environment?: NodeJS.ProcessEnv;
|
|
33
|
+
readonly timeoutMs?: number;
|
|
34
|
+
readonly signal?: AbortSignal;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface OpaquePiReviewerResult {
|
|
38
|
+
readonly stdout: Buffer;
|
|
39
|
+
readonly promptByteLength: number;
|
|
40
|
+
readonly stdoutByteLength: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface OpaquePiReviewerTransportDetails {
|
|
44
|
+
readonly exitCode?: number | null;
|
|
45
|
+
readonly stderr?: Buffer;
|
|
46
|
+
readonly timedOut?: boolean;
|
|
47
|
+
readonly cancelled?: boolean;
|
|
48
|
+
/** Wall time for a launched Pi process; absent when no process was launched. */
|
|
49
|
+
readonly elapsedMs?: number;
|
|
50
|
+
/** The bound applied to a launched Pi process; absent when no process was launched. */
|
|
51
|
+
readonly timeoutMs?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export class OpaquePiReviewerTransportError extends Error {
|
|
55
|
+
readonly kind: OpaquePiReviewerTransportFailureKind;
|
|
56
|
+
readonly exitCode: number | null;
|
|
57
|
+
readonly stderr: Buffer;
|
|
58
|
+
readonly timedOut: boolean;
|
|
59
|
+
readonly cancelled: boolean;
|
|
60
|
+
readonly elapsedMs: number | null;
|
|
61
|
+
readonly timeoutMs: number | null;
|
|
62
|
+
|
|
63
|
+
constructor(kind: OpaquePiReviewerTransportFailureKind, message: string, details: OpaquePiReviewerTransportDetails = {}) {
|
|
64
|
+
super(message);
|
|
65
|
+
this.name = "OpaquePiReviewerTransportError";
|
|
66
|
+
this.kind = kind;
|
|
67
|
+
this.exitCode = details.exitCode ?? null;
|
|
68
|
+
this.stderr = details.stderr ?? Buffer.alloc(0);
|
|
69
|
+
this.timedOut = details.timedOut ?? false;
|
|
70
|
+
this.cancelled = details.cancelled ?? false;
|
|
71
|
+
this.elapsedMs = details.elapsedMs ?? null;
|
|
72
|
+
this.timeoutMs = details.timeoutMs ?? null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface OpaquePiProcessResult {
|
|
77
|
+
readonly stdout: Buffer;
|
|
78
|
+
readonly stderr: Buffer;
|
|
79
|
+
readonly exitCode: number | null;
|
|
80
|
+
readonly timedOut: boolean;
|
|
81
|
+
readonly cancelled: boolean;
|
|
82
|
+
readonly elapsedMs: number;
|
|
83
|
+
readonly timeoutMs: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const DEFAULT_OPAQUE_PI_TIMEOUT_MS = 600_000;
|
|
87
|
+
|
|
88
|
+
export interface PiLaunch {
|
|
89
|
+
readonly file: string;
|
|
90
|
+
readonly arguments: readonly string[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
interface PiHostProcess {
|
|
94
|
+
readonly execPath: string;
|
|
95
|
+
readonly entry: string | undefined;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The exact spawn shape for the fresh Pi process. A bare `pi` on Windows
|
|
100
|
+
* resolves to pi.cmd, pi.ps1, or a POSIX shim, none of which Node can spawn
|
|
101
|
+
* with shell:false (EINVAL or ENOENT). This adapter already runs inside Pi, so
|
|
102
|
+
* on win32 the host's own JavaScript entry is spawned through the host's
|
|
103
|
+
* process.execPath instead; a shell is never enabled. Every other platform,
|
|
104
|
+
* and every explicit launcher, keeps the exact shape it always had.
|
|
105
|
+
*/
|
|
106
|
+
export function resolvePiLaunch(
|
|
107
|
+
piExecutable: string | undefined,
|
|
108
|
+
platform: NodeJS.Platform = process.platform,
|
|
109
|
+
host: PiHostProcess = { execPath: process.execPath, entry: process.argv[1] },
|
|
110
|
+
): PiLaunch {
|
|
111
|
+
if (piExecutable !== undefined) return { file: piExecutable, arguments: [...OPAQUE_PI_REVIEWER_ARGV] };
|
|
112
|
+
if (platform !== "win32") return { file: "pi", arguments: [...OPAQUE_PI_REVIEWER_ARGV] };
|
|
113
|
+
if (typeof host.entry !== "string" || host.entry.length === 0 || !(platform === "win32" ? win32 : posix).isAbsolute(host.entry)) {
|
|
114
|
+
throw new Error(`Pi host entry could not be resolved from the running process (received ${JSON.stringify(host.entry ?? null)}); a bare pi launcher cannot be spawned on Windows without a shell`);
|
|
115
|
+
}
|
|
116
|
+
return { file: host.execPath, arguments: [host.entry, ...OPAQUE_PI_REVIEWER_ARGV] };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function errorMessage(error: unknown): string {
|
|
120
|
+
return error instanceof Error ? error.message : String(error);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function runPiProcess(prompt: Buffer, scratchDirectory: string, options: OpaquePiReviewerOptions): Promise<OpaquePiProcessResult> {
|
|
124
|
+
return new Promise((resolve, reject) => {
|
|
125
|
+
const startedAt = Date.now();
|
|
126
|
+
let launch: PiLaunch;
|
|
127
|
+
try {
|
|
128
|
+
launch = resolvePiLaunch(options.piExecutable);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
reject(error);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const child = spawn(launch.file, [...launch.arguments], {
|
|
134
|
+
cwd: scratchDirectory,
|
|
135
|
+
env: options.environment ?? process.env,
|
|
136
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
137
|
+
shell: false,
|
|
138
|
+
windowsHide: true,
|
|
139
|
+
});
|
|
140
|
+
const stdout: Buffer[] = [];
|
|
141
|
+
const stderr: Buffer[] = [];
|
|
142
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_OPAQUE_PI_TIMEOUT_MS;
|
|
143
|
+
let timedOut = false;
|
|
144
|
+
let cancelled = false;
|
|
145
|
+
let settled = false;
|
|
146
|
+
const timer = timeoutMs > 0
|
|
147
|
+
? setTimeout(() => {
|
|
148
|
+
timedOut = true;
|
|
149
|
+
child.kill("SIGKILL");
|
|
150
|
+
}, timeoutMs)
|
|
151
|
+
: undefined;
|
|
152
|
+
timer?.unref();
|
|
153
|
+
const cancel = () => {
|
|
154
|
+
cancelled = true;
|
|
155
|
+
child.kill("SIGKILL");
|
|
156
|
+
};
|
|
157
|
+
const clear = () => {
|
|
158
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
159
|
+
options.signal?.removeEventListener("abort", cancel);
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk));
|
|
163
|
+
child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk));
|
|
164
|
+
child.on("error", (error) => {
|
|
165
|
+
if (settled) return;
|
|
166
|
+
settled = true;
|
|
167
|
+
clear();
|
|
168
|
+
reject(error);
|
|
169
|
+
});
|
|
170
|
+
child.on("close", (code) => {
|
|
171
|
+
if (settled) return;
|
|
172
|
+
settled = true;
|
|
173
|
+
clear();
|
|
174
|
+
resolve({
|
|
175
|
+
stdout: Buffer.concat(stdout),
|
|
176
|
+
stderr: Buffer.concat(stderr),
|
|
177
|
+
exitCode: code,
|
|
178
|
+
timedOut,
|
|
179
|
+
cancelled,
|
|
180
|
+
elapsedMs: Date.now() - startedAt,
|
|
181
|
+
timeoutMs,
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
if (options.signal?.aborted) cancel();
|
|
185
|
+
else options.signal?.addEventListener("abort", cancel, { once: true });
|
|
186
|
+
child.stdin.on("error", () => undefined);
|
|
187
|
+
child.stdin.end(prompt);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Runs raw prompt bytes through one fixed, isolated Pi process. */
|
|
192
|
+
export async function runOpaquePiReviewer(prompt: Buffer, options: OpaquePiReviewerOptions = {}): Promise<OpaquePiReviewerResult> {
|
|
193
|
+
if (options.signal?.aborted) {
|
|
194
|
+
throw new OpaquePiReviewerTransportError(
|
|
195
|
+
OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.CANCELLED,
|
|
196
|
+
"Pi process was cancelled before launch",
|
|
197
|
+
{ cancelled: true },
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
let scratchDirectory: string | undefined;
|
|
202
|
+
let primaryFailure = false;
|
|
203
|
+
try {
|
|
204
|
+
try {
|
|
205
|
+
scratchDirectory = await mkdtemp(join(tmpdir(), "gentle-pi-opaque-reviewer-"));
|
|
206
|
+
await chmod(scratchDirectory, 0o700);
|
|
207
|
+
} catch (error) {
|
|
208
|
+
throw new OpaquePiReviewerTransportError(
|
|
209
|
+
OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.SCRATCH_FAILED,
|
|
210
|
+
`Pi scratch directory could not be prepared: ${errorMessage(error)}`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
let processResult: OpaquePiProcessResult;
|
|
215
|
+
try {
|
|
216
|
+
processResult = await runPiProcess(prompt, scratchDirectory, options);
|
|
217
|
+
} catch (error) {
|
|
218
|
+
if (options.signal?.aborted) {
|
|
219
|
+
throw new OpaquePiReviewerTransportError(
|
|
220
|
+
OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.CANCELLED,
|
|
221
|
+
"Pi process was cancelled",
|
|
222
|
+
{ cancelled: true },
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
throw new OpaquePiReviewerTransportError(
|
|
226
|
+
OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.LAUNCH_FAILED,
|
|
227
|
+
`Pi process could not start: ${errorMessage(error)}`,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
const timing = {
|
|
231
|
+
elapsedMs: processResult.elapsedMs,
|
|
232
|
+
timeoutMs: processResult.timeoutMs,
|
|
233
|
+
};
|
|
234
|
+
if (processResult.timedOut) {
|
|
235
|
+
throw new OpaquePiReviewerTransportError(
|
|
236
|
+
OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.TIMED_OUT,
|
|
237
|
+
"Pi process timed out",
|
|
238
|
+
{ exitCode: processResult.exitCode, stderr: processResult.stderr, timedOut: true, ...timing },
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
if (processResult.cancelled) {
|
|
242
|
+
throw new OpaquePiReviewerTransportError(
|
|
243
|
+
OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.CANCELLED,
|
|
244
|
+
"Pi process was cancelled",
|
|
245
|
+
{ exitCode: processResult.exitCode, stderr: processResult.stderr, cancelled: true, ...timing },
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
if (processResult.exitCode !== 0) {
|
|
249
|
+
throw new OpaquePiReviewerTransportError(
|
|
250
|
+
OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.NONZERO_EXIT,
|
|
251
|
+
"Pi process failed",
|
|
252
|
+
{ exitCode: processResult.exitCode, stderr: processResult.stderr, ...timing },
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
if (processResult.stdout.length === 0) {
|
|
256
|
+
throw new OpaquePiReviewerTransportError(
|
|
257
|
+
OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.EMPTY_OUTPUT,
|
|
258
|
+
"Pi process produced no output bytes",
|
|
259
|
+
{ exitCode: 0, stderr: processResult.stderr, ...timing },
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
stdout: processResult.stdout,
|
|
264
|
+
promptByteLength: prompt.length,
|
|
265
|
+
stdoutByteLength: processResult.stdout.length,
|
|
266
|
+
};
|
|
267
|
+
} catch (error) {
|
|
268
|
+
primaryFailure = true;
|
|
269
|
+
throw error;
|
|
270
|
+
} finally {
|
|
271
|
+
if (scratchDirectory !== undefined) {
|
|
272
|
+
try {
|
|
273
|
+
await rm(scratchDirectory, { recursive: true, force: true });
|
|
274
|
+
} catch (error) {
|
|
275
|
+
if (!primaryFailure) {
|
|
276
|
+
throw new OpaquePiReviewerTransportError(
|
|
277
|
+
OPAQUE_PI_REVIEWER_TRANSPORT_FAILURE.CLEANUP_FAILED,
|
|
278
|
+
`Pi scratch directory cleanup failed: ${errorMessage(error)}`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
@@ -31,6 +31,8 @@ export const PI_RUNTIME_IDENTITY = "pi";
|
|
|
31
31
|
export const SUPPORTED_PROVIDER_CONTRACT_MAJOR = 1;
|
|
32
32
|
// `runtimes` became part of the manifest in contract 1.1.0.
|
|
33
33
|
export const RUNTIMES_REQUIRED_SINCE_MINOR = 1;
|
|
34
|
+
// `orchestration` became part of the manifest in contract 1.2.0.
|
|
35
|
+
export const ORCHESTRATION_REQUIRED_SINCE_MINOR = 2;
|
|
34
36
|
// Exactly the provider roles gentle-pi supports, in the manifest's strict order.
|
|
35
37
|
export const PROVIDER_CONTRACT_ROLE_IDS = ["lens", "refuter", "targeted-validator"] as const;
|
|
36
38
|
// Every mandatory capability a role may declare. An unknown mandatory
|
|
@@ -42,6 +44,13 @@ const MAX_FILE_BYTES = 4 * 1024 * 1024;
|
|
|
42
44
|
const MAX_BUNDLE_BYTES = 16 * 1024 * 1024;
|
|
43
45
|
const MAX_MANIFEST_BYTES = 64 * 1024;
|
|
44
46
|
const BUNDLE_ENTRY_COUNT = 8;
|
|
47
|
+
// Generous fixed safety bound on raw archive/tree entries read BEFORE the
|
|
48
|
+
// manifest itself is parsed (the exact expected count, base entries plus one
|
|
49
|
+
// per `orchestration` runtime, is known only after manifest decode). Real
|
|
50
|
+
// contracts register a handful of runtimes; this bound only rules out an
|
|
51
|
+
// absurd smuggled inventory, it is not the exact-count check.
|
|
52
|
+
const MAX_ORCHESTRATION_ENTRIES = 64;
|
|
53
|
+
const MAX_RAW_BUNDLE_ENTRIES = BUNDLE_ENTRY_COUNT + MAX_ORCHESTRATION_ENTRIES;
|
|
45
54
|
// setuid/setgid/sticky, any execute bit, or world-writable content is never
|
|
46
55
|
// acceptable for a data-only contract bundle.
|
|
47
56
|
const FORBIDDEN_MODE_BITS = 0o7113;
|
|
@@ -84,7 +93,13 @@ export interface VerifiedProviderContractBundle {
|
|
|
84
93
|
/** Present exactly when the manifest declares the runtime registry (>= 1.1.0). */
|
|
85
94
|
readonly runtimes: readonly string[] | undefined;
|
|
86
95
|
readonly roles: readonly VerifiedProviderRole[];
|
|
87
|
-
/**
|
|
96
|
+
/**
|
|
97
|
+
* Verified runtime-bound review execution contract text, runtime identity
|
|
98
|
+
* -> UTF-8 `orchestration/<runtime>.md` contents. Empty before contract
|
|
99
|
+
* 1.2.0, where `orchestration` does not exist in the manifest.
|
|
100
|
+
*/
|
|
101
|
+
readonly orchestration: ReadonlyMap<string, string>;
|
|
102
|
+
/** Canonical path -> exact verified bytes for all entries (base eight plus one per orchestration runtime). */
|
|
88
103
|
readonly entries: ReadonlyMap<string, Buffer>;
|
|
89
104
|
/** Canonical path -> SHA-256 of the exact verified bytes. */
|
|
90
105
|
readonly entrySha256: ReadonlyMap<string, string>;
|
|
@@ -306,6 +321,23 @@ function parseManifestRole(value: unknown, index: number): ManifestRole {
|
|
|
306
321
|
};
|
|
307
322
|
}
|
|
308
323
|
|
|
324
|
+
interface ManifestOrchestrationEntry {
|
|
325
|
+
readonly runtime: string;
|
|
326
|
+
readonly file: ManifestFileReference;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function parseOrchestrationEntry(value: unknown, index: number): ManifestOrchestrationEntry {
|
|
330
|
+
const where = `manifest.orchestration[${index}]`;
|
|
331
|
+
const object = requireObject(value, where);
|
|
332
|
+
requireExactKeys(object, ["runtime", "file"], ["runtime", "file"], where);
|
|
333
|
+
const runtime = requireString(object.runtime, `${where}.runtime`);
|
|
334
|
+
if (!RUNTIME_IDENTITY_PATTERN.test(runtime)) invalid(`${where}.runtime ${JSON.stringify(runtime)} is not a runtime identity`);
|
|
335
|
+
const file = parseFileReference(object.file, `${where}.file`);
|
|
336
|
+
const expectedPath = `orchestration/${runtime}.md`;
|
|
337
|
+
if (file.path !== expectedPath) invalid(`${where}.file.path must be ${JSON.stringify(expectedPath)}`);
|
|
338
|
+
return { runtime, file };
|
|
339
|
+
}
|
|
340
|
+
|
|
309
341
|
interface ParsedManifest {
|
|
310
342
|
readonly contractSemver: string;
|
|
311
343
|
readonly major: number;
|
|
@@ -315,13 +347,14 @@ interface ParsedManifest {
|
|
|
315
347
|
readonly runtimes: readonly string[] | undefined;
|
|
316
348
|
readonly readme: ManifestFileReference;
|
|
317
349
|
readonly roles: readonly ManifestRole[];
|
|
350
|
+
readonly orchestration: readonly ManifestOrchestrationEntry[];
|
|
318
351
|
}
|
|
319
352
|
|
|
320
353
|
function parseManifest(payload: Buffer): ParsedManifest {
|
|
321
354
|
const object = decodeStrictManifestObject(payload);
|
|
322
355
|
requireExactKeys(
|
|
323
356
|
object,
|
|
324
|
-
["schema", "contract_semver", "transport_capability", "runtimes", "readme", "roles"],
|
|
357
|
+
["schema", "contract_semver", "transport_capability", "runtimes", "readme", "roles", "orchestration"],
|
|
325
358
|
["schema", "contract_semver", "transport_capability", "readme", "roles"],
|
|
326
359
|
"manifest",
|
|
327
360
|
);
|
|
@@ -365,6 +398,33 @@ function parseManifest(payload: Buffer): ParsedManifest {
|
|
|
365
398
|
invalid(`manifest.runtimes does not register the ${JSON.stringify(PI_RUNTIME_IDENTITY)} runtime identity`);
|
|
366
399
|
}
|
|
367
400
|
|
|
401
|
+
const orchestrationRequired = minor >= ORCHESTRATION_REQUIRED_SINCE_MINOR;
|
|
402
|
+
let orchestration: readonly ManifestOrchestrationEntry[] = [];
|
|
403
|
+
if (Object.prototype.hasOwnProperty.call(object, "orchestration")) {
|
|
404
|
+
if (!orchestrationRequired) {
|
|
405
|
+
invalid(`manifest.orchestration is not part of contract ${contractSemver}; it exists from 1.${ORCHESTRATION_REQUIRED_SINCE_MINOR}.0`);
|
|
406
|
+
}
|
|
407
|
+
if (!Array.isArray(object.orchestration) || object.orchestration.length === 0) {
|
|
408
|
+
invalid("manifest.orchestration must be a non-empty array");
|
|
409
|
+
}
|
|
410
|
+
const parsedOrchestration = object.orchestration.map((entry, index) => parseOrchestrationEntry(entry, index));
|
|
411
|
+
for (let position = 0; position < parsedOrchestration.length; position += 1) {
|
|
412
|
+
const entry = parsedOrchestration[position] as ManifestOrchestrationEntry;
|
|
413
|
+
if (position > 0 && (parsedOrchestration[position - 1] as ManifestOrchestrationEntry).runtime >= entry.runtime) {
|
|
414
|
+
invalid("manifest.orchestration must be strictly sorted by runtime and unique");
|
|
415
|
+
}
|
|
416
|
+
if (runtimes === undefined || !runtimes.includes(entry.runtime)) {
|
|
417
|
+
invalid(`manifest.orchestration[${position}].runtime ${JSON.stringify(entry.runtime)} is not registered in manifest.runtimes`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
if (!parsedOrchestration.some((entry) => entry.runtime === PI_RUNTIME_IDENTITY)) {
|
|
421
|
+
invalid(`manifest.orchestration does not register the ${JSON.stringify(PI_RUNTIME_IDENTITY)} runtime identity`);
|
|
422
|
+
}
|
|
423
|
+
orchestration = parsedOrchestration;
|
|
424
|
+
} else if (orchestrationRequired) {
|
|
425
|
+
invalid(`manifest.orchestration is required from contract 1.${ORCHESTRATION_REQUIRED_SINCE_MINOR}.0 (got ${contractSemver} without it)`);
|
|
426
|
+
}
|
|
427
|
+
|
|
368
428
|
if (!Array.isArray(object.roles)) invalid("manifest.roles must be an array");
|
|
369
429
|
const roles = object.roles.map((role, index) => parseManifestRole(role, index));
|
|
370
430
|
if (roles.length !== PROVIDER_CONTRACT_ROLE_IDS.length) {
|
|
@@ -385,6 +445,7 @@ function parseManifest(payload: Buffer): ParsedManifest {
|
|
|
385
445
|
runtimes,
|
|
386
446
|
readme: parseFileReference(object.readme, "manifest.readme"),
|
|
387
447
|
roles,
|
|
448
|
+
orchestration,
|
|
388
449
|
};
|
|
389
450
|
}
|
|
390
451
|
|
|
@@ -467,8 +528,8 @@ export function bundleTreeSha256(entrySha256: ReadonlyMap<string, string>): stri
|
|
|
467
528
|
|
|
468
529
|
/** Verifies a complete in-memory bundle inventory. The single trust anchor. */
|
|
469
530
|
export function verifyProviderContractBundleEntries(rawEntries: ReadonlyMap<string, Buffer>): VerifiedProviderContractBundle {
|
|
470
|
-
if (rawEntries.size
|
|
471
|
-
invalid(`bundle inventory has ${rawEntries.size} files;
|
|
531
|
+
if (rawEntries.size > MAX_RAW_BUNDLE_ENTRIES) {
|
|
532
|
+
invalid(`bundle inventory has ${rawEntries.size} files; at most ${MAX_RAW_BUNDLE_ENTRIES} are ever expected`);
|
|
472
533
|
}
|
|
473
534
|
for (const [name, payload] of rawEntries) {
|
|
474
535
|
if (!isCanonicalBundlePath(name)) invalid(`bundle path ${JSON.stringify(name)} is unsafe`);
|
|
@@ -478,6 +539,14 @@ export function verifyProviderContractBundleEntries(rawEntries: ReadonlyMap<stri
|
|
|
478
539
|
if (manifestPayload === undefined) invalid("bundle is missing manifest.json");
|
|
479
540
|
const manifest = parseManifest(manifestPayload);
|
|
480
541
|
|
|
542
|
+
// The exact expected inventory size is only known once the manifest is
|
|
543
|
+
// decoded: base entries plus exactly one `orchestration/<runtime>.md` per
|
|
544
|
+
// registered orchestration runtime.
|
|
545
|
+
const expectedEntryCount = BUNDLE_ENTRY_COUNT + manifest.orchestration.length;
|
|
546
|
+
if (rawEntries.size !== expectedEntryCount) {
|
|
547
|
+
invalid(`bundle inventory has ${rawEntries.size} files; exactly ${expectedEntryCount} are expected`);
|
|
548
|
+
}
|
|
549
|
+
|
|
481
550
|
verifyFileReference(rawEntries, manifest.readme, "README.md");
|
|
482
551
|
const expected = new Set(["README.md", "manifest.json"]);
|
|
483
552
|
for (const role of manifest.roles) {
|
|
@@ -485,6 +554,13 @@ export function verifyProviderContractBundleEntries(rawEntries: ReadonlyMap<stri
|
|
|
485
554
|
expected.add(`schemas/${role.id}.schema.json`);
|
|
486
555
|
expected.add(`vectors/${role.id}.json`);
|
|
487
556
|
}
|
|
557
|
+
const orchestration = new Map<string, string>();
|
|
558
|
+
for (const entry of manifest.orchestration) {
|
|
559
|
+
const path = `orchestration/${entry.runtime}.md`;
|
|
560
|
+
const payload = verifyFileReference(rawEntries, entry.file, path);
|
|
561
|
+
orchestration.set(entry.runtime, payload.toString("utf8"));
|
|
562
|
+
expected.add(path);
|
|
563
|
+
}
|
|
488
564
|
for (const name of rawEntries.keys()) {
|
|
489
565
|
if (!expected.has(name)) invalid(`bundle has unexpected file ${JSON.stringify(name)}`);
|
|
490
566
|
}
|
|
@@ -514,6 +590,7 @@ export function verifyProviderContractBundleEntries(rawEntries: ReadonlyMap<stri
|
|
|
514
590
|
vectorPath: role.vector.path,
|
|
515
591
|
vectorSha256: role.vector.sha256,
|
|
516
592
|
})),
|
|
593
|
+
orchestration,
|
|
517
594
|
entries,
|
|
518
595
|
entrySha256,
|
|
519
596
|
treeSha256: bundleTreeSha256(entrySha256),
|
|
@@ -566,7 +643,7 @@ export function verifyProviderContractBundleTree(directory: string): VerifiedPro
|
|
|
566
643
|
}
|
|
567
644
|
if (!isCanonicalBundlePath(relativeName)) invalid(`bundle tree path ${JSON.stringify(relativeName)} is unsafe`);
|
|
568
645
|
if (details.size > MAX_FILE_BYTES) invalid(`bundle tree entry ${JSON.stringify(relativeName)} exceeds its size limit`);
|
|
569
|
-
if (entries.size >=
|
|
646
|
+
if (entries.size >= MAX_RAW_BUNDLE_ENTRIES) invalid(`bundle tree has more than ${MAX_RAW_BUNDLE_ENTRIES} files`);
|
|
570
647
|
entries.set(relativeName, readFileSync(absolute));
|
|
571
648
|
}
|
|
572
649
|
};
|
|
@@ -628,7 +705,7 @@ function readTarEntries(expanded: Buffer): Map<string, Buffer> {
|
|
|
628
705
|
if (size > MAX_FILE_BYTES) invalid(`tar entry ${JSON.stringify(name)} exceeds its size limit`);
|
|
629
706
|
totalBytes += size;
|
|
630
707
|
if (totalBytes > MAX_BUNDLE_BYTES) invalid("tar entries exceed the bundle size limit");
|
|
631
|
-
if (entries.size >=
|
|
708
|
+
if (entries.size >= MAX_RAW_BUNDLE_ENTRIES) invalid(`tar has more than ${MAX_RAW_BUNDLE_ENTRIES} entries`);
|
|
632
709
|
const padding = (TAR_BLOCK_SIZE - (size % TAR_BLOCK_SIZE)) % TAR_BLOCK_SIZE;
|
|
633
710
|
if (expanded.length - offset < size + padding) invalid("tar stream ends before a complete entry");
|
|
634
711
|
entries.set(name, Buffer.from(expanded.subarray(offset, offset + size)));
|
|
@@ -696,6 +773,11 @@ export function generateProviderContractBaselines(bundle: VerifiedProviderContra
|
|
|
696
773
|
mandatory_capabilities: mandatory,
|
|
697
774
|
runtimes: bundle.runtimes === undefined ? null : [...bundle.runtimes],
|
|
698
775
|
pi_registered: piRuntimeRegistration(bundle).registered,
|
|
776
|
+
orchestration: [...bundle.orchestration.keys()].sort().map((runtime) => ({
|
|
777
|
+
runtime,
|
|
778
|
+
path: `orchestration/${runtime}.md`,
|
|
779
|
+
sha256: bundle.entrySha256.get(`orchestration/${runtime}.md`),
|
|
780
|
+
})),
|
|
699
781
|
};
|
|
700
782
|
return new Map([
|
|
701
783
|
[PROVIDER_ROLES_BASELINE_FILE, `${JSON.stringify(roles, null, 2)}\n`],
|