@bjoernrochel/sevro 0.1.0-rc.2 → 0.1.0-rc.21
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/CONTRIBUTING.md +170 -0
- package/LICENSE +63 -136
- package/README.md +83 -66
- package/docs/README.md +44 -0
- package/docs/acknowledgements.md +14 -0
- package/docs/architecture.md +146 -0
- package/docs/assets/README.md +27 -0
- package/docs/assets/sevro-logo.png +0 -0
- package/docs/assets/sevro-symbol.svg +1 -0
- package/docs/assets/sevro-wordmark.svg +1 -0
- package/docs/builtin-graders-v1.md +41 -13
- package/docs/codex-host.md +184 -52
- package/docs/contracts.md +19 -0
- package/docs/creating-cases.md +139 -0
- package/docs/development-cli.md +190 -30
- package/docs/documentation-quality.md +61 -0
- package/docs/evaluation-workflow.md +110 -0
- package/docs/evidence/code-mode-controls-71.md +64 -0
- package/docs/evidence/code-mode-programs-85.md +64 -0
- package/docs/evidence/codex-state-73/README.md +204 -0
- package/docs/evidence/codex-state-73/darrow-migration.patch +182 -0
- package/docs/evidence/codex-state-73/results.json +661 -0
- package/docs/evidence/local-http-74.md +104 -0
- package/docs/evidence/runtime-startup-30/README.md +185 -0
- package/docs/evidence/runtime-startup-30/after.json +319 -0
- package/docs/evidence/runtime-startup-30/baseline-timeout.jsonl +2 -0
- package/docs/evidence/runtime-startup-30/before.json +403 -0
- package/docs/evidence/runtime-startup-30/consumer-smoke.json +63 -0
- package/docs/evidence/runtime-startup-30/full-policy-diagnostic.jsonl +3 -0
- package/docs/evidence/runtime-startup-30/minimal-policy-diagnostic.jsonl +3 -0
- package/docs/evidence/runtime-startup-30/plugin-template-assessment.json +20 -0
- package/docs/evidence/runtime-startup-30/seedless-timeout.jsonl +2 -0
- package/docs/evidence/skill-reads-70/results.json +823 -0
- package/docs/evidence.md +21 -0
- package/docs/extension-protocol-v1.md +50 -16
- package/docs/fixture-cleanup-validation.md +3 -0
- package/docs/getting-started.md +102 -0
- package/docs/guide-evaluation.md +89 -0
- package/docs/guide-verification.json +239 -0
- package/docs/identity-v1.md +35 -1
- package/docs/installing.md +88 -0
- package/docs/licenses/BUSL-1.1-0.1.0-rc.2.txt +159 -0
- package/docs/licensing.md +42 -0
- package/docs/native-controls.md +76 -5
- package/docs/native-hosts.md +228 -0
- package/docs/reading-results.md +141 -0
- package/docs/recipes.md +489 -0
- package/docs/releases.md +81 -40
- package/docs/report-v1.md +29 -1
- package/docs/results-v1.md +180 -0
- package/docs/run-settings-v1.md +147 -0
- package/docs/running-evaluations.md +31 -0
- package/docs/runtime-v1.md +614 -0
- package/docs/specs/repository-guide.md +58 -0
- package/docs/troubleshooting.md +30 -0
- package/docs/typescript-quality.md +234 -0
- package/docs/usability-study.md +114 -0
- package/examples/basic/host.ts +2 -2
- package/examples/recipes/candidate.ts +26 -0
- package/examples/recipes/cases/coding-change.json +21 -0
- package/examples/recipes/cases/host-tools.json +21 -0
- package/examples/recipes/cases/mise.json +21 -0
- package/examples/recipes/cases/node.json +21 -0
- package/examples/recipes/cases/python-uv.json +21 -0
- package/examples/recipes/cases/semantic.json +17 -0
- package/examples/recipes/coding-change/check.ts +23 -0
- package/examples/recipes/extension-artifacts.ts +65 -0
- package/examples/recipes/extension-methods.ts +174 -0
- package/examples/recipes/extension.ts +37 -0
- package/examples/recipes/repository-skill/.agents/skills/startup-guide/SKILL.md +7 -0
- package/examples/recipes/repository-skill/.claude/skills/startup-guide/SKILL.md +7 -0
- package/examples/recipes/repository-skill/README.md +3 -0
- package/examples/recipes/run-inputs.ts +188 -0
- package/examples/recipes/run-options.ts +131 -0
- package/examples/recipes/run.ts +63 -0
- package/examples/recipes/runtime/empty.json +1 -0
- package/examples/recipes/runtime/goals.json +1 -0
- package/examples/recipes/runtime/mise.json +6 -0
- package/examples/recipes/runtime/path.json +1 -0
- package/examples/recipes/runtime/plugins.json +4 -0
- package/examples/recipes/runtime/python-uv.json +10 -0
- package/examples/recipes/semantic-judge.ts +29 -0
- package/examples/templates/coding/adapter.ts +17 -0
- package/examples/templates/coding/case.json +15 -0
- package/examples/templates/repository-skill/.agents/skills/startup-guide/SKILL.md +6 -0
- package/examples/templates/repository-skill/extension-data.ts +64 -0
- package/examples/templates/repository-skill/extension.ts +31 -0
- package/examples/templates/response/adapter.ts +12 -0
- package/examples/templates/response/case.json +13 -0
- package/package.json +39 -6
- package/schemas/report-v1.schema.json +9 -0
- package/schemas/run-evidence-v1.schema.json +2 -1
- package/schemas/runtime-v1.schema.json +193 -0
- package/src/advisory.ts +10 -5
- package/src/candidate-artifacts.ts +208 -0
- package/src/candidate-codex-state.ts +185 -0
- package/src/case-validation.ts +105 -0
- package/src/cli/cancellation.ts +20 -0
- package/src/cli/command.ts +81 -0
- package/src/cli/completion.ts +15 -0
- package/src/cli/demo.ts +59 -0
- package/src/cli/evaluation.ts +55 -0
- package/src/cli/extension.ts +79 -0
- package/src/cli/help.ts +222 -0
- package/src/cli/hosts.ts +59 -0
- package/src/cli/init-settings.ts +30 -0
- package/src/cli/init.ts +111 -0
- package/src/cli/invocation.ts +627 -0
- package/src/cli/loading.ts +151 -0
- package/src/cli/preparation.ts +163 -0
- package/src/cli/presentation.ts +91 -0
- package/src/cli/progress.ts +6 -0
- package/src/cli/run-manifest.ts +133 -0
- package/src/cli/run-options.ts +101 -0
- package/src/cli/run-overrides.ts +83 -0
- package/src/cli/settings.ts +160 -0
- package/src/cli/summary.ts +43 -0
- package/src/cli.ts +13 -843
- package/src/contract-types.ts +94 -0
- package/src/engine.ts +19 -2373
- package/src/evaluation/advisory.ts +150 -0
- package/src/evaluation/check-plan.ts +510 -0
- package/src/evaluation/collection.ts +88 -0
- package/src/evaluation/context.ts +10 -0
- package/src/evaluation/grading-host.ts +39 -0
- package/src/evaluation/grading.ts +258 -0
- package/src/evaluation/invocation-plan.ts +548 -0
- package/src/evaluation/plan.ts +736 -0
- package/src/evaluation/progress.ts +19 -0
- package/src/evaluation/resources.ts +241 -0
- package/src/evaluation/run.ts +206 -0
- package/src/{trial-scheduler.ts → evaluation/scheduler.ts} +3 -1
- package/src/evaluation/semantic.ts +312 -0
- package/src/evaluation/trial.ts +866 -0
- package/src/evaluation/types.ts +207 -0
- package/src/evidence/host-results.ts +92 -0
- package/src/evidence/retained-artifacts.ts +18 -0
- package/src/evidence/trial-writer.ts +203 -0
- package/src/evidence/usage.ts +17 -0
- package/src/extension-client.ts +228 -148
- package/src/extension-session.ts +326 -195
- package/src/fixtures/advisory.ts +338 -0
- package/src/fixtures/bin.ts +57 -0
- package/src/{fixture-cleanup.ts → fixtures/cleanup.ts} +13 -7
- package/src/fixtures/create.ts +187 -0
- package/src/fixtures/generated.ts +300 -0
- package/src/fixtures/git-hooks.ts +24 -0
- package/src/fixtures/preparation.ts +270 -0
- package/src/{repository-fixture.ts → fixtures/repository.ts} +207 -85
- package/src/fixtures/setup.ts +224 -0
- package/src/fixtures/tools.ts +88 -0
- package/src/fixtures/workspace-state.ts +212 -0
- package/src/generated/cli-result.cjs +2 -0
- package/src/generated/cli-result.d.cts +3 -0
- package/src/generated/extension.cjs +2 -0
- package/src/generated/extension.d.cts +3 -0
- package/src/generated/report.cjs +2 -0
- package/src/generated/report.d.cts +3 -0
- package/src/generated/run-evidence.cjs +2 -0
- package/src/generated/run-evidence.d.cts +3 -0
- package/src/generated/runtime.cjs +2 -0
- package/src/generated/runtime.d.cts +3 -0
- package/src/graders/artifact.ts +56 -23
- package/src/graders/git-head.ts +79 -75
- package/src/graders/output.ts +222 -140
- package/src/graders/semantic.ts +102 -43
- package/src/graders/shell.ts +379 -207
- package/src/host-diagnostic-redaction.ts +52 -0
- package/src/host-diagnostics.ts +319 -0
- package/src/hosts/claude-continuation.ts +63 -18
- package/src/hosts/claude-credential.ts +43 -62
- package/src/hosts/claude-events.ts +86 -51
- package/src/hosts/claude-keychain.ts +59 -0
- package/src/hosts/claude-native-goal.ts +57 -33
- package/src/hosts/claude-process.ts +111 -0
- package/src/hosts/claude-repository-invocation.ts +208 -117
- package/src/hosts/claude-settings.ts +240 -21
- package/src/hosts/claude-skill-activations.ts +166 -0
- package/src/hosts/claude-state.ts +403 -0
- package/src/hosts/claude-tool-calls.ts +171 -106
- package/src/hosts/claude.ts +561 -451
- package/src/hosts/codex-app-server.ts +326 -178
- package/src/hosts/codex-configuration.ts +44 -18
- package/src/hosts/codex-events.ts +97 -60
- package/src/hosts/codex-exec-controls.ts +516 -0
- package/src/hosts/codex-invocation.ts +70 -0
- package/src/hosts/codex-marketplace.ts +283 -0
- package/src/hosts/codex-native-calls.ts +546 -500
- package/src/hosts/codex-process.ts +202 -0
- package/src/hosts/codex-profile.ts +257 -28
- package/src/hosts/codex-session-files.ts +106 -0
- package/src/hosts/codex-state.ts +492 -0
- package/src/hosts/codex.ts +783 -832
- package/src/hosts/isolation-common.ts +94 -0
- package/src/hosts/isolation-roots.ts +54 -27
- package/src/hosts/isolation.ts +10 -0
- package/src/hosts/linux-sandbox.ts +159 -0
- package/src/hosts/mac-sandbox.ts +187 -61
- package/src/hosts/native-controls.ts +152 -62
- package/src/hosts/process-lifecycle.ts +37 -0
- package/src/hosts/protected-worktrees.ts +51 -19
- package/src/hosts/skill-hook-catalog.ts +131 -0
- package/src/hosts/skill-hook-handler.ts +374 -0
- package/src/hosts/skill-hooks.ts +580 -0
- package/src/hosts/workspace-fingerprint.ts +55 -35
- package/src/identity.ts +102 -48
- package/src/instrumentation.ts +109 -48
- package/src/native-transcript-state.ts +131 -0
- package/src/native-transcript-view.ts +90 -0
- package/src/native-transcripts.ts +286 -0
- package/src/process-lifecycle.ts +150 -0
- package/src/provenance.ts +161 -81
- package/src/prune.ts +215 -0
- package/src/report.ts +172 -140
- package/src/resolved-case.ts +33 -0
- package/src/results-lock.ts +29 -0
- package/src/results.ts +133 -86
- package/src/run-owner.ts +154 -63
- package/src/runtime/config.ts +456 -0
- package/src/runtime/discovery-apple.ts +90 -0
- package/src/runtime/discovery-providers.ts +63 -0
- package/src/runtime/discovery.ts +70 -0
- package/src/runtime/hook-processes.ts +97 -0
- package/src/runtime/hooks.ts +476 -0
- package/src/runtime/paths.ts +313 -0
- package/src/runtime/query.ts +85 -0
- package/src/runtime/seeds.ts +134 -0
- package/src/runtime/shared-cache.ts +143 -0
- package/src/runtime/state.ts +143 -0
- package/src/schema-types.ts +198 -0
- package/src/schema.ts +15 -19
- package/src/transcript-retention.ts +146 -0
- package/src/value-guards.ts +20 -0
- package/src/advisory-fixture.ts +0 -282
- package/src/fixture-bin.ts +0 -87
- package/src/fixture-setup.ts +0 -163
- package/src/generated-fixture.ts +0 -220
- package/src/git-hooks.ts +0 -63
- package/src/hosts/claude-nested-skills.ts +0 -218
- package/src/hosts/codex-command-output.ts +0 -399
- package/src/hosts/codex-executor-command.ts +0 -195
- package/src/hosts/codex-skill-reads.ts +0 -391
- package/src/preparation.ts +0 -211
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import type { Dirent, Stats } from "node:fs";
|
|
2
|
+
import { isMissingFile } from "./tools";
|
|
3
|
+
import {
|
|
4
|
+
lstat,
|
|
5
|
+
mkdir,
|
|
6
|
+
readlink,
|
|
7
|
+
readdir,
|
|
8
|
+
realpath,
|
|
9
|
+
rm,
|
|
10
|
+
symlink,
|
|
11
|
+
copyFile,
|
|
12
|
+
} from "node:fs/promises";
|
|
13
|
+
import { allocateWorkspace, removeWorkspace } from "./workspace-state";
|
|
14
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
15
|
+
import { fixtureParts } from "./preparation";
|
|
16
|
+
|
|
17
|
+
const MAX_GIT_OUTPUT = 16 * 1024 * 1024;
|
|
18
|
+
const MAX_UNTRACKED_BYTES = 32 * 1024 * 1024;
|
|
19
|
+
const MAX_UNTRACKED_FILES = 1024;
|
|
20
|
+
const HIDDEN_ROOTS = [".agents", ".claude", ".codex", ".git"];
|
|
21
|
+
|
|
22
|
+
function contained(root: string, path: string): boolean {
|
|
23
|
+
const child = relative(root, path);
|
|
24
|
+
return (
|
|
25
|
+
child === "" ||
|
|
26
|
+
(child !== ".." && !child.startsWith(`..${sep}`) && !isAbsolute(child))
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function excluded(path: string, roots: string[]): boolean {
|
|
31
|
+
return roots.some((root) => path === root || path.startsWith(`${root}/`));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function bounded(stream: ReadableStream<Uint8Array>): Promise<Buffer> {
|
|
35
|
+
const chunks: Uint8Array[] = [];
|
|
36
|
+
let size = 0;
|
|
37
|
+
for await (const chunk of stream) {
|
|
38
|
+
size += chunk.byteLength;
|
|
39
|
+
if (size > MAX_GIT_OUTPUT)
|
|
40
|
+
throw new Error("advisory fixture Git output is too large");
|
|
41
|
+
chunks.push(chunk);
|
|
42
|
+
}
|
|
43
|
+
return Buffer.concat(chunks);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function nullGitConfig(): string {
|
|
47
|
+
return process.platform === "win32" ? "NUL" : "/dev/null";
|
|
48
|
+
}
|
|
49
|
+
function gitEnvironment(): Record<string, string> {
|
|
50
|
+
return {
|
|
51
|
+
PATH: process.env.PATH ?? "/usr/bin:/bin",
|
|
52
|
+
...(process.env.SystemRoot ? { SystemRoot: process.env.SystemRoot } : {}),
|
|
53
|
+
GIT_CONFIG_NOSYSTEM: "1",
|
|
54
|
+
GIT_CONFIG_GLOBAL: nullGitConfig(),
|
|
55
|
+
GIT_TERMINAL_PROMPT: "0",
|
|
56
|
+
GIT_AUTHOR_NAME: "Sevro Advisory",
|
|
57
|
+
GIT_AUTHOR_EMAIL: "advisory@sevro.invalid",
|
|
58
|
+
GIT_COMMITTER_NAME: "Sevro Advisory",
|
|
59
|
+
GIT_COMMITTER_EMAIL: "advisory@sevro.invalid",
|
|
60
|
+
GIT_AUTHOR_DATE: "2000-01-01T00:00:00+00:00",
|
|
61
|
+
GIT_COMMITTER_DATE: "2000-01-01T00:00:00+00:00",
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function git(
|
|
66
|
+
cwd: string,
|
|
67
|
+
args: string[],
|
|
68
|
+
input?: Uint8Array,
|
|
69
|
+
): Promise<Buffer> {
|
|
70
|
+
const proc = Bun.spawn(
|
|
71
|
+
["git", "-c", `core.hooksPath=${nullGitConfig()}`, ...args],
|
|
72
|
+
{
|
|
73
|
+
cwd,
|
|
74
|
+
stdin: input ? "pipe" : "ignore",
|
|
75
|
+
stdout: "pipe",
|
|
76
|
+
stderr: "pipe",
|
|
77
|
+
env: gitEnvironment(),
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
try {
|
|
81
|
+
const write = (async () => {
|
|
82
|
+
if (!input) return;
|
|
83
|
+
if (!proc.stdin || typeof proc.stdin === "number")
|
|
84
|
+
throw new Error("advisory fixture Git input unavailable");
|
|
85
|
+
await proc.stdin.write(input);
|
|
86
|
+
await proc.stdin.end();
|
|
87
|
+
})();
|
|
88
|
+
const [out, , code] = await Promise.all([
|
|
89
|
+
bounded(proc.stdout),
|
|
90
|
+
bounded(proc.stderr),
|
|
91
|
+
proc.exited,
|
|
92
|
+
write,
|
|
93
|
+
]);
|
|
94
|
+
if (code !== 0) throw new Error("advisory fixture Git operation failed");
|
|
95
|
+
return out;
|
|
96
|
+
} catch (cause) {
|
|
97
|
+
try {
|
|
98
|
+
proc.kill("SIGKILL");
|
|
99
|
+
} catch {
|
|
100
|
+
// The process may already have exited.
|
|
101
|
+
}
|
|
102
|
+
await proc.exited;
|
|
103
|
+
throw new Error("advisory fixture Git operation failed", { cause });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function requireRelativeSymlink(
|
|
108
|
+
root: string,
|
|
109
|
+
path: string,
|
|
110
|
+
target: string,
|
|
111
|
+
): void {
|
|
112
|
+
if (isAbsolute(target) || !contained(root, resolve(dirname(path), target)))
|
|
113
|
+
throw new Error("advisory fixture contains an escaping symlink");
|
|
114
|
+
}
|
|
115
|
+
async function checkSymlink(root: string, path: string): Promise<void> {
|
|
116
|
+
const target = await readlink(path);
|
|
117
|
+
requireRelativeSymlink(root, path, target);
|
|
118
|
+
const actual = await realpath(path).catch((cause: unknown) => {
|
|
119
|
+
throw new Error("advisory fixture contains a broken symlink", { cause });
|
|
120
|
+
});
|
|
121
|
+
if (!contained(root, actual))
|
|
122
|
+
throw new Error("advisory fixture contains an escaping symlink");
|
|
123
|
+
}
|
|
124
|
+
async function checkAdvisoryEntry(
|
|
125
|
+
root: string,
|
|
126
|
+
path: string,
|
|
127
|
+
entry: Dirent,
|
|
128
|
+
): Promise<void> {
|
|
129
|
+
if (entry.isDirectory()) await checkSymlinks(root, path);
|
|
130
|
+
else if (entry.isSymbolicLink()) await checkSymlink(root, path);
|
|
131
|
+
}
|
|
132
|
+
async function checkSymlinks(root: string, directory = root): Promise<void> {
|
|
133
|
+
for (const entry of await readdir(directory, { withFileTypes: true })) {
|
|
134
|
+
if (directory === root && entry.name === ".git") continue;
|
|
135
|
+
await checkAdvisoryEntry(root, join(directory, entry.name), entry);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function safeParent(root: string, parts: string[]): Promise<void> {
|
|
140
|
+
let directory = root;
|
|
141
|
+
for (const part of parts.slice(0, -1)) {
|
|
142
|
+
directory = join(directory, part);
|
|
143
|
+
await mkdir(directory, { mode: 0o700 }).catch((error: unknown) => {
|
|
144
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
145
|
+
});
|
|
146
|
+
if (!(await lstat(directory)).isDirectory())
|
|
147
|
+
throw new Error("advisory fixture path has a non-directory parent");
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
async function copyUntracked(
|
|
152
|
+
source: string,
|
|
153
|
+
destination: string,
|
|
154
|
+
exclusions: string[],
|
|
155
|
+
): Promise<void> {
|
|
156
|
+
const output = await git(source, [
|
|
157
|
+
"ls-files",
|
|
158
|
+
"--others",
|
|
159
|
+
"--exclude-standard",
|
|
160
|
+
"-z",
|
|
161
|
+
]);
|
|
162
|
+
const paths = new TextDecoder("utf-8", { fatal: true })
|
|
163
|
+
.decode(output)
|
|
164
|
+
.split("\0")
|
|
165
|
+
.filter(Boolean)
|
|
166
|
+
.filter((path) => !excluded(path, exclusions));
|
|
167
|
+
if (paths.length > MAX_UNTRACKED_FILES)
|
|
168
|
+
throw new Error("advisory fixture has too many untracked files");
|
|
169
|
+
const state = { totalBytes: 0 };
|
|
170
|
+
for (const path of paths)
|
|
171
|
+
await copyUntrackedPath(path, source, destination, state);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
async function untrackedTargetExists(path: string): Promise<boolean> {
|
|
175
|
+
return lstat(path).then(
|
|
176
|
+
() => true,
|
|
177
|
+
(error: unknown) => {
|
|
178
|
+
if (isMissingFile(error)) return false;
|
|
179
|
+
throw error;
|
|
180
|
+
},
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
async function copyUntrackedPath(
|
|
184
|
+
path: string,
|
|
185
|
+
source: string,
|
|
186
|
+
destination: string,
|
|
187
|
+
state: { totalBytes: number },
|
|
188
|
+
): Promise<void> {
|
|
189
|
+
const parts = fixtureParts(path),
|
|
190
|
+
from = join(source, ...parts),
|
|
191
|
+
to = join(destination, ...parts);
|
|
192
|
+
if (!contained(source, from) || !contained(destination, to))
|
|
193
|
+
throw new Error("advisory fixture path escapes its root");
|
|
194
|
+
const info = await lstat(from);
|
|
195
|
+
state.totalBytes += info.size;
|
|
196
|
+
if (state.totalBytes > MAX_UNTRACKED_BYTES)
|
|
197
|
+
throw new Error("advisory fixture untracked files exceed the size limit");
|
|
198
|
+
await safeParent(destination, parts);
|
|
199
|
+
if (await untrackedTargetExists(to))
|
|
200
|
+
throw new Error("advisory fixture untracked path already exists");
|
|
201
|
+
await copyUntrackedEntry(from, to, destination, info);
|
|
202
|
+
}
|
|
203
|
+
async function copyUntrackedEntry(
|
|
204
|
+
from: string,
|
|
205
|
+
to: string,
|
|
206
|
+
destination: string,
|
|
207
|
+
info: Stats,
|
|
208
|
+
): Promise<void> {
|
|
209
|
+
if (info.isFile()) {
|
|
210
|
+
await copyFile(from, to);
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
if (info.isSymbolicLink()) {
|
|
214
|
+
const target = await readlink(from);
|
|
215
|
+
requireRelativeSymlink(destination, to, target);
|
|
216
|
+
await symlink(target, to);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
throw new Error("advisory fixture has an unsupported untracked entry");
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export interface BlindAdvisoryOptions {
|
|
223
|
+
/** Fixture HEAD before candidate execution. */
|
|
224
|
+
baseRevision: string;
|
|
225
|
+
/** Additional root-relative evaluator paths to omit from the judge view. */
|
|
226
|
+
excludedPaths?: string[];
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Capture the candidate fixture's baseline before the host edits it. */
|
|
230
|
+
export async function advisoryBaseRevision(workspace: string): Promise<string> {
|
|
231
|
+
const revision = (await git(workspace, ["rev-parse", "--verify", "HEAD"]))
|
|
232
|
+
.toString("utf8")
|
|
233
|
+
.trim();
|
|
234
|
+
if (!/^[a-f0-9]{40,64}$/.test(revision))
|
|
235
|
+
throw new Error("advisory fixture has no valid Git baseline");
|
|
236
|
+
return revision;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Rebuild a condition-blind Git view of the complete candidate change. */
|
|
240
|
+
export async function buildBlindAdvisoryFixture(
|
|
241
|
+
candidateWorkspace: string,
|
|
242
|
+
options: BlindAdvisoryOptions,
|
|
243
|
+
): Promise<string> {
|
|
244
|
+
requireAdvisorySource(candidateWorkspace, options.baseRevision);
|
|
245
|
+
const source = await realpath(candidateWorkspace);
|
|
246
|
+
const exclusions = advisoryExclusions(options);
|
|
247
|
+
const temporary = await allocateWorkspace();
|
|
248
|
+
try {
|
|
249
|
+
const destination = await realpath(temporary);
|
|
250
|
+
await initializeAdvisoryBaseline(
|
|
251
|
+
source,
|
|
252
|
+
destination,
|
|
253
|
+
options.baseRevision,
|
|
254
|
+
exclusions,
|
|
255
|
+
);
|
|
256
|
+
await applyCandidateChange(
|
|
257
|
+
source,
|
|
258
|
+
destination,
|
|
259
|
+
options.baseRevision,
|
|
260
|
+
exclusions,
|
|
261
|
+
);
|
|
262
|
+
return destination;
|
|
263
|
+
} catch (error) {
|
|
264
|
+
await removeWorkspace(temporary);
|
|
265
|
+
throw error;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function requireAdvisorySource(workspace: string, revision: string): void {
|
|
270
|
+
if (!isAbsolute(workspace) || !/^[a-f0-9]{40,64}$/.test(revision))
|
|
271
|
+
throw new Error("invalid advisory fixture source");
|
|
272
|
+
}
|
|
273
|
+
function advisoryExclusions(options: BlindAdvisoryOptions): string[] {
|
|
274
|
+
const paths = options.excludedPaths ?? [];
|
|
275
|
+
for (const path of paths) {
|
|
276
|
+
fixtureParts(path);
|
|
277
|
+
if (path.toLowerCase() === ".git" || path.toLowerCase().startsWith(".git/"))
|
|
278
|
+
throw new Error("invalid advisory exclusion");
|
|
279
|
+
}
|
|
280
|
+
return [...HIDDEN_ROOTS, ...paths];
|
|
281
|
+
}
|
|
282
|
+
async function initializeAdvisoryBaseline(
|
|
283
|
+
source: string,
|
|
284
|
+
destination: string,
|
|
285
|
+
baseRevision: string,
|
|
286
|
+
exclusions: string[],
|
|
287
|
+
): Promise<void> {
|
|
288
|
+
await git(source, [
|
|
289
|
+
"clone",
|
|
290
|
+
"--local",
|
|
291
|
+
"--no-hardlinks",
|
|
292
|
+
"--no-checkout",
|
|
293
|
+
"--quiet",
|
|
294
|
+
source,
|
|
295
|
+
destination,
|
|
296
|
+
]);
|
|
297
|
+
await git(destination, ["checkout", "--detach", "--quiet", baseRevision]);
|
|
298
|
+
for (const root of exclusions)
|
|
299
|
+
await rm(join(destination, ...fixtureParts(root)), {
|
|
300
|
+
recursive: true,
|
|
301
|
+
force: true,
|
|
302
|
+
});
|
|
303
|
+
await rm(join(destination, ".git"), { recursive: true, force: true });
|
|
304
|
+
await git(destination, ["init", "--quiet", "--initial-branch=main"]);
|
|
305
|
+
await git(destination, ["add", "-A"]);
|
|
306
|
+
await git(destination, [
|
|
307
|
+
"commit",
|
|
308
|
+
"--quiet",
|
|
309
|
+
"--allow-empty",
|
|
310
|
+
"-m",
|
|
311
|
+
"Advisory baseline",
|
|
312
|
+
]);
|
|
313
|
+
}
|
|
314
|
+
async function applyCandidateChange(
|
|
315
|
+
source: string,
|
|
316
|
+
destination: string,
|
|
317
|
+
baseRevision: string,
|
|
318
|
+
exclusions: string[],
|
|
319
|
+
): Promise<void> {
|
|
320
|
+
const pathspec = exclusions.flatMap((path) => [
|
|
321
|
+
`:(exclude)${path}`,
|
|
322
|
+
`:(exclude)${path}/**`,
|
|
323
|
+
]);
|
|
324
|
+
const patch = await git(source, [
|
|
325
|
+
"diff",
|
|
326
|
+
"--binary",
|
|
327
|
+
"--no-ext-diff",
|
|
328
|
+
"--no-textconv",
|
|
329
|
+
"--no-renames",
|
|
330
|
+
baseRevision,
|
|
331
|
+
"--",
|
|
332
|
+
".",
|
|
333
|
+
...pathspec,
|
|
334
|
+
]);
|
|
335
|
+
if (patch.length) await git(destination, ["apply", "--binary", "-"], patch);
|
|
336
|
+
await copyUntracked(source, destination, exclusions);
|
|
337
|
+
await checkSymlinks(destination);
|
|
338
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import {
|
|
2
|
+
prepareFixtureTools,
|
|
3
|
+
installFixtureTools,
|
|
4
|
+
isMissingFile,
|
|
5
|
+
} from "./tools";
|
|
6
|
+
import { lstat, mkdir, realpath } from "node:fs/promises";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
/** Validate executable fixture tools without allowing path traversal. */
|
|
10
|
+
export function prepareFixtureBin(
|
|
11
|
+
value: unknown,
|
|
12
|
+
): Record<string, string> | null {
|
|
13
|
+
return prepareFixtureTools(
|
|
14
|
+
value,
|
|
15
|
+
/^[A-Za-z][A-Za-z0-9._-]*$/,
|
|
16
|
+
64,
|
|
17
|
+
"invalid fixture binaries",
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Install declared tools inside the fixture's Git metadata, after commits. */
|
|
22
|
+
export async function installFixtureBin(
|
|
23
|
+
bin: Record<string, string> | undefined,
|
|
24
|
+
workspace: string,
|
|
25
|
+
): Promise<void> {
|
|
26
|
+
if (!bin || Object.keys(bin).length === 0) return;
|
|
27
|
+
const directory = join(workspace, ".git", "fixture-bin");
|
|
28
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
29
|
+
await installFixtureTools(bin, directory, {
|
|
30
|
+
directory: "fixture binary directory is invalid",
|
|
31
|
+
target: "fixture binary target is invalid",
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Setup may also install trusted tools, such as a repository ticket stub. */
|
|
36
|
+
export async function fixtureBinDirectory(
|
|
37
|
+
workspace: string,
|
|
38
|
+
): Promise<string | null> {
|
|
39
|
+
const path = join(workspace, ".git", "fixture-bin");
|
|
40
|
+
try {
|
|
41
|
+
await validateBinDirectory(path);
|
|
42
|
+
const canonicalWorkspace = await realpath(workspace);
|
|
43
|
+
const canonicalBin = await realpath(path);
|
|
44
|
+
if (canonicalBin !== join(canonicalWorkspace, ".git", "fixture-bin"))
|
|
45
|
+
throw new Error("fixture binary directory escapes the workspace");
|
|
46
|
+
return path;
|
|
47
|
+
} catch (error) {
|
|
48
|
+
if (isMissingFile(error)) return null;
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function validateBinDirectory(path: string): Promise<void> {
|
|
54
|
+
const entry = await lstat(path);
|
|
55
|
+
if (!entry.isDirectory() || entry.isSymbolicLink())
|
|
56
|
+
throw new Error("fixture binary directory is invalid");
|
|
57
|
+
}
|
|
@@ -15,6 +15,18 @@ async function restoreDirectoryAccess(path: string): Promise<void> {
|
|
|
15
15
|
await restoreDirectoryAccess(join(path, child.name));
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
/** Remove owned disposable state without following cache or fixture symlinks. */
|
|
19
|
+
export async function removeDisposableDirectory(path: string): Promise<void> {
|
|
20
|
+
try {
|
|
21
|
+
await rm(path, { recursive: true, force: true });
|
|
22
|
+
} catch (error) {
|
|
23
|
+
if ((error as NodeJS.ErrnoException).code === "ENOTDIR") return;
|
|
24
|
+
if (!permissionFailure(error)) throw error;
|
|
25
|
+
await restoreDirectoryAccess(path);
|
|
26
|
+
await rm(path, { recursive: true, force: true });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
18
30
|
/** Clear a completed fixture while preserving its reserved root for active peers. */
|
|
19
31
|
export async function clearFixtureContents(workspace: string): Promise<void> {
|
|
20
32
|
const root = await lstat(workspace);
|
|
@@ -26,13 +38,7 @@ export async function clearFixtureContents(workspace: string): Promise<void> {
|
|
|
26
38
|
await Promise.all(
|
|
27
39
|
names.map(async (name) => {
|
|
28
40
|
const path = join(workspace, name);
|
|
29
|
-
|
|
30
|
-
await rm(path, { recursive: true, force: true });
|
|
31
|
-
} catch (error) {
|
|
32
|
-
if (!permissionFailure(error)) throw error;
|
|
33
|
-
await restoreDirectoryAccess(path);
|
|
34
|
-
await rm(path, { recursive: true, force: true });
|
|
35
|
-
}
|
|
41
|
+
await removeDisposableDirectory(path);
|
|
36
42
|
}),
|
|
37
43
|
);
|
|
38
44
|
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { appendFile, mkdir, writeFile } from "node:fs/promises";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { allocateWorkspace, removeWorkspace } from "./workspace-state";
|
|
4
|
+
import { fixtureBinDirectory, installFixtureBin } from "./bin";
|
|
5
|
+
import { installGitHooks } from "./git-hooks";
|
|
6
|
+
import {
|
|
7
|
+
materializeGeneratedFixture,
|
|
8
|
+
type GeneratedFixture,
|
|
9
|
+
} from "./generated";
|
|
10
|
+
import { runFixtureSetup, type FixtureSetup } from "./setup";
|
|
11
|
+
import {
|
|
12
|
+
fixtureParts,
|
|
13
|
+
safePreparationTarget,
|
|
14
|
+
type InlineArtifact,
|
|
15
|
+
type PreparationSources,
|
|
16
|
+
} from "./preparation";
|
|
17
|
+
import {
|
|
18
|
+
applyRepositoryOverlay,
|
|
19
|
+
cloneRepositorySource,
|
|
20
|
+
type RepositoryFixture,
|
|
21
|
+
type RepositorySource,
|
|
22
|
+
} from "./repository";
|
|
23
|
+
import {
|
|
24
|
+
EvaluationConfigurationError,
|
|
25
|
+
type ResolvedCase,
|
|
26
|
+
} from "../evaluation/types";
|
|
27
|
+
|
|
28
|
+
export async function createFixture(
|
|
29
|
+
fixture: ResolvedCase["fixture"],
|
|
30
|
+
artifacts: InlineArtifact[],
|
|
31
|
+
sources: PreparationSources | undefined,
|
|
32
|
+
repository: RepositorySource | null,
|
|
33
|
+
repositoryFixture: RepositoryFixture | null,
|
|
34
|
+
generated: GeneratedFixture | null,
|
|
35
|
+
setup: FixtureSetup | null,
|
|
36
|
+
projectRoot: string,
|
|
37
|
+
signal?: AbortSignal,
|
|
38
|
+
reservedWorkspace?: string,
|
|
39
|
+
candidateRuntimeRoot?: string,
|
|
40
|
+
): Promise<string> {
|
|
41
|
+
const paths = inlineFixturePaths(fixture, generated, repository);
|
|
42
|
+
const workspace = reservedWorkspace ?? (await allocateWorkspace());
|
|
43
|
+
try {
|
|
44
|
+
await initializeFixtureHistory(
|
|
45
|
+
fixture,
|
|
46
|
+
sources,
|
|
47
|
+
repository,
|
|
48
|
+
repositoryFixture,
|
|
49
|
+
generated,
|
|
50
|
+
workspace,
|
|
51
|
+
);
|
|
52
|
+
await installDeclaredFixtureTools(
|
|
53
|
+
generated ?? repositoryFixture,
|
|
54
|
+
workspace,
|
|
55
|
+
);
|
|
56
|
+
await writeInlineFixtureFiles(paths, workspace);
|
|
57
|
+
await setupFixture(
|
|
58
|
+
setup,
|
|
59
|
+
workspace,
|
|
60
|
+
projectRoot,
|
|
61
|
+
signal,
|
|
62
|
+
candidateRuntimeRoot,
|
|
63
|
+
);
|
|
64
|
+
await mountPreparationArtifacts(artifacts, workspace);
|
|
65
|
+
await excludePreparationArtifacts(artifacts, workspace);
|
|
66
|
+
await fixtureBinDirectory(workspace);
|
|
67
|
+
return workspace;
|
|
68
|
+
} catch (error) {
|
|
69
|
+
if (!reservedWorkspace) await removeWorkspace(workspace);
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function inlineFixturePaths(
|
|
75
|
+
fixture: ResolvedCase["fixture"],
|
|
76
|
+
generated: GeneratedFixture | null,
|
|
77
|
+
repository: RepositorySource | null,
|
|
78
|
+
) {
|
|
79
|
+
return Object.entries(
|
|
80
|
+
generated || repository ? {} : (fixture.files ?? {}),
|
|
81
|
+
).map(([path, content]) => ({ path, parts: fixtureParts(path), content }));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function cloneFixtureRepository(
|
|
85
|
+
fixture: ResolvedCase["fixture"],
|
|
86
|
+
sources: PreparationSources | undefined,
|
|
87
|
+
repository: RepositorySource,
|
|
88
|
+
workspace: string,
|
|
89
|
+
): Promise<void> {
|
|
90
|
+
if (!fixture.sourceRef || !sources)
|
|
91
|
+
throw new EvaluationConfigurationError(
|
|
92
|
+
"repository source root is required and must be absolute",
|
|
93
|
+
);
|
|
94
|
+
await cloneRepositorySource(
|
|
95
|
+
fixture.sourceRef,
|
|
96
|
+
sources,
|
|
97
|
+
repository,
|
|
98
|
+
workspace,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function initializeFixtureHistory(
|
|
103
|
+
fixture: ResolvedCase["fixture"],
|
|
104
|
+
sources: PreparationSources | undefined,
|
|
105
|
+
repository: RepositorySource | null,
|
|
106
|
+
repositoryFixture: RepositoryFixture | null,
|
|
107
|
+
generated: GeneratedFixture | null,
|
|
108
|
+
workspace: string,
|
|
109
|
+
): Promise<void> {
|
|
110
|
+
if (repository)
|
|
111
|
+
await cloneFixtureRepository(fixture, sources, repository, workspace);
|
|
112
|
+
if (generated) await materializeGeneratedFixture(generated, workspace);
|
|
113
|
+
if (repositoryFixture)
|
|
114
|
+
await applyRepositoryOverlay(repositoryFixture, workspace);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function installDeclaredFixtureTools(
|
|
118
|
+
fixture: GeneratedFixture | RepositoryFixture | null,
|
|
119
|
+
workspace: string,
|
|
120
|
+
): Promise<void> {
|
|
121
|
+
await installFixtureBin(fixture?.bin, workspace);
|
|
122
|
+
await installGitHooks(fixture?.hooks, workspace);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function writeInlineFixtureFiles(
|
|
126
|
+
files: ReturnType<typeof inlineFixturePaths>,
|
|
127
|
+
workspace: string,
|
|
128
|
+
): Promise<void> {
|
|
129
|
+
for (const file of files) {
|
|
130
|
+
const target = join(workspace, ...file.parts);
|
|
131
|
+
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
132
|
+
await writeFile(target, file.content, { flag: "wx", mode: 0o600 });
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
async function setupFixture(
|
|
137
|
+
setup: FixtureSetup | null,
|
|
138
|
+
workspace: string,
|
|
139
|
+
projectRoot: string,
|
|
140
|
+
signal?: AbortSignal,
|
|
141
|
+
candidateRuntimeRoot?: string,
|
|
142
|
+
): Promise<void> {
|
|
143
|
+
if (!setup) return;
|
|
144
|
+
await runFixtureSetup(setup, {
|
|
145
|
+
workspace,
|
|
146
|
+
projectRoot,
|
|
147
|
+
fixtureBinDir: (await fixtureBinDirectory(workspace)) ?? undefined,
|
|
148
|
+
signal,
|
|
149
|
+
candidateRuntimeRoot,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function mountPreparationArtifacts(
|
|
154
|
+
artifacts: InlineArtifact[],
|
|
155
|
+
workspace: string,
|
|
156
|
+
): Promise<void> {
|
|
157
|
+
for (const artifact of artifacts) {
|
|
158
|
+
const target = await safePreparationTarget(
|
|
159
|
+
workspace,
|
|
160
|
+
artifact.relativePath,
|
|
161
|
+
);
|
|
162
|
+
await writeFile(target, artifact.bytes, {
|
|
163
|
+
flag: "wx",
|
|
164
|
+
mode: artifact.executable ? 0o700 : 0o600,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function excludePreparationArtifacts(
|
|
170
|
+
artifacts: InlineArtifact[],
|
|
171
|
+
workspace: string,
|
|
172
|
+
): Promise<void> {
|
|
173
|
+
const gitExcluded = artifacts.filter((artifact) => artifact.gitExclude);
|
|
174
|
+
if (!gitExcluded.length) return;
|
|
175
|
+
const special = new Set(["\\", "*", "?", "[", "]", "#", "!", " "]);
|
|
176
|
+
const patterns = gitExcluded.map((artifact) =>
|
|
177
|
+
Array.from(artifact.relativePath)
|
|
178
|
+
.map((character) =>
|
|
179
|
+
special.has(character) ? `\\${character}` : character,
|
|
180
|
+
)
|
|
181
|
+
.join(""),
|
|
182
|
+
);
|
|
183
|
+
await appendFile(
|
|
184
|
+
join(workspace, ".git", "info", "exclude"),
|
|
185
|
+
`\n${patterns.map((path) => `/${path}`).join("\n")}\n`,
|
|
186
|
+
);
|
|
187
|
+
}
|