firegoat 0.25.0 → 0.27.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.
Files changed (3) hide show
  1. package/README.md +2 -0
  2. package/dist/cli.js +377 -110
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -37,6 +37,8 @@ tests:
37
37
  - run: grep -qE 'evt_[A-Za-z0-9]+' "$FIREGOAT_TRANSCRIPT"
38
38
  ```
39
39
 
40
+ Each run starts in a fresh, empty folder outside your repo, `~/.firegoat/runs/<hash>/<random>/work`, so its path doesn't name your project and the agent doesn't wander into your source. Logs and results stay in the repo, in `.firegoat/runs/`.
41
+
40
42
  ## Uploads
41
43
 
42
44
  Log in to your firegoat.ai account and each trace is uploaded to our system automatically. Secrets are masked in everything you see, but the uploaded files are stored as sent. You can opt out of uploading with `eval --no-upload`. You can also delete your traces on the dashboard, and the files and the record are gone from our site.
package/dist/cli.js CHANGED
@@ -6947,7 +6947,7 @@ var require_public_api = __commonJS(function(exports) {
6947
6947
  // package.json
6948
6948
  var package_default = {
6949
6949
  name: "firegoat",
6950
- version: "0.25.0",
6950
+ version: "0.27.0",
6951
6951
  repository: {
6952
6952
  type: "git",
6953
6953
  url: "git+https://github.com/firegoat-ai/firegoat-cli.git"
@@ -6976,7 +6976,7 @@ var package_default = {
6976
6976
  },
6977
6977
  scripts: {
6978
6978
  build: "bun build src/cli.ts --target node --outfile dist/cli.js",
6979
- test: "bun test",
6979
+ test: "bun test --timeout 30000",
6980
6980
  typecheck: "bun check",
6981
6981
  lint: "biome check .",
6982
6982
  format: "biome check --write .",
@@ -7157,7 +7157,7 @@ function read(stdout, exited, timeoutMs) {
7157
7157
  reportedModel,
7158
7158
  version,
7159
7159
  ok: false,
7160
- reason: `claude result is an error (subtype=${result.subtype})${result.result ? `: ${result.result.slice(0, 300)}` : ""}`,
7160
+ reason: `claude reported an error${result.subtype === undefined || result.subtype === "success" ? "" : ` (${result.subtype})`}${result.result ? `: ${result.result.slice(0, 300)}` : ""}`,
7161
7161
  sessionId
7162
7162
  };
7163
7163
  return { ...trace, reportedModel, version, ok: true, sessionId };
@@ -7231,6 +7231,7 @@ var claude = {
7231
7231
  }),
7232
7232
  authLabel: () => process.env.ANTHROPIC_API_KEY ? "ANTHROPIC_API_KEY (claude bills the API key; unset it to use your claude login)" : "your claude login",
7233
7233
  checkLogin,
7234
+ isFinal: (line) => parse(line)?.type === "result",
7234
7235
  progress,
7235
7236
  read,
7236
7237
  sessionsDir: (configDir) => join(configDir, "projects"),
@@ -7549,6 +7550,10 @@ var codex = {
7549
7550
  }),
7550
7551
  authLabel: () => `your codex login (${join3(userCodexHome(), "auth.json")})${process.env.CODEX_API_KEY ? "; CODEX_API_KEY is set and passes through" : ""}`,
7551
7552
  checkLogin: checkLogin2,
7553
+ isFinal: (line) => {
7554
+ const type = parse2(line)?.type;
7555
+ return type === "turn.completed" || type === "turn.failed";
7556
+ },
7552
7557
  progress: progress2,
7553
7558
  read: read2,
7554
7559
  sessionsDir: (configDir) => join3(configDir, "sessions"),
@@ -7653,7 +7658,7 @@ function steps3(events) {
7653
7658
  function resultError(result) {
7654
7659
  const errors = Array.isArray(result.errors) ? result.errors.filter((x) => typeof x === "string").join("; ") : "";
7655
7660
  const said = errors || result.result || "";
7656
- return `grok result is an error (subtype=${result.subtype})${said ? `: ${said.slice(0, 300)}` : ""}`;
7661
+ return `grok reported an error${result.subtype === undefined || result.subtype === "success" ? "" : ` (${result.subtype})`}${said ? `: ${said.slice(0, 300)}` : ""}`;
7657
7662
  }
7658
7663
  function read3(stdout, exited, timeoutMs) {
7659
7664
  const events = stdout.split(`
@@ -7836,6 +7841,7 @@ var grok = {
7836
7841
  }),
7837
7842
  authLabel: () => `your grok login (${USER_AUTH})`,
7838
7843
  checkLogin: checkLogin3,
7844
+ isFinal: (line) => parse3(line)?.type === "result",
7839
7845
  progress: progress3,
7840
7846
  read: read3,
7841
7847
  sessionsDir: (configDir) => join4(configDir, "sessions"),
@@ -8150,10 +8156,54 @@ import { randomBytes as randomBytes2 } from "node:crypto";
8150
8156
  import {
8151
8157
  appendFileSync,
8152
8158
  createWriteStream as createWriteStream2,
8153
- mkdirSync as mkdirSync4,
8159
+ mkdirSync as mkdirSync5,
8154
8160
  writeFileSync as writeFileSync4
8155
8161
  } from "node:fs";
8156
- import { basename as basename3, delimiter as delimiter2, join as join9 } from "node:path";
8162
+ import { basename as basename3, delimiter as delimiter2, join as join10 } from "node:path";
8163
+
8164
+ // src/agent-dirs.ts
8165
+ import { createHash, randomInt } from "node:crypto";
8166
+ import { mkdirSync as mkdirSync2, realpathSync, symlinkSync as symlinkSync3 } from "node:fs";
8167
+ import { homedir as homedir3 } from "node:os";
8168
+ import { join as join5 } from "node:path";
8169
+ var ID_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789";
8170
+ var ID_LENGTH = 8;
8171
+ var MAX_TRIES = 10;
8172
+ function projectAgentDir(projectRoot) {
8173
+ const hash = createHash("sha256").update(realpathSync(projectRoot)).digest("hex").slice(0, 12);
8174
+ return join5(firegoatHome(), "runs", hash);
8175
+ }
8176
+ function firegoatHome() {
8177
+ return join5(realpathSync(homedir3()), ".firegoat");
8178
+ }
8179
+ function randomId() {
8180
+ let id = "";
8181
+ for (let i = 0;i < ID_LENGTH; i++)
8182
+ id += ID_CHARS[randomInt(ID_CHARS.length)];
8183
+ return id;
8184
+ }
8185
+ function ensureProjectAgentDir(projectRoot) {
8186
+ mkdirSync2(firegoatHome(), { recursive: true, mode: 448 });
8187
+ const dir = projectAgentDir(projectRoot);
8188
+ mkdirSync2(dir, { recursive: true });
8189
+ return dir;
8190
+ }
8191
+ function makeAgentDir(projectDir) {
8192
+ for (let i = 0;i < MAX_TRIES; i++) {
8193
+ const dir = join5(projectDir, randomId());
8194
+ try {
8195
+ mkdirSync2(dir);
8196
+ return dir;
8197
+ } catch (e) {
8198
+ if (e.code !== "EEXIST")
8199
+ throw e;
8200
+ }
8201
+ }
8202
+ throw new Error(`Could not create a new run folder in ${projectDir}: ${MAX_TRIES} random names were taken`);
8203
+ }
8204
+ function linkDir(target, link) {
8205
+ symlinkSync3(target, link, "dir");
8206
+ }
8157
8207
 
