firegoat 0.17.1 → 0.18.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 +18 -3
  2. package/dist/cli.js +131 -58
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -130,21 +130,36 @@ tests:
130
130
  - run: ls "$ACME_HOME"/sessions | grep -q .
131
131
  ```
132
132
 
133
- `env:` goes to the agent, and to `install` and checks of the same run, so checks see the same values. Placeholders: `{{vars.KEY}}`, `{{run.id}}` (the eval's run id), `{{run.n}}` (1..runs, or 1..runs×models), `{{test.name}}`, and `$FIREGOAT_OUT` / `$FIREGOAT_WORKDIR` (also `${...}`), the same dirs `build`, `install` and checks see. There is no other `$` expansion: `$HOME` stays the literal text `$HOME`. `build` runs once, before any run: it gets only the top-level entries that use nothing but `{{vars.*}}`, `{{run.id}}` and `$FIREGOAT_OUT`. Values are strings (quote numbers). `PATH`, `CLAUDE_CONFIG_DIR` and `FIREGOAT_*` are firegoat's and can't be set; `ANTHROPIC_MODEL` neither: choose the model with `model:`.
133
+ `env:` goes to the agent, and to `install`, checks and `cleanup` of the same run, so checks see the same values. Placeholders: `{{vars.KEY}}`, `{{run.id}}` (the eval's run id), `{{run.n}}` (1..runs, or 1..runs×models), `{{test.name}}`, and `$FIREGOAT_OUT` / `$FIREGOAT_WORKDIR` (also `${...}`), the same dirs `build`, `install` and checks see. There is no other `$` expansion: `$HOME` stays the literal text `$HOME`. `build` runs once, before any run: it gets only the top-level entries that use nothing but `{{vars.*}}`, `{{run.id}}` and `$FIREGOAT_OUT`. Values are strings (quote numbers). `PATH`, `CLAUDE_CONFIG_DIR` and `FIREGOAT_*` are firegoat's and can't be set; `ANTHROPIC_MODEL` neither: choose the model with `model:`.
134
134
 
135
135
  Setting `HOME`: on macOS claude reads its login from `$HOME/Library`, so with a per-run `HOME` every run says "Not logged in" unless `install` runs `ln -s <real home>/Library "$HOME/Library"`. Get the real home in `build`, which doesn't get entries using `{{test.name}}` or `{{run.n}}` (e.g. `build: echo "$HOME" > "$FIREGOAT_OUT/.real-home"`).
136
136
 
137
137
  ## What eval does
138
138
 
139
139
  - claude runs on its own login (or `ANTHROPIC_API_KEY`, if set) with `CLAUDE_CONFIG_DIR=.firegoat/.claude` and `--setting-sources "" --strict-mcp-config --disable-slash-commands`: none of your settings, CLAUDE.md, plugins, skills or MCP servers. claude saves its session there, in its own format.
140
- - No sandbox: the agent's folder is inside `.firegoat/runs/`, and it can reach the rest of your machine. Commit `.firegoat/` with `firegoat.yaml`: its `.gitignore` ignores only `runs/` and `.claude/`, test fixtures (scripts, sample data) go in `.firegoat/fixtures/`, and its `README.md` says what each folder is.
140
+ - No sandbox: the agent's folder is inside `.firegoat/runs/`, and it can reach the rest of your machine. `.firegoat/` holds local run output and ignores itself in git. Keep test fixtures (scripts, sample data) outside it, e.g. in `fixtures/` next to `firegoat.yaml`, and commit them.
141
141
  - In a linked git worktree (`git worktree add`), `.firegoat/runs/` and `.firegoat/.claude/` are in the main checkout, so runs (and their session files) survive `git worktree remove`; `firegoat.yaml`, fixtures and `build:` stay in the worktree. Each `summary.json` records `dir` (where eval ran) and `git: {branch, commit, dirty}`, and `.firegoat/runs/index.jsonl` gets one line per eval (`runId, startedAt, dir, branch, commit, passed, failed, notGraded, cost, uploaded, uploadFailed`) to tell runs from different branches apart.
142
142
  - The agent gets your environment plus `env:`, never `vars:`. Vars that npm/npx set for firegoat itself (`npm_config_*`, `npm_lifecycle_*`, `npm_package_*`, `npm_execpath`, `npm_node_execpath`, `npm_command`, `INIT_CWD`) are removed from every env firegoat starts (agent, build, install, checks): `npx -y firegoat` sets `npm_config_yes=true`, which would make the agent's `npm init` / `npx create-*` skip their prompts. Set one under `env:` to keep it. Every `FIREGOAT_*` var firegoat was started with (`FIREGOAT_API_KEY` above all) is removed too.
143
143
  - Runs (tests × models × `runs`) go 4 at a time, each in its own folder with its own session; `--parallel N` changes that, `--parallel 1` runs them one after another. `summary.json` lists them in `firegoat.yaml` order.
144
144
  - Script checks run in the agent's folder afterwards and set the exit code. `end_goal` is not graded by firegoat: it's for you, or your coding agent, to judge from `run.json`, the session and the folder.
145
145
  - Output per run, in `.firegoat/runs/<runId>/<test>/<n>/`: `transcript.txt` (each tool call with its command and short output, the final answer, files made; checks get it as `$FIREGOAT_TRANSCRIPT`; greppable line by line: every line of a shell call starts with `[n] $ `, every line of the agent's text and final answer with `agent: `, output is indented, so `grep -E '^\[[0-9]+\] \$ .*npm install'` matches only commands, also inside heredocs and multi-line scripts), `run.json` (status, prompt, end_goal, check results, paths), `work/`, logs. Read the transcript to judge `end_goal`; claude's full session file is ~50x larger. `summary.json` covers the whole eval; `--json` prints it.
146
146
 
147
- `firegoat.yaml` keys: `runs`, `model`, `build`, `install`, `vars`, `env`, and `tests` (`name`, `prompt`, `end_goal`, `checks`, `timeout`, `env`). Unknown keys are errors. Full reference: `npx firegoat init --help`.
147
+ `firegoat.yaml` keys: `runs`, `model`, `build`, `install`, `cleanup`, `vars`, `env`, and `tests` (`name`, `prompt`, `end_goal`, `checks`, `timeout`, `env`, `install`, `cleanup`). Unknown keys are errors. Full reference: `npx firegoat init --help`.
148
+
149
+ ### Stop what the agent left running, seed one test
150
+
151
+ ```yaml
152
+ install: npm init -y >/dev/null
153
+ cleanup: pkill -f "$FIREGOAT_WORKDIR" || true # A dev server the agent started
154
+ tests:
155
+ - name: migrate
156
+ prompt: Migrate the app in this folder to acme v2 and start it.
157
+ end_goal: The app runs on acme v2.
158
+ install: cp -R "$FIREGOAT_OUT"/v1-app/. . # This test only, after the top-level install
159
+ cleanup: acme down || true # This test only, before the top-level cleanup
160
+ ```
161
+
162
+ Step order: `build` once per eval, in your folder; then per run, in its folder: `install` (top-level, then the test's), the agent, checks, `cleanup` (the test's, then top-level). `cleanup` runs whenever the run's folder exists: after the checks, and also after a failed install, a claude error or a timeout. Every cleanup step runs; a failing one prints a warning and is recorded in `run.json` (`cleanup: [{run, code}]`, output in `cleanup.txt`), but never changes the run's status or eval's exit code.
148
163
 
149
164
  Want a plain `npm i acme-sdk` to get your build, with no hint in the prompt? See [`examples/local-npm-registry`](examples/local-npm-registry/README.md).
150
165
 
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.17.1",
6950
+ version: "0.18.0",
6951
6951
  repository: {
6952
6952
  type: "git",
6953
6953
  url: "git+https://github.com/firegoat-ai/firegoat-cli.git"
@@ -7078,6 +7078,7 @@ function parseConfig(text) {
7078
7078
  "model",
7079
7079
  "build",
7080
7080
  "install",
7081
+ "cleanup",
7081
7082
  "vars",
7082
7083
  "env",
7083
7084
  "tests"
@@ -7086,6 +7087,7 @@ function parseConfig(text) {
7086
7087
  const models = parseModels(raw.model);
7087
7088
  const build = commands(raw.build, "build");
7088
7089
  const install = commands(raw.install, "install");
7090
+ const cleanup = commands(raw.cleanup, "cleanup");
7089
7091
  const vars = parseVars(raw.vars);
7090
7092
  const env = parseEnv(raw.env, "env");
7091
7093
  if (!Array.isArray(raw.tests) || raw.tests.length === 0)
@@ -7095,7 +7097,16 @@ function parseConfig(text) {
7095
7097
  const p = `tests[${i}]`;
7096
7098
  if (!isRecord(t))
7097
7099
  fail(p, "must be a mapping");
7098
- onlyKeys(t, p, ["name", "prompt", "end_goal", "checks", "timeout", "env"]);
7100
+ onlyKeys(t, p, [
7101
+ "name",
7102
+ "prompt",
7103
+ "end_goal",
7104
+ "checks",
7105
+ "timeout",
7106
+ "env",
7107
+ "install",
7108
+ "cleanup"
7109
+ ]);
7099
7110
  const name = str(t.name, `${p}.name`);
7100
7111
  if (!NAME.test(name))
7101
7112
  fail(`${p}.name`, "may only contain letters, digits, '.', '_' and '-'");
@@ -7119,7 +7130,9 @@ function parseConfig(text) {
7119
7130
  end_goal: str(t.end_goal, `${p}.end_goal`),
7120
7131
  checks,
7121
7132
  timeout: posInt(t.timeout, `${p}.timeout`, DEFAULT_TIMEOUT),
7122
- env: { ...env, ...parseEnv(t.env, `${p}.env`) }
7133
+ env: { ...env, ...parseEnv(t.env, `${p}.env`) },
7134
+ install: commands(t.install, `${p}.install`),
7135
+ cleanup: commands(t.cleanup, `${p}.cleanup`)
7123
7136
  };
7124
7137
  });
7125
7138
  return {
@@ -7127,6 +7140,7 @@ function parseConfig(text) {
7127
7140
  models,
7128
7141
  build,
7129
7142
  install,
7143
+ cleanup,
7130
7144
  vars,
7131
7145
  env,
7132
7146
  tests
@@ -7252,7 +7266,7 @@ import { randomBytes as randomBytes2 } from "node:crypto";
7252
7266
  import {
7253
7267
  appendFileSync,
7254
7268
  createWriteStream as createWriteStream2,
7255
- existsSync as existsSync4,
7269
+ existsSync as existsSync3,
7256
7270
  mkdirSync as mkdirSync3,
7257
7271
  readdirSync as readdirSync2,
7258
7272
  writeFileSync as writeFileSync3
@@ -7732,28 +7746,14 @@ function resolveKey(base) {
7732
7746
  var EXIT = { pass: 0, fail: 1, notGraded: 2, usage: 3 };
7733
7747
 
7734
7748
  // src/firegoat-dir.ts
7735
- import { existsSync as existsSync2, mkdirSync as mkdirSync2, writeFileSync } from "node:fs";
7749
+ import { mkdirSync as mkdirSync2, writeFileSync } from "node:fs";
7736
7750
  import { join as join2 } from "node:path";
7737
- var GITIGNORE = `runs/
7738
- .claude/
7739
- `;
7740
- var README = `# .firegoat
7741
-
7742
- Files for the evals in firegoat.yaml (run them with \`npx firegoat eval\`).
7743
-
7744
- - \`fixtures/\`: test fixtures (helper scripts, sample data) for build:, install: and checks. Commit them.
7745
- - \`runs/\`: one folder per eval (transcripts, sessions, check output). Ignored by git.
7746
- - \`.claude/\`: the empty claude config eval runs the agent with. Ignored by git.
7747
-
7748
- Reference: \`npx firegoat init --help\`.
7751
+ var GITIGNORE = `*
7749
7752
  `;
7750
7753
  function ensureFiregoatDir(root) {
7751
7754
  const dir = join2(root, ".firegoat");
7752
- mkdirSync2(join2(dir, "fixtures"), { recursive: true });
7755
+ mkdirSync2(dir, { recursive: true });
7753
7756
  writeFileSync(join2(dir, ".gitignore"), GITIGNORE);
7754
- const readme = join2(dir, "README.md");
7755
- if (!existsSync2(readme))
7756
- writeFileSync(readme, README);
7757
7757
  return dir;
7758
7758
  }
7759
7759
 
@@ -7822,7 +7822,7 @@ var EXIT_CODES = `Exit codes:
7822
7822
  3 Usage or config error (bad flag, unknown test, invalid or missing firegoat.yaml,
7823
7823
  firegoat.ai answered 401/403 for the upload key); any other failure of
7824
7824
  that key check exits 2
7825
- Upload failures don't change the exit code.`;
7825
+ Upload and cleanup failures don't change the exit code.`;
7826
7826
  function topHelp(version) {
7827
7827
  return `firegoat ${version}: local AX evals. Your own claude gets one prompt in a fresh
7828
7828
  folder, with none of your claude config; firegoat saves the session and runs your
@@ -7850,9 +7850,9 @@ In the current folder: creates .firegoat/ and a starter firegoat.yaml, installs
7850
7850
  firegoat-onboarding skill globally, into ~/.claude/skills/ (npx skills add
7851
7851
  firegoat-ai/skills -g; in a terminal it asks: global, this repo, or none), offers
7852
7852
  firegoat login (in a terminal only; otherwise prints how), then prints a prompt to
7853
- paste into your coding agent. Commit .firegoat/ with firegoat.yaml: its .gitignore
7854
- ignores only runs/ and .claude/, and test fixtures (helper scripts, sample data)
7855
- go in .firegoat/fixtures/. Its README.md says what each folder is.
7853
+ paste into your coding agent. .firegoat/ holds local run output and ignores itself
7854
+ in git. Keep test fixtures (helper scripts, sample data) outside it, e.g. in
7855
+ fixtures/ next to firegoat.yaml, and commit them.
7856
7856
 
7857
7857
  --force Replace an existing firegoat.yaml
7858
7858
  --local-skills Install the skill into this repo's .claude/skills/ instead
@@ -7868,11 +7868,17 @@ firegoat.yaml reference:
7868
7868
  --model, claude's default. eval --model overrides
7869
7869
  build: <sh> | [<sh>, ...] Once per eval, in this folder; what it writes to
7870
7870
  $FIREGOAT_OUT is first on the agent's PATH
7871
- install: <sh> | [<sh>, ...] In each fresh folder, before the agent starts
7871
+ install: <sh> | [<sh>, ...] In each fresh folder, before the agent starts; a
7872
+ failure: that run is not graded
7873
+ cleanup: <sh> | [<sh>, ...] In each run's folder once the run is over: after the
7874
+ checks, and also after an error or timeout. Stops
7875
+ what the agent left running (servers, daemons).
7876
+ Every step runs; a failure is a warning (run.json
7877
+ cleanup, <test>/<n>/cleanup.txt), never the verdict
7872
7878
  vars: {KEY: value} Defaults for {{vars.KEY}} in prompts and checks;
7873
7879
  --var KEY=value overrides
7874
- env: {NAME: value} Env vars for the agent, and for install and
7875
- checks of the same run; values are strings and can
7880
+ env: {NAME: value} Env vars for the agent, and for install, checks
7881
+ and cleanup of the same run; values are strings and can
7876
7882
  use {{vars.KEY}}, {{run.id}}, {{run.n}},
7877
7883
  {{test.name}}, $FIREGOAT_OUT, $FIREGOAT_WORKDIR
7878
7884
  (no other $ expansion: $HOME stays literal).
@@ -7897,10 +7903,15 @@ firegoat.yaml reference:
7897
7903
  - run: ... Afterwards; exit 0 = pass; they set eval's exit code
7898
7904
  timeout: 600 Optional, seconds
7899
7905
  env: {NAME: value} Optional, on top of the top-level env:
7906
+ install: <sh> | [...] Optional, runs after the top-level install
7907
+ cleanup: <sh> | [...] Optional, runs before the top-level cleanup
7908
+
7909
+ Step order: build once per eval; then per run: install (top-level, then the
7910
+ test's), the agent, checks, cleanup (the test's, then top-level).
7900
7911
 
7901
7912
  Unknown keys are errors. Keep helper files out of firegoat.yaml: put them in
7902
- .firegoat/fixtures/ and copy them in build or install. The build and install
7903
- commands and the checks get the vars as env vars, plus $FIREGOAT_OUT and
7913
+ e.g. fixtures/ (committed) and copy them in build or install. The build, install
7914
+ and cleanup commands and the checks get the vars as env vars, plus $FIREGOAT_OUT and
7904
7915
  $FIREGOAT_WORKDIR. Checks also get $FIREGOAT_TRANSCRIPT, a short plain-text
7905
7916
  transcript, greppable line by line: "[1] $ <command>" on every line of a shell
7906
7917
  call (one line per line of the command, same n), "[2] Write <path>" for other
@@ -7917,6 +7928,7 @@ firegoat.yaml reference:
7917
7928
  Example:
7918
7929
  build: npm pack --pack-destination "$FIREGOAT_OUT"
7919
7930
  install: npm init -y >/dev/null && npm install "$FIREGOAT_OUT"/*.tgz
7931
+ cleanup: pkill -f "$FIREGOAT_WORKDIR" || true
7920
7932
  tests:
7921
7933
  - name: first-call
7922
7934
  prompt: >-
@@ -7973,6 +7985,12 @@ Runs each test in firegoat.yaml (in the current folder): your claude gets the pr
7973
7985
  in a fresh, empty folder, .firegoat/runs/<runId>/<test>/<n>/work, with build:'s
7974
7986
  outputs on PATH. Then the test's checks run in that folder.
7975
7987
 
7988
+ Step order: build: once, in this folder; then per run, in its folder: install:
7989
+ (top-level, then the test's), the agent, checks, cleanup: (the test's, then
7990
+ top-level). cleanup: runs whenever the folder exists, also after a failed install,
7991
+ a claude error or a timeout; a failing cleanup step prints a warning and never
7992
+ changes the run's status or the exit code.
7993
+
7976
7994
  claude runs on its own login (or ANTHROPIC_API_KEY if set, as claude always does)
7977
7995
  with a fresh config dir, .firegoat/.claude: none of your settings, CLAUDE.md,
7978
7996
  plugins, skills or MCP servers. claude saves its session there. There is no
@@ -7989,7 +8007,8 @@ fixtures and build: stay in the current folder.
7989
8007
  --parallel N Runs at once (default 4; 1 runs them one after another).
7990
8008
  Tools with global on-disk state collide: give each run its
7991
8009
  own home via env: ({{run.id}}-{{test.name}}-{{run.n}}) or use 1
7992
- --var KEY=value Fills {{vars.KEY}}; env for build, install and checks
8010
+ --var KEY=value Fills {{vars.KEY}}; env for build, install, checks and
8011
+ cleanup
7993
8012
  --json The summary as JSON on stdout (or {error, exitCode} if eval
7994
8013
  stops early); progress goes to stderr
7995
8014
  --no-upload Keep runs local even when logged in to firegoat.ai
@@ -8016,16 +8035,17 @@ Output: .firegoat/runs/<runId>/
8016
8035
  <test>/<n>/run.json One run: status and reason (from the checks), model
8017
8036
  (asked for, or null) and claudeModel (what claude's
8018
8037
  init line reported), prompt, end_goal,
8019
- checks [{run, code}], and paths: workdir,
8038
+ checks [{run, code}], cleanup [{run, code}],
8039
+ and paths: workdir,
8020
8040
  transcript, session (claude's session file), stderr,
8021
- checksLog; startedAt; upload ({status: "uploaded",
8041
+ checksLog, cleanupLog; startedAt; upload ({status: "uploaded",
8022
8042
  url} | {status: "failed", error} | {status:
8023
8043
  "skipped", reason}, null when not uploading)
8024
8044
  <test>/<n>/transcript.txt What the agent did: each tool call with its command
8025
8045
  ("[n] $ " on every line of a shell call), short output
8026
8046
  (indented), its text and final answer ("agent: " on
8027
8047
  every line), files it made
8028
- build.txt, <test>/<n>/install.txt, checks.txt Logs
8048
+ build.txt, <test>/<n>/install.txt, checks.txt, cleanup.txt Logs
8029
8049
 
8030
8050
  Grading: firegoat runs only the script checks; they set the exit code. end_goal is
8031
8051
  yours to judge: read transcript.txt first (a few KB); the session file has everything
@@ -8085,6 +8105,23 @@ async function runSteps(opts) {
8085
8105
  await new Promise((r) => log.end(r));
8086
8106
  }
8087
8107
  }
8108
+ async function runAllSteps(opts) {
8109
+ const log = createWriteStream(opts.logPath);
8110
+ const outcomes = [];
8111
+ try {
8112
+ for (const run of opts.commands)
8113
+ outcomes.push(await runShell({
8114
+ run,
8115
+ cwd: opts.cwd,
8116
+ env: opts.env,
8117
+ timeoutMs: STEP_TIMEOUT_MS,
8118
+ log
8119
+ }));
8120
+ return outcomes;
8121
+ } finally {
8122
+ await new Promise((r) => log.end(r));
8123
+ }
8124
+ }
8088
8125
 
8089
8126
  // src/transcript.ts
8090
8127
  import { readdirSync, statSync, writeFileSync as writeFileSync2 } from "node:fs";
@@ -8459,7 +8496,7 @@ function ttyUI(out, opts) {
8459
8496
  }
8460
8497
 
8461
8498
  // src/upload.ts
8462
- import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
8499
+ import { existsSync as existsSync2, readFileSync as readFileSync3 } from "node:fs";
8463
8500
  import { basename as basename2 } from "node:path";
8464
8501
  var MAX_FILE = 20 * 1024 * 1024;
8465
8502
  var MAX_CHECKS = 200;
@@ -8535,7 +8572,7 @@ async function uploadRun(t, r) {
8535
8572
  };
8536
8573
  const bytes = {};
8537
8574
  for (const name of FILES) {
8538
- const b = existsSync3(paths[name]) ? readFileSync3(paths[name]) : Buffer.alloc(0);
8575
+ const b = existsSync2(paths[name]) ? readFileSync3(paths[name]) : Buffer.alloc(0);
8539
8576
  if (b.byteLength > MAX_FILE)
8540
8577
  return {
8541
8578
  status: "failed",
@@ -8704,11 +8741,11 @@ function parseArgs(args) {
8704
8741
  }
8705
8742
  function findSession(configDir, sessionId) {
8706
8743
  const projects = join5(configDir, "projects");
8707
- if (!existsSync4(projects))
8744
+ if (!existsSync3(projects))
8708
8745
  return null;
8709
8746
  for (const d of readdirSync2(projects)) {
8710
8747
  const f = join5(projects, d, `${sessionId}.jsonl`);
8711
- if (existsSync4(f))
8748
+ if (existsSync3(f))
8712
8749
  return f;
8713
8750
  }
8714
8751
  return null;
@@ -8779,19 +8816,14 @@ async function runOne(opts) {
8779
8816
  transcript: join5(dir, "transcript.txt"),
8780
8817
  stderr: join5(dir, "stderr.txt"),
8781
8818
  checksLog: join5(dir, "checks.txt"),
8819
+ cleanup: [],
8820
+ cleanupLog: join5(dir, "cleanup.txt"),
8782
8821
  num_turns: null,
8783
8822
  cost: null,
8784
8823
  duration_ms: 0,
8785
8824
  startedAt: new Date(started).toISOString(),
8786
8825
  upload: null
8787
8826
  };
8788
- const done = (status, reason) => {
8789
- r.status = status;
8790
- r.reason = reason;
8791
- r.duration_ms = Date.now() - started;
8792
- writeRunJson(r);
8793
- return r;
8794
- };
8795
8827
  const env = renderEnv(test.env, opts.vars, {
8796
8828
  "run.id": opts.runId,
8797
8829
  "run.n": String(n),
@@ -8800,16 +8832,35 @@ async function runOne(opts) {
8800
8832
  FIREGOAT_WORKDIR: workdir
8801
8833
  });
8802
8834
  const stepEnv = { ...opts.stepEnv, ...env, FIREGOAT_WORKDIR: workdir };
8803
- if (opts.install.length > 0) {
8835
+ const [status, reason] = await attempt(opts, r, env, stepEnv);
8836
+ const cleanup = [...test.cleanup, ...opts.cleanup];
8837
+ if (cleanup.length > 0)
8838
+ r.cleanup = await runAllSteps({
8839
+ commands: cleanup,
8840
+ cwd: workdir,
8841
+ env: stepEnv,
8842
+ logPath: r.cleanupLog
8843
+ });
8844
+ r.status = status;
8845
+ r.reason = reason;
8846
+ r.duration_ms = Date.now() - started;
8847
+ writeRunJson(r);
8848
+ return r;
8849
+ }
8850
+ async function attempt(opts, r, env, stepEnv) {
8851
+ const { test, n } = opts;
8852
+ const { dir, workdir } = r;
8853
+ const install = [...opts.install, ...test.install];
8854
+ if (install.length > 0) {
8804
8855
  const error = await runSteps({
8805
8856
  what: "install",
8806
- commands: opts.install,
8857
+ commands: install,
8807
8858
  cwd: workdir,
8808
8859
  env: stepEnv,
8809
8860
  logPath: join5(dir, "install.txt")
8810
8861
  });
8811
8862
  if (error)
8812
- return done("error", `not graded: ${error}`);
8863
+ return ["error", `not graded: ${error}`];
8813
8864
  }
8814
8865
  const stderr = createWriteStream2(r.stderr);
8815
8866
  const outcome = await runClaude({
@@ -8836,9 +8887,12 @@ async function runOne(opts) {
8836
8887
  r.num_turns = outcome.result?.num_turns ?? null;
8837
8888
  r.cost = outcome.result?.total_cost_usd ?? null;
8838
8889
  if (!outcome.ok)
8839
- return done("error", `not graded: ${outcome.reason}`);
8890
+ return ["error", `not graded: ${outcome.reason}`];
8840
8891
  if (!r.session)
8841
- return done("error", `not graded: claude saved no session file for ${outcome.sessionId} under ${opts.configDir}/projects`);
8892
+ return [
8893
+ "error",
8894
+ `not graded: claude saved no session file for ${outcome.sessionId} under ${opts.configDir}/projects`
8895
+ ];
8842
8896
  const log = createWriteStream2(r.checksLog);
8843
8897
  try {
8844
8898
  for (const c of test.checks)
@@ -8858,8 +8912,14 @@ async function runOne(opts) {
8858
8912
  }
8859
8913
  const failed = r.checks.filter((c) => c.code !== 0);
8860
8914
  if (failed.length > 0)
8861
- return done("fail", failed.map((c) => `check failed (${c.error ?? `exit ${c.code}`}): ${c.run}`).join("; "));
8862
- return done("pass", test.checks.length === 0 ? "no script checks" : `${r.checks.length}/${r.checks.length} checks passed`);
8915
+ return [
8916
+ "fail",
8917
+ failed.map((c) => `check failed (${c.error ?? `exit ${c.code}`}): ${c.run}`).join("; ")
8918
+ ];
8919
+ return [
8920
+ "pass",
8921
+ test.checks.length === 0 ? "no script checks" : `${r.checks.length}/${r.checks.length} checks passed`
8922
+ ];
8863
8923
  }
8864
8924
  async function evalCommand(args, root) {
8865
8925
  if (args.includes("--help") || args.includes("-h")) {
@@ -9011,6 +9071,7 @@ async function evalCommand(args, root) {
9011
9071
  claudeBin: claudePath,
9012
9072
  configDir,
9013
9073
  install: config.install,
9074
+ cleanup: config.cleanup,
9014
9075
  out,
9015
9076
  stepEnv,
9016
9077
  vars
@@ -9076,6 +9137,10 @@ async function evalCommand(args, root) {
9076
9137
  rows,
9077
9138
  seconds: Math.round((Date.now() - evalStarted) / 1000)
9078
9139
  });
9140
+ for (const r of results)
9141
+ for (const c of r.cleanup)
9142
+ if (c.code !== 0)
9143
+ console.error(`firegoat eval: Warning: ${r.test}#${r.n} cleanup failed (${c.error ?? `exit ${c.code}`}): ${c.run} (log: ${r.cleanupLog})`);
9079
9144
  if (uploadsFailed > 0)
9080
9145
  console.error(`firegoat eval: ${uploadsFailed} upload${uploadsFailed === 1 ? "" : "s"} failed (each run's upload.error in summary.json)`);
9081
9146
  if (opts.json)
@@ -9352,7 +9417,7 @@ firegoat ${package_default.version}` : `firegoat ${package_default.version}`,
9352
9417
 
9353
9418
  // src/init.ts
9354
9419
  import { spawnSync as spawnSync3 } from "node:child_process";
9355
- import { existsSync as existsSync5, writeFileSync as writeFileSync4 } from "node:fs";
9420
+ import { existsSync as existsSync4, writeFileSync as writeFileSync4 } from "node:fs";
9356
9421
  import { basename as basename4, join as join6 } from "node:path";
9357
9422
  import { createInterface } from "node:readline/promises";
9358
9423
 
@@ -9579,12 +9644,20 @@ runs: 1
9579
9644
  # (An npm/pypi CLI that users run via npx/uvx: pack + install it as above instead;
9580
9645
  # a binary on PATH does not stop "npx mycli" from fetching the registry release)
9581
9646
  #
9582
- # Helper files (scripts, sample data) go in .firegoat/fixtures/ (committed), not inline here.
9583
- # The build step runs in this folder, so: build: cp -R .firegoat/fixtures/. "$FIREGOAT_OUT"
9647
+ # Helper files (scripts, sample data) go in e.g. fixtures/ (committed), not inline here.
9648
+ # The build step runs in this folder, so: build: cp -R fixtures/. "$FIREGOAT_OUT"
9584
9649
  # Then e.g. install: cp "$FIREGOAT_OUT/wizard.mjs" .
9585
9650
 
9586
- # Optional: env vars for the agent (and for install and checks of the same
9587
- # run). The agent gets none of vars:, only these. Placeholders: {{vars.KEY}},
9651
+ # Optional: cleanup: runs in each run's folder once the run is over (after the
9652
+ # checks, also after an error or timeout), e.g. to stop a server the agent left
9653
+ # running. A failing cleanup step is a warning, never the verdict.
9654
+ # cleanup:
9655
+ # - pkill -f "$FIREGOAT_WORKDIR" || true
9656
+ #
9657
+ # A test can add its own install: (after this one) and cleanup: (before this one).
9658
+
9659
+ # Optional: env vars for the agent (and for install, checks and cleanup of the
9660
+ # same run). The agent gets none of vars:, only these. Placeholders: {{vars.KEY}},
9588
9661
  # {{run.id}}, {{run.n}}, {{test.name}}, and $FIREGOAT_OUT / $FIREGOAT_WORKDIR (no
9589
9662
  # other $ expansion: $HOME stays literal). A test can add its own env:.
9590
9663
  # Runs go 4 at a time (--parallel), so give a tool with a global home (~/.mytool)
@@ -9726,7 +9799,7 @@ ${INIT_HELP}`);
9726
9799
  }
9727
9800
  ensureFiregoatDir(cwd);
9728
9801
  const configPath = join6(cwd, CONFIG_FILE);
9729
- const keep = existsSync5(configPath) && !force;
9802
+ const keep = existsSync4(configPath) && !force;
9730
9803
  if (!keep)
9731
9804
  writeFileSync4(configPath, template(basename4(cwd)));
9732
9805
  if (interactive) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "firegoat",
3
- "version": "0.17.1",
3
+ "version": "0.18.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/firegoat-ai/firegoat-cli.git"