@bjoernrochel/sevro 0.1.0-rc.1
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/LICENSE +159 -0
- package/README.md +71 -0
- package/docs/advisory-fixture.md +23 -0
- package/docs/builtin-graders-v1.md +77 -0
- package/docs/cache-ownership-validation.md +49 -0
- package/docs/claude-continuation-validation.md +47 -0
- package/docs/codex-host.md +134 -0
- package/docs/concurrency-validation.md +86 -0
- package/docs/configuration-root-validation.md +70 -0
- package/docs/development-cli.md +327 -0
- package/docs/engine-slice.md +114 -0
- package/docs/extension-protocol-v1.md +292 -0
- package/docs/fixture-cleanup-validation.md +70 -0
- package/docs/identity-v1.md +65 -0
- package/docs/native-controls.md +29 -0
- package/docs/releases.md +88 -0
- package/docs/report-v1.md +32 -0
- package/docs/results-v1.md +118 -0
- package/examples/basic/graded.json +13 -0
- package/examples/basic/host.ts +8 -0
- package/examples/basic/prompt-only.json +7 -0
- package/package.json +45 -0
- package/schemas/cli-result-v1.schema.json +184 -0
- package/schemas/extension-v1.schema.json +659 -0
- package/schemas/report-v1.schema.json +164 -0
- package/schemas/run-evidence-v1.schema.json +510 -0
- package/src/advisory-fixture.ts +282 -0
- package/src/advisory.ts +105 -0
- package/src/cli.ts +837 -0
- package/src/engine.ts +2341 -0
- package/src/extension-client.ts +257 -0
- package/src/extension-session.ts +335 -0
- package/src/fixture-bin.ts +87 -0
- package/src/fixture-cleanup.ts +38 -0
- package/src/fixture-setup.ts +163 -0
- package/src/generated-fixture.ts +220 -0
- package/src/git-hooks.ts +63 -0
- package/src/graders/git-head.ts +176 -0
- package/src/graders/output.ts +359 -0
- package/src/graders/semantic.ts +120 -0
- package/src/graders/shell.ts +277 -0
- package/src/hosts/claude-continuation.ts +94 -0
- package/src/hosts/claude-credential.ts +73 -0
- package/src/hosts/claude-events.ts +128 -0
- package/src/hosts/claude-nested-skills.ts +218 -0
- package/src/hosts/claude-repository-invocation.ts +239 -0
- package/src/hosts/claude-settings.ts +50 -0
- package/src/hosts/claude-tool-calls.ts +159 -0
- package/src/hosts/claude.ts +511 -0
- package/src/hosts/codex-configuration.ts +85 -0
- package/src/hosts/codex-events.ts +102 -0
- package/src/hosts/codex-native-calls.ts +702 -0
- package/src/hosts/codex-profile.ts +78 -0
- package/src/hosts/codex-skill-reads.ts +345 -0
- package/src/hosts/codex.ts +769 -0
- package/src/hosts/isolation-roots.ts +60 -0
- package/src/hosts/mac-sandbox.ts +108 -0
- package/src/hosts/native-controls.ts +124 -0
- package/src/hosts/protected-worktrees.ts +56 -0
- package/src/hosts/workspace-fingerprint.ts +55 -0
- package/src/identity.ts +156 -0
- package/src/instrumentation.ts +106 -0
- package/src/preparation.ts +211 -0
- package/src/provenance.ts +244 -0
- package/src/report.ts +351 -0
- package/src/repository-fixture.ts +286 -0
- package/src/results.ts +160 -0
- package/src/run-owner.ts +276 -0
- package/src/schema.ts +28 -0
- package/src/storage.ts +23 -0
- package/src/trial-scheduler.ts +27 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import Ajv2020 from "ajv/dist/2020.js";
|
|
3
|
+
import extensionSchema from "../schemas/extension-v1.schema.json";
|
|
4
|
+
|
|
5
|
+
const MAX_MESSAGE_BYTES = 8 * 1024 * 1024;
|
|
6
|
+
const MAX_DIAGNOSTIC_BYTES = 64 * 1024;
|
|
7
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
8
|
+
const PROTOCOL = "sevro.extension.v1";
|
|
9
|
+
|
|
10
|
+
const ajv = new Ajv2020({
|
|
11
|
+
strict: true,
|
|
12
|
+
strictRequired: false,
|
|
13
|
+
strictTypes: false,
|
|
14
|
+
});
|
|
15
|
+
const validExchange = ajv.compile(extensionSchema);
|
|
16
|
+
|
|
17
|
+
export class ExtensionProtocolError extends Error {
|
|
18
|
+
constructor(message: string) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.name = "ExtensionProtocolError";
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ExtensionRequest {
|
|
25
|
+
protocol: string;
|
|
26
|
+
id: string;
|
|
27
|
+
method: "describe" | "resolve" | "prepare" | "evaluate";
|
|
28
|
+
params: Record<string, unknown>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ExtensionResponse {
|
|
32
|
+
protocol: string;
|
|
33
|
+
id: string;
|
|
34
|
+
method: ExtensionRequest["method"];
|
|
35
|
+
result?: Record<string, unknown>;
|
|
36
|
+
error?: { code: string; message: string };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ExchangeOptions {
|
|
40
|
+
cwd?: string;
|
|
41
|
+
timeoutMs?: number;
|
|
42
|
+
signal?: AbortSignal;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function readBounded(
|
|
46
|
+
stream: ReadableStream<Uint8Array>,
|
|
47
|
+
limit: number,
|
|
48
|
+
truncate: boolean,
|
|
49
|
+
): Promise<Uint8Array> {
|
|
50
|
+
const reader = stream.getReader();
|
|
51
|
+
const chunks: Uint8Array[] = [];
|
|
52
|
+
let size = 0;
|
|
53
|
+
while (true) {
|
|
54
|
+
const { value, done } = await reader.read();
|
|
55
|
+
if (done) break;
|
|
56
|
+
if (!truncate && size + value.byteLength > limit)
|
|
57
|
+
throw new ExtensionProtocolError("extension message exceeds 8 MiB");
|
|
58
|
+
const kept = value.subarray(0, Math.max(0, limit - size));
|
|
59
|
+
if (kept.byteLength) chunks.push(kept);
|
|
60
|
+
size += kept.byteLength;
|
|
61
|
+
}
|
|
62
|
+
const result = new Uint8Array(size);
|
|
63
|
+
let offset = 0;
|
|
64
|
+
for (const chunk of chunks) {
|
|
65
|
+
result.set(chunk, offset);
|
|
66
|
+
offset += chunk.byteLength;
|
|
67
|
+
}
|
|
68
|
+
return result;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function terminate(proc: Bun.Subprocess): void {
|
|
72
|
+
if (process.platform !== "win32") {
|
|
73
|
+
try {
|
|
74
|
+
process.kill(-proc.pid, "SIGKILL");
|
|
75
|
+
return;
|
|
76
|
+
} catch {
|
|
77
|
+
// The group may have exited while the runner was handling the error.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
proc.kill("SIGKILL");
|
|
82
|
+
} catch {
|
|
83
|
+
// The process already exited.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Run exactly one request in a fresh extension process. */
|
|
88
|
+
export async function exchangeExtension(
|
|
89
|
+
command: string[],
|
|
90
|
+
request: ExtensionRequest,
|
|
91
|
+
options: ExchangeOptions = {},
|
|
92
|
+
): Promise<ExtensionResponse> {
|
|
93
|
+
if (!command.length || command.some((part) => !part))
|
|
94
|
+
throw new ExtensionProtocolError(
|
|
95
|
+
"extension command must be a nonempty argv array",
|
|
96
|
+
);
|
|
97
|
+
if (!validExchange(request))
|
|
98
|
+
throw new ExtensionProtocolError("invalid extension request");
|
|
99
|
+
if (options.signal?.aborted)
|
|
100
|
+
throw new ExtensionProtocolError("extension request cancelled");
|
|
101
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
102
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0)
|
|
103
|
+
throw new ExtensionProtocolError(
|
|
104
|
+
"extension timeout must be a positive integer",
|
|
105
|
+
);
|
|
106
|
+
const payload = new TextEncoder().encode(JSON.stringify(request));
|
|
107
|
+
if (payload.byteLength > MAX_MESSAGE_BYTES)
|
|
108
|
+
throw new ExtensionProtocolError("extension request exceeds 8 MiB");
|
|
109
|
+
|
|
110
|
+
let proc: Bun.Subprocess;
|
|
111
|
+
try {
|
|
112
|
+
proc = Bun.spawn(command, {
|
|
113
|
+
cwd: options.cwd,
|
|
114
|
+
stdin: "pipe",
|
|
115
|
+
stdout: "pipe",
|
|
116
|
+
stderr: "pipe",
|
|
117
|
+
detached: process.platform !== "win32",
|
|
118
|
+
});
|
|
119
|
+
} catch {
|
|
120
|
+
throw new ExtensionProtocolError("could not start extension process");
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
124
|
+
let cancel: (() => void) | undefined;
|
|
125
|
+
try {
|
|
126
|
+
const { stdin, stdout, stderr } = proc;
|
|
127
|
+
if (
|
|
128
|
+
!stdin ||
|
|
129
|
+
typeof stdin === "number" ||
|
|
130
|
+
!stdout ||
|
|
131
|
+
typeof stdout === "number" ||
|
|
132
|
+
!stderr ||
|
|
133
|
+
typeof stderr === "number"
|
|
134
|
+
)
|
|
135
|
+
throw new ExtensionProtocolError("extension pipes are unavailable");
|
|
136
|
+
const write = (async () => {
|
|
137
|
+
await stdin.write(payload);
|
|
138
|
+
await stdin.end();
|
|
139
|
+
})();
|
|
140
|
+
const output = (async () => {
|
|
141
|
+
const [responseBytes, _diagnostic, exitCode] = await Promise.all([
|
|
142
|
+
readBounded(stdout, MAX_MESSAGE_BYTES, false),
|
|
143
|
+
readBounded(stderr, MAX_DIAGNOSTIC_BYTES, true),
|
|
144
|
+
proc.exited,
|
|
145
|
+
write,
|
|
146
|
+
]);
|
|
147
|
+
if (exitCode !== 0)
|
|
148
|
+
throw new ExtensionProtocolError(
|
|
149
|
+
`extension exited with code ${exitCode}`,
|
|
150
|
+
);
|
|
151
|
+
let response: unknown;
|
|
152
|
+
try {
|
|
153
|
+
response = JSON.parse(
|
|
154
|
+
new TextDecoder("utf-8", { fatal: true }).decode(responseBytes),
|
|
155
|
+
);
|
|
156
|
+
} catch {
|
|
157
|
+
throw new ExtensionProtocolError(
|
|
158
|
+
"extension did not return one UTF-8 JSON response",
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
if (!validExchange(response))
|
|
162
|
+
throw new ExtensionProtocolError("invalid extension response");
|
|
163
|
+
const envelope = response as unknown as ExtensionResponse;
|
|
164
|
+
if (
|
|
165
|
+
envelope.id !== request.id ||
|
|
166
|
+
envelope.protocol !== request.protocol ||
|
|
167
|
+
envelope.method !== request.method
|
|
168
|
+
)
|
|
169
|
+
throw new ExtensionProtocolError(
|
|
170
|
+
"extension response does not match request",
|
|
171
|
+
);
|
|
172
|
+
if (envelope.error)
|
|
173
|
+
throw new ExtensionProtocolError(
|
|
174
|
+
`extension reported ${envelope.error.code}`,
|
|
175
|
+
);
|
|
176
|
+
return envelope;
|
|
177
|
+
})();
|
|
178
|
+
const timeout = new Promise<never>((_resolve, reject) => {
|
|
179
|
+
timer = setTimeout(
|
|
180
|
+
() => reject(new ExtensionProtocolError("extension timed out")),
|
|
181
|
+
timeoutMs,
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
const aborted = new Promise<never>((_resolve, reject) => {
|
|
185
|
+
if (!options.signal) return;
|
|
186
|
+
cancel = () =>
|
|
187
|
+
reject(new ExtensionProtocolError("extension request cancelled"));
|
|
188
|
+
if (options.signal.aborted) cancel();
|
|
189
|
+
else options.signal.addEventListener("abort", cancel, { once: true });
|
|
190
|
+
});
|
|
191
|
+
return await Promise.race([output, timeout, aborted]);
|
|
192
|
+
} catch (error) {
|
|
193
|
+
terminate(proc);
|
|
194
|
+
if (error instanceof ExtensionProtocolError) throw error;
|
|
195
|
+
throw new ExtensionProtocolError("extension transport failed");
|
|
196
|
+
} finally {
|
|
197
|
+
if (timer) clearTimeout(timer);
|
|
198
|
+
if (cancel) options.signal?.removeEventListener("abort", cancel);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export interface NegotiatedExtension {
|
|
203
|
+
id: string;
|
|
204
|
+
version: string;
|
|
205
|
+
protocol: string;
|
|
206
|
+
capabilities: string[];
|
|
207
|
+
graders: string[];
|
|
208
|
+
taskVerdictPolicies: string[];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Discover an extension and select the v1 protocol and available capabilities. */
|
|
212
|
+
export async function negotiateExtension(
|
|
213
|
+
command: string[],
|
|
214
|
+
available: { engineCapabilities: string[]; hostCapabilities: string[] },
|
|
215
|
+
options: ExchangeOptions = {},
|
|
216
|
+
): Promise<NegotiatedExtension> {
|
|
217
|
+
const response = await exchangeExtension(
|
|
218
|
+
command,
|
|
219
|
+
{
|
|
220
|
+
protocol: "sevro.discovery.v1",
|
|
221
|
+
id: randomUUID(),
|
|
222
|
+
method: "describe",
|
|
223
|
+
params: {
|
|
224
|
+
protocols: [PROTOCOL],
|
|
225
|
+
engineCapabilities: available.engineCapabilities,
|
|
226
|
+
hostCapabilities: available.hostCapabilities,
|
|
227
|
+
},
|
|
228
|
+
},
|
|
229
|
+
options,
|
|
230
|
+
);
|
|
231
|
+
const result = response.result!;
|
|
232
|
+
const offered = result.protocols as string[];
|
|
233
|
+
if (!offered.includes(PROTOCOL))
|
|
234
|
+
throw new ExtensionProtocolError("extension has no compatible protocol");
|
|
235
|
+
const supported = new Set([
|
|
236
|
+
...available.engineCapabilities,
|
|
237
|
+
...available.hostCapabilities,
|
|
238
|
+
]);
|
|
239
|
+
const required = result.requiredCapabilities as string[];
|
|
240
|
+
for (const capability of required)
|
|
241
|
+
if (!supported.has(capability))
|
|
242
|
+
throw new ExtensionProtocolError(
|
|
243
|
+
`unsupported required capability: ${capability}`,
|
|
244
|
+
);
|
|
245
|
+
const optional = (result.optionalCapabilities as string[]).filter((item) =>
|
|
246
|
+
supported.has(item),
|
|
247
|
+
);
|
|
248
|
+
const extension = result.extension as { id: string; version: string };
|
|
249
|
+
return {
|
|
250
|
+
id: extension.id,
|
|
251
|
+
version: extension.version,
|
|
252
|
+
protocol: PROTOCOL,
|
|
253
|
+
capabilities: [...new Set([...required, ...optional])],
|
|
254
|
+
graders: result.graders as string[],
|
|
255
|
+
taskVerdictPolicies: result.taskVerdictPolicies as string[],
|
|
256
|
+
};
|
|
257
|
+
}
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { createReadStream } from "node:fs";
|
|
3
|
+
import { isAbsolute } from "node:path";
|
|
4
|
+
import { canonicalJson, hashJson } from "./identity";
|
|
5
|
+
import type { GeneratedFixture } from "./generated-fixture";
|
|
6
|
+
import type { RepositoryFixture } from "./repository-fixture";
|
|
7
|
+
import type { FixtureSetupDeclaration } from "./fixture-setup";
|
|
8
|
+
import {
|
|
9
|
+
ExtensionProtocolError,
|
|
10
|
+
exchangeExtension,
|
|
11
|
+
negotiateExtension,
|
|
12
|
+
type ExchangeOptions,
|
|
13
|
+
type NegotiatedExtension,
|
|
14
|
+
} from "./extension-client";
|
|
15
|
+
|
|
16
|
+
const PROTOCOL = "sevro.extension.v1";
|
|
17
|
+
|
|
18
|
+
export interface ExtensionCase {
|
|
19
|
+
id: string;
|
|
20
|
+
prompt: string;
|
|
21
|
+
followUpPrompt?: string;
|
|
22
|
+
fixture:
|
|
23
|
+
| { kind: "inline"; files: Record<string, string> }
|
|
24
|
+
| RepositoryFixture
|
|
25
|
+
| GeneratedFixture;
|
|
26
|
+
checks: {
|
|
27
|
+
id: string;
|
|
28
|
+
grader: string;
|
|
29
|
+
configuration: Record<string, unknown>;
|
|
30
|
+
}[];
|
|
31
|
+
requiredEvidence: string[];
|
|
32
|
+
extensionData: Record<string, unknown>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface PreparationResult {
|
|
36
|
+
artifacts: {
|
|
37
|
+
id: string;
|
|
38
|
+
relativePath: string;
|
|
39
|
+
sha256: string;
|
|
40
|
+
contentBase64?: string;
|
|
41
|
+
sourceRef?: string;
|
|
42
|
+
gitExclude?: boolean;
|
|
43
|
+
executable?: boolean;
|
|
44
|
+
}[];
|
|
45
|
+
requestedInstrumentation: {
|
|
46
|
+
id: string;
|
|
47
|
+
configuration: Record<string, unknown>;
|
|
48
|
+
}[];
|
|
49
|
+
fixtureSetup?: FixtureSetupDeclaration;
|
|
50
|
+
codexMarketplace?: {
|
|
51
|
+
artifactRoot: string;
|
|
52
|
+
marketplaceName: string;
|
|
53
|
+
pluginNames: string[];
|
|
54
|
+
};
|
|
55
|
+
claudePluginDirs?: { artifactRoots: string[] };
|
|
56
|
+
codexSkillInvocation?: {
|
|
57
|
+
pluginName: string;
|
|
58
|
+
skillName: string;
|
|
59
|
+
};
|
|
60
|
+
codexRepositorySkillInvocation?: { skillName: string };
|
|
61
|
+
claudeRepositorySkillInvocation?: { skillName: string };
|
|
62
|
+
claudeSkillInvocation?: {
|
|
63
|
+
pluginName: string;
|
|
64
|
+
skillName: string;
|
|
65
|
+
};
|
|
66
|
+
extensionData: Record<string, unknown>;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface EvaluationRequest {
|
|
70
|
+
caseId: string;
|
|
71
|
+
execution: {
|
|
72
|
+
status: "completed" | "failed" | "cancelled" | "not_run";
|
|
73
|
+
errorCode?: string;
|
|
74
|
+
};
|
|
75
|
+
observations: {
|
|
76
|
+
id: string;
|
|
77
|
+
source: string;
|
|
78
|
+
completeness: "complete" | "partial" | "unavailable";
|
|
79
|
+
data: Record<string, unknown>;
|
|
80
|
+
}[];
|
|
81
|
+
builtinChecks: {
|
|
82
|
+
id: string;
|
|
83
|
+
status: "passed" | "failed" | "unavailable";
|
|
84
|
+
detail?: string;
|
|
85
|
+
evidenceRefs: string[];
|
|
86
|
+
}[];
|
|
87
|
+
artifacts: {
|
|
88
|
+
id: string;
|
|
89
|
+
path: string;
|
|
90
|
+
sha256: string;
|
|
91
|
+
gitExclude?: boolean;
|
|
92
|
+
executable?: boolean;
|
|
93
|
+
}[];
|
|
94
|
+
extensionData: Record<string, unknown>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface EvaluationResult {
|
|
98
|
+
checks: {
|
|
99
|
+
id: string;
|
|
100
|
+
status: "passed" | "failed" | "unavailable";
|
|
101
|
+
detail?: string;
|
|
102
|
+
evidenceRefs: string[];
|
|
103
|
+
}[];
|
|
104
|
+
metrics: { id: string; value: number | null; unit: string }[];
|
|
105
|
+
domainOutcomes?: {
|
|
106
|
+
id: string;
|
|
107
|
+
status: "passed" | "failed" | "unavailable";
|
|
108
|
+
detail?: string;
|
|
109
|
+
evidenceRefs: string[];
|
|
110
|
+
data?: Record<string, unknown>;
|
|
111
|
+
}[];
|
|
112
|
+
taskVerdictRecommendation?: "passed" | "failed" | "not_assessed";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export interface ExtensionSessionOptions extends ExchangeOptions {
|
|
116
|
+
command: string[];
|
|
117
|
+
sourceFiles: string[];
|
|
118
|
+
configuration: Record<string, unknown>;
|
|
119
|
+
redactedConfiguration: Record<string, unknown>;
|
|
120
|
+
engineCapabilities: string[];
|
|
121
|
+
hostCapabilities: string[];
|
|
122
|
+
taskVerdictPolicy?: string;
|
|
123
|
+
replaceBuiltinGraders?: string[];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface ExtensionIdentity extends NegotiatedExtension {
|
|
127
|
+
sourceDigest: string;
|
|
128
|
+
configurationDigest: string;
|
|
129
|
+
selectedTaskVerdictPolicy: string | null;
|
|
130
|
+
replacedBuiltinGraders: string[];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function digestSources(paths: string[]): Promise<string> {
|
|
134
|
+
const hashes: string[] = [];
|
|
135
|
+
for (const path of paths) {
|
|
136
|
+
try {
|
|
137
|
+
const hash = createHash("sha256");
|
|
138
|
+
for await (const chunk of createReadStream(path)) hash.update(chunk);
|
|
139
|
+
hashes.push(hash.digest("hex"));
|
|
140
|
+
} catch {
|
|
141
|
+
throw new ExtensionProtocolError(
|
|
142
|
+
"declared extension source is unreadable",
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return hashJson(hashes);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function jsonCopy<T>(value: T): T {
|
|
150
|
+
return JSON.parse(canonicalJson(value)) as T;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Bind a stateless extension command to a negotiated identity and source snapshot. */
|
|
154
|
+
export async function openExtensionSession(options: ExtensionSessionOptions) {
|
|
155
|
+
const command = [...options.command];
|
|
156
|
+
const sourceFiles = [...options.sourceFiles];
|
|
157
|
+
const sourceClosure = [...new Set([command[0], ...sourceFiles])];
|
|
158
|
+
const taskVerdictPolicy = options.taskVerdictPolicy;
|
|
159
|
+
const replacedBuiltinGraders = [
|
|
160
|
+
...(options.replaceBuiltinGraders ?? []),
|
|
161
|
+
].sort();
|
|
162
|
+
const exchangeOptions: ExchangeOptions = {
|
|
163
|
+
cwd: options.cwd,
|
|
164
|
+
timeoutMs: options.timeoutMs,
|
|
165
|
+
signal: options.signal,
|
|
166
|
+
};
|
|
167
|
+
if (
|
|
168
|
+
!command.length ||
|
|
169
|
+
!isAbsolute(command[0] ?? "") ||
|
|
170
|
+
!sourceFiles.length ||
|
|
171
|
+
sourceFiles.some((path) => !isAbsolute(path)) ||
|
|
172
|
+
new Set(sourceFiles).size !== sourceFiles.length
|
|
173
|
+
)
|
|
174
|
+
throw new ExtensionProtocolError(
|
|
175
|
+
"extension source files must be unique absolute paths",
|
|
176
|
+
);
|
|
177
|
+
if (new Set(replacedBuiltinGraders).size !== replacedBuiltinGraders.length)
|
|
178
|
+
throw new ExtensionProtocolError("duplicate built-in grader replacement");
|
|
179
|
+
const configuration = jsonCopy(options.configuration);
|
|
180
|
+
const redactedConfiguration = jsonCopy(options.redactedConfiguration);
|
|
181
|
+
const sourceDigest = await digestSources(sourceClosure);
|
|
182
|
+
const negotiated = await negotiateExtension(
|
|
183
|
+
command,
|
|
184
|
+
{
|
|
185
|
+
engineCapabilities: options.engineCapabilities,
|
|
186
|
+
hostCapabilities: options.hostCapabilities,
|
|
187
|
+
},
|
|
188
|
+
exchangeOptions,
|
|
189
|
+
);
|
|
190
|
+
if ((await digestSources(sourceClosure)) !== sourceDigest)
|
|
191
|
+
throw new ExtensionProtocolError(
|
|
192
|
+
"extension source changed during discovery",
|
|
193
|
+
);
|
|
194
|
+
if (
|
|
195
|
+
taskVerdictPolicy &&
|
|
196
|
+
!negotiated.taskVerdictPolicies.includes(taskVerdictPolicy)
|
|
197
|
+
)
|
|
198
|
+
throw new ExtensionProtocolError(
|
|
199
|
+
"extension did not advertise the selected task policy",
|
|
200
|
+
);
|
|
201
|
+
const identity: ExtensionIdentity = {
|
|
202
|
+
...negotiated,
|
|
203
|
+
sourceDigest,
|
|
204
|
+
configurationDigest: hashJson({
|
|
205
|
+
command,
|
|
206
|
+
configuration: redactedConfiguration,
|
|
207
|
+
taskVerdictPolicy: taskVerdictPolicy ?? null,
|
|
208
|
+
replacedBuiltinGraders,
|
|
209
|
+
}),
|
|
210
|
+
selectedTaskVerdictPolicy: taskVerdictPolicy ?? null,
|
|
211
|
+
replacedBuiltinGraders,
|
|
212
|
+
};
|
|
213
|
+
Object.freeze(identity.capabilities);
|
|
214
|
+
Object.freeze(identity.graders);
|
|
215
|
+
Object.freeze(identity.taskVerdictPolicies);
|
|
216
|
+
Object.freeze(identity.replacedBuiltinGraders);
|
|
217
|
+
Object.freeze(identity);
|
|
218
|
+
|
|
219
|
+
async function call(
|
|
220
|
+
method: "resolve" | "prepare" | "evaluate",
|
|
221
|
+
params: object,
|
|
222
|
+
) {
|
|
223
|
+
if ((await digestSources(sourceClosure)) !== sourceDigest)
|
|
224
|
+
throw new ExtensionProtocolError("extension source changed during run");
|
|
225
|
+
const response = await exchangeExtension(
|
|
226
|
+
command,
|
|
227
|
+
{
|
|
228
|
+
protocol: PROTOCOL,
|
|
229
|
+
id: randomUUID(),
|
|
230
|
+
method,
|
|
231
|
+
params: { ...params, configuration },
|
|
232
|
+
},
|
|
233
|
+
exchangeOptions,
|
|
234
|
+
);
|
|
235
|
+
if ((await digestSources(sourceClosure)) !== sourceDigest)
|
|
236
|
+
throw new ExtensionProtocolError("extension source changed during run");
|
|
237
|
+
return response.result!;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
identity,
|
|
242
|
+
get redactedConfiguration() {
|
|
243
|
+
return jsonCopy(redactedConfiguration);
|
|
244
|
+
},
|
|
245
|
+
async resolve(
|
|
246
|
+
projectRoot: string,
|
|
247
|
+
selectors: Record<string, unknown>,
|
|
248
|
+
host?: {
|
|
249
|
+
id: string;
|
|
250
|
+
model: string;
|
|
251
|
+
effort: string;
|
|
252
|
+
capabilities: string[];
|
|
253
|
+
},
|
|
254
|
+
): Promise<ExtensionCase[]> {
|
|
255
|
+
const result = await call("resolve", {
|
|
256
|
+
projectRoot,
|
|
257
|
+
selectors,
|
|
258
|
+
...(host ? { host } : {}),
|
|
259
|
+
});
|
|
260
|
+
const cases = result.cases as ExtensionCase[];
|
|
261
|
+
const ids = cases.map((item) => item.id);
|
|
262
|
+
if (new Set(ids).size !== ids.length)
|
|
263
|
+
throw new ExtensionProtocolError(
|
|
264
|
+
"extension resolved duplicate case IDs",
|
|
265
|
+
);
|
|
266
|
+
return cases;
|
|
267
|
+
},
|
|
268
|
+
async prepare(
|
|
269
|
+
resolvedCase: ExtensionCase,
|
|
270
|
+
host: { id: string; capabilities: string[] },
|
|
271
|
+
condition: "passive" | "enforced",
|
|
272
|
+
): Promise<PreparationResult> {
|
|
273
|
+
return (await call("prepare", {
|
|
274
|
+
case: resolvedCase,
|
|
275
|
+
host,
|
|
276
|
+
condition,
|
|
277
|
+
})) as unknown as PreparationResult;
|
|
278
|
+
},
|
|
279
|
+
async evaluate(request: EvaluationRequest): Promise<EvaluationResult> {
|
|
280
|
+
const result = (await call("evaluate", {
|
|
281
|
+
...request,
|
|
282
|
+
...(taskVerdictPolicy
|
|
283
|
+
? { selectedTaskVerdictPolicy: taskVerdictPolicy }
|
|
284
|
+
: {}),
|
|
285
|
+
})) as unknown as EvaluationResult;
|
|
286
|
+
const outcomes = result.domainOutcomes ?? [];
|
|
287
|
+
const ids = [
|
|
288
|
+
...result.checks.map((check) => check.id),
|
|
289
|
+
...outcomes.map((outcome) => outcome.id),
|
|
290
|
+
];
|
|
291
|
+
if (
|
|
292
|
+
new Set(ids).size !== ids.length ||
|
|
293
|
+
ids.some((id) => !id.startsWith(`${identity.id}.`))
|
|
294
|
+
)
|
|
295
|
+
throw new ExtensionProtocolError(
|
|
296
|
+
"extension returned duplicate or foreign result IDs",
|
|
297
|
+
);
|
|
298
|
+
if (result.taskVerdictRecommendation && !taskVerdictPolicy)
|
|
299
|
+
throw new ExtensionProtocolError(
|
|
300
|
+
"extension task policy was not selected",
|
|
301
|
+
);
|
|
302
|
+
if (taskVerdictPolicy && !result.taskVerdictRecommendation)
|
|
303
|
+
throw new ExtensionProtocolError(
|
|
304
|
+
"extension omitted the selected task policy recommendation",
|
|
305
|
+
);
|
|
306
|
+
const available = new Map(
|
|
307
|
+
request.observations.map((item) => [item.id, item.completeness]),
|
|
308
|
+
);
|
|
309
|
+
for (const item of request.artifacts) available.set(item.id, "complete");
|
|
310
|
+
for (const item of request.builtinChecks)
|
|
311
|
+
available.set(
|
|
312
|
+
item.id,
|
|
313
|
+
item.status === "unavailable" ? "unavailable" : "complete",
|
|
314
|
+
);
|
|
315
|
+
for (const item of [...result.checks, ...outcomes]) {
|
|
316
|
+
if (item.status === "passed" && item.evidenceRefs.length === 0)
|
|
317
|
+
throw new ExtensionProtocolError(
|
|
318
|
+
"extension passed a result without evidence",
|
|
319
|
+
);
|
|
320
|
+
if (item.evidenceRefs.some((id) => !available.has(id)))
|
|
321
|
+
throw new ExtensionProtocolError(
|
|
322
|
+
"extension result cites unknown evidence",
|
|
323
|
+
);
|
|
324
|
+
if (
|
|
325
|
+
item.status === "passed" &&
|
|
326
|
+
item.evidenceRefs.some((id) => available.get(id) !== "complete")
|
|
327
|
+
)
|
|
328
|
+
throw new ExtensionProtocolError(
|
|
329
|
+
"extension passed a result with incomplete evidence",
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
return result;
|
|
333
|
+
},
|
|
334
|
+
};
|
|
335
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { chmod, lstat, mkdir, realpath, writeFile } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
const MAX_TOOLS = 64;
|
|
5
|
+
const MAX_BYTES = 1024 * 1024;
|
|
6
|
+
|
|
7
|
+
/** Validate executable fixture tools without allowing path traversal. */
|
|
8
|
+
export function prepareFixtureBin(
|
|
9
|
+
value: unknown,
|
|
10
|
+
): Record<string, string> | null {
|
|
11
|
+
if (value === undefined) return null;
|
|
12
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
13
|
+
throw new Error("invalid fixture binaries");
|
|
14
|
+
const bin = value as Record<string, unknown>;
|
|
15
|
+
const entries = Object.entries(bin);
|
|
16
|
+
if (
|
|
17
|
+
entries.length > MAX_TOOLS ||
|
|
18
|
+
entries.some(
|
|
19
|
+
([name, content]) =>
|
|
20
|
+
!/^[A-Za-z][A-Za-z0-9._-]*$/.test(name) ||
|
|
21
|
+
name.length > 64 ||
|
|
22
|
+
typeof content !== "string" ||
|
|
23
|
+
!content ||
|
|
24
|
+
content.includes("\0"),
|
|
25
|
+
) ||
|
|
26
|
+
entries.reduce(
|
|
27
|
+
(size, [name, content]) =>
|
|
28
|
+
size +
|
|
29
|
+
Buffer.byteLength(name, "utf8") +
|
|
30
|
+
Buffer.byteLength(content as string, "utf8"),
|
|
31
|
+
0,
|
|
32
|
+
) > MAX_BYTES
|
|
33
|
+
)
|
|
34
|
+
throw new Error("invalid fixture binaries");
|
|
35
|
+
return bin as Record<string, string>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Install declared tools inside the fixture's Git metadata, after commits. */
|
|
39
|
+
export async function installFixtureBin(
|
|
40
|
+
bin: Record<string, string> | undefined,
|
|
41
|
+
workspace: string,
|
|
42
|
+
): Promise<void> {
|
|
43
|
+
if (!bin || Object.keys(bin).length === 0) return;
|
|
44
|
+
const directory = join(workspace, ".git", "fixture-bin");
|
|
45
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
46
|
+
const root = await lstat(directory);
|
|
47
|
+
if (!root.isDirectory() || root.isSymbolicLink())
|
|
48
|
+
throw new Error("fixture binary directory is invalid");
|
|
49
|
+
for (const [name, content] of Object.entries(bin)) {
|
|
50
|
+
const target = join(directory, name);
|
|
51
|
+
try {
|
|
52
|
+
const existing = await lstat(target);
|
|
53
|
+
if (!existing.isFile() || existing.isSymbolicLink())
|
|
54
|
+
throw new Error("fixture binary target is invalid");
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (!(
|
|
57
|
+
error instanceof Error &&
|
|
58
|
+
"code" in error &&
|
|
59
|
+
error.code === "ENOENT"
|
|
60
|
+
))
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
await writeFile(target, content, { mode: 0o755 });
|
|
64
|
+
await chmod(target, 0o755);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Setup may also install trusted tools, such as a repository ticket stub. */
|
|
69
|
+
export async function fixtureBinDirectory(
|
|
70
|
+
workspace: string,
|
|
71
|
+
): Promise<string | null> {
|
|
72
|
+
const path = join(workspace, ".git", "fixture-bin");
|
|
73
|
+
try {
|
|
74
|
+
const entry = await lstat(path);
|
|
75
|
+
if (!entry.isDirectory() || entry.isSymbolicLink())
|
|
76
|
+
throw new Error("fixture binary directory is invalid");
|
|
77
|
+
const canonicalWorkspace = await realpath(workspace);
|
|
78
|
+
const canonicalBin = await realpath(path);
|
|
79
|
+
if (canonicalBin !== join(canonicalWorkspace, ".git", "fixture-bin"))
|
|
80
|
+
throw new Error("fixture binary directory escapes the workspace");
|
|
81
|
+
return path;
|
|
82
|
+
} catch (error) {
|
|
83
|
+
if (error instanceof Error && "code" in error && error.code === "ENOENT")
|
|
84
|
+
return null;
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
}
|