firegoat 0.26.0 → 0.28.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 +1 -1
  2. package/dist/cli.js +929 -153
  3. package/package.json +1 -1
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.26.0",
6950
+ version: "0.28.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 steps(events) {
7078
7078
  if (b.type === "tool_use")
7079
7079
  out.push({
7080
7080
  kind: "tool_call",
7081
+ id: typeof b.id === "string" ? b.id : null,
7081
7082
  name: typeof b.name === "string" ? b.name : "",
7082
7083
  input: typeof b.input === "object" && b.input !== null ? b.input : null
7083
7084
  });
@@ -7089,6 +7090,7 @@ function steps(events) {
7089
7090
  if (b.type === "tool_result")
7090
7091
  out.push({
7091
7092
  kind: "tool_result",
7093
+ callId: typeof b.tool_use_id === "string" ? b.tool_use_id : null,
7092
7094
  isError: b.is_error === true,
7093
7095
  text: resultText(b.content).trimEnd()
7094
7096
  });
@@ -7157,7 +7159,7 @@ function read(stdout, exited, timeoutMs) {
7157
7159
  reportedModel,
7158
7160
  version,
7159
7161
  ok: false,
7160
- reason: `claude result is an error (subtype=${result.subtype})${result.result ? `: ${result.result.slice(0, 300)}` : ""}`,
7162
+ reason: `claude reported an error${result.subtype === undefined || result.subtype === "success" ? "" : ` (${result.subtype})`}${result.result ? `: ${result.result.slice(0, 300)}` : ""}`,
7161
7163
  sessionId
7162
7164
  };
7163
7165
  return { ...trace, reportedModel, version, ok: true, sessionId };
@@ -7208,6 +7210,7 @@ var claude = {
7208
7210
  bin: "claude",
7209
7211
  installHint: "Install Claude Code (https://claude.com/claude-code) or pass --harness-bin PATH.",
7210
7212
  configDirName: ".claude",
7213
+ argsKey: "claude_args",
7211
7214
  reservedArgs: [
7212
7215
  [["-p", "--print", ...FLAGS.filter((f) => f.startsWith("-"))], SET],
7213
7216
  [["--model"], "choose the model with top-level model: (or eval --model)"],
@@ -7216,7 +7219,7 @@ var claude = {
7216
7219
  "every run is a fresh session"
7217
7220
  ]
7218
7221
  ],
7219
- uploads: true,
7222
+ claudeSessionFile: true,
7220
7223
  args: ({ prompt, model, extraArgs }) => [
7221
7224
  "-p",
7222
7225
  prompt,
@@ -7231,6 +7234,7 @@ var claude = {
7231
7234
  }),
7232
7235
  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
7236
  checkLogin,
7237
+ isFinal: (line) => parse(line)?.type === "result",
7234
7238
  progress,
7235
7239
  read,
7236
7240
  sessionsDir: (configDir) => join(configDir, "projects"),
@@ -7342,24 +7346,27 @@ function itemInput(item) {
7342
7346
  function steps2(events) {
7343
7347
  const out = [];
7344
7348
  const started = new Set;
7345
- for (const e of events) {
7349
+ for (const [i, e] of events.entries()) {
7346
7350
  const item = e?.item;
7347
7351
  if (!item)
7348
7352
  continue;
7353
+ const id = typeof item.id === "string" ? item.id : `event_${i}`;
7349
7354
  if (item.type === "command_execution") {
7350
7355
  const call = {
7351
7356
  kind: "tool_call",
7357
+ id,
7352
7358
  name: "shell",
7353
7359
  input: { command: unwrapShell(str(item.command)) }
7354
7360
  };
7355
7361
  if (e?.type === "item.started") {
7356
- started.add(item.id);
7362
+ started.add(id);
7357
7363
  out.push(call);
7358
7364
  } else if (e?.type === "item.completed") {
7359
- if (item.id === undefined || !started.has(item.id))
7365
+ if (!started.has(id))
7360
7366
  out.push(call);
7361
7367
  out.push({
7362
7368
  kind: "tool_result",
7369
+ callId: id,
7363
7370
  isError: item.exit_code !== 0,
7364
7371
  text: str(item.aggregated_output).trimEnd()
7365
7372
  });
@@ -7374,6 +7381,7 @@ function steps2(events) {
7374
7381
  } else if (item.type !== "reasoning" && item.type !== "error")
7375
7382
  out.push({
7376
7383
  kind: "tool_call",
7384
+ id,
7377
7385
  name: str(item.type),
7378
7386
  input: itemInput(item)
7379
7387
  });
@@ -7522,6 +7530,7 @@ var codex = {
7522
7530
  bin: "codex",
7523
7531
  installHint: "Install Codex CLI (https://github.com/openai/codex) or pass --harness-bin PATH.",
7524
7532
  configDirName: ".codex",
7533
+ argsKey: "codex_args",
7525
7534
  reservedArgs: [
7526
7535
  [[...FLAGS2, "-C", "--cd"], "firegoat sets it for every run"],
7527
7536
  [
@@ -7531,7 +7540,7 @@ var codex = {
7531
7540
  [["resume", "fork"], "every run is a fresh session"],
7532
7541
  [["--ephemeral"], "firegoat reads the session file codex saves"]
7533
7542
  ],
7534
- uploads: false,
7543
+ claudeSessionFile: false,
7535
7544
  args: ({ prompt, model, extraArgs, cwd }) => [
7536
7545
  "exec",
7537
7546
  ...FLAGS2,
@@ -7549,6 +7558,10 @@ var codex = {
7549
7558
  }),
7550
7559
  authLabel: () => `your codex login (${join3(userCodexHome(), "auth.json")})${process.env.CODEX_API_KEY ? "; CODEX_API_KEY is set and passes through" : ""}`,
7551
7560
  checkLogin: checkLogin2,
7561
+ isFinal: (line) => {
7562
+ const type = parse2(line)?.type;
7563
+ return type === "turn.completed" || type === "turn.failed";
7564
+ },
7552
7565
  progress: progress2,
7553
7566
  read: read2,
7554
7567
  sessionsDir: (configDir) => join3(configDir, "sessions"),
@@ -7624,6 +7637,7 @@ function toolResult(b) {
7624
7637
  `) : "";
7625
7638
  return {
7626
7639
  kind: "tool_result",
7640
+ callId: typeof b.tool_use_id === "string" ? b.tool_use_id : null,
7627
7641
  isError: b.is_error === true || failed,
7628
7642
  text: text.trimEnd()
7629
7643
  };
@@ -7636,6 +7650,7 @@ function steps3(events) {
7636
7650
  if (b.type === "tool_use")
7637
7651
  out.push({
7638
7652
  kind: "tool_call",
7653
+ id: typeof b.id === "string" ? b.id : null,
7639
7654
  name: typeof b.name === "string" ? b.name : "",
7640
7655
  input: isObject(b.input) ? b.input : null
7641
7656
  });
@@ -7653,7 +7668,7 @@ function steps3(events) {
7653
7668
  function resultError(result) {
7654
7669
  const errors = Array.isArray(result.errors) ? result.errors.filter((x) => typeof x === "string").join("; ") : "";
7655
7670
  const said = errors || result.result || "";
7656
- return `grok result is an error (subtype=${result.subtype})${said ? `: ${said.slice(0, 300)}` : ""}`;
7671
+ return `grok reported an error${result.subtype === undefined || result.subtype === "success" ? "" : ` (${result.subtype})`}${said ? `: ${said.slice(0, 300)}` : ""}`;
7657
7672
  }
7658
7673
  function read3(stdout, exited, timeoutMs) {
7659
7674
  const events = stdout.split(`
@@ -7786,6 +7801,7 @@ var grok = {
7786
7801
  bin: BIN,
7787
7802
  installHint: "Install grok (curl -fsSL https://x.ai/cli/install.sh | bash) or pass --harness-bin PATH.",
7788
7803
  configDirName: ".grok",
7804
+ argsKey: "grok_args",
7789
7805
  reservedArgs: [
7790
7806
  [
7791
7807
  [
@@ -7821,7 +7837,7 @@ var grok = {
7821
7837
  "every run is a fresh session"
7822
7838
  ]
7823
7839
  ],
7824
- uploads: false,
7840
+ claudeSessionFile: false,
7825
7841
  args: ({ prompt, model, extraArgs }) => [
7826
7842
  "-p",
7827
7843
  prompt,
@@ -7836,6 +7852,7 @@ var grok = {
7836
7852
  }),
7837
7853
  authLabel: () => `your grok login (${USER_AUTH})`,
7838
7854
  checkLogin: checkLogin3,
7855
+ isFinal: (line) => parse3(line)?.type === "result",
7839
7856
  progress: progress3,
7840
7857
  read: read3,
7841
7858
  sessionsDir: (configDir) => join4(configDir, "sessions"),
@@ -7906,7 +7923,7 @@ function parseConfig(text) {
7906
7923
  "cleanup",
7907
7924
  "vars",
7908
7925
  "env",
7909
- "harness_args",
7926
+ ...ARGS_KEYS,
7910
7927
  "tests"
7911
7928
  ]);
7912
7929
  const runs = posInt(raw.runs, "runs", 1);
@@ -7921,7 +7938,7 @@ function parseConfig(text) {
7921
7938
  const cleanup = commands(raw.cleanup, "cleanup", RUN_PLACEHOLDERS);
7922
7939
  const vars = parseVars(raw.vars);
7923
7940
  const env = parseEnv(raw.env, "env");
7924
- const harnessArgs = parseHarnessArgs(raw.harness_args, "harness_args");
7941
+ const harnessArgs = parseAllHarnessArgs(raw, "");
7925
7942
  if (!Array.isArray(raw.tests) || raw.tests.length === 0)
7926
7943
  fail("tests", "must be a non-empty list");
7927
7944
  const names = new Set;
@@ -7938,7 +7955,7 @@ function parseConfig(text) {
7938
7955
  "env",
7939
7956
  "install",
7940
7957
  "cleanup",
7941
- "harness_args"
7958
+ ...ARGS_KEYS
7942
7959
  ]);
7943
7960
  const name = str2(t.name, `${p}.name`);
7944
7961
  if (!NAME.test(name))
@@ -7968,10 +7985,7 @@ function parseConfig(text) {
7968
7985
  env: { ...env, ...parseEnv(t.env, `${p}.env`) },
7969
7986
  install: commands(t.install, `${p}.install`, RUN_PLACEHOLDERS),
7970
7987
  cleanup: commands(t.cleanup, `${p}.cleanup`, RUN_PLACEHOLDERS),
7971
- harnessArgs: [
7972
- ...harnessArgs,
7973
- ...parseHarnessArgs(t.harness_args, `${p}.harness_args`)
7974
- ]
7988
+ harnessArgs: mergeHarnessArgs(harnessArgs, parseAllHarnessArgs(t, `${p}.`))
7975
7989
  };
7976
7990
  });
7977
7991
  return {
@@ -8014,26 +8028,35 @@ function nameList(raw, key, what) {
8014
8028
  return name;
8015
8029
  });
8016
8030
  }
8017
- function parseHarnessArgs(raw, path) {
8031
+ var ARGS_KEYS = [...HARNESSES.values()].map((h) => h.argsKey);
8032
+ function parseAllHarnessArgs(raw, prefix) {
8033
+ const out = {};
8034
+ for (const h of HARNESSES.values())
8035
+ out[h.name] = parseHarnessArgs(raw[h.argsKey], `${prefix}${h.argsKey}`, h);
8036
+ return out;
8037
+ }
8038
+ function mergeHarnessArgs(top, test) {
8039
+ const out = {};
8040
+ for (const name of HARNESSES.keys())
8041
+ out[name] = [...top[name] ?? [], ...test[name] ?? []];
8042
+ return out;
8043
+ }
8044
+ function parseHarnessArgs(raw, path, harness) {
8018
8045
  if (raw === undefined)
8019
8046
  return [];
8020
8047
  if (!Array.isArray(raw))
8021
- fail(path, "must be a list of harness arguments, one per entry, e.g. [--chrome]");
8048
+ fail(path, `must be a list of ${harness.name} arguments, one per entry, e.g. [--flag, value]`);
8022
8049
  return raw.map((a, i) => {
8023
8050
  const p = `${path}[${i}]`;
8024
8051
  const arg = str2(a, p);
8025
8052
  checkPlaceholders(arg, p, RUN_PLACEHOLDERS);
8053
+ for (const [flags, why] of harness.reservedArgs)
8054
+ for (const f of flags)
8055
+ if (arg === f || arg.startsWith(`${f}=`))
8056
+ fail(p, `${f} is not allowed for ${harness.name}: ${why}`);
8026
8057
  return { arg, path: p };
8027
8058
  });
8028
8059
  }
8029
- function checkHarnessArgs(tests, harness) {
8030
- for (const t of tests)
8031
- for (const { arg, path } of t.harnessArgs)
8032
- for (const [flags, why] of harness.reservedArgs)
8033
- for (const f of flags)
8034
- if (arg === f || arg.startsWith(`${f}=`))
8035
- fail(path, `${f} is not allowed for ${harness.name}: ${why}`);
8036
- }
8037
8060
  var VAR_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
8038
8061
  function parseVars(raw) {
8039
8062
  if (raw === undefined)
@@ -8151,9 +8174,9 @@ import {
8151
8174
  appendFileSync,
8152
8175
  createWriteStream as createWriteStream2,
8153
8176
  mkdirSync as mkdirSync5,
8154
- writeFileSync as writeFileSync4
8177
+ writeFileSync as writeFileSync5
8155
8178
  } from "node:fs";
8156
- import { basename as basename3, delimiter as delimiter2, join as join10 } from "node:path";
8179
+ import { basename as basename2, delimiter as delimiter2, join as join10 } from "node:path";
8157
8180
 
8158
8181
  // src/agent-dirs.ts
8159
8182
  import { createHash, randomInt } from "node:crypto";
@@ -8516,6 +8539,26 @@ var CLAUDE_AUTH = /^CLAUDE_CODE_(OAUTH_TOKEN|OAUTH_REFRESH_TOKEN|USE_[A-Z_]+|SKI
8516
8539
  function hostEnv() {
8517
8540
  return Object.fromEntries(Object.entries(process.env).filter(([k]) => !NPM_INJECTED.test(k) && !k.startsWith("FIREGOAT_") && (!CLAUDE_SESSION.test(k) || CLAUDE_AUTH.test(k))));
8518
8541
  }
8542
+ var GRACE_MS = 5000;
8543
+ var liveGroups = new Set;
8544
+ var signalsHooked = false;
8545
+ function hookSignals() {
8546
+ if (signalsHooked)
8547
+ return;
8548
+ signalsHooked = true;
8549
+ const onSignal = (signal) => {
8550
+ for (const g of liveGroups)
8551
+ try {
8552
+ process.kill(-g, "SIGKILL");
8553
+ } catch {}
8554
+ process.kill(process.pid, signal);
8555
+ };
8556
+ for (const s of ["SIGINT", "SIGTERM", "SIGHUP"])
8557
+ process.once(s, onSignal);
8558
+ }
8559
+ function forgetGroup(pgid) {
8560
+ liveGroups.delete(pgid);
8561
+ }
8519
8562
  function processTree(pid) {
8520
8563
  const children = new Map;
8521
8564
  const ps = execFileSync("ps", ["-A", "-o", "pid=,ppid="], {
@@ -8533,7 +8576,11 @@ function processTree(pid) {
8533
8576
  tree.push(...children.get(tree[i]) ?? []);
8534
8577
  return tree;
8535
8578
  }
8536
- function killTree(pid) {
8579
+ function killTree(pid, group) {
8580
+ if (group)
8581
+ try {
8582
+ process.kill(-pid, "SIGKILL");
8583
+ } catch {}
8537
8584
  let tree = [pid];
8538
8585
  let problem;
8539
8586
  try {
@@ -8549,18 +8596,27 @@ function killTree(pid) {
8549
8596
  }
8550
8597
  function run(opts) {
8551
8598
  return new Promise((resolve) => {
8599
+ const detached = opts.detached === true;
8600
+ if (detached)
8601
+ hookSignals();
8552
8602
  const child = spawn(opts.cmd, opts.args, {
8553
8603
  cwd: opts.cwd,
8554
8604
  env: opts.env,
8555
- stdio: ["ignore", "pipe", "pipe"]
8605
+ stdio: ["ignore", "pipe", "pipe"],
8606
+ detached
8556
8607
  });
8608
+ if (child.pid !== undefined) {
8609
+ if (detached)
8610
+ liveGroups.add(child.pid);
8611
+ opts.onSpawn?.(child.pid);
8612
+ }
8557
8613
  let timedOut = false;
8558
8614
  let settled = false;
8559
8615
  const timer = setTimeout(() => {
8560
8616
  timedOut = true;
8561
8617
  if (child.pid === undefined)
8562
8618
  return;
8563
- const problem = killTree(child.pid);
8619
+ const problem = killTree(child.pid, detached);
8564
8620
  if (problem)
8565
8621
  opts.onStderr(Buffer.from(`firegoat: timed out; could not list ${opts.cmd}'s child processes (${problem}), killed only ${opts.cmd}
8566
8622
  `));
@@ -8574,6 +8630,7 @@ function run(opts) {
8574
8630
  };
8575
8631
  child.stdout.on("data", opts.onStdout);
8576
8632
  child.stderr.on("data", opts.onStderr);
8633
+ opts.finalSeen?.then(() => setTimeout(() => done({ code: null, timedOut: false, ended: true }), GRACE_MS).unref());
8577
8634
  child.on("error", (error) => done({ code: null, timedOut, error }));
8578
8635
  child.on("close", (code) => done({ code, timedOut }));
8579
8636
  child.on("exit", (code) => setTimeout(() => {
@@ -8666,41 +8723,54 @@ function gitRepo(dir) {
8666
8723
  async function runAgent(harness, opts) {
8667
8724
  let stdout = "";
8668
8725
  let pending = "";
8726
+ let group;
8727
+ let markFinal = () => {};
8728
+ const finalSeen = new Promise((r) => {
8729
+ markFinal = r;
8730
+ });
8669
8731
  const exited = await run({
8670
8732
  cmd: opts.bin,
8671
8733
  args: harness.args(opts),
8672
8734
  cwd: opts.cwd,
8673
8735
  env: opts.env,
8674
8736
  timeoutMs: opts.timeoutMs,
8737
+ detached: true,
8738
+ finalSeen,
8739
+ onSpawn: (pid) => {
8740
+ group = pid;
8741
+ },
8675
8742
  onStdout: (c) => {
8676
8743
  const text = c.toString();
8677
8744
  stdout += text;
8678
- if (!opts.onProgress)
8679
- return;
8680
8745
  pending += text;
8681
8746
  const lines = pending.split(`
8682
8747
  `);
8683
8748
  pending = lines.pop() ?? "";
8684
8749
  for (const line of lines) {
8685
- const p = harness.progress(line);
8750
+ if (harness.isFinal(line))
8751
+ markFinal();
8752
+ const p = opts.onProgress && harness.progress(line);
8686
8753
  if (p)
8687
- opts.onProgress(p);
8754
+ opts.onProgress?.(p);
8688
8755
  }
8689
8756
  },
8690
8757
  onStderr: (c) => opts.stderr.write(c)
8691
8758
  });
8692
8759
  if (exited.error)
8693
8760
  return {
8694
- ok: false,
8695
- reason: `could not start ${opts.bin}: ${exited.error.message}`,
8696
- steps: [],
8697
- final: "",
8698
- reportedModel: null,
8699
- version: null,
8700
- turns: null,
8701
- cost: null
8761
+ group,
8762
+ outcome: {
8763
+ ok: false,
8764
+ reason: `could not start ${opts.bin}: ${exited.error.message}`,
8765
+ steps: [],
8766
+ final: "",
8767
+ reportedModel: null,
8768
+ version: null,
8769
+ turns: null,
8770
+ cost: null
8771
+ }
8702
8772
  };
8703
- return harness.read(stdout, exited, opts.timeoutMs);
8773
+ return { group, outcome: harness.read(stdout, exited, opts.timeoutMs) };
8704
8774
  }
8705
8775
 
8706
8776
  // src/help.ts
@@ -8743,19 +8813,22 @@ turns it off; FIREGOAT_TELEMETRY accepts only 0 or 1.
8743
8813
 
8744
8814
  ${EXIT_CODES}`;
8745
8815
  }
8746
- var INIT_HELP = `Usage: firegoat init [--force] [--local-skills | --no-skills] [--no-login]
8816
+ var INIT_HELP = `Usage: firegoat init [--force] [--local-skills | --global-skills | --no-skills]
8817
+ [--no-login]
8747
8818
 
8748
8819
  In the current folder: creates .firegoat/ and a starter firegoat.yaml, installs the
8749
- firegoat-onboarding skill globally, into ~/.claude/skills/ (npx skills add
8750
- firegoat-ai/skills -g; in a terminal it asks: global, this repo, or none), offers
8751
- firegoat login (in a terminal only; otherwise prints how), then prints a prompt to
8820
+ firegoat-onboarding skill (npx skills add firegoat-ai/skills; in a terminal it asks:
8821
+ global into ~/.claude/skills/, this repo, or none; otherwise, e.g. run by a coding
8822
+ agent, it installs into this repo's .claude/skills/), offers firegoat login (in a terminal only; otherwise prints how), then prints a prompt to
8752
8823
  paste into your coding agent. .firegoat/ holds local run output and ignores itself
8753
8824
  in git. Keep test fixtures (helper scripts, sample data) in fixtures/ next to
8754
8825
  firegoat.yaml, committed.
8755
8826
 
8756
8827
  --force Replace an existing firegoat.yaml
8757
- --local-skills Install the skill into this repo's .claude/skills/ instead
8758
- (commit it so teammates and CI get it), without asking
8828
+ --local-skills Install the skill into this repo's .claude/skills/ (commit it
8829
+ so teammates and CI get it), without asking
8830
+ --global-skills Install the skill into ~/.claude/skills/ (skills add -g),
8831
+ without asking
8759
8832
  --no-skills Don't install the skill
8760
8833
  --no-login Don't offer firegoat login
8761
8834
 
@@ -8778,8 +8851,10 @@ firegoat.yaml reference:
8778
8851
  install: <sh> | [<sh>, ...] In each fresh folder, before the agent starts; a
8779
8852
  failure: that run is not graded
8780
8853
  cleanup: <sh> | [<sh>, ...] In each run's folder once the run is over: after the
8781
- checks, and also after an error or timeout. Stops
8782
- what the agent left running (servers, daemons).
8854
+ checks, and also after an error or timeout. For
8855
+ what lives outside the run (e.g. seeded data);
8856
+ processes the agent left running are stopped by
8857
+ firegoat after cleanup (see eval --help).
8783
8858
  Every step runs; a failure is a warning (run.json
8784
8859
  cleanup, <test>/<n>/cleanup.txt), never the verdict
8785
8860
  vars: {KEY: value} Defaults for {{vars.KEY}} in prompts, checks and
@@ -8801,27 +8876,33 @@ firegoat.yaml reference:
8801
8876
  tool that keeps state in ~ starts fresh in each
8802
8877
  run. Only a tool that ignores HOME needs its own
8803
8878
  var, e.g. MYTOOL_HOME: "$FIREGOAT_HOME/.mytool"
8804
- harness_args: [<arg>, ...] Extra harness arguments, after firegoat's own flags
8805
- and --model (for codex, before the prompt), to
8806
- every selected harness. Each entry is one
8807
- argument, passed as-is (no shell splitting):
8808
- claude: [--chrome] (Claude in Chrome, for tests
8809
- that need a browser), [--plugin-dir, ./plugins/x],
8810
- --mcp-config, --agents, --append-system-prompt;
8811
- codex: [-c, key=value], --enable, --add-dir.
8879
+ claude_args: [<arg>, ...] Extra claude-code arguments, after firegoat's own
8880
+ flags and --model. Each entry is one argument,
8881
+ passed as-is (no shell splitting): [--chrome]
8882
+ (Claude in Chrome, for tests that need a browser),
8883
+ [--plugin-dir, ./plugins/x], --mcp-config,
8884
+ --agents, --append-system-prompt
8885
+ codex_args: [<arg>, ...] Extra codex-cli arguments, before the prompt:
8886
+ [-c, key=value], --enable, --add-dir
8887
+ grok_args: [<arg>, ...] Extra grok-cli arguments, after firegoat's flags.
8888
+ Each key goes to its harness only. A key for a
8889
+ harness this eval doesn't run is checked but not
8890
+ used, so one firegoat.yaml can carry args for
8891
+ every --harness.
8812
8892
  Entries expand like env: values
8813
8893
  ($FIREGOAT_WORKDIR, $FIREGOAT_HOME, $FIREGOAT_OUT,
8814
8894
  {{vars.KEY}}, {{run.*}}); run.json harnessArgs has
8815
- the result. Flags firegoat sets are errors, also
8816
- as --flag=value. claude-code: -p, --print,
8817
- --output-format, --verbose, --setting-sources,
8818
- --strict-mcp-config, --disable-slash-commands,
8895
+ the run's harness's result. Flags firegoat sets
8896
+ are errors, also as --flag=value. claude_args:
8897
+ -p, --print, --output-format, --verbose,
8898
+ --setting-sources, --strict-mcp-config,
8899
+ --disable-slash-commands,
8819
8900
  --dangerously-skip-permissions, --model (use
8820
8901
  model:), --session-id, --resume, -r, --continue,
8821
- -c. codex-cli: --json, --skip-git-repo-check,
8902
+ -c. codex_args: --json, --skip-git-repo-check,
8822
8903
  --dangerously-bypass-approvals-and-sandbox, -C,
8823
8904
  --cd, -m, --model (use model:), resume, fork,
8824
- --ephemeral. grok-cli: -p, --single,
8905
+ --ephemeral. grok_args: -p, --single,
8825
8906
  --prompt-file, --prompt-json, --output-format,
8826
8907
  --json-schema, --always-approve,
8827
8908
  --permission-mode, --no-auto-update, --cwd, -w,
@@ -8839,7 +8920,8 @@ firegoat.yaml reference:
8839
8920
  env: {NAME: value} Optional, on top of the top-level env:
8840
8921
  install: <sh> | [...] Optional, runs after the top-level install
8841
8922
  cleanup: <sh> | [...] Optional, runs before the top-level cleanup
8842
- harness_args: [...] Optional, after the top-level harness_args
8923
+ claude_args: [...] Optional, after the top-level claude_args (same
8924
+ for codex_args, grok_args)
8843
8925
 
8844
8926
  Step order: build once per eval; then per run: install (top-level, then the
8845
8927
  test's), the agent, checks, cleanup (the test's, then top-level).
@@ -8875,7 +8957,7 @@ firegoat.yaml reference:
8875
8957
  so the agent's commands see them too.
8876
8958
 
8877
8959
  Testing an MCP server: commit its config as fixtures/mcp.json, copy it in build:
8878
- and pass it with harness_args (claude-code). Only the configs you pass load: --strict-mcp-config
8960
+ and pass it with claude_args. Only the configs you pass load: --strict-mcp-config
8879
8961
  is always on. An MCP server inherits claude's env, which keeps your real HOME
8880
8962
  (claude's login is there), so set the server's HOME to the run's home, or it
8881
8963
  writes to yours. claude gets $FIREGOAT_HOME, $FIREGOAT_WORKDIR and $FIREGOAT_OUT
@@ -8886,12 +8968,11 @@ relative paths in it are from the run's folder, claude's cwd:
8886
8968
  "env": {"HOME": "\${FIREGOAT_HOME}"}}}}
8887
8969
  firegoat.yaml:
8888
8970
  build: cp -R fixtures/. "$FIREGOAT_OUT"
8889
- harness_args: [--mcp-config, $FIREGOAT_OUT/mcp.json]
8971
+ claude_args: [--mcp-config, $FIREGOAT_OUT/mcp.json]
8890
8972
 
8891
8973
  Example:
8892
8974
  build: npm pack --pack-destination "$FIREGOAT_OUT"
8893
8975
  install: npm init -y >/dev/null && npm install "$FIREGOAT_OUT"/*.tgz
8894
- cleanup: pkill -f "$FIREGOAT_WORKDIR" || true
8895
8976
  tests:
8896
8977
  - name: first-call
8897
8978
  prompt: >-
@@ -8910,7 +8991,7 @@ Example:
8910
8991
  Get an acme API key from the acme dashboard (I'm logged in in Chrome) and put
8911
8992
  it in .env as ACME_API_KEY.
8912
8993
  end_goal: .env holds a key copied from the dashboard, not a made-up one.
8913
- harness_args: [--chrome]
8994
+ claude_args: [--chrome]
8914
8995
 
8915
8996
  init also says that firegoat sends anonymous usage stats; FIREGOAT_TELEMETRY=0
8916
8997
  (or DO_NOT_TRACK=1) turns them off. Fields: firegoat --help, Telemetry.
@@ -8919,7 +9000,7 @@ Exit codes: 0 done, 1 the firegoat login failed (the files stay), 2 the skill
8919
9000
  install failed, 3 usage error.`;
8920
9001
  var LOGIN_HELP = `Usage: firegoat login
8921
9002
 
8922
- Logs in to firegoat.ai so eval uploads each run's claude session there as a private
9003
+ Logs in to firegoat.ai so eval uploads each run's session there as a private
8923
9004
  trace (you share it from its page). OAuth device flow: prints a URL and a code (and
8924
9005
  opens the browser in a terminal); approve it there, and the trace key is saved to
8925
9006
  ~/.firegoat/credentials.json (mode 0600). The key is never passed to the agent,
@@ -8929,7 +9010,9 @@ read ~/.firegoat, and a FIREGOAT_API_KEY in firegoat's own process env (ps eww,
8929
9010
  your own traces; revoke it on the firegoat.ai traces page.
8930
9011
 
8931
9012
  What eval uploads per run: session.jsonl (prompts, tool calls and outputs, file
8932
- contents: possibly secrets from your env or repo), checks.txt and stderr.txt (check
9013
+ contents: possibly secrets from your env or repo; claude's own session file, or for
9014
+ codex and grok one in claude's format made from the run's stream, the run dir's
9015
+ session.jsonl), checks.txt and stderr.txt (check
8933
9016
  output, which sees vars:). firegoat.ai masks secrets when showing a trace but stores
8934
9017
  the text as sent. eval --no-upload keeps a run local.
8935
9018
 
@@ -8973,9 +9056,20 @@ top-level). cleanup: runs whenever the folder exists, also after a failed instal
8973
9056
  a claude error or a timeout; a failing cleanup step prints a warning and never
8974
9057
  changes the run's status or the exit code.
8975
9058
 
9059
+ The agent's run ends with its final result (claude's and grok's result line,
9060
+ codex's turn.completed or turn.failed): a harness that hasn't exited 5s later
9061
+ (a server it started in the background keeps it alive) is left running, so the
9062
+ checks can still reach that server. After cleanup, firegoat stops whatever is
9063
+ still running from the run (SIGTERM, then SIGKILL after 2s): the harness's
9064
+ process group, every process below it, and every process whose working
9065
+ directory is inside the run's folder (lsof on macOS, /proc on Linux). They are
9066
+ listed in run.json leftover, with one warning per run; never the verdict. Not
9067
+ found: a daemon that left the group and changed its working directory out of
9068
+ the run's folder.
9069
+
8976
9070
  claude runs on its own login (or ANTHROPIC_API_KEY if set, as claude always does)
8977
9071
  with a fresh config dir, ~/.firegoat/runs/<hash>/.claude: none of your settings,
8978
- CLAUDE.md, plugins, skills or MCP servers (harness_args: in firegoat.yaml adds
9072
+ CLAUDE.md, plugins, skills or MCP servers (claude_args: in firegoat.yaml adds
8979
9073
  claude flags, e.g. --chrome or --plugin-dir). claude saves its session there. There
8980
9074
  is no sandbox: the agent can still reach the rest of your machine, including the
8981
9075
  repo, if it goes looking.
@@ -8985,9 +9079,10 @@ fresh CODEX_HOME, ~/.firegoat/runs/<hash>/.codex, holding a link to your codex l
8985
9079
  (~/.codex/auth.json, or $CODEX_HOME/auth.json; run codex login first), and with
8986
9080
  -c project_doc_max_bytes=0: no AGENTS.md, config.toml, skills or plugins of
8987
9081
  yours. codex's own HOME is the run's home (below), also for its Read/Write. It
8988
- saves its session (rollout) under that CODEX_HOME's sessions/. firegoat.ai doesn't
8989
- read codex sessions yet: those runs are not uploaded. codex reports no turns or
8990
- cost; its model comes from the session file.
9082
+ saves its session (rollout) under that CODEX_HOME's sessions/. firegoat.ai reads
9083
+ claude's session format: the upload is the run dir's session.jsonl, made from the
9084
+ --json stream. codex reports no turns or cost; its model comes from the session
9085
+ file.
8991
9086
 
8992
9087
  grok-cli runs ~/.grok/bin/grok (where grok's installer puts it; never a grok on
8993
9088
  PATH, which may be a wrapper such as cmux's: a #! script is refused) as grok -p
@@ -8999,8 +9094,8 @@ your real one it loads Claude Code's skills, plugins, MCP servers and hooks. eva
8999
9094
  refuses a login that expires within 5 minutes (run grok -p hi once to refresh it):
9000
9095
  a refresh inside a run would replace the link with a file, which the next eval
9001
9096
  asks you to move back over ~/.grok/auth.json. The session is a directory,
9002
- $GROK_HOME/sessions/<cwd>/<id>/ ($FIREGOAT_SESSION). firegoat.ai doesn't read
9003
- grok sessions yet: those runs are not uploaded.
9097
+ $GROK_HOME/sessions/<cwd>/<id>/ ($FIREGOAT_SESSION). Like codex's, its upload is
9098
+ the run dir's session.jsonl, made from the stream in claude's session format.
9004
9099
 
9005
9100
  Every run gets its own HOME, ~/.firegoat/runs/<hash>/<random>/home next to work
9006
9101
  (with XDG_CONFIG_HOME, XDG_CACHE_HOME, XDG_DATA_HOME, XDG_STATE_HOME inside it), as
@@ -9047,7 +9142,8 @@ firegoat.yaml, fixtures and build: stay in the current folder.
9047
9142
  --dry-run Check firegoat.yaml as eval would (test names, --var,
9048
9143
  --model, --harness, missing vars), print each planned
9049
9144
  run ("<test>#<n> [harness model]" with its install, checks
9050
- and cleanup counts and rendered harness_args, paths under
9145
+ and cleanup counts and its harness's rendered args
9146
+ (claude_args, ...), paths under
9051
9147
  ~/.firegoat/runs/<hash>/<random>/) and exit 0. Builds
9052
9148
  nothing, starts no agent, uploads nothing, writes no
9053
9149
  files. Config errors
@@ -9085,11 +9181,16 @@ Output: .firegoat/runs/<runId>/
9085
9181
  <test>/<n>/run.json One run: status and reason (from the checks), harness,
9086
9182
  model (asked for, or null) and reportedModel (what
9087
9183
  the harness reported running, or null), harnessArgs
9088
- (harness_args passed), prompt, end_goal,
9184
+ (its harness's claude_args/codex_args/grok_args
9185
+ passed, expanded), prompt, end_goal,
9089
9186
  checks [{run, code}], cleanup [{run, code}],
9090
- and paths: dir (this folder), workdir, home (the
9091
- run's HOME; both real paths, under ~/.firegoat),
9092
- transcript, session (the harness's session file), stderr,
9187
+ leftover [{pid, command}] (processes stopped after
9188
+ cleanup), and paths: dir (this folder), workdir, home
9189
+ (the run's HOME; both real paths, under ~/.firegoat),
9190
+ transcript, session (the harness's session file),
9191
+ claudeSession (the session.jsonl uploaded: claude's
9192
+ own file, else <test>/<n>/session.jsonl made from the
9193
+ stream) and sessionId (its id, the trace's), stderr,
9093
9194
  checksLog, cleanupLog; startedAt; upload ({status: "uploaded",
9094
9195
  url} | {status: "failed", error} | {status:
9095
9196
  "skipped", reason}, null when not uploading)
@@ -9153,6 +9254,659 @@ Example:
9153
9254
 
9154
9255
  Exit codes: 0 sent, 2 the service failed (or rate limit), 3 usage error.`;
9155
9256
 
9257
+ // src/leftover.ts
9258
+ import { spawn as spawn2 } from "node:child_process";
9259
+ import { readdirSync as readdirSync4, readlinkSync as readlinkSync2, realpathSync as realpathSync2 } from "node:fs";
9260
+ var TERM_WAIT_MS = 2000;
9261
+ function output(cmd, args) {
9262
+ return new Promise((resolve, reject) => {
9263
+ const child = spawn2(cmd, args, { stdio: ["ignore", "pipe", "pipe"] });
9264
+ let out = "";
9265
+ let err = "";
9266
+ child.stdout.on("data", (c) => {
9267
+ out += c;
9268
+ });
9269
+ child.stderr.on("data", (c) => {
9270
+ err += c;
9271
+ });
9272
+ child.on("error", (e) => reject(new Error(`could not run ${cmd}: ${e.message}`)));
9273
+ child.on("close", (code) => code === 0 || out !== "" ? resolve(out) : reject(new Error(`${cmd} exited ${code}: ${err.trim().slice(0, 300)}`)));
9274
+ });
9275
+ }
9276
+ async function processes() {
9277
+ const ps = await output("ps", [
9278
+ "-A",
9279
+ "-o",
9280
+ "pid=,ppid=,pgid=,stat=,command="
9281
+ ]);
9282
+ const procs = [];
9283
+ for (const line of ps.split(`
9284
+ `)) {
9285
+ const m = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s*(.*)$/.exec(line);
9286
+ if (!m || m[4]?.startsWith("Z"))
9287
+ continue;
9288
+ procs.push({
9289
+ pid: Number(m[1]),
9290
+ ppid: Number(m[2]),
9291
+ pgid: Number(m[3]),
9292
+ command: m[5] ?? ""
9293
+ });
9294
+ }
9295
+ return procs;
9296
+ }
9297
+ async function cwdInside(dir) {
9298
+ const inside = (p) => p === dir || p.startsWith(`${dir}/`);
9299
+ if (process.platform === "linux") {
9300
+ const pids = [];
9301
+ for (const name of readdirSync4("/proc")) {
9302
+ if (!/^\d+$/.test(name))
9303
+ continue;
9304
+ try {
9305
+ if (inside(readlinkSync2(`/proc/${name}/cwd`)))
9306
+ pids.push(Number(name));
9307
+ } catch {}
9308
+ }
9309
+ return pids;
9310
+ }
9311
+ if (process.platform === "darwin") {
9312
+ const pids = [];
9313
+ let pid = 0;
9314
+ for (const line of (await output("lsof", ["-d", "cwd", "-Fpn", "-w"])).split(`
9315
+ `)) {
9316
+ if (line.startsWith("p"))
9317
+ pid = Number(line.slice(1));
9318
+ else if (line.startsWith("n") && inside(line.slice(1)))
9319
+ pids.push(pid);
9320
+ }
9321
+ return pids;
9322
+ }
9323
+ throw new Error(`finding processes by cwd is not supported on ${process.platform}`);
9324
+ }
9325
+ function alive(pid) {
9326
+ try {
9327
+ process.kill(pid, 0);
9328
+ return true;
9329
+ } catch {
9330
+ return false;
9331
+ }
9332
+ }
9333
+ function signal(pids, group, sig) {
9334
+ for (const p of group === undefined ? pids : [...pids, -group])
9335
+ try {
9336
+ process.kill(p, sig);
9337
+ } catch {}
9338
+ }
9339
+ async function stopLeftovers(opts) {
9340
+ const workdir = realpathSync2(opts.workdir);
9341
+ const [procs, inWorkdir] = await Promise.all([
9342
+ processes(),
9343
+ cwdInside(workdir)
9344
+ ]);
9345
+ const found = new Set(inWorkdir);
9346
+ if (opts.group !== undefined) {
9347
+ for (const p of procs)
9348
+ if (p.pgid === opts.group)
9349
+ found.add(p.pid);
9350
+ }
9351
+ for (let grew = true;grew; ) {
9352
+ grew = false;
9353
+ for (const p of procs)
9354
+ if (found.has(p.ppid) && !found.has(p.pid)) {
9355
+ found.add(p.pid);
9356
+ grew = true;
9357
+ }
9358
+ }
9359
+ found.delete(process.pid);
9360
+ const leftover = procs.filter((p) => found.has(p.pid)).map((p) => ({ pid: p.pid, command: printable(p.command, 200) }));
9361
+ const pids = leftover.map((l) => l.pid);
9362
+ signal(pids, opts.group, "SIGTERM");
9363
+ const deadline = Date.now() + TERM_WAIT_MS;
9364
+ while (pids.some(alive) && Date.now() < deadline)
9365
+ await new Promise((r) => setTimeout(r, 100));
9366
+ signal(pids.filter(alive), opts.group, "SIGKILL");
9367
+ if (opts.group !== undefined)
9368
+ forgetGroup(opts.group);
9369
+ return leftover;
9370
+ }
9371
+
9372
+ // src/session.ts
9373
+ import { randomUUID } from "node:crypto";
9374
+ import { writeFileSync as writeFileSync3 } from "node:fs";
9375
+
9376
+ // src/atif.ts
9377
+ var MAX_BLOCK_CHARS = 50000;
9378
+ function flattenContent(value) {
9379
+ if (typeof value === "string")
9380
+ return value;
9381
+ if (Array.isArray(value)) {
9382
+ return value.map((item) => {
9383
+ if (typeof item === "string")
9384
+ return item;
9385
+ if (item && typeof item === "object") {
9386
+ const obj = item;
9387
+ if (typeof obj.text === "string")
9388
+ return obj.text;
9389
+ if (typeof obj.content === "string")
9390
+ return obj.content;
9391
+ }
9392
+ return JSON.stringify(item);
9393
+ }).join(`
9394
+ `);
9395
+ }
9396
+ if (value == null)
9397
+ return "";
9398
+ return JSON.stringify(value);
9399
+ }
9400
+ function cap(text) {
9401
+ return text.length > MAX_BLOCK_CHARS ? text.slice(0, MAX_BLOCK_CHARS) : text;
9402
+ }
9403
+ function shellQuote2(path) {
9404
+ return `'${path.replace(/'/g, `'\\''`)}'`;
9405
+ }
9406
+ var CAPPED_STRING_FIELDS = [
9407
+ "content",
9408
+ "old_string",
9409
+ "new_string",
9410
+ "command",
9411
+ "input"
9412
+ ];
9413
+ function capToolInput(args) {
9414
+ const input = args ?? {};
9415
+ const serialized = JSON.stringify(input);
9416
+ if (serialized.length <= MAX_BLOCK_CHARS)
9417
+ return input;
9418
+ if (input && typeof input === "object") {
9419
+ const obj = input;
9420
+ const capped = {};
9421
+ for (const [key, value] of Object.entries(obj)) {
9422
+ if (CAPPED_STRING_FIELDS.includes(key) && typeof value === "string") {
9423
+ capped[key] = cap(value);
9424
+ } else if (key === "edits" && Array.isArray(value)) {
9425
+ capped[key] = value.map((edit) => edit && typeof edit === "object" ? {
9426
+ ...edit,
9427
+ old_string: typeof edit.old_string === "string" ? cap(edit.old_string) : edit.old_string,
9428
+ new_string: typeof edit.new_string === "string" ? cap(edit.new_string) : edit.new_string
9429
+ } : edit);
9430
+ } else {
9431
+ capped[key] = value;
9432
+ }
9433
+ }
9434
+ if (JSON.stringify(capped).length <= MAX_BLOCK_CHARS)
9435
+ return capped;
9436
+ }
9437
+ return { _truncated: serialized.slice(0, MAX_BLOCK_CHARS) };
9438
+ }
9439
+ var PATCH_FILE_MARKERS = [
9440
+ { re: /^\*\*\* Update File: (.+)$/, op: "modify" },
9441
+ { re: /^\*\*\* Add File: (.+)$/, op: "add" },
9442
+ { re: /^\*\*\* Delete File: (.+)$/, op: "delete" }
9443
+ ];
9444
+ function parseCodexPatch(patch) {
9445
+ if (typeof patch !== "string" || patch.length === 0)
9446
+ return null;
9447
+ const lines = patch.split(`
9448
+ `);
9449
+ const sections = [];
9450
+ let current = null;
9451
+ for (const line of lines) {
9452
+ if (line === "*** Begin Patch")
9453
+ continue;
9454
+ if (line === "*** End Patch")
9455
+ break;
9456
+ const marker = PATCH_FILE_MARKERS.map((m) => ({
9457
+ m,
9458
+ hit: line.match(m.re)
9459
+ })).find((x) => x.hit);
9460
+ if (marker) {
9461
+ current = {
9462
+ op: marker.m.op,
9463
+ path: marker.hit[1],
9464
+ body: []
9465
+ };
9466
+ sections.push(current);
9467
+ continue;
9468
+ }
9469
+ const move = line.match(/^\*\*\* Move to: (.+)$/);
9470
+ if (move && current) {
9471
+ current.renameTo = move[1];
9472
+ continue;
9473
+ }
9474
+ if (line.startsWith("*** "))
9475
+ continue;
9476
+ if (current)
9477
+ current.body.push(line);
9478
+ }
9479
+ if (sections.length === 0)
9480
+ return null;
9481
+ return sections;
9482
+ }
9483
+ function reconstructHunk(hunkLines) {
9484
+ const oldLines = [];
9485
+ const newLines = [];
9486
+ for (const line of hunkLines) {
9487
+ const marker = line[0];
9488
+ if (marker === "-") {
9489
+ oldLines.push(line.slice(1));
9490
+ } else if (marker === "+") {
9491
+ newLines.push(line.slice(1));
9492
+ } else if (marker === " ") {
9493
+ oldLines.push(line.slice(1));
9494
+ newLines.push(line.slice(1));
9495
+ } else if (line === "") {
9496
+ oldLines.push("");
9497
+ newLines.push("");
9498
+ }
9499
+ }
9500
+ return { old_string: oldLines.join(`
9501
+ `), new_string: newLines.join(`
9502
+ `) };
9503
+ }
9504
+ function splitHunks(body) {
9505
+ const hunks = [];
9506
+ let current = null;
9507
+ for (const line of body) {
9508
+ if (line.startsWith("@@")) {
9509
+ current = [];
9510
+ hunks.push(current);
9511
+ continue;
9512
+ }
9513
+ if (current)
9514
+ current.push(line);
9515
+ }
9516
+ return hunks.filter((h) => h.length > 0);
9517
+ }
9518
+ function sectionToBlock(section) {
9519
+ if (section.op === "delete") {
9520
+ return [
9521
+ {
9522
+ name: "Bash",
9523
+ input: { command: `rm -f -- ${shellQuote2(section.path)}` }
9524
+ }
9525
+ ];
9526
+ }
9527
+ if (section.op === "add") {
9528
+ const content = section.body.filter((l) => l.startsWith("+")).map((l) => l.slice(1)).join(`
9529
+ `);
9530
+ return [{ name: "Write", input: { file_path: section.path, content } }];
9531
+ }
9532
+ const targetPath = section.renameTo ?? section.path;
9533
+ const rename = section.renameTo ? [
9534
+ {
9535
+ name: "Bash",
9536
+ input: {
9537
+ command: `mv -- ${shellQuote2(section.path)} ${shellQuote2(section.renameTo)}`
9538
+ }
9539
+ }
9540
+ ] : [];
9541
+ const edits = splitHunks(section.body).map(reconstructHunk);
9542
+ if (edits.length === 0)
9543
+ return rename;
9544
+ const [only] = edits;
9545
+ if (edits.length === 1 && only) {
9546
+ return [
9547
+ ...rename,
9548
+ {
9549
+ name: "Edit",
9550
+ input: {
9551
+ file_path: targetPath,
9552
+ old_string: only.old_string,
9553
+ new_string: only.new_string
9554
+ }
9555
+ }
9556
+ ];
9557
+ }
9558
+ return [
9559
+ ...rename,
9560
+ { name: "MultiEdit", input: { file_path: targetPath, edits } }
9561
+ ];
9562
+ }
9563
+ var str3 = (v) => typeof v === "string";
9564
+ function withReadRange(input, a) {
9565
+ if (typeof a.offset === "number")
9566
+ input.offset = a.offset;
9567
+ if (typeof a.limit === "number")
9568
+ input.limit = a.limit;
9569
+ return input;
9570
+ }
9571
+ var TOOL_TRANSFORMS = {
9572
+ shellToolCall: (a) => str3(a.command) ? { name: "Bash", input: { command: a.command } } : null,
9573
+ readToolCall: (a) => str3(a.path) ? { name: "Read", input: { file_path: a.path } } : null,
9574
+ editToolCall: (a) => str3(a.path) ? {
9575
+ name: "Write",
9576
+ input: { file_path: a.path, content: a.streamContent }
9577
+ } : null,
9578
+ globToolCall: (a) => str3(a.globPattern) ? {
9579
+ name: "Glob",
9580
+ input: { pattern: a.globPattern, path: a.targetDirectory }
9581
+ } : null,
9582
+ grepToolCall: (a) => str3(a.pattern) ? { name: "Grep", input: { pattern: a.pattern, path: a.path } } : null,
9583
+ webFetchToolCall: (a) => str3(a.url) ? { name: "WebFetch", input: { url: a.url } } : null,
9584
+ webSearchToolCall: (a) => str3(a.searchTerm) ? { name: "WebSearch", input: { query: a.searchTerm } } : null,
9585
+ bash: (a) => str3(a.command) ? { name: "Bash", input: { command: a.command } } : null,
9586
+ glob: (a) => str3(a.pattern) ? { name: "Glob", input: { pattern: a.pattern, path: a.path } } : null,
9587
+ grep: (a) => str3(a.pattern) ? { name: "Grep", input: { pattern: a.pattern, path: a.path } } : null,
9588
+ webfetch: (a) => str3(a.url) ? { name: "WebFetch", input: { url: a.url } } : null,
9589
+ todowrite: (a) => Array.isArray(a.todos) ? {
9590
+ name: "TodoWrite",
9591
+ input: {
9592
+ todos: a.todos.map((t) => ({
9593
+ content: t.content,
9594
+ status: t.status
9595
+ }))
9596
+ }
9597
+ } : null,
9598
+ read: (a) => {
9599
+ const filePath = str3(a.filePath) ? a.filePath : str3(a.path) ? a.path : null;
9600
+ return filePath ? { name: "Read", input: withReadRange({ file_path: filePath }, a) } : null;
9601
+ },
9602
+ exec: (a) => str3(a.command) ? { name: "Bash", input: { command: a.command } } : null,
9603
+ write: (a) => str3(a.path) ? { name: "Write", input: { file_path: a.path, content: a.content } } : null,
9604
+ edit: (a) => {
9605
+ if (!str3(a.path) || !Array.isArray(a.edits) || a.edits.length === 0)
9606
+ return null;
9607
+ if (a.edits.length === 1) {
9608
+ const e = a.edits[0];
9609
+ return {
9610
+ name: "Edit",
9611
+ input: {
9612
+ file_path: a.path,
9613
+ old_string: e.oldText,
9614
+ new_string: e.newText
9615
+ }
9616
+ };
9617
+ }
9618
+ return {
9619
+ name: "MultiEdit",
9620
+ input: {
9621
+ file_path: a.path,
9622
+ edits: a.edits.map((e) => ({
9623
+ old_string: e.oldText,
9624
+ new_string: e.newText
9625
+ }))
9626
+ }
9627
+ };
9628
+ },
9629
+ web_fetch: (a) => str3(a.url) ? { name: "WebFetch", input: { url: a.url } } : null,
9630
+ update_plan: (a) => Array.isArray(a.plan) ? {
9631
+ name: "TodoWrite",
9632
+ input: {
9633
+ todos: a.plan.map((p) => ({
9634
+ content: p.step,
9635
+ status: p.status
9636
+ }))
9637
+ }
9638
+ } : null,
9639
+ shell: (a) => str3(a.command) && (a.action === undefined || a.action === "run") ? { name: "Bash", input: { command: a.command } } : null,
9640
+ read_file: (a) => {
9641
+ if (!str3(a.path))
9642
+ return null;
9643
+ const input = { file_path: a.path };
9644
+ if (typeof a.start_line === "number")
9645
+ input.offset = a.start_line;
9646
+ if (typeof a.line_count === "number")
9647
+ input.limit = a.line_count;
9648
+ return { name: "Read", input };
9649
+ },
9650
+ write_file: (a) => str3(a.path) ? { name: "Write", input: { file_path: a.path, content: a.content } } : null,
9651
+ edit_file: (a) => str3(a.path) ? {
9652
+ name: "Edit",
9653
+ input: {
9654
+ file_path: a.path,
9655
+ old_string: a.old_string,
9656
+ new_string: a.new_string
9657
+ }
9658
+ } : null,
9659
+ glob_files: (a) => str3(a.pattern) ? { name: "Glob", input: { pattern: a.pattern, path: a.path } } : null,
9660
+ grep_files: (a) => str3(a.pattern) ? { name: "Grep", input: { pattern: a.pattern, path: a.path } } : null,
9661
+ exa_search: (a) => str3(a.query) ? { name: "WebSearch", input: { query: a.query } } : null
9662
+ };
9663
+ function normalizeToolCall(functionName, args) {
9664
+ const obj = args && typeof args === "object" ? args : null;
9665
+ if (functionName === "exec_command" && obj && "cmd" in obj) {
9666
+ const { cmd, ...rest } = obj;
9667
+ return [{ name: "Bash", input: { ...rest, command: cmd } }];
9668
+ }
9669
+ if (functionName === "apply_patch" && obj) {
9670
+ const rawPatch = obj.input ?? obj.patchText;
9671
+ const sections = parseCodexPatch(rawPatch);
9672
+ if (sections) {
9673
+ const blocks = sections.flatMap(sectionToBlock);
9674
+ if (blocks.length > 0)
9675
+ return blocks;
9676
+ }
9677
+ return [{ name: functionName, input: args }];
9678
+ }
9679
+ if (functionName === "web_search_call" && obj) {
9680
+ const actionType = obj.action_type;
9681
+ if (actionType === "open_page")
9682
+ return [{ name: "WebFetch", input: obj }];
9683
+ if (actionType === "search") {
9684
+ if ("query" in obj && typeof obj.query === "string")
9685
+ return [{ name: "WebSearch", input: obj }];
9686
+ const altKey = ["queries", "q", "search_query"].find((k) => (k in obj));
9687
+ if (altKey) {
9688
+ const { [altKey]: raw, ...rest } = obj;
9689
+ const query = Array.isArray(raw) ? raw.find((q) => typeof q === "string") : raw;
9690
+ if (typeof query !== "string")
9691
+ return [{ name: functionName, input: args }];
9692
+ const preserved = Array.isArray(raw) ? { queries: raw } : {};
9693
+ return [{ name: "WebSearch", input: { ...rest, ...preserved, query } }];
9694
+ }
9695
+ return [{ name: functionName, input: args }];
9696
+ }
9697
+ return [{ name: functionName, input: args }];
9698
+ }
9699
+ if (functionName && functionName in TOOL_TRANSFORMS && obj) {
9700
+ const block = TOOL_TRANSFORMS[functionName](obj);
9701
+ if (block)
9702
+ return [block];
9703
+ }
9704
+ return [{ name: functionName, input: args }];
9705
+ }
9706
+ function toolCallId(tc, step, index) {
9707
+ if (tc.tool_call_id)
9708
+ return tc.tool_call_id;
9709
+ return `call_${step.step_id}_${tc.function_name ?? "tool"}_${index}`;
9710
+ }
9711
+ var HARBOR_PLACEHOLDER_MESSAGES = new Set([
9712
+ "(tool use)",
9713
+ "(no assistant text)"
9714
+ ]);
9715
+ function isExecutedRestatement(message, toolCalls) {
9716
+ const trimmed = message.trimStart();
9717
+ if (!trimmed.startsWith("Executed "))
9718
+ return false;
9719
+ const rest = trimmed.slice("Executed ".length);
9720
+ return toolCalls.some((tc) => tc.function_name && rest.startsWith(tc.function_name));
9721
+ }
9722
+ var ENVELOPE_META = [
9723
+ /^Chunk ID:/,
9724
+ /^Wall time:/,
9725
+ /^Original token count:/,
9726
+ /^Process exited with code /,
9727
+ /^Exit code:/
9728
+ ];
9729
+ function stripCodexResultEnvelope(content) {
9730
+ const lines = content.split(`
9731
+ `);
9732
+ const outputIdx = lines.findIndex((l) => l === "Output:");
9733
+ if (outputIdx === -1)
9734
+ return content;
9735
+ const preamble = lines.slice(0, outputIdx);
9736
+ if (!preamble.some((l) => ENVELOPE_META.some((re) => re.test(l))))
9737
+ return content;
9738
+ let exitCode = 0;
9739
+ for (const line of preamble) {
9740
+ const m = line.match(/^(?:Process exited with code|Exit code:) (\d+)$/);
9741
+ if (m)
9742
+ exitCode = Number(m[1]);
9743
+ }
9744
+ const output = lines.slice(outputIdx + 1).join(`
9745
+ `);
9746
+ return exitCode === 0 ? output : `[exit code ${exitCode}]
9747
+ ${output}`;
9748
+ }
9749
+ function convertAtifToSession(trajectory) {
9750
+ if (!Array.isArray(trajectory?.steps)) {
9751
+ throw new Error("atif: the trajectory has no steps array");
9752
+ }
9753
+ const events = [];
9754
+ for (const step of trajectory.steps) {
9755
+ if (step.source === "system")
9756
+ continue;
9757
+ if (step.source === "user") {
9758
+ const userText = flattenContent(step.message);
9759
+ if (userText.includes("<environment_context>"))
9760
+ continue;
9761
+ events.push({
9762
+ uuid: `atif-${step.step_id}-user`,
9763
+ type: "user",
9764
+ timestamp: step.timestamp,
9765
+ message: { role: "user", content: cap(userText) }
9766
+ });
9767
+ continue;
9768
+ }
9769
+ if (step.source !== "agent")
9770
+ continue;
9771
+ const toolCalls = step.tool_calls ?? [];
9772
+ const blocks = [];
9773
+ const reasoning = flattenContent(step.reasoning_content);
9774
+ if (reasoning)
9775
+ blocks.push({ type: "thinking", thinking: cap(reasoning) });
9776
+ const messageText = flattenContent(step.message);
9777
+ if (messageText && !isExecutedRestatement(messageText, toolCalls) && !HARBOR_PLACEHOLDER_MESSAGES.has(messageText.trim())) {
9778
+ blocks.push({ type: "text", text: cap(messageText) });
9779
+ }
9780
+ const toolUseIds = [];
9781
+ for (let i = 0;i < toolCalls.length; i++) {
9782
+ const tc = toolCalls[i];
9783
+ const id = toolCallId(tc, step, i);
9784
+ toolUseIds.push(id);
9785
+ const split = normalizeToolCall(tc.function_name, tc.arguments);
9786
+ split.forEach(({ name, input }, k) => {
9787
+ const isLast = k === split.length - 1;
9788
+ blocks.push({
9789
+ type: "tool_use",
9790
+ id: isLast ? id : `${id}__patch${k}`,
9791
+ name,
9792
+ input: capToolInput(input)
9793
+ });
9794
+ });
9795
+ }
9796
+ if (blocks.length > 0) {
9797
+ events.push({
9798
+ uuid: `atif-${step.step_id}-assistant`,
9799
+ type: "assistant",
9800
+ timestamp: step.timestamp,
9801
+ message: {
9802
+ id: `atif-step-${step.step_id}`,
9803
+ role: "assistant",
9804
+ model: step.model_name,
9805
+ content: blocks
9806
+ }
9807
+ });
9808
+ }
9809
+ const results = step.observation?.results;
9810
+ if (results && results.length > 0) {
9811
+ const resultBlocks = results.map((result, i) => {
9812
+ const content = result.subagent_trajectory_ref ? `[subagent trajectory: ${result.subagent_trajectory_ref}]` : stripCodexResultEnvelope(flattenContent(result.content));
9813
+ return {
9814
+ type: "tool_result",
9815
+ tool_use_id: result.source_call_id || toolUseIds[i] || `call_${step.step_id}_result${i}`,
9816
+ content: cap(content)
9817
+ };
9818
+ });
9819
+ events.push({
9820
+ uuid: `atif-${step.step_id}-results`,
9821
+ type: "user",
9822
+ timestamp: step.timestamp,
9823
+ message: { role: "user", content: resultBlocks }
9824
+ });
9825
+ }
9826
+ }
9827
+ return events;
9828
+ }
9829
+
9830
+ // src/session.ts
9831
+ var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
9832
+ function convertedSessionId(harnessId) {
9833
+ return UUID.test(harnessId) ? harnessId.toLowerCase() : randomUUID();
9834
+ }
9835
+ function toTrajectory(t) {
9836
+ const steps = [{ step_id: 1, source: "user", message: t.prompt }];
9837
+ const agent = (fields) => {
9838
+ const step = {
9839
+ step_id: steps.length + 1,
9840
+ source: "agent",
9841
+ message: "",
9842
+ ...t.model === null ? {} : { model_name: t.model },
9843
+ ...fields
9844
+ };
9845
+ steps.push(step);
9846
+ return step;
9847
+ };
9848
+ const calls = new Map;
9849
+ const failed = new Set;
9850
+ let lastText = null;
9851
+ for (const s of t.steps) {
9852
+ if (s.kind === "text") {
9853
+ agent({ message: s.text });
9854
+ lastText = s.text;
9855
+ } else if (s.kind === "tool_call") {
9856
+ const id = s.id ?? `firegoat_call_${steps.length + 1}`;
9857
+ const step = agent({
9858
+ tool_calls: [
9859
+ { tool_call_id: id, function_name: s.name, arguments: s.input }
9860
+ ]
9861
+ });
9862
+ calls.set(id, step);
9863
+ } else {
9864
+ const call = s.callId === null ? undefined : calls.get(s.callId);
9865
+ const id = s.callId ?? `firegoat_result_${steps.length + 1}`;
9866
+ const result = { source_call_id: id, content: s.text };
9867
+ if (!call)
9868
+ agent({ observation: { results: [result] } });
9869
+ else if (call.observation)
9870
+ call.observation.results.push(result);
9871
+ else
9872
+ call.observation = { results: [result] };
9873
+ if (s.isError)
9874
+ failed.add(id);
9875
+ }
9876
+ }
9877
+ if (t.final !== "" && t.final !== lastText)
9878
+ agent({ message: t.final });
9879
+ return {
9880
+ trajectory: {
9881
+ schema_version: "ATIF-v1.6",
9882
+ session_id: t.sessionId,
9883
+ agent: {
9884
+ name: t.harness,
9885
+ ...t.version === null ? {} : { version: t.version },
9886
+ ...t.model === null ? {} : { model_name: t.model }
9887
+ },
9888
+ steps
9889
+ },
9890
+ failed
9891
+ };
9892
+ }
9893
+ function toClaudeSession(t) {
9894
+ const { trajectory, failed } = toTrajectory(t);
9895
+ return convertAtifToSession(trajectory).map((e) => {
9896
+ const content = e.message?.content;
9897
+ if (Array.isArray(content)) {
9898
+ for (const b of content)
9899
+ if (b.type === "tool_result" && failed.has(b.tool_use_id))
9900
+ b.is_error = true;
9901
+ }
9902
+ return { ...e, sessionId: t.sessionId };
9903
+ });
9904
+ }
9905
+ function writeClaudeSession(path, t) {
9906
+ writeFileSync3(path, toClaudeSession(t).map((e) => `${JSON.stringify(e)}
9907
+ `).join(""));
9908
+ }
9909
+
9156
9910
  // src/steps.ts
9157
9911
  import { createWriteStream } from "node:fs";
9158
9912
  var STEP_TIMEOUT_MS = 600000;
@@ -9287,7 +10041,7 @@ async function sendTelemetry(event, exitCode, startedAt) {
9287
10041
  }
9288
10042
 
9289
10043
  // src/transcript.ts
9290
- import { readdirSync as readdirSync4, statSync as statSync2, writeFileSync as writeFileSync3 } from "node:fs";
10044
+ import { readdirSync as readdirSync5, statSync as statSync2, writeFileSync as writeFileSync4 } from "node:fs";
9291
10045
  import { join as join8, relative } from "node:path";
9292
10046
  var OUTPUT_LINES = 15;
9293
10047
  var OUTPUT_CHARS = 1500;
@@ -9337,7 +10091,7 @@ function describeToolCall(step, c) {
9337
10091
  function workdirFiles(dir) {
9338
10092
  const out = [];
9339
10093
  const walk = (d) => {
9340
- for (const name of readdirSync4(d)) {
10094
+ for (const name of readdirSync5(d)) {
9341
10095
  if (SKIP_DIRS.has(name))
9342
10096
  continue;
9343
10097
  const p = join8(d, name);
@@ -9371,7 +10125,7 @@ ${indent(truncate(s.text, OUTPUT_LINES, OUTPUT_CHARS))}` : ` → ${status}`);
9371
10125
  out.pop();
9372
10126
  out.push("", "## final answer", final ? prose(final) : "(none)");
9373
10127
  out.push("", "## files in the workdir", ...workdirFiles(opts.workdir));
9374
- writeFileSync3(opts.path, `${out.join(`
10128
+ writeFileSync4(opts.path, `${out.join(`
9375
10129
  `)}
9376
10130
  `);
9377
10131
  }
@@ -9420,7 +10174,7 @@ function runTag(harness, model) {
9420
10174
  return parts.length > 0 ? ` [${parts.join(" ")}]` : "";
9421
10175
  }
9422
10176
  function notGradedWhy(reason) {
9423
- const said = /claude result is an error \(subtype=[^)]*\): (.*)$/s.exec(reason);
10177
+ const said = /claude reported an error(?: \([^)]*\))?: (.*)$/s.exec(reason);
9424
10178
  if (said)
9425
10179
  return `claude said: "${said[1]}"`;
9426
10180
  return reason.replace(/^not graded: /, "");
@@ -9676,11 +10430,9 @@ function ttyUI(out, opts) {
9676
10430
 
9677
10431
  // src/upload.ts
9678
10432
  import { existsSync as existsSync5, readFileSync as readFileSync5 } from "node:fs";
9679
- import { basename as basename2 } from "node:path";
9680
10433
  var MAX_FILE = 20 * 1024 * 1024;
9681
10434
  var MAX_CHECKS = 200;
9682
10435
  var MAX_TEXT = 8192;
9683
- var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
9684
10436
  var FILES = ["session.jsonl", "checks.txt", "stderr.txt"];
9685
10437
  function clip(s, max, marker) {
9686
10438
  return s.length > max ? `${s.slice(0, max - marker.length)}${marker}` : s;
@@ -9737,9 +10489,11 @@ async function put(name, entry, bytes) {
9737
10489
  throw new ApiError(`PUT ${name} ${r.status}`, r.status);
9738
10490
  }
9739
10491
  async function uploadRun(t, r) {
9740
- if (!r.session)
10492
+ if (!r.claudeSession)
9741
10493
  return { status: "skipped", reason: "No session file" };
9742
- const sessionId = basename2(r.session, ".jsonl");
10494
+ const { sessionId } = r;
10495
+ if (sessionId === null)
10496
+ throw new Error(`run ${r.test}#${r.n} has a session file but no session id`);
9743
10497
  if (!UUID.test(sessionId))
9744
10498
  return {
9745
10499
  status: "failed",
@@ -9751,7 +10505,7 @@ async function uploadRun(t, r) {
9751
10505
  error: `${r.checks.length} checks, the upload takes at most ${MAX_CHECKS}`
9752
10506
  };
9753
10507
  const paths = {
9754
- "session.jsonl": r.session,
10508
+ "session.jsonl": r.claudeSession,
9755
10509
  "checks.txt": r.checksLog,
9756
10510
  "stderr.txt": r.stderr
9757
10511
  };
@@ -9792,6 +10546,7 @@ async function uploadRun(t, r) {
9792
10546
  num_turns: r.num_turns,
9793
10547
  cost: r.cost,
9794
10548
  duration_ms: r.duration_ms,
10549
+ harness: r.harness,
9795
10550
  model,
9796
10551
  ...git && { git }
9797
10552
  },
@@ -9941,7 +10696,7 @@ function newRunId() {
9941
10696
  return `${ts}-${randomBytes2(2).toString("hex")}`;
9942
10697
  }
9943
10698
  function writeRunJson(r) {
9944
- writeFileSync4(join10(r.dir, "run.json"), `${JSON.stringify(r, null, 2)}
10699
+ writeFileSync5(join10(r.dir, "run.json"), `${JSON.stringify(r, null, 2)}
9945
10700
  `);
9946
10701
  }
9947
10702
  async function uploadPreflight(opts, ui, project, runGroup, git) {
@@ -9992,8 +10747,8 @@ function runFill(runId, n, testName, out, agentDir) {
9992
10747
  FIREGOAT_HOME: join10(agentDir, "home")
9993
10748
  };
9994
10749
  }
9995
- function renderHarnessArgs(test, vars, fill) {
9996
- return test.harnessArgs.map((a) => renderValue(a.arg, vars, fill, a.path));
10750
+ function renderHarnessArgs(test, harness, vars, fill) {
10751
+ return (test.harnessArgs[harness] ?? []).map((a) => renderValue(a.arg, vars, fill, a.path));
9997
10752
  }
9998
10753
  function renderSteps(what, commands, vars, fill) {
9999
10754
  return commands.map((c, i) => renderStep(c, vars, fill, `${what}[${i}]`));
@@ -10030,7 +10785,7 @@ function dryRun(opts) {
10030
10785
  renderEnv(test.env, opts.vars, fill);
10031
10786
  return {
10032
10787
  ...p,
10033
- harnessArgs: renderHarnessArgs(test, opts.vars, fill),
10788
+ harnessArgs: renderHarnessArgs(test, p.harness, opts.vars, fill),
10034
10789
  ...renderRunSteps(test, opts.config, opts.vars, fill)
10035
10790
  };
10036
10791
  });
@@ -10042,7 +10797,7 @@ function dryRun(opts) {
10042
10797
  const count = (n, what) => `${n} ${what}${n === 1 ? "" : "s"}`;
10043
10798
  opts.say(`Dry run: ${count(runs.length, "run")}, build ${count(opts.config.build.length, "step")}. Nothing was started.`);
10044
10799
  for (const r of runs)
10045
- opts.say(`${r.test}#${r.n}${runTag(opts.showHarness ? r.harness : null, r.model)} install ${r.install.length}, checks ${r.checks.length}, cleanup ${r.cleanup.length}${r.harnessArgs.length > 0 ? `, harness_args ${JSON.stringify(r.harnessArgs)}` : ""}`);
10800
+ opts.say(`${r.test}#${r.n}${runTag(opts.showHarness ? r.harness : null, r.model)} install ${r.install.length}, checks ${r.checks.length}, cleanup ${r.cleanup.length}${r.harnessArgs.length > 0 ? `, ${HARNESSES.get(r.harness).argsKey} ${JSON.stringify(r.harnessArgs)}` : ""}`);
10046
10801
  }
10047
10802
  async function runOne(opts) {
10048
10803
  const { test, n } = opts;
@@ -10062,7 +10817,7 @@ async function runOne(opts) {
10062
10817
  harness: opts.harness.name,
10063
10818
  model: opts.model,
10064
10819
  reportedModel: null,
10065
- harnessArgs: renderHarnessArgs(test, opts.vars, fill),
10820
+ harnessArgs: renderHarnessArgs(test, opts.harness.name, opts.vars, fill),
10066
10821
  status: "error",
10067
10822
  reason: "",
10068
10823
  prompt: render(test.prompt, opts.vars),
@@ -10072,11 +10827,14 @@ async function runOne(opts) {
10072
10827
  workdir,
10073
10828
  home,
10074
10829
  session: null,
10830
+ claudeSession: null,
10831
+ sessionId: null,
10075
10832
  transcript: join10(dir, "transcript.txt"),
10076
10833
  stderr: join10(dir, "stderr.txt"),
10077
10834
  checksLog: join10(dir, "checks.txt"),
10078
10835
  cleanup: [],
10079
10836
  cleanupLog: join10(dir, "cleanup.txt"),
10837
+ leftover: [],
10080
10838
  num_turns: null,
10081
10839
  cost: null,
10082
10840
  duration_ms: 0,
@@ -10094,7 +10852,8 @@ async function runOne(opts) {
10094
10852
  FIREGOAT_REPO: opts.repo
10095
10853
  };
10096
10854
  const steps = renderRunSteps(test, opts, opts.vars, fill);
10097
- const [status, reason] = await attempt(opts, r, steps, env, stepEnv, envFile);
10855
+ const agent = {};
10856
+ const [status, reason] = await attempt(opts, r, steps, env, stepEnv, envFile, agent);
10098
10857
  if (steps.cleanup.length > 0)
10099
10858
  r.cleanup = await runAllSteps({
10100
10859
  commands: steps.cleanup,
@@ -10102,6 +10861,7 @@ async function runOne(opts) {
10102
10861
  env: stepEnv,
10103
10862
  logPath: r.cleanupLog
10104
10863
  });
10864
+ r.leftover = await stopLeftovers({ group: agent.group, workdir });
10105
10865
  r.status = status;
10106
10866
  r.reason = reason;
10107
10867
  r.duration_ms = Date.now() - started;
@@ -10111,7 +10871,7 @@ async function runOne(opts) {
10111
10871
  function withoutOldPwd(env) {
10112
10872
  return Object.fromEntries(Object.entries(env).filter(([k]) => k !== "OLDPWD"));
10113
10873
  }
10114
- async function attempt(opts, r, steps, env, stepEnv, homeEnvFile) {
10874
+ async function attempt(opts, r, steps, env, stepEnv, homeEnvFile, agent) {
10115
10875
  const { test, n } = opts;
10116
10876
  const { dir, workdir } = r;
10117
10877
  if (steps.install.length > 0) {
@@ -10127,7 +10887,7 @@ async function attempt(opts, r, steps, env, stepEnv, homeEnvFile) {
10127
10887
  }
10128
10888
  const stderr = createWriteStream2(r.stderr);
10129
10889
  const { harness } = opts;
10130
- const outcome = await runAgent(harness, {
10890
+ const { outcome, group } = await runAgent(harness, {
10131
10891
  bin: opts.bin,
10132
10892
  prompt: r.prompt,
10133
10893
  cwd: workdir,
@@ -10150,12 +10910,31 @@ async function attempt(opts, r, steps, env, stepEnv, homeEnvFile) {
10150
10910
  extraArgs: r.harnessArgs,
10151
10911
  onProgress: opts.onProgress
10152
10912
  });
10913
+ agent.group = group;
10153
10914
  if (outcome.version !== null)
10154
10915
  evalStats({ claudeVersion: claudeVersion(outcome.version) });
10155
10916
  await new Promise((res) => stderr.end(res));
10156
10917
  if (outcome.sessionId)
10157
10918
  r.session = harness.findSession(opts.configDir, outcome.sessionId);
10158
10919
  r.reportedModel = outcome.reportedModel ?? (r.session && harness.sessionModel ? harness.sessionModel(r.session) : null);
10920
+ if (r.session && outcome.sessionId) {
10921
+ if (harness.claudeSessionFile) {
10922
+ r.claudeSession = r.session;
10923
+ r.sessionId = outcome.sessionId;
10924
+ } else {
10925
+ r.sessionId = convertedSessionId(outcome.sessionId);
10926
+ r.claudeSession = join10(dir, "session.jsonl");
10927
+ writeClaudeSession(r.claudeSession, {
10928
+ sessionId: r.sessionId,
10929
+ harness: harness.name,
10930
+ version: outcome.version,
10931
+ model: r.reportedModel,
10932
+ prompt: r.prompt,
10933
+ steps: outcome.steps,
10934
+ final: outcome.final
10935
+ });
10936
+ }
10937
+ }
10159
10938
  const about = [
10160
10939
  opts.shownHarness,
10161
10940
  opts.model === null ? null : `model ${opts.model}`
@@ -10205,9 +10984,6 @@ async function attempt(opts, r, steps, env, stepEnv, homeEnvFile) {
10205
10984
  test.checks.length === 0 ? "no script checks" : `${r.checks.length}/${r.checks.length} checks passed`
10206
10985
  ];
10207
10986
  }
10208
- function notUploaded(h) {
10209
- return `firegoat.ai doesn't read ${h.name} sessions yet: its runs stay local`;
10210
- }
10211
10987
  var MAX_LISTED_URLS = 5;
10212
10988
  function uploadedSummary(origin, results, runDir) {
10213
10989
  const urls = results.flatMap((r) => r.upload?.status === "uploaded" ? [r.upload.url] : []);
@@ -10246,7 +11022,7 @@ async function evalCommand(args, root) {
10246
11022
  const say = opts.json ? console.error : console.log;
10247
11023
  const stream = opts.json ? process.stderr : process.stdout;
10248
11024
  const ui = stream.isTTY ? ttyUI(stream, {
10249
- project: basename3(root),
11025
+ project: basename2(root),
10250
11026
  version: package_default.version,
10251
11027
  color: !process.env.NO_COLOR
10252
11028
  }) : plainUI(say);
@@ -10275,14 +11051,6 @@ async function evalCommand(args, root) {
10275
11051
  return fatal(EXIT.usage, `--harness-bin needs exactly one harness, this eval runs ${harnessNames.length} (${harnessNames.join(", ")}): pick one with --harness`, "usage");
10276
11052
  const harnesses = harnessNames.map((h) => HARNESSES.get(h));
10277
11053
  const showHarness = showsHarness(harnessNames);
10278
- try {
10279
- for (const h of harnesses)
10280
- checkHarnessArgs(tests, h);
10281
- } catch (e) {
10282
- if (e instanceof ConfigError)
10283
- return fatal(EXIT.usage, e.message, "config");
10284
- throw e;
10285
- }
10286
11054
  evalStats({
10287
11055
  testCount: tests.length,
10288
11056
  runCount: tests.length * runs * Math.max(models.length, 1) * harnesses.length,
@@ -10295,7 +11063,7 @@ async function evalCommand(args, root) {
10295
11063
  t.prompt,
10296
11064
  ...t.checks.map((c) => c.run),
10297
11065
  ...Object.values(t.env),
10298
- ...t.harnessArgs.map((a) => a.arg),
11066
+ ...harnessNames.flatMap((h) => (t.harnessArgs[h] ?? []).map((a) => a.arg)),
10299
11067
  ...t.install,
10300
11068
  ...t.cleanup
10301
11069
  ]).concat(config.build, config.install, config.cleanup).flatMap(varRefs).filter((k) => !(k in vars)))
@@ -10367,15 +11135,10 @@ async function evalCommand(args, root) {
10367
11135
  const runId = newRunId();
10368
11136
  let upload;
10369
11137
  try {
10370
- const pre = await uploadPreflight(opts, ui, basename3(repo?.mainCheckout ?? repo?.toplevel ?? root), runId, repo?.info ?? null);
11138
+ const pre = await uploadPreflight(opts, ui, basename2(repo?.mainCheckout ?? repo?.toplevel ?? root), runId, repo?.info ?? null);
10371
11139
  if (pre && "exitCode" in pre)
10372
11140
  return fatal(pre.exitCode, pre.error, pre.code);
10373
11141
  upload = pre;
10374
- if (upload) {
10375
- for (const h of harnesses)
10376
- if (!h.uploads)
10377
- ui.note(notUploaded(h));
10378
- }
10379
11142
  } catch (e) {
10380
11143
  if (e instanceof ConfigError)
10381
11144
  return fatal(EXIT.usage, e.message, "config");
@@ -10460,10 +11223,7 @@ async function evalCommand(args, root) {
10460
11223
  command: undefined
10461
11224
  });
10462
11225
  ui.update();
10463
- if (upload && !harness.uploads) {
10464
- r.upload = { status: "skipped", reason: notUploaded(harness) };
10465
- writeRunJson(r);
10466
- } else if (upload) {
11226
+ if (upload) {
10467
11227
  row.upload = "uploading";
10468
11228
  ui.update();
10469
11229
  r.upload = await uploadRun(upload, r);
@@ -10509,7 +11269,7 @@ async function evalCommand(args, root) {
10509
11269
  status,
10510
11270
  exitCode
10511
11271
  };
10512
- writeFileSync4(join10(runDir, "summary.json"), `${JSON.stringify(doc, null, 2)}
11272
+ writeFileSync5(join10(runDir, "summary.json"), `${JSON.stringify(doc, null, 2)}
10513
11273
  `);
10514
11274
  appendFileSync(join10(runsRoot, "index.jsonl"), `${JSON.stringify({
10515
11275
  runId,
@@ -10538,6 +11298,9 @@ async function evalCommand(args, root) {
10538
11298
  for (const c of r.cleanup)
10539
11299
  if (c.code !== 0)
10540
11300
  console.error(`firegoat eval: Warning: ${r.test}#${r.n} cleanup failed (${c.error ?? `exit ${c.code}`}): ${c.run} (log: ${r.cleanupLog})`);
11301
+ for (const r of results)
11302
+ if (r.leftover.length > 0)
11303
+ 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)`);
10541
11304
  if (uploadsFailed > 0)
10542
11305
  console.error(`firegoat eval: ${uploadsFailed} upload${uploadsFailed === 1 ? "" : "s"} failed (each run's upload.error in summary.json)`);
10543
11306
  if (opts.json)
@@ -10822,12 +11585,12 @@ firegoat ${package_default.version}` : `firegoat ${package_default.version}`,
10822
11585
  }
10823
11586
 
10824
11587
  // src/init.ts
10825
- import { spawn as spawn3, spawnSync as spawnSync4 } from "node:child_process";
10826
- import { existsSync as existsSync6, writeFileSync as writeFileSync5 } from "node:fs";
10827
- import { basename as basename4, join as join11 } from "node:path";
11588
+ import { spawn as spawn4, spawnSync as spawnSync4 } from "node:child_process";
11589
+ import { existsSync as existsSync6, writeFileSync as writeFileSync6 } from "node:fs";
11590
+ import { basename as basename3, join as join11 } from "node:path";
10828
11591
 
10829
11592
  // src/login.ts
10830
- import { spawn as spawn2 } from "node:child_process";
11593
+ import { spawn as spawn3 } from "node:child_process";
10831
11594
  var TIMEOUT_MS3 = 30000;
10832
11595
  var MAX_EXPIRES_S = 900;
10833
11596
  var MAX_INTERVAL_S = 60;
@@ -10835,7 +11598,7 @@ var FORM = { "Content-Type": "application/x-www-form-urlencoded" };
10835
11598
  var UPLOADS_ON = "Eval uploads each session to firegoat.ai as a private trace; --no-upload keeps runs local.";
10836
11599
  var UPLOAD_NOTICE = `${UPLOADS_ON} A session holds prompts, tool outputs and file contents: possibly secrets from your env or repo. The agent runs as you and can read ~/.firegoat (and a FIREGOAT_API_KEY in firegoat's own process env).`;
10837
11600
  var TERMS = "By logging in, you agree to the firegoat.ai Terms of Service (https://firegoat.ai/terms) and Privacy Policy (https://firegoat.ai/privacy).";
10838
- function str3(o, k, step) {
11601
+ function str4(o, k, step) {
10839
11602
  const v = o?.[k];
10840
11603
  if (typeof v !== "string" || v === "")
10841
11604
  throw new ApiError(`${step}: response has no ${k}`);
@@ -10844,7 +11607,7 @@ function str3(o, k, step) {
10844
11607
  function openBrowser(url) {
10845
11608
  const cmd = process.platform === "darwin" ? "open" : "xdg-open";
10846
11609
  try {
10847
- const child = spawn2(cmd, [url], {
11610
+ const child = spawn3(cmd, [url], {
10848
11611
  env: hostEnv(),
10849
11612
  stdio: "ignore",
10850
11613
  detached: true
@@ -10866,9 +11629,9 @@ async function loginFlow(show, interactive) {
10866
11629
  });
10867
11630
  if (cfg.status !== 200)
10868
11631
  throw replyError(cfgStep, cfg);
10869
- const clientId = str3(cfg.json, "client_id", cfgStep);
10870
- const deviceUrl = assertSafeUrl(str3(cfg.json, "device_authorization_endpoint", cfgStep), "device_authorization_endpoint");
10871
- const tokenUrl = assertSafeUrl(str3(cfg.json, "token_endpoint", cfgStep), "token_endpoint");
11632
+ const clientId = str4(cfg.json, "client_id", cfgStep);
11633
+ const deviceUrl = assertSafeUrl(str4(cfg.json, "device_authorization_endpoint", cfgStep), "device_authorization_endpoint");
11634
+ const tokenUrl = assertSafeUrl(str4(cfg.json, "token_endpoint", cfgStep), "token_endpoint");
10872
11635
  if (tokenUrl.origin !== origin)
10873
11636
  throw new ApiError(`token_endpoint must be on ${origin}, got ${printable(tokenUrl.origin, 100)}`);
10874
11637
  const devStep = "Device authorization";
@@ -10877,16 +11640,19 @@ async function loginFlow(show, interactive) {
10877
11640
  url: deviceUrl,
10878
11641
  method: "POST",
10879
11642
  headers: FORM,
10880
- body: new URLSearchParams({ client_id: clientId }).toString(),
11643
+ body: new URLSearchParams({
11644
+ client_id: clientId,
11645
+ scope: "openid profile email"
11646
+ }).toString(),
10881
11647
  timeoutMs: TIMEOUT_MS3
10882
11648
  });
10883
11649
  if (dev.status !== 200)
10884
11650
  throw replyError(devStep, dev);
10885
- const deviceCode = str3(dev.json, "device_code", devStep);
10886
- const userCode = str3(dev.json, "user_code", devStep);
11651
+ const deviceCode = str4(dev.json, "device_code", devStep);
11652
+ const userCode = str4(dev.json, "user_code", devStep);
10887
11653
  if (!/^[A-Za-z0-9-]{1,64}$/.test(userCode))
10888
11654
  throw new ApiError(`${devStep}: user_code has unexpected characters`);
10889
- const pageRaw = typeof dev.json?.verification_uri_complete === "string" ? dev.json.verification_uri_complete : str3(dev.json, "verification_uri", devStep);
11655
+ const pageRaw = typeof dev.json?.verification_uri_complete === "string" ? dev.json.verification_uri_complete : str4(dev.json, "verification_uri", devStep);
10890
11656
  const page = assertSafeUrl(pageRaw, "verification_uri");
10891
11657
  if (page.protocol !== "https:" || pageRaw.startsWith("-"))
10892
11658
  throw new ApiError("verification_uri must be https");
@@ -11168,10 +11934,12 @@ runs: 1
11168
11934
  # install: cp -R "$FIREGOAT_OUT/starter-app/." .
11169
11935
 
11170
11936
  # Optional: cleanup: runs in each run's folder once the run is over (after the
11171
- # checks, also after an error or timeout), e.g. to stop a server the agent left
11172
- # running. A failing cleanup step is a warning, never the verdict.
11937
+ # checks, also after an error or timeout), for what lives outside the run, e.g.
11938
+ # data install: seeded in a shared service. Servers the agent left running need
11939
+ # none: firegoat stops them after cleanup. A failing cleanup step is a warning,
11940
+ # never the verdict.
11173
11941
  # cleanup:
11174
- # - pkill -f "$FIREGOAT_WORKDIR" || true
11942
+ # - acme db drop "eval-{{run.id}}-{{run.n}}" || true
11175
11943
  #
11176
11944
  # A test can add its own install: (after this one) and cleanup: (before this one).
11177
11945
 
@@ -11186,6 +11954,11 @@ runs: 1
11186
11954
  # MYTOOL_HOME: "$FIREGOAT_HOME/.mytool"
11187
11955
  # UV_FIND_LINKS: "$FIREGOAT_OUT"
11188
11956
 
11957
+ # Optional: extra arguments for one harness, after firegoat's own flags, one per
11958
+ # entry: claude_args, codex_args, grok_args. A test can add its own. E.g. Claude in
11959
+ # Chrome, for tests that need a browser:
11960
+ # claude_args: [--chrome]
11961
+
11189
11962
  # Optional: values for {{vars.KEY}} in prompts, checks and build/install/cleanup
11190
11963
  # steps (pasted in as-is, unquoted); eval --var KEY=value overrides them. Steps
11191
11964
  # and checks also get each one as an env var ("$KEY": safe for any value).
@@ -11273,7 +12046,7 @@ class Timeline {
11273
12046
  }
11274
12047
  function runQuiet(cmd, args, opts) {
11275
12048
  return new Promise((resolve) => {
11276
- const child = spawn3(cmd, args, {
12049
+ const child = spawn4(cmd, args, {
11277
12050
  ...opts,
11278
12051
  stdio: ["ignore", "pipe", "pipe"]
11279
12052
  });
@@ -11342,8 +12115,7 @@ ${UPLOADS_ON}` : LOGIN_HINT);
11342
12115
  }
11343
12116
  async function initCommand(args, cwd) {
11344
12117
  let force = false;
11345
- let noSkills = false;
11346
- let localSkills = false;
12118
+ const skillFlags = [];
11347
12119
  let login = true;
11348
12120
  for (const a of args) {
11349
12121
  if (a === "--help" || a === "-h") {
@@ -11353,9 +12125,11 @@ async function initCommand(args, cwd) {
11353
12125
  if (a === "--force" || a === "-f")
11354
12126
  force = true;
11355
12127
  else if (a === "--no-skills")
11356
- noSkills = true;
12128
+ skillFlags.push({ flag: a, scope: "none" });
11357
12129
  else if (a === "--local-skills")
11358
- localSkills = true;
12130
+ skillFlags.push({ flag: a, scope: "local" });
12131
+ else if (a === "--global-skills")
12132
+ skillFlags.push({ flag: a, scope: "global" });
11359
12133
  else if (a === "--no-login")
11360
12134
  login = false;
11361
12135
  else {
@@ -11365,15 +12139,17 @@ ${INIT_HELP}`);
11365
12139
  return EXIT.usage;
11366
12140
  }
11367
12141
  }
11368
- if (noSkills && localSkills) {
11369
- console.error(`firegoat init: --no-skills and --local-skills contradict each other: pass one
12142
+ const flags = [...new Set(skillFlags.map((f) => f.flag))];
12143
+ if (flags.length > 1) {
12144
+ console.error(`firegoat init: ${flags.join(" and ")} contradict each other: pass one
11370
12145
 
11371
12146
  ${INIT_HELP}`);
11372
12147
  return EXIT.usage;
11373
12148
  }
11374
- let scope = noSkills ? "none" : localSkills ? "local" : "global";
11375
- initSkills(scope);
11376
12149
  const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
12150
+ const asked = skillFlags[0] === undefined && interactive;
12151
+ let scope = skillFlags[0]?.scope ?? (interactive ? "global" : "local");
12152
+ initSkills(scope);
11377
12153
  const color = interactive && !process.env.NO_COLOR;
11378
12154
  const paint = (code, text) => color ? `\x1B[${code}m${text}\x1B[0m` : text;
11379
12155
  const width = Math.min(process.stdout.columns || 80, 80);
@@ -11388,7 +12164,7 @@ ${INIT_HELP}`);
11388
12164
  const configPath = join11(cwd, CONFIG_FILE);
11389
12165
  const keep = existsSync6(configPath) && !force;
11390
12166
  if (!keep)
11391
- writeFileSync5(configPath, template(basename4(cwd)));
12167
+ writeFileSync6(configPath, template(basename3(cwd)));
11392
12168
  if (ui) {
11393
12169
  console.log(ui.done(1, CONFIG_FILE, keep ? "Kept (--force replaces it)" : "Created"));
11394
12170
  ui.gap();
@@ -11398,7 +12174,7 @@ ${INIT_HELP}`);
11398
12174
  const skill = "firegoat-onboarding skill";
11399
12175
  const block = liveBlock();
11400
12176
  ui?.gap();
11401
- if (ui && !noSkills && !localSkills) {
12177
+ if (ui && asked) {
11402
12178
  const picked = await ui.ask(block, 3, "Install the firegoat-onboarding skill", [
11403
12179
  ["Global", skillsDir("global")],
11404
12180
  ["This repo", `${skillsDir("local")} (commit it for your team)`],