@autor3search/javascript 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +500 -0
- package/bin/autor3search-javascript.js +4 -0
- package/package.json +50 -0
- package/src/adapters/bench/index.js +31 -0
- package/src/adapters/bench/vitest.js +128 -0
- package/src/adapters/driver-child.js +72 -0
- package/src/adapters/driver-hooks.js +18 -0
- package/src/adapters/driver.js +192 -0
- package/src/adapters/gates/index.js +64 -0
- package/src/adapters/gates/lint.js +46 -0
- package/src/adapters/gates/test.js +27 -0
- package/src/adapters/gates/typecheck.js +98 -0
- package/src/adapters/gates/util.js +45 -0
- package/src/adapters/vitest-shim.js +51 -0
- package/src/bench/parse.js +120 -0
- package/src/bench/set.js +101 -0
- package/src/bench/stats.js +443 -0
- package/src/cli/cmd-baseline.js +136 -0
- package/src/cli/cmd-doctor.js +38 -0
- package/src/cli/cmd-eval.js +231 -0
- package/src/cli/cmd-init.js +136 -0
- package/src/cli/cmd-profile.js +38 -0
- package/src/cli/cmd-report.js +96 -0
- package/src/cli/cmd-status.js +68 -0
- package/src/cli/cmd-stop.js +98 -0
- package/src/cli/cmd-version.js +31 -0
- package/src/cli/context.js +98 -0
- package/src/cli/main.js +62 -0
- package/src/config.js +209 -0
- package/src/discover.js +239 -0
- package/src/doctor.js +282 -0
- package/src/duration.js +75 -0
- package/src/freeze.js +234 -0
- package/src/gitx.js +115 -0
- package/src/measure.js +127 -0
- package/src/pipeline.js +391 -0
- package/src/profile.js +100 -0
- package/src/results.js +124 -0
- package/src/runner.js +198 -0
- package/src/scope.js +92 -0
- package/src/state/index.js +312 -0
- package/src/state/lock.js +189 -0
- package/src/state/stop.js +56 -0
- package/src/verdict.js +214 -0
- package/templates/program.md +264 -0
package/src/doctor.js
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checks whether this machine can measure benchmarks reliably.
|
|
3
|
+
*
|
|
4
|
+
* Every check is INFORMATIONAL. Numbers are only as good as the machine
|
|
5
|
+
* producing them, and a thermally throttled laptop on battery produces noise
|
|
6
|
+
* dressed as data — but whether that is acceptable is the human's call, not
|
|
7
|
+
* the harness's, so doctor reports and never blocks.
|
|
8
|
+
*/
|
|
9
|
+
import { execFile } from 'node:child_process'
|
|
10
|
+
import { readFile, stat, statfs } from 'node:fs/promises'
|
|
11
|
+
import { cpus, loadavg, platform } from 'node:os'
|
|
12
|
+
import { resolve as resolvePath } from 'node:path'
|
|
13
|
+
import { promisify } from 'node:util'
|
|
14
|
+
import * as gitx from './gitx.js'
|
|
15
|
+
import { vitestBin } from './adapters/bench/vitest.js'
|
|
16
|
+
|
|
17
|
+
const exec = promisify(execFile)
|
|
18
|
+
|
|
19
|
+
export const SEVERITY = { OK: 0, WARN: 1, FAIL: 2, NA: -1 }
|
|
20
|
+
|
|
21
|
+
/** The Node floor: parseArgs, module.register and --heap-prof all need it. */
|
|
22
|
+
const MIN_NODE_MAJOR = 20
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Runs every check. Each one is individually guarded, so an unfamiliar
|
|
26
|
+
* platform or a missing tool degrades one line rather than the whole report.
|
|
27
|
+
*
|
|
28
|
+
* @param {string} dir the repository root, or a directory inside it
|
|
29
|
+
* @returns {Promise<{name: string, detail: string, severity: number}[]>}
|
|
30
|
+
*/
|
|
31
|
+
export async function check(dir) {
|
|
32
|
+
// `-C` defaults to '.', so every check is given an ABSOLUTE directory once,
|
|
33
|
+
// up front: the git checks shell out with it as cwd, the disk check statfs's
|
|
34
|
+
// it, and the vitest check reports the path it looked in — a relative one
|
|
35
|
+
// would make that message meaningless. Resolving here keeps every check
|
|
36
|
+
// consistent regardless of what the caller passed.
|
|
37
|
+
const absDir = resolvePath(dir)
|
|
38
|
+
|
|
39
|
+
const platformCheck =
|
|
40
|
+
platform() === 'darwin'
|
|
41
|
+
? ['power', checkDarwin]
|
|
42
|
+
: platform() === 'linux'
|
|
43
|
+
? ['cpu governor', checkLinux]
|
|
44
|
+
: platform() === 'win32'
|
|
45
|
+
? ['platform', checkWindows]
|
|
46
|
+
: null
|
|
47
|
+
|
|
48
|
+
const checks = [
|
|
49
|
+
['node', checkNode],
|
|
50
|
+
['git', checkGit],
|
|
51
|
+
['git repo', () => checkGitRepo(absDir)],
|
|
52
|
+
['cpu', checkCpu],
|
|
53
|
+
['load', checkLoad],
|
|
54
|
+
['vitest', () => checkVitest(absDir)],
|
|
55
|
+
['heap hint', checkHeapHint],
|
|
56
|
+
platformCheck,
|
|
57
|
+
['disk', () => checkDisk(absDir)],
|
|
58
|
+
].filter(Boolean)
|
|
59
|
+
|
|
60
|
+
const findings = []
|
|
61
|
+
for (const [name, fn] of checks) {
|
|
62
|
+
try {
|
|
63
|
+
findings.push(await fn())
|
|
64
|
+
} catch (err) {
|
|
65
|
+
findings.push({ name, detail: `check failed: ${err.message}`, severity: SEVERITY.NA })
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return findings
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function checkNode() {
|
|
72
|
+
const major = Number(process.versions.node.split('.')[0])
|
|
73
|
+
return major >= MIN_NODE_MAJOR
|
|
74
|
+
? { name: 'node', detail: `node ${process.versions.node}`, severity: SEVERITY.OK }
|
|
75
|
+
: {
|
|
76
|
+
name: 'node',
|
|
77
|
+
detail: `node ${process.versions.node} is too old, need >= ${MIN_NODE_MAJOR}`,
|
|
78
|
+
severity: SEVERITY.FAIL,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function checkGit() {
|
|
83
|
+
try {
|
|
84
|
+
const { stdout } = await exec('git', ['--version'])
|
|
85
|
+
return { name: 'git', detail: stdout.trim(), severity: SEVERITY.OK }
|
|
86
|
+
} catch {
|
|
87
|
+
return { name: 'git', detail: 'git not found on PATH', severity: SEVERITY.FAIL }
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function checkGitRepo(dir) {
|
|
92
|
+
try {
|
|
93
|
+
return { name: 'git repo', detail: await gitx.root(dir), severity: SEVERITY.OK }
|
|
94
|
+
} catch {
|
|
95
|
+
return { name: 'git repo', detail: `${dir} is not inside a git repository`, severity: SEVERITY.FAIL }
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function checkCpu() {
|
|
100
|
+
const n = cpus().length
|
|
101
|
+
return {
|
|
102
|
+
name: 'cpu',
|
|
103
|
+
detail: `${n} logical core(s) — ${cpus()[0]?.model ?? 'unknown model'}`,
|
|
104
|
+
severity: n >= 2 ? SEVERITY.OK : SEVERITY.WARN,
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function checkLoad() {
|
|
109
|
+
const [one] = loadavg()
|
|
110
|
+
const perCore = one / Math.max(1, cpus().length)
|
|
111
|
+
return {
|
|
112
|
+
name: 'load',
|
|
113
|
+
detail:
|
|
114
|
+
`1-minute load ${one.toFixed(2)} (${perCore.toFixed(2)} per core)` +
|
|
115
|
+
(perCore > 0.5 ? ' — other work on this machine will show up as measurement noise' : ''),
|
|
116
|
+
severity: perCore > 0.5 ? SEVERITY.WARN : SEVERITY.OK,
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Resolves vitest from the MEASURED repository, not this harness's own
|
|
122
|
+
* node_modules — a repo with no vitest installed must not look fine just
|
|
123
|
+
* because the harness happens to depend on it for its own tests.
|
|
124
|
+
*/
|
|
125
|
+
async function checkVitest(dir) {
|
|
126
|
+
// Check the exact file the adapter spawns, not a module resolution of it.
|
|
127
|
+
// require.resolve('vitest/vitest.mjs') honours the package `exports` map,
|
|
128
|
+
// and Vitest 2 exposes a `./*` wildcard there while Vitest 3 and 4 do not —
|
|
129
|
+
// so the resolution answered "not installed" for every Vitest 3/4 repository
|
|
130
|
+
// even though the file is present and benchmarks measure fine. doctor exists
|
|
131
|
+
// to predict whether measurement will work, so it must ask what measurement
|
|
132
|
+
// asks.
|
|
133
|
+
const bin = vitestBin(dir)
|
|
134
|
+
const present = await stat(bin).then(
|
|
135
|
+
(st) => st.isFile(),
|
|
136
|
+
() => false,
|
|
137
|
+
)
|
|
138
|
+
return present
|
|
139
|
+
? { name: 'vitest', detail: 'vitest resolves in this repository', severity: SEVERITY.OK }
|
|
140
|
+
: {
|
|
141
|
+
name: 'vitest',
|
|
142
|
+
detail: `vitest is not installed here — benchmarks cannot be measured until it is (looked for ${bin})`,
|
|
143
|
+
severity: SEVERITY.WARN,
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Windows is not a supported platform, and the reason is the stop path.
|
|
149
|
+
*
|
|
150
|
+
* 448 of 458 tests pass there, so most of the harness works — but Node cannot
|
|
151
|
+
* deliver SIGINT to a child process group on Windows the way it does on POSIX,
|
|
152
|
+
* so an interrupted `eval` does not reach the ABORTED path: it exits with a
|
|
153
|
+
* null code instead of 2, and the run may leave a claim behind. An unattended
|
|
154
|
+
* harness that cannot be reliably stopped is the wrong thing to be quiet about,
|
|
155
|
+
* so this says it up front rather than at 3am.
|
|
156
|
+
*
|
|
157
|
+
* WSL reports linux and is unaffected.
|
|
158
|
+
*/
|
|
159
|
+
function checkWindows() {
|
|
160
|
+
return {
|
|
161
|
+
name: 'platform',
|
|
162
|
+
detail:
|
|
163
|
+
'Windows is not a supported platform: an interrupted eval does not reach the ABORTED path, so a run ' +
|
|
164
|
+
'may not stop cleanly and can leave its claim behind. Measurement itself works. Use WSL for a supported setup.',
|
|
165
|
+
severity: SEVERITY.WARN,
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function checkHeapHint() {
|
|
170
|
+
// Reported up front so a run does not discover mid-flight that the hint is
|
|
171
|
+
// unavailable, which would otherwise look like the benchmark allocating
|
|
172
|
+
// nothing. The driver always spawns its own child with --expose-gc, so
|
|
173
|
+
// this is OK regardless of whether gc() happens to be exposed in THIS
|
|
174
|
+
// process — but say so explicitly rather than implying it was probed here.
|
|
175
|
+
return {
|
|
176
|
+
name: 'heap hint',
|
|
177
|
+
detail: 'the driver spawns its own --expose-gc child, so the bytes/op hint should be available',
|
|
178
|
+
severity: SEVERITY.OK,
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Runs a command and returns its trimmed stdout, or null if it failed —
|
|
184
|
+
* missing binary, non-zero exit, or unrecognized flag. Every macOS-specific
|
|
185
|
+
* probe below tolerates null so one absent tool degrades one note, not the
|
|
186
|
+
* whole finding.
|
|
187
|
+
*/
|
|
188
|
+
async function tryExec(cmd, args) {
|
|
189
|
+
try {
|
|
190
|
+
const { stdout } = await exec(cmd, args)
|
|
191
|
+
return stdout
|
|
192
|
+
} catch {
|
|
193
|
+
return null
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function checkDarwin() {
|
|
198
|
+
const notes = []
|
|
199
|
+
let severity = SEVERITY.OK
|
|
200
|
+
|
|
201
|
+
const batt = await tryExec('pmset', ['-g', 'batt'])
|
|
202
|
+
if (batt === null) {
|
|
203
|
+
notes.push('pmset unavailable — power source unknown')
|
|
204
|
+
} else if (/Battery Power/.test(batt)) {
|
|
205
|
+
notes.push('running on battery — macOS clocks down aggressively')
|
|
206
|
+
severity = SEVERITY.WARN
|
|
207
|
+
} else {
|
|
208
|
+
notes.push('on AC power')
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const gen = await tryExec('pmset', ['-g'])
|
|
212
|
+
if (gen === null) {
|
|
213
|
+
notes.push('Low Power Mode unknown')
|
|
214
|
+
} else {
|
|
215
|
+
const m = gen.match(/lowpowermode\s+(\d+)/i)
|
|
216
|
+
if (m && m[1] !== '0') {
|
|
217
|
+
notes.push('Low Power Mode is on')
|
|
218
|
+
severity = SEVERITY.WARN
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// `machdep.xcpm.cpu_thermal_level` is Intel-only (xcpm = Intel's power
|
|
223
|
+
// manager) and does not exist on Apple Silicon, where sysctl exits
|
|
224
|
+
// non-zero with "unknown oid". `pmset -g therm` works on both, but macOS
|
|
225
|
+
// only starts recording these fields once a thermal EVENT has actually
|
|
226
|
+
// occurred since boot — a fresh boot legitimately prints
|
|
227
|
+
// "No thermal warning level has been recorded", which means "nothing to
|
|
228
|
+
// report", not "unknown". Anything else unrecognized is reported as such
|
|
229
|
+
// rather than silently assumed fine.
|
|
230
|
+
const therm = await tryExec('pmset', ['-g', 'therm'])
|
|
231
|
+
if (therm === null) {
|
|
232
|
+
notes.push('thermal state unknown (pmset -g therm unavailable)')
|
|
233
|
+
} else {
|
|
234
|
+
const limit = therm.match(/CPU_Speed_Limit\s*=\s*(\d+)/)
|
|
235
|
+
if (limit) {
|
|
236
|
+
const pct = Number(limit[1])
|
|
237
|
+
if (pct < 100) {
|
|
238
|
+
notes.push(`CPU speed limited to ${pct}% by thermal pressure`)
|
|
239
|
+
severity = SEVERITY.WARN
|
|
240
|
+
} else {
|
|
241
|
+
notes.push('no thermal throttling')
|
|
242
|
+
}
|
|
243
|
+
} else if (/No thermal warning level has been recorded/i.test(therm)) {
|
|
244
|
+
notes.push('no thermal event recorded since boot')
|
|
245
|
+
} else {
|
|
246
|
+
notes.push('thermal state format not recognized on this machine')
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
notes.push('P/E core scheduling makes macOS numbers jump; a quiet Linux box gives cleaner results')
|
|
251
|
+
return { name: 'power', detail: notes.join('; '), severity }
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async function checkLinux() {
|
|
255
|
+
const governor = await readFile('/sys/devices/system/cpu/cpu0/cpufreq/scaling_governor', 'utf8').catch(() => null)
|
|
256
|
+
const noTurbo = await readFile('/sys/devices/system/cpu/intel_pstate/no_turbo', 'utf8').catch(() => null)
|
|
257
|
+
const notes = []
|
|
258
|
+
let severity = SEVERITY.OK
|
|
259
|
+
if (governor === null) {
|
|
260
|
+
notes.push('cpufreq governor not readable')
|
|
261
|
+
} else if (governor.trim() !== 'performance') {
|
|
262
|
+
notes.push(`cpufreq governor is "${governor.trim()}" — "performance" gives steadier numbers`)
|
|
263
|
+
severity = SEVERITY.WARN
|
|
264
|
+
} else {
|
|
265
|
+
notes.push('cpufreq governor is "performance"')
|
|
266
|
+
}
|
|
267
|
+
if (noTurbo !== null && noTurbo.trim() === '0') {
|
|
268
|
+
notes.push('turbo boost is enabled — clock varies with temperature')
|
|
269
|
+
severity = SEVERITY.WARN
|
|
270
|
+
}
|
|
271
|
+
return { name: 'cpu governor', detail: notes.join('; '), severity }
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async function checkDisk(dir) {
|
|
275
|
+
const stats = await statfs(dir)
|
|
276
|
+
const freeGb = (stats.bavail * stats.bsize) / 1024 ** 3
|
|
277
|
+
return {
|
|
278
|
+
name: 'disk',
|
|
279
|
+
detail: `${freeGb.toFixed(1)} GB free`,
|
|
280
|
+
severity: freeGb < 2 ? SEVERITY.WARN : SEVERITY.OK,
|
|
281
|
+
}
|
|
282
|
+
}
|
package/src/duration.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Duration parsing, used for `timeout` in the run configuration. Values are
|
|
3
|
+
* written as a number and a unit ("1s", "500ms", "15m"), optionally
|
|
4
|
+
* compounded ("1m30s") — the notation most performance tooling already uses,
|
|
5
|
+
* so there is nothing new to learn here.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Milliseconds per unit. */
|
|
9
|
+
const UNITS = {
|
|
10
|
+
ns: 1e-6,
|
|
11
|
+
us: 1e-3,
|
|
12
|
+
µs: 1e-3,
|
|
13
|
+
ms: 1,
|
|
14
|
+
s: 1000,
|
|
15
|
+
m: 60_000,
|
|
16
|
+
h: 3_600_000,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** One `<number><unit>` term, e.g. "1h", "30.5s". */
|
|
20
|
+
const TERM = /(\d+(?:\.\d+)?)(ns|us|µs|ms|s|m|h)/gy
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parses a duration into milliseconds. Fractional values and compound terms
|
|
24
|
+
* ("1m30s") are both accepted; a negative or unitless value is not.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} s
|
|
27
|
+
* @returns {number} milliseconds
|
|
28
|
+
* @throws {Error} when s is not a duration
|
|
29
|
+
*/
|
|
30
|
+
export function parseDuration(s) {
|
|
31
|
+
const bad = () => new Error(`${JSON.stringify(s)} is not a duration (want e.g. "500ms", "1s", "15m")`)
|
|
32
|
+
if (typeof s !== 'string' || s.length === 0) throw bad()
|
|
33
|
+
|
|
34
|
+
TERM.lastIndex = 0
|
|
35
|
+
let total = 0
|
|
36
|
+
let terms = 0
|
|
37
|
+
let lastIndex = 0
|
|
38
|
+
let match
|
|
39
|
+
while ((match = TERM.exec(s)) !== null) {
|
|
40
|
+
total += Number(match[1]) * UNITS[match[2]]
|
|
41
|
+
terms++
|
|
42
|
+
lastIndex = TERM.lastIndex
|
|
43
|
+
}
|
|
44
|
+
// The sticky flag anchors each term to the end of the previous one, so a
|
|
45
|
+
// full match means lastIndex reached the end of the string. Anything left
|
|
46
|
+
// over — a leading sign, a stray unit, trailing text — means this was not a
|
|
47
|
+
// duration, and must not be silently accepted as its parseable prefix.
|
|
48
|
+
if (terms === 0 || lastIndex !== s.length) throw bad()
|
|
49
|
+
return total
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Renders milliseconds back into the same notation parseDuration accepts.
|
|
54
|
+
* Used only for human-facing output.
|
|
55
|
+
*
|
|
56
|
+
* @param {number} ms
|
|
57
|
+
* @returns {string}
|
|
58
|
+
*/
|
|
59
|
+
export function formatDuration(ms) {
|
|
60
|
+
if (ms < 1000) return `${round(ms)}ms`
|
|
61
|
+
const totalSeconds = ms / 1000
|
|
62
|
+
const hours = Math.floor(totalSeconds / 3600)
|
|
63
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
|
64
|
+
const seconds = round(totalSeconds % 60)
|
|
65
|
+
let out = ''
|
|
66
|
+
if (hours) out += `${hours}h`
|
|
67
|
+
if (minutes) out += `${minutes}m`
|
|
68
|
+
if (seconds || out === '') out += `${seconds}s`
|
|
69
|
+
return out
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Rounds to at most three decimal places, dropping a trailing ".0". */
|
|
73
|
+
function round(n) {
|
|
74
|
+
return Number(n.toFixed(3))
|
|
75
|
+
}
|
package/src/freeze.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Snapshots test and benchmark files at baseline time and restores them
|
|
3
|
+
* before every evaluation, so an agent cannot weaken its own success
|
|
4
|
+
* criteria — or rewrite the benchmark that defines the metric.
|
|
5
|
+
*
|
|
6
|
+
* Every path constant here is relative to the run's OUT-OF-TREE state
|
|
7
|
+
* directory, never to the repository root. The frozen store and its manifest
|
|
8
|
+
* are part of what the score depends on, so they must live where the agent
|
|
9
|
+
* being measured cannot reach them; a caller joining these onto the
|
|
10
|
+
* repository root would silently reintroduce that hole.
|
|
11
|
+
*/
|
|
12
|
+
import { createHash } from 'node:crypto'
|
|
13
|
+
import { lstat, mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
14
|
+
import { dirname, join, posix, isAbsolute } from 'node:path'
|
|
15
|
+
|
|
16
|
+
export const STORE_DIR = 'frozen'
|
|
17
|
+
export const MANIFEST_PATH = 'frozen/manifest.json'
|
|
18
|
+
|
|
19
|
+
/** `.code` on an error raised by a symlink anywhere along a frozen path. */
|
|
20
|
+
export const ERR_SYMLINK = 'A3S_SYMLINK'
|
|
21
|
+
|
|
22
|
+
/** `.code` on an error raised when a golden copy no longer matches its hash. */
|
|
23
|
+
export const ERR_STORE_TAMPERED = 'A3S_STORE_TAMPERED'
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* `.code` on an error raised by a HARD link at a frozen path.
|
|
27
|
+
*
|
|
28
|
+
* A hard link defeats the symlink defence entirely: `lstat().isSymbolicLink()`
|
|
29
|
+
* is false for one, so the path looks like an ordinary regular file. Writing
|
|
30
|
+
* to it writes to every other name for that inode — so an agent can point a
|
|
31
|
+
* frozen path at any file it may write and have `restore` clobber it with
|
|
32
|
+
* test source. That is an uncontrolled write outside the repository, which is
|
|
33
|
+
* exactly what this module exists to prevent.
|
|
34
|
+
*/
|
|
35
|
+
export const ERR_HARD_LINK = 'A3S_HARD_LINK'
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Copies each file into storeDir and records its hash.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} repoRoot
|
|
41
|
+
* @param {string} storeDir
|
|
42
|
+
* @param {string[]} files repo-relative paths
|
|
43
|
+
* @returns {Promise<{files: Record<string, string>}>}
|
|
44
|
+
*/
|
|
45
|
+
export async function snapshot(repoRoot, storeDir, files) {
|
|
46
|
+
const manifest = { files: {} }
|
|
47
|
+
for (const rel of [...files].sort()) {
|
|
48
|
+
const src = safeJoin(repoRoot, rel, 'snapshot')
|
|
49
|
+
await refuseSymlink(repoRoot, rel, 'snapshot', 'in the repository')
|
|
50
|
+
// The store is harness-owned, but a directory left behind by an earlier
|
|
51
|
+
// attempt under the same tag is not necessarily pristine: refuse to write
|
|
52
|
+
// the golden copy through a link there either.
|
|
53
|
+
await refuseSymlink(storeDir, rel, 'snapshot', 'inside the frozen store')
|
|
54
|
+
|
|
55
|
+
const content = await readFile(src)
|
|
56
|
+
const dst = safeJoin(storeDir, rel, 'snapshot')
|
|
57
|
+
await mkdir(dirname(dst), { recursive: true })
|
|
58
|
+
await refuseHardLink(dst, rel, 'snapshot', 'inside the frozen store')
|
|
59
|
+
await writeFile(dst, content)
|
|
60
|
+
manifest.files[rel] = hash(content)
|
|
61
|
+
}
|
|
62
|
+
return manifest
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Rewrites every frozen file in the working tree from the store, recreating
|
|
67
|
+
* files the agent deleted. Returns the paths it changed, sorted.
|
|
68
|
+
*
|
|
69
|
+
* Both sides are checked against the hash recorded at baseline, and the
|
|
70
|
+
* working tree is examined BEFORE the store is read. That ordering is what
|
|
71
|
+
* makes the common case — an eval where the agent touched no frozen file —
|
|
72
|
+
* cost one read per file instead of two: the destination already hashes to
|
|
73
|
+
* the manifest value, so the golden copy is never opened at all.
|
|
74
|
+
*
|
|
75
|
+
* @returns {Promise<string[]>}
|
|
76
|
+
*/
|
|
77
|
+
export async function restore(repoRoot, storeDir, manifest) {
|
|
78
|
+
const changed = []
|
|
79
|
+
for (const rel of sortedPaths(manifest)) {
|
|
80
|
+
// Before any read or write: readFile follows links exactly as writeFile
|
|
81
|
+
// does, so this has to come first, or the check would read THROUGH a link
|
|
82
|
+
// and conclude the file was fine.
|
|
83
|
+
await refuseSymlink(repoRoot, rel, 'restore', 'in the repository')
|
|
84
|
+
const dst = safeJoin(repoRoot, rel, 'restore')
|
|
85
|
+
|
|
86
|
+
const current = await readFile(dst).catch(() => null)
|
|
87
|
+
if (current && hash(current) === manifest.files[rel]) continue
|
|
88
|
+
|
|
89
|
+
await refuseSymlink(storeDir, rel, 'restore', 'inside the frozen store')
|
|
90
|
+
const src = safeJoin(storeDir, rel, 'restore')
|
|
91
|
+
const golden = await readFile(src)
|
|
92
|
+
const got = hash(golden)
|
|
93
|
+
if (got !== manifest.files[rel]) {
|
|
94
|
+
throw tagged(
|
|
95
|
+
`restore ${rel}: frozen store copy does not match the hash recorded at baseline ` +
|
|
96
|
+
`(store hashes to ${got}, manifest records ${manifest.files[rel]})`,
|
|
97
|
+
ERR_STORE_TAMPERED,
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
await mkdir(dirname(dst), { recursive: true })
|
|
102
|
+
await refuseHardLink(dst, rel, 'restore', 'in the repository')
|
|
103
|
+
await writeFile(dst, golden)
|
|
104
|
+
changed.push(rel)
|
|
105
|
+
}
|
|
106
|
+
return changed
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Reports which frozen files currently differ from the baseline. A deleted
|
|
111
|
+
* file counts as changed, and so does one reached through a symlink — that is
|
|
112
|
+
* at least as suspicious as a deletion, and is reported rather than followed.
|
|
113
|
+
*
|
|
114
|
+
* @returns {Promise<string[]>}
|
|
115
|
+
*/
|
|
116
|
+
export async function verify(repoRoot, manifest) {
|
|
117
|
+
const changed = []
|
|
118
|
+
for (const rel of sortedPaths(manifest)) {
|
|
119
|
+
const linked = await symlinkComponent(repoRoot, rel)
|
|
120
|
+
if (linked) {
|
|
121
|
+
changed.push(rel)
|
|
122
|
+
continue
|
|
123
|
+
}
|
|
124
|
+
const content = await readFile(safeJoin(repoRoot, rel, 'verify')).catch(() => null)
|
|
125
|
+
if (content === null || hash(content) !== manifest.files[rel]) changed.push(rel)
|
|
126
|
+
}
|
|
127
|
+
return changed
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Writes the manifest as indented JSON. */
|
|
131
|
+
export async function saveManifest(path, manifest) {
|
|
132
|
+
await mkdir(dirname(path), { recursive: true })
|
|
133
|
+
await writeFile(path, `${JSON.stringify(manifest, null, 2)}\n`)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Reads a manifest written by saveManifest. */
|
|
137
|
+
export async function loadManifest(path) {
|
|
138
|
+
let text
|
|
139
|
+
try {
|
|
140
|
+
text = await readFile(path, 'utf8')
|
|
141
|
+
} catch (err) {
|
|
142
|
+
throw new Error(`read manifest ${path}: ${err.message}`, { cause: err })
|
|
143
|
+
}
|
|
144
|
+
let parsed
|
|
145
|
+
try {
|
|
146
|
+
parsed = JSON.parse(text)
|
|
147
|
+
} catch (err) {
|
|
148
|
+
throw new Error(`parse manifest ${path}: ${err.message}`, { cause: err })
|
|
149
|
+
}
|
|
150
|
+
return { files: parsed.files ?? {} }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const sortedPaths = (manifest) => Object.keys(manifest.files).sort()
|
|
154
|
+
|
|
155
|
+
const hash = (buffer) => createHash('sha256').update(buffer).digest('hex')
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Joins rel onto root, rejecting anything that would escape it. Manifest
|
|
159
|
+
* entries come from a JSON file on disk, so they are untrusted input:
|
|
160
|
+
* `restore` writes through them before every evaluation.
|
|
161
|
+
*/
|
|
162
|
+
function safeJoin(root, rel, op) {
|
|
163
|
+
if (isAbsolute(rel)) throw new Error(`${op} ${rel}: frozen path must be relative`)
|
|
164
|
+
const clean = posix.normalize(rel.split('\\').join('/'))
|
|
165
|
+
if (clean === '..' || clean.startsWith('../')) {
|
|
166
|
+
throw new Error(`${op} ${rel}: frozen path escapes the repository root`)
|
|
167
|
+
}
|
|
168
|
+
return join(root, clean)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Throws when any component of rel, beneath root, is a symlink.
|
|
173
|
+
*
|
|
174
|
+
* Checking only the FINAL component is not enough, and that is the hole this
|
|
175
|
+
* closes: readFile and writeFile resolve the whole path, so replacing a
|
|
176
|
+
* parent DIRECTORY with a link redirects the write just as effectively as
|
|
177
|
+
* replacing the file itself, landing the frozen content outside the
|
|
178
|
+
* repository. An lstat on the file then reports a perfectly ordinary regular
|
|
179
|
+
* file, because the link was already followed to get there.
|
|
180
|
+
*
|
|
181
|
+
* `root` itself is deliberately not examined: a repository legitimately
|
|
182
|
+
* reached through a symlinked ancestor — macOS's /tmp, a home directory on a
|
|
183
|
+
* linked volume — is not tampering, and refusing to work there would break
|
|
184
|
+
* ordinary setups.
|
|
185
|
+
*/
|
|
186
|
+
async function refuseSymlink(root, rel, op, where) {
|
|
187
|
+
const linked = await symlinkComponent(root, rel)
|
|
188
|
+
if (!linked) return
|
|
189
|
+
throw tagged(
|
|
190
|
+
`${op} ${rel}: ${linked} is a symlink ${where}; refusing to read or write through it, ` +
|
|
191
|
+
`which could reach a file outside the repository`,
|
|
192
|
+
ERR_SYMLINK,
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** The first component of rel, beneath root, that is a symlink — or null. */
|
|
197
|
+
async function symlinkComponent(root, rel) {
|
|
198
|
+
const parts = posix.normalize(rel.split('\\').join('/')).split('/').filter((p) => p !== '' && p !== '.')
|
|
199
|
+
let path = root
|
|
200
|
+
for (const [i, part] of parts.entries()) {
|
|
201
|
+
path = join(path, part)
|
|
202
|
+
const stats = await lstat(path).catch(() => null)
|
|
203
|
+
if (stats?.isSymbolicLink()) return parts.slice(0, i + 1).join('/')
|
|
204
|
+
}
|
|
205
|
+
return null
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Throws when the destination is a hard link — a file with more than one name.
|
|
210
|
+
*
|
|
211
|
+
* Checked immediately before every write, on both the working tree and the
|
|
212
|
+
* store. A regular file in a checkout has exactly one link; more than one
|
|
213
|
+
* means writing here also writes somewhere else, potentially outside the
|
|
214
|
+
* repository. Only an existing regular file is examined: a path that does not
|
|
215
|
+
* exist yet cannot be linked, and a directory's link count is unrelated.
|
|
216
|
+
*/
|
|
217
|
+
async function refuseHardLink(path, rel, op, where) {
|
|
218
|
+
const stats = await lstat(path).catch(() => null)
|
|
219
|
+
if (stats?.isFile() && stats.nlink > 1) {
|
|
220
|
+
throw tagged(
|
|
221
|
+
`${op} ${rel}: ${rel} has ${stats.nlink} names (a hard link) ${where}; refusing to write through ` +
|
|
222
|
+
`it, because that would also overwrite the other name(s) for this file — possibly outside the ` +
|
|
223
|
+
`repository. Replace it with a regular file and rerun.`,
|
|
224
|
+
ERR_HARD_LINK,
|
|
225
|
+
)
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Builds an Error carrying a stable `.code` the pipeline branches on. */
|
|
230
|
+
function tagged(message, code) {
|
|
231
|
+
const err = new Error(message)
|
|
232
|
+
err.code = code
|
|
233
|
+
return err
|
|
234
|
+
}
|