8158
8208
  // src/credentials.ts
8159
8209
  import { randomBytes } from "node:crypto";
@@ -8162,15 +8212,15 @@ import {
8162
8212
  closeSync as closeSync2,
8163
8213
  fsyncSync,
8164
8214
  lstatSync as lstatSync3,
8165
- mkdirSync as mkdirSync2,
8215
+ mkdirSync as mkdirSync3,
8166
8216
  openSync as openSync2,
8167
8217
  readFileSync as readFileSync4,
8168
8218
  renameSync,
8169
8219
  unlinkSync as unlinkSync3,
8170
8220
  writeSync
8171
8221
  } from "node:fs";
8172
- import { homedir as homedir3 } from "node:os";
8173
- import { join as join5 } from "node:path";
8222
+ import { homedir as homedir4 } from "node:os";
8223
+ import { join as join6 } from "node:path";
8174
8224
 
8175
8225
  // src/firegoat-api.ts
8176
8226
  var DEFAULT_BASE_URL = "https://firegoat.ai/evals";
@@ -8322,10 +8372,10 @@ async function whoami(base, key) {
8322
8372
  // src/credentials.ts
8323
8373
  var KEY = /^fire_[\x21-\x7e]+$/;
8324
8374
  function credentialsDir() {
8325
- return join5(homedir3(), ".firegoat");
8375
+ return join6(homedir4(), ".firegoat");
8326
8376
  }
8327
8377
  function credentialsPath() {
8328
- return join5(credentialsDir(), "credentials.json");
8378
+ return join6(credentialsDir(), "credentials.json");
8329
8379
  }
8330
8380
  function uid() {
8331
8381
  if (!process.getuid)
@@ -8388,7 +8438,7 @@ function saveCredentials(c) {
8388
8438
  if (st)
8389
8439
  assertOwned(dir, st, "dir");
8390
8440
  else
8391
- mkdirSync2(dir, { mode: 448 });
8441
+ mkdirSync3(dir, { mode: 448 });
8392
8442
  chmodSync(dir, 448);
8393
8443
  const tmp = `${credentialsPath()}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
8394
8444
  const fd = openSync2(tmp, "wx", 384);
@@ -8444,15 +8494,15 @@ function resolveKey(base) {
8444
8494
  var EXIT = { pass: 0, fail: 1, notGraded: 2, usage: 3 };
8445
8495
 
8446
8496
  // src/firegoat-dir.ts
8447
- import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "node:fs";
8448
- import { join as join6 } from "node:path";
8497
+ import { mkdirSync as mkdirSync4, writeFileSync as writeFileSync2 } from "node:fs";
8498
+ import { join as join7 } from "node:path";
8449
8499
  var GITIGNORE = `*
8450
8500
  `;
8451
8501
  function ensureFiregoatDir(root) {
8452
- const dir = join6(root, ".firegoat");
8453
- mkdirSync3(dir, { recursive: true });
8502
+ const dir = join7(root, ".firegoat");
8503
+ mkdirSync4(dir, { recursive: true });
8454
8504
  try {
8455
- writeFileSync2(join6(dir, ".gitignore"), GITIGNORE, { flag: "wx" });
8505
+ writeFileSync2(join7(dir, ".gitignore"), GITIGNORE, { flag: "wx" });
8456
8506
  } catch (e) {
8457
8507
  if (e.code !== "EEXIST")
8458
8508
  throw e;
@@ -8472,6 +8522,26 @@ var CLAUDE_AUTH = /^CLAUDE_CODE_(OAUTH_TOKEN|OAUTH_REFRESH_TOKEN|USE_[A-Z_]+|SKI
8472
8522
  function hostEnv() {
8473
8523
  return Object.fromEntries(Object.entries(process.env).filter(([k]) => !NPM_INJECTED.test(k) && !k.startsWith("FIREGOAT_") && (!CLAUDE_SESSION.test(k) || CLAUDE_AUTH.test(k))));
8474
8524
  }
8525
+ var GRACE_MS = 5000;
8526
+ var liveGroups = new Set;
8527
+ var signalsHooked = false;
8528
+ function hookSignals() {
8529
+ if (signalsHooked)
8530
+ return;
8531
+ signalsHooked = true;
8532
+ const onSignal = (signal) => {
8533
+ for (const g of liveGroups)
8534
+ try {
8535
+ process.kill(-g, "SIGKILL");
8536
+ } catch {}
8537
+ process.kill(process.pid, signal);
8538
+ };
8539
+ for (const s of ["SIGINT", "SIGTERM", "SIGHUP"])
8540
+ process.once(s, onSignal);
8541
+ }
8542
+ function forgetGroup(pgid) {
8543
+ liveGroups.delete(pgid);
8544
+ }
8475
8545
  function processTree(pid) {
8476
8546
  const children = new Map;
8477
8547
  const ps = execFileSync("ps", ["-A", "-o", "pid=,ppid="], {
@@ -8489,7 +8559,11 @@ function processTree(pid) {
8489
8559
  tree.push(...children.get(tree[i]) ?? []);
8490
8560
  return tree;
8491
8561
  }
8492
- function killTree(pid) {
8562
+ function killTree(pid, group) {
8563
+ if (group)
8564
+ try {
8565
+ process.kill(-pid, "SIGKILL");
8566
+ } catch {}
8493
8567
  let tree = [pid];
8494
8568
  let problem;
8495
8569
  try {
@@ -8505,18 +8579,27 @@ function killTree(pid) {
8505
8579
  }
8506
8580
  function run(opts) {
8507
8581
  return new Promise((resolve) => {
8582
+ const detached = opts.detached === true;
8583
+ if (detached)
8584
+ hookSignals();
8508
8585
  const child = spawn(opts.cmd, opts.args, {
8509
8586
  cwd: opts.cwd,
8510
8587
  env: opts.env,
8511
- stdio: ["ignore", "pipe", "pipe"]
8588
+ stdio: ["ignore", "pipe", "pipe"],
8589
+ detached
8512
8590
  });
8591
+ if (child.pid !== undefined) {
8592
+ if (detached)
8593
+ liveGroups.add(child.pid);
8594
+ opts.onSpawn?.(child.pid);
8595
+ }
8513
8596
  let timedOut = false;
8514
8597
  let settled = false;
8515
8598
  const timer = setTimeout(() => {
8516
8599
  timedOut = true;
8517
8600
  if (child.pid === undefined)
8518
8601
  return;
8519
- const problem = killTree(child.pid);
8602
+ const problem = killTree(child.pid, detached);
8520
8603
  if (problem)
8521
8604
  opts.onStderr(Buffer.from(`firegoat: timed out; could not list ${opts.cmd}'s child processes (${problem}), killed only ${opts.cmd}
8522
8605
  `));
@@ -8530,6 +8613,7 @@ function run(opts) {
8530
8613
  };
8531
8614
  child.stdout.on("data", opts.onStdout);
8532
8615
  child.stderr.on("data", opts.onStderr);
8616
+ opts.finalSeen?.then(() => setTimeout(() => done({ code: null, timedOut: false, ended: true }), GRACE_MS).unref());
8533
8617
  child.on("error", (error) => done({ code: null, timedOut, error }));
8534
8618
  child.on("close", (code) => done({ code, timedOut }));
8535
8619
  child.on("exit", (code) => setTimeout(() => {
@@ -8622,41 +8706,54 @@ function gitRepo(dir) {
8622
8706
  async function runAgent(harness, opts) {
8623
8707
  let stdout = "";
8624
8708
  let pending = "";
8709
+ let group;
8710
+ let markFinal = () => {};
8711
+ const finalSeen = new Promise((r) => {
8712
+ markFinal = r;
8713
+ });
8625
8714
  const exited = await run({
8626
8715
  cmd: opts.bin,
8627
8716
  args: harness.args(opts),
8628
8717
  cwd: opts.cwd,
8629
8718
  env: opts.env,
8630
8719
  timeoutMs: opts.timeoutMs,
8720
+ detached: true,
8721
+ finalSeen,
8722
+ onSpawn: (pid) => {
8723
+ group = pid;
8724
+ },
8631
8725
  onStdout: (c) => {
8632
8726
  const text = c.toString();
8633
8727
  stdout += text;
8634
- if (!opts.onProgress)
8635
- return;
8636
8728
  pending += text;
8637
8729
  const lines = pending.split(`
8638
8730
  `);
8639
8731
  pending = lines.pop() ?? "";
8640
8732
  for (const line of lines) {
8641
- const p = harness.progress(line);
8733
+ if (harness.isFinal(line))
8734
+ markFinal();
8735
+ const p = opts.onProgress && harness.progress(line);
8642
8736
  if (p)
8643
- opts.onProgress(p);
8737
+ opts.onProgress?.(p);
8644
8738
  }
8645
8739
  },
8646
8740
  onStderr: (c) => opts.stderr.write(c)
8647
8741
  });
8648
8742
  if (exited.error)
8649
8743
  return {
8650
- ok: false,
8651
- reason: `could not start ${opts.bin}: ${exited.error.message}`,
8652
- steps: [],
8653
- final: "",
8654
- reportedModel: null,
8655
- version: null,
8656
- turns: null,
8657
- cost: null
8744
+ group,
8745
+ outcome: {
8746
+ ok: false,
8747
+ reason: `could not start ${opts.bin}: ${exited.error.message}`,
8748
+ steps: [],
8749
+ final: "",
8750
+ reportedModel: null,
8751
+ version: null,
8752
+ turns: null,
8753
+ cost: null
8754
+ }
8658
8755
  };
8659
- return harness.read(stdout, exited, opts.timeoutMs);
8756
+ return { group, outcome: harness.read(stdout, exited, opts.timeoutMs) };
8660
8757
  }
8661
8758
 
8662
8759
  // src/help.ts
@@ -8734,8 +8831,10 @@ firegoat.yaml reference:
8734
8831
  install: <sh> | [<sh>, ...] In each fresh folder, before the agent starts; a
8735
8832
  failure: that run is not graded
8736
8833
  cleanup: <sh> | [<sh>, ...] In each run's folder once the run is over: after the
8737
- checks, and also after an error or timeout. Stops
8738
- what the agent left running (servers, daemons).
8834
+ checks, and also after an error or timeout. For
8835
+ what lives outside the run (e.g. seeded data);
8836
+ processes the agent left running are stopped by
8837
+ firegoat after cleanup (see eval --help).
8739
8838
  Every step runs; a failure is a warning (run.json
8740
8839
  cleanup, <test>/<n>/cleanup.txt), never the verdict
8741
8840
  vars: {KEY: value} Defaults for {{vars.KEY}} in prompts, checks and
@@ -8812,7 +8911,11 @@ firegoat.yaml reference:
8812
8911
  for any value.
8813
8912
  The build, install and cleanup commands and the checks get the vars as env
8814
8913
  vars, plus $FIREGOAT_OUT and $FIREGOAT_WORKDIR; install, checks and cleanup also
8815
- get $FIREGOAT_HOME, the run's own home, as HOME (build keeps your real HOME).
8914
+ get $FIREGOAT_HOME, the run's own home, as HOME (build keeps your real HOME),
8915
+ $FIREGOAT_RUN_DIR (the run's .firegoat/runs/<runId>/<test>/<n>/, where its logs
8916
+ go) and $FIREGOAT_REPO (the folder holding firegoat.yaml). The agent gets
8917
+ neither: keep values it must not find in $FIREGOAT_RUN_DIR, e.g. install:
8918
+ echo "$EXPECTED" > "$FIREGOAT_RUN_DIR/expected", and a check reads it back.
8816
8919
  Checks also get $FIREGOAT_TRANSCRIPT, a short plain-text
8817
8920
  transcript, greppable line by line (format: eval --help, Transcript). So
8818
8921
  '^\\[[0-9]+\\] \\$ .*foo' matches only commands. Checks also get
@@ -8843,7 +8946,6 @@ relative paths in it are from the run's folder, claude's cwd:
8843
8946
  Example:
8844
8947
  build: npm pack --pack-destination "$FIREGOAT_OUT"
8845
8948
  install: npm init -y >/dev/null && npm install "$FIREGOAT_OUT"/*.tgz
8846
- cleanup: pkill -f "$FIREGOAT_WORKDIR" || true
8847
8949
  tests:
8848
8950
  - name: first-call
8849
8951
  prompt: >-
@@ -8911,8 +9013,13 @@ var EVAL_HELP = `Usage: firegoat eval [test..] [--runs N] [--model NAME].. [--pa
8911
9013
 
8912
9014
  Runs each test in firegoat.yaml (in the current folder): your agent harness
8913
9015
  (claude-code by default, codex-cli or grok-cli; harness: in firegoat.yaml) gets the prompt
8914
- in a fresh, empty folder, .firegoat/runs/<runId>/<test>/<n>/work, with build:'s
8915
- outputs on PATH. Then the test's checks run in that folder.
9016
+ in a fresh, empty folder outside the repo, ~/.firegoat/runs/<hash>/<random>/work,
9017
+ with build:'s outputs on PATH. Then the test's checks run in that folder. <hash>
9018
+ is a hash of the repo's path (a linked worktree uses its main checkout's) and
9019
+ <random> is new for each run, so the agent's paths name neither the project nor
9020
+ the test, and git there sees no repo. The run's logs stay in the repo, in
9021
+ .firegoat/runs/<runId>/<test>/<n>/, whose work and home are symlinks to the
9022
+ run's folders.
8916
9023
 
8917
9024
  Step order: build: once, in this folder; then per run, in its folder: install:
8918
9025
  (top-level, then the test's), the agent, checks, cleanup: (the test's, then
@@ -8920,39 +9027,51 @@ top-level). cleanup: runs whenever the folder exists, also after a failed instal
8920
9027
  a claude error or a timeout; a failing cleanup step prints a warning and never
8921
9028
  changes the run's status or the exit code.
8922
9029
 
9030
+ The agent's run ends with its final result (claude's and grok's result line,
9031
+ codex's turn.completed or turn.failed): a harness that hasn't exited 5s later
9032
+ (a server it started in the background keeps it alive) is left running, so the
9033
+ checks can still reach that server. After cleanup, firegoat stops whatever is
9034
+ still running from the run (SIGTERM, then SIGKILL after 2s): the harness's
9035
+ process group, every process below it, and every process whose working
9036
+ directory is inside the run's folder (lsof on macOS, /proc on Linux). They are
9037
+ listed in run.json leftover, with one warning per run; never the verdict. Not
9038
+ found: a daemon that left the group and changed its working directory out of
9039
+ the run's folder.
9040
+
8923
9041
  claude runs on its own login (or ANTHROPIC_API_KEY if set, as claude always does)
8924
- with a fresh config dir, .firegoat/.claude: none of your settings, CLAUDE.md,
8925
- plugins, skills or MCP servers (harness_args: in firegoat.yaml adds claude flags,
8926
- e.g. --chrome or --plugin-dir). claude saves its session there. There is no
8927
- sandbox: the agent can reach the rest of your machine, including the repo.
9042
+ with a fresh config dir, ~/.firegoat/runs/<hash>/.claude: none of your settings,
9043
+ CLAUDE.md, plugins, skills or MCP servers (harness_args: in firegoat.yaml adds
9044
+ claude flags, e.g. --chrome or --plugin-dir). claude saves its session there. There
9045
+ is no sandbox: the agent can still reach the rest of your machine, including the
9046
+ repo, if it goes looking.
8928
9047
 
8929
9048
  codex-cli runs codex exec --json --dangerously-bypass-approvals-and-sandbox with a
8930
- fresh CODEX_HOME, .firegoat/.codex, holding a link to your codex login
9049
+ fresh CODEX_HOME, ~/.firegoat/runs/<hash>/.codex, holding a link to your codex login
8931
9050
  (~/.codex/auth.json, or $CODEX_HOME/auth.json; run codex login first), and with
8932
9051
  -c project_doc_max_bytes=0: no AGENTS.md, config.toml, skills or plugins of
8933
9052
  yours. codex's own HOME is the run's home (below), also for its Read/Write. It
8934
- saves its session (rollout) under .firegoat/.codex/sessions/. firegoat.ai doesn't
9053
+ saves its session (rollout) under that CODEX_HOME's sessions/. firegoat.ai doesn't
8935
9054
  read codex sessions yet: those runs are not uploaded. codex reports no turns or
8936
9055
  cost; its model comes from the session file.
8937
9056
 
8938
9057
  grok-cli runs ~/.grok/bin/grok (where grok's installer puts it; never a grok on
8939
9058
  PATH, which may be a wrapper such as cmux's: a #! script is refused) as grok -p
8940
9059
  --output-format streaming-messages-json --always-approve --no-auto-update, with a
8941
- fresh GROK_HOME, .firegoat/.grok, holding a link to your grok login
9060
+ fresh GROK_HOME, ~/.firegoat/runs/<hash>/.grok, holding a link to your grok login
8942
9061
  ($GROK_HOME/auth.json or ~/.grok/auth.json; run grok login first; XAI_API_KEY alone
8943
9062
  does not log headless grok in). grok's own HOME is the run's home (below): with
8944
9063
  your real one it loads Claude Code's skills, plugins, MCP servers and hooks. eval
8945
9064
  refuses a login that expires within 5 minutes (run grok -p hi once to refresh it):
8946
9065
  a refresh inside a run would replace the link with a file, which the next eval
8947
9066
  asks you to move back over ~/.grok/auth.json. The session is a directory,
8948
- .firegoat/.grok/sessions/<cwd>/<id>/ ($FIREGOAT_SESSION). firegoat.ai doesn't read
9067
+ $GROK_HOME/sessions/<cwd>/<id>/ ($FIREGOAT_SESSION). firegoat.ai doesn't read
8949
9068
  grok sessions yet: those runs are not uploaded.
8950
9069
 
8951
- Every run gets its own HOME, .firegoat/runs/<runId>/<test>/<n>/home (with
8952
- XDG_CONFIG_HOME, XDG_CACHE_HOME, XDG_DATA_HOME, XDG_STATE_HOME inside it), as
9070
+ Every run gets its own HOME, ~/.firegoat/runs/<hash>/<random>/home next to work
9071
+ (with XDG_CONFIG_HOME, XDG_CACHE_HOME, XDG_DATA_HOME, XDG_STATE_HOME inside it), as
8953
9072
  $FIREGOAT_HOME: tools that keep state in ~ start fresh and parallel runs don't
8954
9073
  collide. claude itself keeps your real HOME (its login is there) and sources
8955
- <n>/home.env (CLAUDE_ENV_FILE) before each Bash command the agent runs; install,
9074
+ home.env next to it (CLAUDE_ENV_FILE) before each Bash command the agent runs; install,
8956
9075
  checks and cleanup get the run's HOME directly; build keeps yours. Limits: claude's
8957
9076
  own Read/Write tools expand ~ to your real home; getpwuid lookups (os.userInfo,
8958
9077
  python pwd) still see it; keychain logins (gh, docker, git credential helpers) look
@@ -8968,9 +9087,9 @@ nested session. Login and provider vars stay: ANTHROPIC_*, CLAUDE_CODE_OAUTH_TOK
8968
9087
  CLAUDE_CODE_OAUTH_REFRESH_TOKEN, CLAUDE_CODE_USE_*, CLAUDE_CODE_SKIP_*_AUTH,
8969
9088
  CLAUDE_CODE_CLIENT_CERT, CLAUDE_CODE_CLIENT_KEY, CLAUDE_CODE_CLIENT_KEY_PASSPHRASE.
8970
9089
 
8971
- In a linked git worktree (git worktree add), .firegoat/runs/ and the harness
8972
- config dirs (.firegoat/.claude, .firegoat/.codex, .firegoat/.grok) live in the main checkout instead, so runs outlive the worktree; firegoat.yaml,
8973
- fixtures and build: stay in the current folder.
9090
+ In a linked git worktree (git worktree add), .firegoat/runs/ lives in the main
9091
+ checkout instead, and <hash> is the main checkout's, so runs outlive the worktree;
9092
+ firegoat.yaml, fixtures and build: stay in the current folder.
8974
9093
 
8975
9094
  test.. Only these tests (by name)
8976
9095
  --runs N Runs per test (overrides runs: in firegoat.yaml)
@@ -8994,8 +9113,9 @@ fixtures and build: stay in the current folder.
8994
9113
  --model, --harness, missing vars), print each planned
8995
9114
  run ("<test>#<n> [harness model]" with its install, checks
8996
9115
  and cleanup counts and rendered harness_args, paths under
8997
- runs/<runId>/) and exit 0. Builds nothing, starts no
8998
- agent, uploads nothing, writes no files. Config errors
9116
+ ~/.firegoat/runs/<hash>/<random>/) and exit 0. Builds
9117
+ nothing, starts no agent, uploads nothing, writes no
9118
+ files. Config errors
8999
9119
  exit 3. With --json: {dryRun, build, runs: [{test, n,
9000
9120
  harness, model, harnessArgs, install, checks,
9001
9121
  cleanup}]}, steps and checks with their placeholders
@@ -9017,6 +9137,8 @@ Runs without a session file are not uploaded. FIREGOAT_BASE_URL points elsewhere
9017
9137
  (default https://firegoat.ai/evals). See firegoat login --help.
9018
9138
 
9019
9139
  Output: .firegoat/runs/<runId>/
9140
+ out A symlink to $FIREGOAT_OUT,
9141
+ ~/.firegoat/runs/<hash>/<random>/out
9020
9142
  summary.json firegoatVersion, runId, runsDir, dir (where eval
9021
9143
  ran), git {branch, commit, dirty} (no git outside a
9022
9144
  repo), harnesses, upload {origin, email} (null when
@@ -9030,14 +9152,18 @@ Output: .firegoat/runs/<runId>/
9030
9152
  the harness reported running, or null), harnessArgs
9031
9153
  (harness_args passed), prompt, end_goal,
9032
9154
  checks [{run, code}], cleanup [{run, code}],
9033
- and paths: workdir, home (the run's HOME),
9155
+ leftover [{pid, command}] (processes stopped after
9156
+ cleanup), and paths: dir (this folder), workdir, home
9157
+ (the run's HOME; both real paths, under ~/.firegoat),
9034
9158
  transcript, session (the harness's session file), stderr,
9035
9159
  checksLog, cleanupLog; startedAt; upload ({status: "uploaded",
9036
9160
  url} | {status: "failed", error} | {status:
9037
9161
  "skipped", reason}, null when not uploading)
9038
9162
  <test>/<n>/transcript.txt What the agent did, below
9039
- <test>/<n>/home/, home.env The run's HOME, and the file claude sources
9040
- before the agent's Bash commands
9163
+ <test>/<n>/work, home Symlinks to the agent's folder and the run's HOME
9164
+ (home.env, which claude sources before the agent's
9165
+ Bash commands, is next to them)
9166
+
9041
9167
  build.txt, <test>/<n>/install.txt, checks.txt, cleanup.txt Logs
9042
9168
 
9043
9169
  Transcript ($FIREGOAT_TRANSCRIPT), greppable line by line; n counts tool calls:
@@ -9093,6 +9219,121 @@ Example:
9093
9219
 
9094
9220
  Exit codes: 0 sent, 2 the service failed (or rate limit), 3 usage error.`;
9095
9221
 
9222
+ // src/leftover.ts
9223
+ import { spawn as spawn2 } from "node:child_process";
9224
+ import { readdirSync as readdirSync4, readlinkSync as readlinkSync2, realpathSync as realpathSync2 } from "node:fs";
9225
+ var TERM_WAIT_MS = 2000;
9226
+ function output(cmd, args) {
9227
+ return new Promise((resolve, reject) => {
9228
+ const child = spawn2(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
9229
+ let out = "";
9230
+ let err = "";
9231
+ child.stdout.on("data", (c) => {
9232
+ out += c;
9233
+ });
9234
+ child.stderr.on("data", (c) => {
9235
+ err += c;
9236
+ });
9237
+ child.on("error", (e) => reject(new Error(`could not run ${cmd}: ${e.message}`)));
9238
+ child.on("close", (code) => code === 0 || out !== "" ? resolve(out) : reject(new Error(`${cmd} exited ${code}: ${err.trim().slice(0, 300)}`)));
9239
+ });
9240
+ }
9241
+ async function processes() {
9242
+ const ps = await output("ps", [
9243
+ "-A",
9244
+ "-o",
9245
+ "pid=,ppid=,pgid=,stat=,command="
9246
+ ]);
9247
+ const procs = [];
9248
+ for (const line of ps.split(`
9249
+ `)) {
9250
+ const m = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s*(.*)$/.exec(line);
9251
+ if (!m || m[4]?.startsWith("Z"))
9252
+ continue;
9253
+ procs.push({
9254
+ pid: Number(m[1]),
9255
+ ppid: Number(m[2]),
9256
+ pgid: Number(m[3]),
9257
+ command: m[5] ?? ""
9258
+ });
9259
+ }
9260
+ return procs;
9261
+ }
9262
+ async function cwdInside(dir) {
9263
+ const inside = (p) => p === dir || p.startsWith(`${dir}/`);
9264
+ if (process.platform === "linux") {
9265
+ const pids = [];
9266
+ for (const name of readdirSync4("/proc")) {
9267
+ if (!/^\d+$/.test(name))
9268
+ continue;
9269
+ try {
9270
+ if (inside(readlinkSync2(`/proc/${name}/cwd`)))
9271
+ pids.push(Number(name));
9272
+ } catch {}
9273
+ }
9274
+ return pids;
9275
+ }
9276
+ if (process.platform === "darwin") {
9277
+ const pids = [];
9278
+ let pid = 0;
9279
+ for (const line of (await output("lsof", ["-d", "cwd", "-Fpn", "-w"])).split(`
9280
+ `)) {
9281
+ if (line.startsWith("p"))
9282
+ pid = Number(line.slice(1));
9283
+ else if (line.startsWith("n") && inside(line.slice(1)))
9284
+ pids.push(pid);
9285
+ }
9286
+ return pids;
9287
+ }
9288
+ throw new Error(`finding processes by cwd is not supported on ${process.platform}`);
9289
+ }
9290
+ function alive(pid) {
9291
+ try {
9292
+ process.kill(pid, 0);
9293
+ return true;
9294
+ } catch {
9295
+ return false;
9296
+ }
9297
+ }
9298
+ function signal(pids, group, sig) {
9299
+ for (const p of group === undefined ? pids : [...pids, -group])
9300
+ try {
9301
+ process.kill(p, sig);
9302
+ } catch {}
9303
+ }
9304
+ async function stopLeftovers(opts) {
9305
+ const workdir = realpathSync2(opts.workdir);
9306
+ const [procs, inWorkdir] = await Promise.all([
9307
+ processes(),
9308
+ cwdInside(workdir)
9309
+ ]);
9310
+ const found = new Set(inWorkdir);
9311
+ if (opts.group !== undefined) {
9312
+ for (const p of procs)
9313
+ if (p.pgid === opts.group)
9314
+ found.add(p.pid);
9315
+ }
9316
+ for (let grew = true;grew; ) {
9317
+ grew = false;
9318
+ for (const p of procs)
9319
+ if (found.has(p.ppid) && !found.has(p.pid)) {
9320
+ found.add(p.pid);
9321
+ grew = true;
9322
+ }
9323
+ }
9324
+ found.delete(process.pid);
9325
+ const leftover = procs.filter((p) => found.has(p.pid)).map((p) => ({ pid: p.pid, command: printable(p.command, 200) }));
9326
+ const pids = leftover.map((l) => l.pid);
9327
+ signal(pids, opts.group, "SIGTERM");
9328
+ const deadline = Date.now() + TERM_WAIT_MS;
9329
+ while (pids.some(alive) && Date.now() < deadline)
9330
+ await new Promise((r) => setTimeout(r, 100));
9331
+ signal(pids.filter(alive), opts.group, "SIGKILL");
9332
+ if (opts.group !== undefined)
9333
+ forgetGroup(opts.group);
9334
+ return leftover;
9335
+ }
9336
+
9096
9337
  // src/steps.ts
9097
9338
  import { createWriteStream } from "node:fs";
9098
9339
  var STEP_TIMEOUT_MS = 600000;
@@ -9227,8 +9468,8 @@ async function sendTelemetry(event, exitCode, startedAt) {
9227
9468
  }
9228
9469
 
9229
9470
  // src/transcript.ts
9230
- import { readdirSync as readdirSync4, statSync as statSync2, writeFileSync as writeFileSync3 } from "node:fs";
9231
- import { join as join7, relative } from "node:path";
9471
+ import { readdirSync as readdirSync5, statSync as statSync2, writeFileSync as writeFileSync3 } from "node:fs";
9472
+ import { join as join8, relative } from "node:path";
9232
9473
  var OUTPUT_LINES = 15;
9233
9474
  var OUTPUT_CHARS = 1500;
9234
9475
  var TEXT_CHARS = 1500;
@@ -9277,10 +9518,10 @@ function describeToolCall(step, c) {
9277
9518
  function workdirFiles(dir) {
9278
9519
  const out = [];
9279
9520
  const walk = (d) => {
9280
- for (const name of readdirSync4(d)) {
9521
+ for (const name of readdirSync5(d)) {
9281
9522
  if (SKIP_DIRS.has(name))
9282
9523
  continue;
9283
- const p = join7(d, name);
9524
+ const p = join8(d, name);
9284
9525
  if (statSync2(p).isDirectory())
9285
9526
  walk(p);
9286
9527
  else
@@ -9360,7 +9601,7 @@ function runTag(harness, model) {
9360
9601
  return parts.length > 0 ? ` [${parts.join(" ")}]` : "";
9361
9602
  }
9362
9603
  function notGradedWhy(reason) {
9363
- const said = /claude result is an error \(subtype=[^)]*\): (.*)$/s.exec(reason);
9604
+ const said = /claude reported an error(?: \([^)]*\))?: (.*)$/s.exec(reason);
9364
9605
  if (said)
9365
9606
  return `claude said: "${said[1]}"`;
9366
9607
  return reason.replace(/^not graded: /, "");
@@ -9780,7 +10021,7 @@ async function uploadRun(t, r) {
9780
10021
 
9781
10022
  // src/which.ts
9782
10023
  import { accessSync, constants, statSync as statSync3 } from "node:fs";
9783
- import { delimiter, join as join8, resolve } from "node:path";
10024
+ import { delimiter, join as join9, resolve } from "node:path";
9784
10025
  function resolveBin(bin) {
9785
10026
  const executable = (p) => {
9786
10027
  try {
@@ -9793,8 +10034,8 @@ function resolveBin(bin) {
9793
10034
  if (bin.includes("/"))
9794
10035
  return executable(bin) ? resolve(bin) : undefined;
9795
10036
  for (const dir of (process.env.PATH ?? "").split(delimiter)) {
9796
- if (dir && executable(join8(dir, bin)))
9797
- return join8(dir, bin);
10037
+ if (dir && executable(join9(dir, bin)))
10038
+ return join9(dir, bin);
9798
10039
  }
9799
10040
  return;
9800
10041
  }
@@ -9881,7 +10122,7 @@ function newRunId() {
9881
10122
  return `${ts}-${randomBytes2(2).toString("hex")}`;
9882
10123
  }
9883
10124
  function writeRunJson(r) {
9884
- writeFileSync4(join9(r.dir, "run.json"), `${JSON.stringify(r, null, 2)}
10125
+ writeFileSync4(join10(r.dir, "run.json"), `${JSON.stringify(r, null, 2)}
9885
10126
  `);
9886
10127
  }
9887
10128
  async function uploadPreflight(opts, ui, project, runGroup, git) {
@@ -9922,14 +10163,14 @@ async function uploadPreflight(opts, ui, project, runGroup, git) {
9922
10163
  };
9923
10164
  }
9924
10165
  }
9925
- function runFill(runId, n, testName, out, dir) {
10166
+ function runFill(runId, n, testName, out, agentDir) {
9926
10167
  return {
9927
10168
  "run.id": runId,
9928
10169
  "run.n": String(n),
9929
10170
  "test.name": testName,
9930
10171
  FIREGOAT_OUT: out,
9931
- FIREGOAT_WORKDIR: join9(dir, "work"),
9932
- FIREGOAT_HOME: join9(dir, "home")
10172
+ FIREGOAT_WORKDIR: join10(agentDir, "work"),
10173
+ FIREGOAT_HOME: join10(agentDir, "home")
9933
10174
  };
9934
10175
  }
9935
10176
  function renderHarnessArgs(test, vars, fill) {
@@ -9962,10 +10203,11 @@ function showsHarness(harnesses) {
9962
10203
  }
9963
10204
  var DRY_RUN_ID = "<runId>";
9964
10205
  function dryRun(opts) {
9965
- const out = join9(opts.runsRoot, DRY_RUN_ID, "out");
10206
+ const agentDir = join10(opts.agentDirs, "<random>");
10207
+ const out = join10(agentDir, "out");
9966
10208
  const runs = opts.plan.map((p) => {
9967
10209
  const test = opts.tests.find((t) => t.name === p.test);
9968
- const fill = runFill(DRY_RUN_ID, p.n, test.name, out, join9(opts.runsRoot, DRY_RUN_ID, test.name, String(p.n)));
10210
+ const fill = runFill(DRY_RUN_ID, p.n, test.name, out, agentDir);
9969
10211
  renderEnv(test.env, opts.vars, fill);
9970
10212
  return {
9971
10213
  ...p,
@@ -9986,11 +10228,15 @@ function dryRun(opts) {
9986
10228
  async function runOne(opts) {
9987
10229
  const { test, n } = opts;
9988
10230
  const started = Date.now();
9989
- const dir = join9(opts.runDir, test.name, String(n));
9990
- const workdir = join9(dir, "work");
9991
- mkdirSync4(workdir, { recursive: true });
9992
- const { home, envFile } = makeRunHome(dir);
9993
- const fill = runFill(opts.runId, n, test.name, opts.out, dir);
10231
+ const dir = join10(opts.runDir, test.name, String(n));
10232
+ mkdirSync5(dir, { recursive: true });
10233
+ const agentDir = makeAgentDir(opts.agentDirs);
10234
+ const workdir = join10(agentDir, "work");
10235
+ mkdirSync5(workdir);
10236
+ const { home, envFile } = makeRunHome(agentDir);
10237
+ linkDir(workdir, join10(dir, "work"));
10238
+ linkDir(home, join10(dir, "home"));
10239
+ const fill = runFill(opts.runId, n, test.name, opts.out, agentDir);
9994
10240
  const r = {
9995
10241
  test: test.name,
9996
10242
  n,
@@ -10007,11 +10253,12 @@ async function runOne(opts) {
10007
10253
  workdir,
10008
10254
  home,
10009
10255
  session: null,
10010
- transcript: join9(dir, "transcript.txt"),
10011
- stderr: join9(dir, "stderr.txt"),
10012
- checksLog: join9(dir, "checks.txt"),
10256
+ transcript: join10(dir, "transcript.txt"),
10257
+ stderr: join10(dir, "stderr.txt"),
10258
+ checksLog: join10(dir, "checks.txt"),
10013
10259
  cleanup: [],
10014
- cleanupLog: join9(dir, "cleanup.txt"),
10260
+ cleanupLog: join10(dir, "cleanup.txt"),
10261
+ leftover: [],
10015
10262
  num_turns: null,
10016
10263
  cost: null,
10017
10264
  duration_ms: 0,
@@ -10024,10 +10271,13 @@ async function runOne(opts) {
10024
10271
  ...env,
10025
10272
  ...homeEnv(home),
10026
10273
  FIREGOAT_WORKDIR: workdir,
10027
- FIREGOAT_HOME: home
10274
+ FIREGOAT_HOME: home,
10275
+ FIREGOAT_RUN_DIR: dir,
10276
+ FIREGOAT_REPO: opts.repo
10028
10277
  };
10029
10278
  const steps = renderRunSteps(test, opts, opts.vars, fill);
10030
- const [status, reason] = await attempt(opts, r, steps, env, stepEnv, envFile);
10279
+ const agent = {};
10280
+ const [status, reason] = await attempt(opts, r, steps, env, stepEnv, envFile, agent);
10031
10281
  if (steps.cleanup.length > 0)
10032
10282
  r.cleanup = await runAllSteps({
10033
10283
  commands: steps.cleanup,
@@ -10035,13 +10285,17 @@ async function runOne(opts) {
10035
10285
  env: stepEnv,
10036
10286
  logPath: r.cleanupLog
10037
10287
  });
10288
+ r.leftover = await stopLeftovers({ group: agent.group, workdir });
10038
10289
  r.status = status;
10039
10290
  r.reason = reason;
10040
10291
  r.duration_ms = Date.now() - started;
10041
10292
  writeRunJson(r);
10042
10293
  return r;
10043
10294
  }
10044
- async function attempt(opts, r, steps, env, stepEnv, homeEnvFile) {
10295
+ function withoutOldPwd(env) {
10296
+ return Object.fromEntries(Object.entries(env).filter(([k]) => k !== "OLDPWD"));
10297
+ }
10298
+ async function attempt(opts, r, steps, env, stepEnv, homeEnvFile, agent) {
10045
10299
  const { test, n } = opts;
10046
10300
  const { dir, workdir } = r;
10047
10301
  if (steps.install.length > 0) {
@@ -10050,24 +10304,25 @@ async function attempt(opts, r, steps, env, stepEnv, homeEnvFile) {
10050
10304
  commands: steps.install,
10051
10305
  cwd: workdir,
10052
10306
  env: stepEnv,
10053
- logPath: join9(dir, "install.txt")
10307
+ logPath: join10(dir, "install.txt")
10054
10308
  });
10055
10309
  if (error)
10056
10310
  return ["error", `not graded: ${error}`];
10057
10311
  }
10058
10312
  const stderr = createWriteStream2(r.stderr);
10059
10313
  const { harness } = opts;
10060
- const outcome = await runAgent(harness, {
10314
+ const { outcome, group } = await runAgent(harness, {
10061
10315
  bin: opts.bin,
10062
10316
  prompt: r.prompt,
10063
10317
  cwd: workdir,
10064
10318
  env: {
10065
- ...hostEnv(),
10319
+ ...withoutOldPwd(hostEnv()),
10066
10320
  ...env,
10067
10321
  ...harness.env({
10068
10322
  configDir: opts.configDir,
10069
10323
  run: { home: r.home, homeEnvFile }
10070
10324
  }),
10325
+ PWD: workdir,
10071
10326
  PATH: opts.stepEnv.PATH,
10072
10327
  FIREGOAT_OUT: opts.out,
10073
10328
  FIREGOAT_WORKDIR: workdir,
@@ -10079,6 +10334,7 @@ async function attempt(opts, r, steps, env, stepEnv, homeEnvFile) {
10079
10334
  extraArgs: r.harnessArgs,
10080
10335
  onProgress: opts.onProgress
10081
10336
  });
10337
+ agent.group = group;
10082
10338
  if (outcome.version !== null)
10083
10339
  evalStats({ claudeVersion: claudeVersion(outcome.version) });
10084
10340
  await new Promise((res) => stderr.end(res));
@@ -10144,7 +10400,7 @@ function uploadedSummary(origin, results, runDir) {
10144
10400
  return;
10145
10401
  const head = `Uploaded ${urls.length} session${urls.length === 1 ? "" : "s"} to ${new URL(origin).host} (private)`;
10146
10402
  if (urls.length > MAX_LISTED_URLS) {
10147
- console.log(`${head}: each run's upload.url in ${join9(runDir, "summary.json")}`);
10403
+ console.log(`${head}: each run's upload.url in ${join10(runDir, "summary.json")}`);
10148
10404
  return;
10149
10405
  }
10150
10406
  console.log(`${head}:`);
@@ -10245,7 +10501,7 @@ async function evalCommand(args, root) {
10245
10501
  plan: planRuns(tests, harnessNames, models, runs),
10246
10502
  showHarness,
10247
10503
  vars,
10248
- runsRoot: join9(repo?.mainCheckout ?? root, ".firegoat", "runs"),
10504
+ agentDirs: projectAgentDir(repo?.mainCheckout ?? root),
10249
10505
  json: opts.json,
10250
10506
  say
10251
10507
  });
@@ -10271,11 +10527,13 @@ async function evalCommand(args, root) {
10271
10527
  } catch (e) {
10272
10528
  return fatal(EXIT.usage, e.message, "config");
10273
10529
  }
10274
- const firegoatDir = ensureFiregoatDir(repo?.mainCheckout ?? root);
10530
+ const project = repo?.mainCheckout ?? root;
10531
+ const firegoatDir = ensureFiregoatDir(project);
10532
+ const agentDirs = ensureProjectAgentDir(project);
10275
10533
  const configDirs = new Map;
10276
10534
  for (const h of harnesses) {
10277
- const configDir = join9(firegoatDir, h.configDirName);
10278
- mkdirSync4(configDir, { recursive: true });
10535
+ const configDir = join10(agentDirs, h.configDirName);
10536
+ mkdirSync5(configDir, { recursive: true });
10279
10537
  configDirs.set(h, configDir);
10280
10538
  }
10281
10539
  ui.auth(harnesses.length === 1 ? harnesses[0].authLabel() : harnesses.map((h) => `${h.name}: ${h.authLabel()}`).join("; "));
@@ -10308,10 +10566,12 @@ async function evalCommand(args, root) {
10308
10566
  return fatal(EXIT.usage, e.message, "config");
10309
10567
  throw e;
10310
10568
  }
10311
- const runsRoot = join9(firegoatDir, "runs");
10312
- const runDir = join9(runsRoot, runId);
10313
- const out = join9(runDir, "out");
10314
- mkdirSync4(out, { recursive: true });
10569
+ const runsRoot = join10(firegoatDir, "runs");
10570
+ const runDir = join10(runsRoot, runId);
10571
+ mkdirSync5(runDir, { recursive: true });
10572
+ const out = join10(makeAgentDir(agentDirs), "out");
10573
+ mkdirSync5(out);
10574
+ linkDir(out, join10(runDir, "out"));
10315
10575
  const stepEnv = {
10316
10576
  ...hostEnv(),
10317
10577
  ...vars,
@@ -10336,7 +10596,7 @@ async function evalCommand(args, root) {
10336
10596
  ...stepEnv,
10337
10597
  ...renderEnv(Object.fromEntries(Object.entries(config.env).filter(([, v]) => isEvalLevel(v))), vars, { "run.id": runId, FIREGOAT_OUT: out })
10338
10598
  },
10339
- logPath: join9(runDir, "build.txt")
10599
+ logPath: join10(runDir, "build.txt")
10340
10600
  });
10341
10601
  const secs = Math.round((Date.now() - buildStarted) / 1000);
10342
10602
  ui.build(error ? "fail" : "ok", secs);
@@ -10363,6 +10623,8 @@ async function evalCommand(args, root) {
10363
10623
  shownHarness: row.harness,
10364
10624
  runId,
10365
10625
  runDir,
10626
+ agentDirs,
10627
+ repo: root,
10366
10628
  harness,
10367
10629
  bin: bins.get(harness),
10368
10630
  configDir: configDirs.get(harness),
@@ -10432,9 +10694,9 @@ async function evalCommand(args, root) {
10432
10694
  status,
10433
10695
  exitCode
10434
10696
  };
10435
- writeFileSync4(join9(runDir, "summary.json"), `${JSON.stringify(doc, null, 2)}
10697
+ writeFileSync4(join10(runDir, "summary.json"), `${JSON.stringify(doc, null, 2)}
10436
10698
  `);
10437
- appendFileSync(join9(runsRoot, "index.jsonl"), `${JSON.stringify({
10699
+ appendFileSync(join10(runsRoot, "index.jsonl"), `${JSON.stringify({
10438
10700
  runId,
10439
10701
  startedAt: new Date(evalStarted).toISOString(),
10440
10702
  dir: root,
@@ -10461,6 +10723,9 @@ async function evalCommand(args, root) {
10461
10723
  for (const c of r.cleanup)
10462
10724
  if (c.code !== 0)
10463
10725
  console.error(`firegoat eval: Warning: ${r.test}#${r.n} cleanup failed (${c.error ?? `exit ${c.code}`}): ${c.run} (log: ${r.cleanupLog})`);
10726
+ for (const r of results)
10727
+ if (r.leftover.length > 0)
10728
+ console.error(`firegoat eval: Warning: ${r.test}#${r.n} left ${r.leftover.length} process${r.leftover.length === 1 ? "" : "es"} running, stopped after cleanup: ${r.leftover.map((l) => `${l.pid} ${l.command}`).join("; ")} (run.json leftover)`);
10464
10729
  if (uploadsFailed > 0)
10465
10730
  console.error(`firegoat eval: ${uploadsFailed} upload${uploadsFailed === 1 ? "" : "s"} failed (each run's upload.error in summary.json)`);
10466
10731
  if (opts.json)
@@ -10745,12 +11010,12 @@ firegoat ${package_default.version}` : `firegoat ${package_default.version}`,
10745
11010
  }
10746
11011
 
10747
11012
  // src/init.ts
10748
- import { spawn as spawn3, spawnSync as spawnSync4 } from "node:child_process";
11013
+ import { spawn as spawn4, spawnSync as spawnSync4 } from "node:child_process";
10749
11014
  import { existsSync as existsSync6, writeFileSync as writeFileSync5 } from "node:fs";
10750
- import { basename as basename4, join as join10 } from "node:path";
11015
+ import { basename as basename4, join as join11 } from "node:path";
10751
11016
 
10752
11017
  // src/login.ts
10753
- import { spawn as spawn2 } from "node:child_process";
11018
+ import { spawn as spawn3 } from "node:child_process";
10754
11019
  var TIMEOUT_MS3 = 30000;
10755
11020
  var MAX_EXPIRES_S = 900;
10756
11021
  var MAX_INTERVAL_S = 60;
@@ -10767,7 +11032,7 @@ function str3(o, k, step) {
10767
11032
  function openBrowser(url) {
10768
11033
  const cmd = process.platform === "darwin" ? "open" : "xdg-open";
10769
11034
  try {
10770
- const child = spawn2(cmd, [url], {
11035
+ const child = spawn3(cmd, [url], {
10771
11036
  env: hostEnv(),
10772
11037
  stdio: "ignore",
10773
11038
  detached: true
@@ -11046,12 +11311,12 @@ function skillsDir(scope) {
11046
11311
  if (scope === "local")
11047
11312
  return ".claude/skills/";
11048
11313
  const config = process.env.CLAUDE_CONFIG_DIR?.trim();
11049
- return config ? `${join10(config, "skills")}/` : "~/.claude/skills/";
11314
+ return config ? `${join11(config, "skills")}/` : "~/.claude/skills/";
11050
11315
  }
11051
11316
  function template(product) {
11052
11317
  return `# firegoat.yaml: evals for how well coding agents use ${product}.
11053
11318
  # Each test runs your agent (claude by default) in an empty folder
11054
- # (.firegoat/runs/...) with \`prompt\` as the first message, then runs \`checks\`
11319
+ # (~/.firegoat/runs/...) with \`prompt\` as the first message, then runs \`checks\`
11055
11320
  # there. Reference: npx firegoat init --help
11056
11321
 
11057
11322
  # How many times to run each test.
@@ -11091,10 +11356,12 @@ runs: 1
11091
11356
  # install: cp -R "$FIREGOAT_OUT/starter-app/." .
11092
11357
 
11093
11358
  # Optional: cleanup: runs in each run's folder once the run is over (after the
11094
- # checks, also after an error or timeout), e.g. to stop a server the agent left
11095
- # running. A failing cleanup step is a warning, never the verdict.
11359
+ # checks, also after an error or timeout), for what lives outside the run, e.g.
11360
+ # data install: seeded in a shared service. Servers the agent left running need
11361
+ # none: firegoat stops them after cleanup. A failing cleanup step is a warning,
11362
+ # never the verdict.
11096
11363
  # cleanup:
11097
- # - pkill -f "$FIREGOAT_WORKDIR" || true
11364
+ # - acme db drop "eval-{{run.id}}-{{run.n}}" || true
11098
11365
  #
11099
11366
  # A test can add its own install: (after this one) and cleanup: (before this one).
11100
11367
 
@@ -11196,7 +11463,7 @@ class Timeline {
11196
11463
  }
11197
11464
  function runQuiet(cmd, args, opts) {
11198
11465
  return new Promise((resolve) => {
11199
- const child = spawn3(cmd, args, {
11466
+ const child = spawn4(cmd, args, {
11200
11467
  ...opts,
11201
11468
  stdio: ["ignore", "pipe", "pipe"]
11202
11469
  });
@@ -11308,7 +11575,7 @@ ${INIT_HELP}`);
11308
11575
  console.log(rule);
11309
11576
  }
11310
11577
  ensureFiregoatDir(cwd);
11311
- const configPath = join10(cwd, CONFIG_FILE);
11578
+ const configPath = join11(cwd, CONFIG_FILE);
11312
11579
  const keep = existsSync6(configPath) && !force;
11313
11580
  if (!keep)
11314
11581
  writeFileSync5(configPath, template(basename4(cwd)));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "firegoat",
3
- "version": "0.25.0",
3
+ "version": "0.27.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/firegoat-ai/firegoat-cli.git"
@@ -29,7 +29,7 @@
29
29
  },
30
30
  "scripts": {
31
31
  "build": "bun build src/cli.ts --target node --outfile dist/cli.js",
32
- "test": "bun test",
32
+ "test": "bun test --timeout 30000",
33
33
  "typecheck": "bun check",
34
34
  "lint": "biome check .",
35
35
  "format": "biome check --write .",