independent-tests 0.1.0 → 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/AGENTS.md CHANGED
@@ -34,7 +34,9 @@ What to tell them, accurately:
34
34
  - Generating the suites costs about $1–4 and 10–25 minutes of Opus 5.5 per spec
35
35
  on their signed-in `claude` CLI, up to about $7 in a repository, plus their
36
36
  own time to confirm the decisions (median person minutes: pending; `confirm`
37
- records each confirmation's duration).
37
+ records each confirmation's duration). Without Claude Code it runs on their
38
+ Codex login or an Anthropic API key instead, on macOS, Linux or Windows
39
+ (Windows without an OS sandbox for the sessions).
38
40
  - They must confirm the spec's decisions themselves before any test is
39
41
  generated. Replaying 11 real commits, suites written from a spec drafted from
40
42
  the commit message rejected all 11; once a session given the real diff
package/README.md CHANGED
@@ -4,37 +4,44 @@
4
4
  <strong>Your agent says all tests pass. Check with tests it never saw.</strong>
5
5
  </p>
6
6
 
7
+ <p align="center">
8
+ <a href="https://www.npmjs.com/package/independent-tests"><img src="https://img.shields.io/npm/v/independent-tests?style=flat-square&label=npm" alt="npm version"></a>
9
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-green?style=flat-square" alt="MIT license"></a>
10
+ <a href="SKILL.md"><img src="https://img.shields.io/badge/Claude_Code-skill-D97757?style=flat-square" alt="Claude Code skill"></a>
11
+ <a href="#install"><img src="https://img.shields.io/badge/judges-any_coding_agent-555?style=flat-square" alt="Judges any coding agent"></a>
12
+ </p>
13
+
14
+ <p align="center">
15
+ <img src="assets/hero.png" width="100%" alt="Coding agents that report a task complete are wrong 71% of the time (75 of 105). Independent tests cut that to 9% (9 of 105).">
16
+ </p>
17
+
18
+ <table>
19
+ <tr>
20
+ <td align="center" width="25%"><h3>75 of 105</h3>green runs failed tests<br>they never saw<br><sub>from a loose ticket</sub></td>
21
+ <td align="center" width="25%"><h3>30 → 81</h3>of 105 pass when the<br>agent gets a public suite<br><sub>same agent, p &lt; 1e-11</sub></td>
22
+ <td align="center" width="25%"><h3>95 of 100</h3>rejections upheld<br>by a blind referee<br><sub>on another model</sub></td>
23
+ <td align="center" width="25%"><h3>0 of 2,522</h3>runs touched a test<br>or the harness<br><sub>wrong without cheating</sub></td>
24
+ </tr>
25
+ </table>
26
+
7
27
  <p align="center">
8
28
  <a href="#before--after">Before/After</a> •
9
29
  <a href="#install">Install</a> •
30
+ <a href="#does-a-hidden-suite-catch-what-green-tests-miss">Proof</a> •
31
+ <a href="#try-it-in-five-minutes">Try it</a> •
10
32
  <a href="#the-numbers">The numbers</a> •
11
33
  <a href="RESULTS.md">RESULTS.md</a>
12
34
  </p>
13
35
 
14
36
  ---
15
37
 
16
- **105 of 105** agents said their own tests passed. **75** of them failed tests
17
- they never saw. None of them cheated.
18
-
19
- ```
20
- own tests green ████████████████████ 105/105
21
- hidden suite, from the ticket ██████░░░░░░░░░░░░░░ 30/105
22
- + a public suite from here ███████████████░░░░░ 81/105 p < 1e-11
23
- ```
24
-
25
- Same agent (Haiku 5.5), same loose tickets, 7 specs × 15 runs. The only change
26
- in the third row: the decisions the ticket left open, confirmed by a person and
27
- handed to the agent as tests it can see, judged by tests it can't. Were the
28
- rejections fair? A blind referee on another model, given the spec, the code and
29
- one anonymised failing test, upheld 95 of 100.
30
-
31
38
  ## Before / After
32
39
 
33
40
  <table>
34
41
  <tr>
35
42
  <td width="50%">
36
43
 
37
- ### 🗣️ The agent's report
44
+ ### 🗣️ The agent: "my 21 tests pass"
38
45
 
39
46
  > "The escrow release logic in `src/index.ts` is implemented. The build is clean
40
47
  > and my 21 tests under `tests/own/` pass."
@@ -45,7 +52,7 @@ one anonymised failing test, upheld 95 of 100.
45
52
  </td>
46
53
  <td width="50%">
47
54
 
48
- ### 🔒 The hidden suite
55
+ ### 🔒 Tests it never saw: 43 of 46
49
56
 
50
57
  ```
51
58
  hidden 43/46
@@ -61,42 +68,66 @@ hidden: [boundary-2] passing hidden tests on a
61
68
  </tr>
62
69
  </table>
63
70
 
71
+ **Same code. 21 green tests. One wrong guess, and the agent knew it was
72
+ guessing.**
73
+
64
74
  escrow-split, run 1. 14 of 15 runs made the same guess. The ticket never said
65
75
  what a $0 bounty does; the agent picked an answer, then wrote 21 tests that
66
76
  agree with it. Green tests from the author of the code test the author's
67
77
  guesses.
68
78
 
69
- In 2025 METR caught frontier models
70
- [reward hacking](https://metr.org/blog/2025-06-05-recent-reward-hacking/) in 30%
71
- of RE-Bench runs: patching the grader, copying its answers. That is what
72
- sandboxes guard against. In 2,522 sandboxed runs here no agent changed a test or the harness, and
73
- the hidden suites still rejected 71% from a loose ticket. The agent does not
74
- need to cheat to be wrong.
75
-
76
79
  ## Install
77
80
 
78
81
  ```sh
79
- git clone https://github.com/lunox-work/independent-tests ~/.claude/skills/independent-tests
82
+ npx skills add lunox-work/independent-tests -g
80
83
  ```
81
84
 
82
- Then ask Claude Code: _"write tests my agent can't see for this ticket"_. The
83
- implementer can be anything that edits a folder: Claude Code, Codex, Cursor,
84
- Copilot, Aider, or a person. [Try the bundled example](#try-it-in-five-minutes)
85
- in five minutes; details in [Install in full](#install-in-full).
85
+ Then ask your coding agent (Claude Code, Codex, Cursor, or any agent that loads
86
+ skills): _"write tests my agent can't see for this ticket"_. The tests are
87
+ written on whichever account you have: Claude Code, a Codex login or an
88
+ Anthropic API key. The implementer can be anything that edits a folder: Claude
89
+ Code, Codex, Cursor, Copilot, Aider, or a person.
90
+ [Try the bundled example](#try-it-in-five-minutes) in five minutes; a git clone
91
+ and the CLI are in [Install in full](#install-in-full).
92
+
93
+ ## Does a hidden suite catch what green tests miss?
94
+
95
+ **105 of 105** agents said their own tests passed. **75** of them failed tests
96
+ they never saw. None of them cheated.
97
+
98
+ <p align="center">
99
+ <img src="assets/runs.png" width="100%" alt="One square per run, 7 specs by 15 runs, red where the hidden suite failed. Agent alone: 75 of 105 wrong. With a public suite: 24 of 105. After one retry told only the failing scenario: 9 of 105.">
100
+ </p>
101
+
102
+ Same agent (Haiku 5.5), same loose tickets, 7 specs × 15 runs. The only change
103
+ in the middle column (p < 1e-11): the decisions the ticket left open, confirmed
104
+ by a person and handed to the agent as tests it can see, judged by tests it
105
+ can't. In the last, each of the 24 rejected runs got one retry told only the
106
+ failing scenario's key, never the test; 15 then passed. Were the
107
+ rejections fair? A blind referee on another model, given the spec, the code and
108
+ one anonymised failing test, upheld 95 of 100.
109
+
110
+ In 2025 METR caught frontier models
111
+ [reward hacking](https://metr.org/blog/2025-06-05-recent-reward-hacking/) in 30%
112
+ of RE-Bench runs: patching the grader, copying its answers. That is what
113
+ sandboxes guard against. In 2,522 sandboxed runs here no agent changed a test or
114
+ the harness, and the hidden suites still rejected 71% from a loose ticket. The
115
+ agent does not need to cheat to be wrong.
86
116
 
87
117
  ## What it is
88
118
 
89
119
  **Hidden, calibrated test suites for AI coding agents, independent of the agent
90
- they judge.** A [Claude Code](https://code.claude.com/docs) skill that turns a
91
- ticket or spec for one module in TypeScript, Python, Go or Rust, for a command
92
- or an HTTP service checked only at that boundary, or for a change to one package
93
- of an existing TypeScript or Python repository, into a **public** test suite the
94
- implementing agent works against and a **hidden** suite, which the agent never
95
- sees, that judges the result. Claude models write both suites and the reference
96
- that calibrates them, in sessions apart from the implementing agent. A person
97
- confirms the spec's decisions before any test is written. The implementing agent
98
- can be anything that edits a folder: Claude Code, Codex, Cursor, Copilot, Aider,
99
- or a person.
120
+ they judge.** An agent skill and CLI, for Claude Code, Codex, Cursor and other
121
+ agents, that turns a ticket or spec for one module in TypeScript, Python, Go or
122
+ Rust, for a command or an HTTP service checked only at that boundary, or for a
123
+ change to one package of an existing TypeScript or Python repository, into a
124
+ **public** test suite the implementing agent works against and a **hidden**
125
+ suite, which the agent never sees, that judges the result. A writer model (Opus
126
+ 5.5 through Claude Code, or Codex or the Anthropic API without it) writes both
127
+ suites and the reference that calibrates them, in sessions apart from the
128
+ implementing agent. A person confirms the spec's decisions before any test is
129
+ written. The implementing agent can be anything that edits a folder: Claude
130
+ Code, Codex, Cursor, Copilot, Aider, or a person.
100
131
 
101
132
  Use it when "all tests pass" comes from the same agent that wrote the tests, and
102
133
  you want acceptance decided by tests it could not have overfitted, gamed or
@@ -166,7 +197,9 @@ What to tell them, accurately:
166
197
  - Generating the suites costs about $1–4 and 10–25 minutes of Opus 5.5 per spec
167
198
  on their signed-in `claude` CLI, up to about $7 in a repository, plus their
168
199
  own time to confirm the decisions (median person minutes: pending; `confirm`
169
- records each confirmation's duration).
200
+ records each confirmation's duration). Without Claude Code it runs on their
201
+ Codex login or an Anthropic API key instead, on macOS, Linux or Windows
202
+ (Windows without an OS sandbox for the sessions).
170
203
  - They must confirm the spec's decisions themselves before any test is
171
204
  generated. Replaying 11 real commits, suites written from a spec drafted from
172
205
  the commit message rejected all 11; once a session given the real diff
@@ -220,10 +253,11 @@ tests, are the whole gain: the same suite handed over as prose scored 76 of 101.
220
253
  in a pull request.
221
254
  3. **Prepare.** Blind writer sessions write an interface skeleton, the public
222
255
  suite, then a hidden suite aimed at what the public one leaves out. The
223
- writers run on the `claude` CLI by default; `INDEPENDENT_TESTS_BACKEND` or
224
- toolchain.json `backend` picks `codex-cli`, `anthropic-api` or `exec` (any
225
- command, prompt on stdin). Calibration keeps a hidden test only if it fails
226
- on the skeleton and a separately written reference passes it
256
+ writers run on the first account found: the `claude` CLI, else the person's
257
+ Codex login (`codex-cli`), else `ANTHROPIC_API_KEY` (`anthropic-api`);
258
+ `INDEPENDENT_TESTS_BACKEND` or toolchain.json `backend` picks one, or `exec`
259
+ (any command, prompt on stdin). Calibration keeps a hidden test only if it
260
+ fails on the skeleton and a separately written reference passes it
227
261
  (`bin/prepare.mjs`). In a repository the skeleton is a stub at the seam, the
228
262
  suites follow the repository's own test idiom and runner, and a reference
229
263
  that breaks an existing test stops the spec. At a black-box seam (toolchain
@@ -246,22 +280,28 @@ tests, are the whole gain: the same suite handed over as prose scored 76 of 101.
246
280
  Sessions run under the OS sandbox with deny rules: no session can read the
247
281
  hidden suites, other workdirs or transcripts. The other backends run under
248
282
  `sandbox-exec` (macOS) or `bwrap` (Linux), reading and writing only the staged
249
- workdir (`bin/backends.mjs`). [SKILL.md](SKILL.md) has the steps as the skill
250
- runs them.
283
+ workdir (`bin/backends.mjs`). Windows has no such sandbox: there the file tools'
284
+ deny rules still hold, but a session's shell is held only by the transcript
285
+ audit, which reports any attempt to read the hidden suite. Run sessions under
286
+ WSL2 for an enforced sandbox; judging and handouts need none.
287
+ [SKILL.md](SKILL.md) has the steps as the skill runs them.
251
288
 
252
289
  ## Install in full
253
290
 
254
291
  ```sh
255
- npx -y independent-tests --help # the CLI; nothing to clone
256
- git clone https://github.com/lunox-work/independent-tests ~/.claude/skills/independent-tests
292
+ npx skills add lunox-work/independent-tests -g # the skill, for Claude Code and other agents
293
+ git clone https://github.com/lunox-work/independent-tests ~/.claude/skills/independent-tests # or the whole repo
294
+ npx -y independent-tests --help # the CLI; nothing to clone
257
295
  ```
258
296
 
259
- The clone is the Claude Code skill (SKILL.md), which calls the same CLI. Needs
260
- Node ≥ 22.12, a signed-in `claude` CLI and macOS or Linux. The first command
261
- that needs the toolchain installs `template/`'s dependencies with npm 10.9.2
262
- under `~/.independent-tests/`, once. Then ask Claude Code for tests an agent
263
- can't see for a ticket, or to judge a submission against a spec. The handout
264
- folder needs only Node, so the implementing agent can run anywhere.
297
+ Either install puts the skill (SKILL.md) in place; it calls the same CLI,
298
+ through `npx` when only SKILL.md was copied. Needs Node 20.19 or later on macOS,
299
+ Linux or Windows, and one of: Claude Code signed in, a Codex login, or
300
+ `ANTHROPIC_API_KEY`. The first command that needs the toolchain installs
301
+ `template/`'s dependencies with npm 10.9.2 under `~/.independent-tests/`, once.
302
+ Then ask your agent for tests an agent can't see for a ticket, or to judge a
303
+ submission against a spec. The handout folder needs only Node, so the
304
+ implementing agent can run anywhere.
265
305
 
266
306
  Specs, runs and results live in `~/.independent-tests/`
267
307
  (`INDEPENDENT_TESTS_HOME`; `independent-tests where` prints the folders), never
@@ -401,6 +441,9 @@ reads the runner's disk and sends it over the network is not stopped.
401
441
 
402
442
  ## Layout
403
443
 
444
+ <details>
445
+ <summary>Every file and folder, in the repository and in <code>~/.independent-tests/</code></summary>
446
+
404
447
  ```
405
448
  SKILL.md the steps the skill runs
406
449
  AGENTS.md this README's section for coding agents, verbatim
@@ -461,3 +504,18 @@ evidence/seams.mjs black-box seams: patch-apply as a command against git apply,
461
504
 
462
505
  A development checkout keeps its own `specs/`, `runs/` and `results.jsonl` with
463
506
  `INDEPENDENT_TESTS_HOME=.`.
507
+
508
+ </details>
509
+
510
+ ---
511
+
512
+ <p align="center">
513
+ <strong>Caught a green run that was wrong? A ⭐ helps the next person find this.</strong>
514
+ <br>
515
+ <sub>
516
+ <a href="RESULTS.md">RESULTS.md</a> ·
517
+ <a href="AGENTS.md">AGENTS.md</a> ·
518
+ <a href="SKILL.md">SKILL.md</a> ·
519
+ <a href="https://github.com/lunox-work/independent-tests/issues">Issues</a>
520
+ </sub>
521
+ </p>
package/SKILL.md CHANGED
@@ -45,16 +45,19 @@ steps marked _Repository_ below are for that target.
45
45
  (UI through Playwright is out: its tests are too noisy to calibrate), nor yet
46
46
  a change inside a repository visible only through a running service: `fit`,
47
47
  `repo` and the judge take `--seam http`, but no brief writes its suites.
48
- - **Preconditions.** Node ≥ 22.12; `claude` signed in (`CLAUDE_BIN`, PATH or the
49
- VS Code extension bundle); macOS or Linux for the OS sandbox. The first
50
- command that needs the bundled toolchain installs it with npm 10.9.2 into the
51
- home (network, once). Python: `python3` (pytest is installed into the home
52
- once); Go and Rust: `go` or `cargo` on PATH. Another writer backend, if the
53
- person asks for one: `INDEPENDENT_TESTS_BACKEND=codex-cli` (their Codex login;
54
- Codex 0.164.0 installed into the home once), `anthropic-api`
55
- (`ANTHROPIC_API_KEY`) or `exec` with `INDEPENDENT_TESTS_EXEC=<command>`; these
56
- run under `sandbox-exec` or `bwrap` instead of Claude Code's deny rules.
57
- - **Cost.** Step 3 starts four blind `claude -p` sessions on Opus 5.5: about
48
+ - **Preconditions.** Node 20.19 or later, on macOS, Linux or Windows. One
49
+ account for the sessions, taken in this order unless
50
+ `INDEPENDENT_TESTS_BACKEND` names one: `claude` signed in (`CLAUDE_BIN`, PATH
51
+ or the VS Code extension bundle), else their Codex login (`codex-cli`; Codex
52
+ 0.164.0 installed into the home once), else `ANTHROPIC_API_KEY`
53
+ (`anthropic-api`); or `exec` with `INDEPENDENT_TESTS_EXEC=<command>`. claude
54
+ sessions are held by Claude Code's sandbox and deny rules, the others by
55
+ `sandbox-exec` or `bwrap`. On Windows there is no OS sandbox: say so before
56
+ step 3, and that WSL2 gives one. The first command that needs the bundled
57
+ toolchain installs it with npm 10.9.2 into the home (network, once). Python:
58
+ `python3` (`python` on Windows; pytest is installed into the home once); Go
59
+ and Rust: `go` or `cargo` on PATH.
60
+ - **Cost.** Step 3 starts four blind sessions, on Opus 5.5 with `claude`: about
58
61
  $1–4 and 10–25 minutes per spec. An agent run is a few cents on Haiku 5.5,
59
62
  about $0.30 on Opus. Say so before step 3. Step 2 costs the person's time:
60
63
  `confirm` records it per spec (`minutes` in confirmed.json, from the first
package/bin/backends.mjs CHANGED
@@ -45,8 +45,12 @@
45
45
  //
46
46
  // `placement`'s denial is checked by evidence/backends.mjs `sandbox`, a
47
47
  // negative test that reads a hidden-suite path and the home from inside.
48
+ // Windows has neither sandbox: there `placement` runs the command as it
49
+ // is, the shell being Git for Windows' bash, and says so once (lib.mjs
50
+ // `warnUnsandboxed`); the staged workdir and the transcript audit are what
51
+ // keep a writer blind, and anthropic-api's file tools stay confined.
48
52
 
49
- import { spawn, spawnSync } from "node:child_process";
53
+ import { spawn } from "node:child_process";
50
54
  import { randomUUID } from "node:crypto";
51
55
  import {
52
56
  appendFileSync,
@@ -61,7 +65,7 @@ import {
61
65
  } from "node:fs";
62
66
  import { createRequire } from "node:module";
63
67
  import { homedir, tmpdir } from "node:os";
64
- import { basename, dirname, join, relative, resolve } from "node:path";
68
+ import { basename, dirname, join, posix, relative, resolve } from "node:path";
65
69
  import { pathToFileURL } from "node:url";
66
70
  import {
67
71
  DEFAULT_MODEL,
@@ -78,14 +82,15 @@ import {
78
82
  log,
79
83
  sha256,
80
84
  takeSlot,
85
+ warnUnsandboxed,
81
86
  } from "./lib.mjs";
87
+ import { GROUP, OS_SANDBOX, WINDOWS, gitBash, inside, pathKey, slash, spawnCommand, which } from "./platform.mjs";
82
88
 
83
89
  export const CODEX_VERSION = "0.164.0";
84
90
  export const SDK_VERSION = "0.137.0";
85
91
  /** Per session: CODEX_HOME, HOME, TMPDIR, the API backend's messages. */
86
92
  export const SESSIONS = join(WORK_ROOT, "sessions");
87
93
 
88
- const inside = (path, folder) => path === folder || path.startsWith(`${folder}/`);
89
94
  /** `path` made real as far as it exists (the sandboxes match real paths). */
90
95
  export function real(path) {
91
96
  let at = resolve(path);
@@ -110,7 +115,7 @@ export function installTool(name, spec, version) {
110
115
  mkdirSync(partial, { recursive: true });
111
116
  writeFileSync(join(partial, "package.json"), `${JSON.stringify({ private: true })}\n`);
112
117
  log(`installing ${spec}@${version} into ${dest} (npm@10.9.2, once)`);
113
- const out = spawnSync("npx", ["-y", "npm@10.9.2", "install", "--no-audit", "--no-fund", "--save-exact", `${spec}@${version}`], {
118
+ const out = spawnCommand("npx", ["-y", "npm@10.9.2", "install", "--no-audit", "--no-fund", "--save-exact", `${spec}@${version}`], {
114
119
  cwd: partial,
115
120
  encoding: "utf8",
116
121
  maxBuffer: 64 * 1024 * 1024,
@@ -131,8 +136,8 @@ export function installTool(name, spec, version) {
131
136
 
132
137
  /** The install prefix of a binary (two folders above its real path), for the sandbox's read list. */
133
138
  function prefixOf(bin) {
134
- const found = bin.includes("/") ? bin : spawnSync("sh", ["-c", `command -v ${bin}`], { encoding: "utf8" }).stdout.trim();
135
- if (found === "" || !existsSync(found)) return null;
139
+ const found = which(bin);
140
+ if (found === null) return null;
136
141
  return dirname(dirname(realpathSync(found)));
137
142
  }
138
143
 
@@ -203,16 +208,28 @@ export function bwrapArgs({ deny, readable, writable }, network, cwd) {
203
208
  return out;
204
209
  }
205
210
 
206
- /** `bin argv` wrapped in the platform's sandbox: [bin, argv]. */
211
+ /**
212
+ * The shell `placement` runs a command line in: /bin/sh and /bin/bash on
213
+ * macOS and Linux, Git for Windows' bash on Windows (what Claude Code's
214
+ * Bash tool runs there).
215
+ */
216
+ export function shellBin(name) {
217
+ if (!WINDOWS) return `/bin/${name}`;
218
+ const bash = gitBash();
219
+ if (bash === null) throw new Error("No bash: install Git for Windows, or set CLAUDE_CODE_GIT_BASH_PATH to its bash.exe.");
220
+ return bash;
221
+ }
222
+ /** `bin argv` wrapped in the platform's sandbox: [bin, argv]. Windows has none: [bin, argv] as given. */
207
223
  export function placement(bin, argv, { cwd, state, read = [], network = true }) {
224
+ if (!OS_SANDBOX) {
225
+ warnUnsandboxed();
226
+ return [bin, argv];
227
+ }
208
228
  const paths = placementPaths({ cwd, state, read });
209
229
  if (process.platform === "darwin") return ["/usr/bin/sandbox-exec", ["-p", seatbeltProfile(paths, network), bin, ...argv]];
210
- if (process.platform === "linux") {
211
- const found = spawnSync("sh", ["-c", "command -v bwrap"], { encoding: "utf8" }).stdout.trim();
212
- if (found === "") throw new Error("No bwrap on PATH: a non-Claude backend on Linux needs bubblewrap for its sandbox.");
213
- return [found, [...bwrapArgs(paths, network, cwd), "--", bin, ...argv]];
214
- }
215
- throw new Error(`No sandbox for ${process.platform}: non-Claude backends run on macOS or Linux.`);
230
+ const found = which("bwrap");
231
+ if (found === null) throw new Error("No bwrap on PATH: a non-Claude backend on Linux needs bubblewrap for its sandbox.");
232
+ return [found, [...bwrapArgs(paths, network, cwd), "--", bin, ...argv]];
216
233
  }
217
234
 
218
235
  /** A session's state folder, kept across a resume of the same transcript and cleared on a fresh start. */
@@ -223,13 +240,25 @@ function stateOf(transcript, resume) {
223
240
  return dir;
224
241
  }
225
242
 
226
- /** The environment a placed process starts with: nothing of the caller's but the basics. */
243
+ /**
244
+ * The environment a placed process starts with: nothing of the caller's
245
+ * but the basics, plus on Windows what any process there needs to start
246
+ * (SystemRoot, ComSpec, PATHEXT) and the state folder as its profile and
247
+ * temp folder too.
248
+ */
227
249
  function childEnv(state, extra = {}) {
228
- const keep = ["PATH", "LANG", "LC_ALL", "LC_CTYPE", "TERM", "USER", "LOGNAME", "SHELL"];
250
+ const keep = ["LANG", "LC_ALL", "LC_CTYPE", "TERM", "USER", "LOGNAME", "SHELL"];
251
+ const windows = ["SystemRoot", "SystemDrive", "windir", "ComSpec", "PATHEXT", "USERNAME", "NUMBER_OF_PROCESSORS", "PROCESSOR_ARCHITECTURE"];
252
+ const home = join(state, "home");
253
+ const temp = join(state, "tmp");
229
254
  return {
230
- ...Object.fromEntries(keep.filter((key) => process.env[key] !== undefined).map((key) => [key, process.env[key]])),
231
- HOME: join(state, "home"),
232
- TMPDIR: join(state, "tmp"),
255
+ [pathKey(process.env)]: process.env[pathKey(process.env)],
256
+ ...Object.fromEntries(
257
+ [...keep, ...(WINDOWS ? windows : [])].filter((key) => process.env[key] !== undefined).map((key) => [key, process.env[key]]),
258
+ ),
259
+ HOME: home,
260
+ TMPDIR: temp,
261
+ ...(WINDOWS ? { USERPROFILE: home, TEMP: temp, TMP: temp, APPDATA: join(home, "AppData", "Roaming"), LOCALAPPDATA: join(home, "AppData", "Local") } : {}),
233
262
  ...extra,
234
263
  };
235
264
  }
@@ -249,7 +278,7 @@ const LIMITED = /usage limit|rate.?limit|quota|too many requests|429|out of cred
249
278
  function runChild(bin, argv, { cwd, env, input, minutes, onLine }) {
250
279
  return new Promise((done) => {
251
280
  // A group leader, its group killed when it closes: nothing it started outlives the session.
252
- const child = spawn(bin, argv, { cwd, env, stdio: ["pipe", "pipe", "pipe"], detached: true });
281
+ const child = spawn(bin, argv, { cwd, env, stdio: ["pipe", "pipe", "pipe"], ...GROUP });
253
282
  const reap = () => killGroup(child.pid);
254
283
  process.once("exit", reap);
255
284
  child.on("exit", reap);
@@ -437,7 +466,7 @@ async function execBackend({ cwd, text, transcript, kind, resume, chain, command
437
466
  return { ...base, ok: false, exitCode: null, timedOut: false, durationMs: 0, subtype: "error_during_execution", finalText, stderr: "" };
438
467
  }
439
468
  const state = stateOf(transcript, resume);
440
- const [bin, argv] = placement("/bin/sh", ["-c", line], { cwd, state });
469
+ const [bin, argv] = placement(shellBin("sh"), ["-c", line], { cwd, state });
441
470
  const passed = (process.env.INDEPENDENT_TESTS_EXEC_ENV ?? "").split(",").filter((key) => key && process.env[key] !== undefined);
442
471
  const env = childEnv(state, Object.fromEntries(passed.map((key) => [key, process.env[key]])));
443
472
  write({ type: "system", subtype: "init", session_id: sessionId, model: null, cwd, backend: "exec", command: line });
@@ -559,7 +588,7 @@ function fileTools(root, runBash) {
559
588
  if (!inside(real(full), realRoot)) throw new Error(`${path} is outside the working directory ${root}`);
560
589
  return full;
561
590
  };
562
- const shown = (full) => relative(root, full) || ".";
591
+ const shown = (full) => slash(relative(root, full)) || ".";
563
592
  return {
564
593
  Bash: (input) => runBash(String(input.command ?? ""), Math.min(Number(input.timeout ?? 120_000), 600_000)),
565
594
  Read: (input) => {
@@ -592,7 +621,7 @@ function fileTools(root, runBash) {
592
621
  const base = confined(input.path);
593
622
  const matches = globRegExp(String(input.pattern));
594
623
  const hits = listFiles(base).filter((path) => matches.test(path) || (!String(input.pattern).includes("/") && matches.test(basename(path))));
595
- return hits.slice(0, 500).map((path) => join(shown(base) === "." ? "" : shown(base), path)).join("\n") || "no files";
624
+ return hits.slice(0, 500).map((path) => posix.join(shown(base) === "." ? "" : shown(base), path)).join("\n") || "no files";
596
625
  },
597
626
  Grep: (input) => {
598
627
  const base = confined(input.path);
@@ -601,7 +630,7 @@ function fileTools(root, runBash) {
601
630
  const out = [];
602
631
  const files = statSync(base).isDirectory() ? listFiles(base).map((path) => join(base, path)) : [base];
603
632
  for (const full of files) {
604
- if (only !== null && !only.test(relative(base, full)) && !only.test(basename(full))) continue;
633
+ if (only !== null && !only.test(slash(relative(base, full))) && !only.test(basename(full))) continue;
605
634
  if (statSync(full).size > 1_000_000) continue;
606
635
  const lines = readFileSync(full, "utf8").split("\n");
607
636
  lines.forEach((line, at) => {
@@ -629,10 +658,11 @@ async function anthropicApi({ cwd, text, transcript, kind, resume, model, maxTur
629
658
  const started = Date.now();
630
659
  const deadline = started + minutes * 60_000;
631
660
  const runBash = (command, timeout) => {
632
- const [bin, argv] = placement("/bin/bash", ["-c", command], { cwd, state, network: false });
661
+ const [bin, argv] = placement(shellBin("bash"), ["-c", command], { cwd, state, network: false });
633
662
  return new Promise((done) => {
634
663
  let output = "";
635
- const child = spawn(bin, argv, { cwd, env: childEnv(state), stdio: ["ignore", "pipe", "pipe"] });
664
+ const child = spawn(bin, argv, { cwd, env: childEnv(state), stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
665
+
636
666
  const take = (chunk) => (output = (output + chunk).slice(-30_000));
637
667
  child.stdout.on("data", take);
638
668
  child.stderr.on("data", take);
package/bin/generate.mjs CHANGED
@@ -29,7 +29,7 @@
29
29
  // uses the same model as every other session.
30
30
 
31
31
  import { existsSync, readFileSync, rmSync } from "node:fs";
32
- import { basename, dirname, join, relative } from "node:path";
32
+ import { join, posix } from "node:path";
33
33
  import {
34
34
  RUNS,
35
35
  args,
@@ -74,7 +74,7 @@ function suitesOf(chain) {
74
74
  dir: "tests/public",
75
75
  folder: publicFolder,
76
76
  own: repo
77
- ? (path) => dirname(path) === publicFolder && chain.isPublicName(basename(path)) && test(path)
77
+ ? (path) => posix.dirname(path) === publicFolder && chain.isPublicName(posix.basename(path)) && test(path)
78
78
  : (path) => path.startsWith(`${publicFolder}/`) && test(path),
79
79
  required: repo ? `${publicFolder}/${chain.publicName} (${chain.testPattern})` : `${publicFolder}/${chain.testPattern}`,
80
80
  prompt: "public-tests",
@@ -255,7 +255,7 @@ async function generate(slug, force, only = null) {
255
255
  if (record[suite.name].problem === null) {
256
256
  rmSync(join(out, target), { recursive: true, force: true });
257
257
  const files = Object.keys(manifestOf(dir, chain)).filter(inSuite);
258
- for (const path of files) copy(join(dir, path), join(out, target, relative(suite.folder, path)));
258
+ for (const path of files) copy(join(dir, path), join(out, target, posix.relative(suite.folder, path)));
259
259
  record[suite.name].files = files;
260
260
  // A hidden suite written against an older public suite is stale.
261
261
  if (suite.name === "public")
package/bin/handout.mjs CHANGED
@@ -26,7 +26,7 @@
26
26
  import { spawnSync } from "node:child_process";
27
27
  import { commitment, recordCommitment } from "./commitment.mjs";
28
28
  import { existsSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
29
- import { join, resolve } from "node:path";
29
+ import { join, posix, resolve, sep } from "node:path";
30
30
  import {
31
31
  AGENT,
32
32
  PRIVATE,
@@ -58,7 +58,7 @@ if (options.repo !== undefined) {
58
58
  process.exit(0);
59
59
  }
60
60
  const dir = resolve(target);
61
- const beside = PRIVATE.find((folder) => dir === folder || dir.startsWith(`${folder}/`));
61
+ const beside = PRIVATE.find((folder) => dir === folder || dir.startsWith(`${folder}${sep}`));
62
62
  if (beside !== undefined) {
63
63
  console.error(`${dir} is inside ${beside}, beside the hidden suites; pick a folder outside it.`);
64
64
  process.exit(1);
@@ -112,7 +112,7 @@ function repoHandout(checkoutPath, remove) {
112
112
  const files = [
113
113
  ...chain.seam.map((path) => ({ path, from: join(spec, "skeleton", path), seam: true })),
114
114
  ...listFiles(join(spec, "tests", "public")).map((name) => ({
115
- path: join(chain.publicDir, name),
115
+ path: posix.join(chain.publicDir, name),
116
116
  from: join(spec, "tests", "public", name),
117
117
  seam: false,
118
118
  })),