firegoat 0.23.0 → 0.25.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 +1663 -689
  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.23.0",
6950
+ version: "0.25.0",
6951
6951
  repository: {
6952
6952
  type: "git",
6953
6953
  url: "git+https://github.com/firegoat-ai/firegoat-cli.git"
@@ -6987,7 +6987,7 @@ var package_default = {
6987
6987
  };
6988
6988
 
6989
6989
  // src/config.ts
6990
- import { existsSync, readFileSync } from "node:fs";
6990
+ import { existsSync as existsSync4, readFileSync as readFileSync3 } from "node:fs";
6991
6991
 
6992
6992
  // node_modules/yaml/dist/index.js
6993
6993
  var composer = require_composer();
@@ -7035,6 +7035,830 @@ var $stringify = publicApi.stringify;
7035
7035
  var $visit = visit.visit;
7036
7036
  var $visitAsync = visit.visitAsync;
7037
7037
 
7038
+ // src/harnesses/claude.ts
7039
+ import { spawnSync } from "node:child_process";
7040
+ import { existsSync, readdirSync } from "node:fs";
7041
+ import { join } from "node:path";
7042
+ var FLAGS = [
7043
+ "--output-format",
7044
+ "stream-json",
7045
+ "--verbose",
7046
+ "--dangerously-skip-permissions",
7047
+ "--strict-mcp-config",
7048
+ "--setting-sources",
7049
+ "",
7050
+ "--disable-slash-commands"
7051
+ ];
7052
+ var SET = "firegoat sets it for every run";
7053
+ function parse(line) {
7054
+ try {
7055
+ const e = JSON.parse(line);
7056
+ return typeof e === "object" && e !== null ? e : undefined;
7057
+ } catch {
7058
+ return;
7059
+ }
7060
+ }
7061
+ function content(e) {
7062
+ const message = e?.message;
7063
+ return Array.isArray(message?.content) ? message.content : [];
7064
+ }
7065
+ function resultText(content) {
7066
+ if (typeof content === "string")
7067
+ return content;
7068
+ if (Array.isArray(content))
7069
+ return content.map((c) => typeof c.text === "string" ? c.text : `[${c.type}]`).join(`
7070
+ `);
7071
+ return "";
7072
+ }
7073
+ function steps(events) {
7074
+ const out = [];
7075
+ for (const e of events) {
7076
+ if (e?.type === "assistant")
7077
+ for (const b of content(e)) {
7078
+ if (b.type === "tool_use")
7079
+ out.push({
7080
+ kind: "tool_call",
7081
+ name: typeof b.name === "string" ? b.name : "",
7082
+ input: typeof b.input === "object" && b.input !== null ? b.input : null
7083
+ });
7084
+ else if (b.type === "text" && typeof b.text === "string")
7085
+ out.push({ kind: "text", text: b.text });
7086
+ }
7087
+ else if (e?.type === "user") {
7088
+ for (const b of content(e))
7089
+ if (b.type === "tool_result")
7090
+ out.push({
7091
+ kind: "tool_result",
7092
+ isError: b.is_error === true,
7093
+ text: resultText(b.content).trimEnd()
7094
+ });
7095
+ }
7096
+ }
7097
+ return out;
7098
+ }
7099
+ function read(stdout, exited, timeoutMs) {
7100
+ const events = stdout.split(`
7101
+ `).filter((l) => l.trim() !== "").map(parse);
7102
+ let final = "";
7103
+ for (const e of events)
7104
+ if (e?.type === "result" && typeof e.result === "string")
7105
+ final = e.result;
7106
+ const result = events.findLast((e) => e?.type === "result");
7107
+ const trace = {
7108
+ steps: steps(events),
7109
+ final,
7110
+ turns: result?.num_turns ?? null,
7111
+ cost: result?.total_cost_usd ?? null
7112
+ };
7113
+ const init = events.find((e) => e?.type === "system" && e.subtype === "init");
7114
+ if (!init) {
7115
+ const said = stdout.trim().split(`
7116
+ `)[0]?.slice(0, 300);
7117
+ return {
7118
+ ...trace,
7119
+ reportedModel: null,
7120
+ version: null,
7121
+ ok: false,
7122
+ reason: `claude did not start a session (exit ${exited.code ?? "by signal"})${said ? `: ${said}` : ", see stderr.txt"}`
7123
+ };
7124
+ }
7125
+ const reportedModel = typeof init.model === "string" ? init.model : null;
7126
+ const version = typeof init.claude_code_version === "string" ? init.claude_code_version : null;
7127
+ if (typeof init.session_id !== "string" || init.session_id === "")
7128
+ return {
7129
+ ...trace,
7130
+ reportedModel,
7131
+ version,
7132
+ ok: false,
7133
+ reason: "claude's init line has no session_id"
7134
+ };
7135
+ const sessionId = init.session_id;
7136
+ if (exited.timedOut)
7137
+ return {
7138
+ ...trace,
7139
+ reportedModel,
7140
+ version,
7141
+ ok: false,
7142
+ reason: `timed out after ${timeoutMs / 1000}s`,
7143
+ sessionId
7144
+ };
7145
+ if (!result)
7146
+ return {
7147
+ ...trace,
7148
+ reportedModel,
7149
+ version,
7150
+ ok: false,
7151
+ reason: `claude exited ${exited.code ?? "by signal"} without a result, see stderr.txt`,
7152
+ sessionId
7153
+ };
7154
+ if (result.is_error === true || result.subtype !== "success")
7155
+ return {
7156
+ ...trace,
7157
+ reportedModel,
7158
+ version,
7159
+ ok: false,
7160
+ reason: `claude result is an error (subtype=${result.subtype})${result.result ? `: ${result.result.slice(0, 300)}` : ""}`,
7161
+ sessionId
7162
+ };
7163
+ return { ...trace, reportedModel, version, ok: true, sessionId };
7164
+ }
7165
+ function progress(line) {
7166
+ const e = parse(line);
7167
+ if (e?.type !== "assistant")
7168
+ return;
7169
+ const p = {};
7170
+ for (const b of content(e)) {
7171
+ const cmd = b.input?.command;
7172
+ if (b.type === "tool_use" && typeof cmd === "string")
7173
+ p.command = cmd.split(`
7174
+ `)[0];
7175
+ }
7176
+ return p;
7177
+ }
7178
+ function checkLogin(opts) {
7179
+ const r = spawnSync(opts.bin, ["auth", "status"], {
7180
+ cwd: opts.cwd,
7181
+ env: opts.env,
7182
+ encoding: "utf8",
7183
+ timeout: 30000
7184
+ });
7185
+ if (r.error)
7186
+ return {
7187
+ loggedOut: false,
7188
+ reason: `could not run ${opts.bin}: ${r.error.message}`
7189
+ };
7190
+ let status;
7191
+ try {
7192
+ status = JSON.parse(r.stdout);
7193
+ } catch {}
7194
+ if (status?.loggedIn === true)
7195
+ return;
7196
+ if (status?.loggedIn === false)
7197
+ return {
7198
+ loggedOut: true,
7199
+ reason: "claude is not logged in. Run `claude` once and log in (or set ANTHROPIC_API_KEY), then run eval again"
7200
+ };
7201
+ return {
7202
+ loggedOut: false,
7203
+ reason: `claude auth status gave no login state (exit ${r.status}): ${(r.stdout + r.stderr).trim().slice(0, 300)}`
7204
+ };
7205
+ }
7206
+ var claude = {
7207
+ name: "claude-code",
7208
+ bin: "claude",
7209
+ installHint: "Install Claude Code (https://claude.com/claude-code) or pass --harness-bin PATH.",
7210
+ configDirName: ".claude",
7211
+ reservedArgs: [
7212
+ [["-p", "--print", ...FLAGS.filter((f) => f.startsWith("-"))], SET],
7213
+ [["--model"], "choose the model with top-level model: (or eval --model)"],
7214
+ [
7215
+ ["--session-id", "--resume", "-r", "--continue", "-c"],
7216
+ "every run is a fresh session"
7217
+ ]
7218
+ ],
7219
+ uploads: true,
7220
+ args: ({ prompt, model, extraArgs }) => [
7221
+ "-p",
7222
+ prompt,
7223
+ ...FLAGS,
7224
+ ...model === undefined ? [] : ["--model", model],
7225
+ ...extraArgs
7226
+ ],
7227
+ env: ({ configDir, run }) => ({
7228
+ CLAUDE_CONFIG_DIR: configDir,
7229
+ CLAUDE_SECURESTORAGE_CONFIG_DIR: "",
7230
+ ...run === undefined ? {} : { CLAUDE_ENV_FILE: run.homeEnvFile }
7231
+ }),
7232
+ authLabel: () => process.env.ANTHROPIC_API_KEY ? "ANTHROPIC_API_KEY (claude bills the API key; unset it to use your claude login)" : "your claude login",
7233
+ checkLogin,
7234
+ progress,
7235
+ read,
7236
+ sessionsDir: (configDir) => join(configDir, "projects"),
7237
+ findSession(configDir, sessionId) {
7238
+ const projects = join(configDir, "projects");
7239
+ if (!existsSync(projects))
7240
+ return null;
7241
+ for (const d of readdirSync(projects)) {
7242
+ const f = join(projects, d, `${sessionId}.jsonl`);
7243
+ if (existsSync(f))
7244
+ return f;
7245
+ }
7246
+ return null;
7247
+ }
7248
+ };
7249
+
7250
+ // src/harnesses/codex.ts
7251
+ import { spawnSync as spawnSync2 } from "node:child_process";
7252
+ import {
7253
+ existsSync as existsSync2,
7254
+ lstatSync,
7255
+ readdirSync as readdirSync2,
7256
+ readFileSync,
7257
+ symlinkSync,
7258
+ unlinkSync
7259
+ } from "node:fs";
7260
+ import { homedir } from "node:os";
7261
+ import { join as join3 } from "node:path";
7262
+
7263
+ // src/home.ts
7264
+ import { mkdirSync, writeFileSync } from "node:fs";
7265
+ import { join as join2 } from "node:path";
7266
+ function homeEnv(home) {
7267
+ return {
7268
+ HOME: home,
7269
+ XDG_CONFIG_HOME: join2(home, ".config"),
7270
+ XDG_CACHE_HOME: join2(home, ".cache"),
7271
+ XDG_DATA_HOME: join2(home, ".local", "share"),
7272
+ XDG_STATE_HOME: join2(home, ".local", "state")
7273
+ };
7274
+ }
7275
+ var shellQuote = (s) => `'${s.replaceAll("'", `'\\''`)}'`;
7276
+ function makeRunHome(dir) {
7277
+ const home = join2(dir, "home");
7278
+ const env = homeEnv(home);
7279
+ for (const d of Object.values(env))
7280
+ mkdirSync(d, { recursive: true });
7281
+ const envFile = join2(dir, "home.env");
7282
+ writeFileSync(envFile, Object.entries(env).map(([k, v]) => `export ${k}=${shellQuote(v)}
7283
+ `).join(""));
7284
+ return { home, envFile };
7285
+ }
7286
+
7287
+ // src/harnesses/codex.ts
7288
+ var FLAGS2 = [
7289
+ "--json",
7290
+ "--skip-git-repo-check",
7291
+ "--dangerously-bypass-approvals-and-sandbox"
7292
+ ];
7293
+ var NO_PROJECT_DOC = ["-c", "project_doc_max_bytes=0"];
7294
+ function parse2(line) {
7295
+ try {
7296
+ const e = JSON.parse(line);
7297
+ return typeof e === "object" && e !== null ? e : undefined;
7298
+ } catch {
7299
+ return;
7300
+ }
7301
+ }
7302
+ function unwrapShell(command) {
7303
+ const m = /^\/bin\/(?:ba|z)?sh -l?c (.+)$/s.exec(command);
7304
+ if (!m)
7305
+ return command;
7306
+ const word = m[1];
7307
+ let out = "";
7308
+ for (let i = 0;i < word.length; i++) {
7309
+ const c = word[i];
7310
+ if (c === "'") {
7311
+ const end = word.indexOf("'", i + 1);
7312
+ if (end < 0)
7313
+ return command;
7314
+ out += word.slice(i + 1, end);
7315
+ i = end;
7316
+ } else if (c === "\\" && i + 1 < word.length)
7317
+ out += word[++i];
7318
+ else if (c === '"') {
7319
+ let j = i + 1;
7320
+ for (;j < word.length && word[j] !== '"'; j++) {
7321
+ if (word[j] === "\\" && '"\\$`'.includes(word[j + 1] ?? ""))
7322
+ j++;
7323
+ out += word[j];
7324
+ }
7325
+ if (j >= word.length)
7326
+ return command;
7327
+ i = j;
7328
+ } else if (/\s/.test(c))
7329
+ return command;
7330
+ else
7331
+ out += c;
7332
+ }
7333
+ return out;
7334
+ }
7335
+ function str(v) {
7336
+ return typeof v === "string" ? v : "";
7337
+ }
7338
+ function itemInput(item) {
7339
+ const { id: _id, type: _type, status: _status, ...rest } = item;
7340
+ return rest;
7341
+ }
7342
+ function steps2(events) {
7343
+ const out = [];
7344
+ const started = new Set;
7345
+ for (const e of events) {
7346
+ const item = e?.item;
7347
+ if (!item)
7348
+ continue;
7349
+ if (item.type === "command_execution") {
7350
+ const call = {
7351
+ kind: "tool_call",
7352
+ name: "shell",
7353
+ input: { command: unwrapShell(str(item.command)) }
7354
+ };
7355
+ if (e?.type === "item.started") {
7356
+ started.add(item.id);
7357
+ out.push(call);
7358
+ } else if (e?.type === "item.completed") {
7359
+ if (item.id === undefined || !started.has(item.id))
7360
+ out.push(call);
7361
+ out.push({
7362
+ kind: "tool_result",
7363
+ isError: item.exit_code !== 0,
7364
+ text: str(item.aggregated_output).trimEnd()
7365
+ });
7366
+ }
7367
+ continue;
7368
+ }
7369
+ if (e?.type !== "item.completed")
7370
+ continue;
7371
+ if (item.type === "agent_message") {
7372
+ if (str(item.text).trim() !== "")
7373
+ out.push({ kind: "text", text: str(item.text) });
7374
+ } else if (item.type !== "reasoning" && item.type !== "error")
7375
+ out.push({
7376
+ kind: "tool_call",
7377
+ name: str(item.type),
7378
+ input: itemInput(item)
7379
+ });
7380
+ }
7381
+ return out;
7382
+ }
7383
+ function errorMessage(raw) {
7384
+ try {
7385
+ const inner = JSON.parse(raw)?.error?.message;
7386
+ if (typeof inner === "string")
7387
+ return inner;
7388
+ } catch {}
7389
+ return raw;
7390
+ }
7391
+ function read2(stdout, exited, timeoutMs) {
7392
+ const events = stdout.split(`
7393
+ `).filter((l) => l.trim() !== "").map(parse2);
7394
+ const trace = {
7395
+ steps: steps2(events),
7396
+ final: events.filter((e) => e?.type === "item.completed" && e.item?.type === "agent_message").map((e) => str(e?.item?.text)).at(-1) ?? "",
7397
+ reportedModel: null,
7398
+ version: null,
7399
+ turns: null,
7400
+ cost: null
7401
+ };
7402
+ const thread = events.find((e) => e?.type === "thread.started");
7403
+ if (!thread) {
7404
+ const said = stdout.trim().split(`
7405
+ `)[0]?.slice(0, 300);
7406
+ return {
7407
+ ...trace,
7408
+ ok: false,
7409
+ reason: `codex did not start a thread (exit ${exited.code ?? "by signal"})${said ? `: ${said}` : ", see stderr.txt"}`
7410
+ };
7411
+ }
7412
+ const sessionId = str(thread.thread_id);
7413
+ if (sessionId === "")
7414
+ return {
7415
+ ...trace,
7416
+ ok: false,
7417
+ reason: "codex's thread.started line has no thread_id"
7418
+ };
7419
+ if (exited.timedOut)
7420
+ return {
7421
+ ...trace,
7422
+ ok: false,
7423
+ reason: `timed out after ${timeoutMs / 1000}s`,
7424
+ sessionId
7425
+ };
7426
+ const failed = events.find((e) => e?.type === "turn.failed");
7427
+ if (failed) {
7428
+ const message = str(failed.error?.message);
7429
+ return {
7430
+ ...trace,
7431
+ ok: false,
7432
+ reason: `codex turn failed${message ? `: ${errorMessage(message).slice(0, 300)}` : ""}`,
7433
+ sessionId
7434
+ };
7435
+ }
7436
+ if (!events.some((e) => e?.type === "turn.completed"))
7437
+ return {
7438
+ ...trace,
7439
+ ok: false,
7440
+ reason: `codex exited ${exited.code ?? "by signal"} without completing its turn, see stderr.txt`,
7441
+ sessionId
7442
+ };
7443
+ return { ...trace, ok: true, sessionId };
7444
+ }
7445
+ function progress2(line) {
7446
+ const e = parse2(line);
7447
+ const item = e?.item;
7448
+ if (e?.type === "item.started" && item?.type === "command_execution")
7449
+ return { command: unwrapShell(str(item.command)).split(`
7450
+ `)[0] };
7451
+ if (e?.type === "item.completed" && item?.type === "agent_message")
7452
+ return {};
7453
+ return;
7454
+ }
7455
+ function userCodexHome() {
7456
+ return process.env.CODEX_HOME || join3(homedir(), ".codex");
7457
+ }
7458
+ function checkLogin2(opts) {
7459
+ const source = join3(userCodexHome(), "auth.json");
7460
+ if (!existsSync2(source))
7461
+ return {
7462
+ loggedOut: true,
7463
+ reason: `codex is not logged in (no ${source}). Run \`codex login\`, then run eval again`
7464
+ };
7465
+ const link = join3(opts.configDir, "auth.json");
7466
+ let current;
7467
+ try {
7468
+ current = lstatSync(link);
7469
+ } catch {}
7470
+ if (current && !current.isSymbolicLink())
7471
+ return {
7472
+ loggedOut: false,
7473
+ reason: `${link} is a file, not firegoat's link to ${source}: codex may have refreshed its login into it. Move it over ${source} if it is newer, else delete it, then run eval again`
7474
+ };
7475
+ if (current)
7476
+ unlinkSync(link);
7477
+ symlinkSync(source, link);
7478
+ const r = spawnSync2(opts.bin, ["login", "status"], {
7479
+ cwd: opts.cwd,
7480
+ env: opts.env,
7481
+ encoding: "utf8",
7482
+ timeout: 30000
7483
+ });
7484
+ if (r.error)
7485
+ return {
7486
+ loggedOut: false,
7487
+ reason: `could not run ${opts.bin}: ${r.error.message}`
7488
+ };
7489
+ if (r.status === 0)
7490
+ return;
7491
+ return {
7492
+ loggedOut: true,
7493
+ reason: `codex is not logged in (codex login status exit ${r.status}: ${(r.stdout + r.stderr).trim().slice(0, 300)}). Run \`codex login\`, then run eval again`
7494
+ };
7495
+ }
7496
+ function findSession(configDir, sessionId) {
7497
+ const sessions = join3(configDir, "sessions");
7498
+ if (!existsSync2(sessions))
7499
+ return null;
7500
+ const suffix = `-${sessionId}.jsonl`;
7501
+ for (const y of readdirSync2(sessions))
7502
+ for (const m of readdirSync2(join3(sessions, y)))
7503
+ for (const d of readdirSync2(join3(sessions, y, m)))
7504
+ for (const f of readdirSync2(join3(sessions, y, m, d)))
7505
+ if (f.startsWith("rollout-") && f.endsWith(suffix))
7506
+ return join3(sessions, y, m, d, f);
7507
+ return null;
7508
+ }
7509
+ function sessionModel(sessionFile) {
7510
+ let model = null;
7511
+ for (const line of readFileSync(sessionFile, "utf8").split(`
7512
+ `)) {
7513
+ const e = parse2(line);
7514
+ const m = e?.payload?.model;
7515
+ if (e?.type === "turn_context" && typeof m === "string")
7516
+ model = m;
7517
+ }
7518
+ return model;
7519
+ }
7520
+ var codex = {
7521
+ name: "codex-cli",
7522
+ bin: "codex",
7523
+ installHint: "Install Codex CLI (https://github.com/openai/codex) or pass --harness-bin PATH.",
7524
+ configDirName: ".codex",
7525
+ reservedArgs: [
7526
+ [[...FLAGS2, "-C", "--cd"], "firegoat sets it for every run"],
7527
+ [
7528
+ ["-m", "--model"],
7529
+ "choose the model with top-level model: (or eval --model)"
7530
+ ],
7531
+ [["resume", "fork"], "every run is a fresh session"],
7532
+ [["--ephemeral"], "firegoat reads the session file codex saves"]
7533
+ ],
7534
+ uploads: false,
7535
+ args: ({ prompt, model, extraArgs, cwd }) => [
7536
+ "exec",
7537
+ ...FLAGS2,
7538
+ "-C",
7539
+ cwd,
7540
+ ...model === undefined ? [] : ["-m", model],
7541
+ ...NO_PROJECT_DOC,
7542
+ ...extraArgs,
7543
+ "--",
7544
+ prompt
7545
+ ],
7546
+ env: ({ configDir, run }) => ({
7547
+ CODEX_HOME: configDir,
7548
+ ...run === undefined ? {} : homeEnv(run.home)
7549
+ }),
7550
+ authLabel: () => `your codex login (${join3(userCodexHome(), "auth.json")})${process.env.CODEX_API_KEY ? "; CODEX_API_KEY is set and passes through" : ""}`,
7551
+ checkLogin: checkLogin2,
7552
+ progress: progress2,
7553
+ read: read2,
7554
+ sessionsDir: (configDir) => join3(configDir, "sessions"),
7555
+ findSession,
7556
+ sessionModel
7557
+ };
7558
+
7559
+ // src/harnesses/grok.ts
7560
+ import {
7561
+ closeSync,
7562
+ existsSync as existsSync3,
7563
+ lstatSync as lstatSync2,
7564
+ openSync,
7565
+ readdirSync as readdirSync3,
7566
+ readFileSync as readFileSync2,
7567
+ readlinkSync,
7568
+ readSync,
7569
+ statSync,
7570
+ symlinkSync as symlinkSync2,
7571
+ unlinkSync as unlinkSync2
7572
+ } from "node:fs";
7573
+ import { homedir as homedir2 } from "node:os";
7574
+ import { join as join4 } from "node:path";
7575
+ var BIN = join4(homedir2(), ".grok", "bin", "grok");
7576
+ var USER_AUTH = join4(process.env.GROK_HOME || join4(homedir2(), ".grok"), "auth.json");
7577
+ var REFRESH_WINDOW_MS = 5 * 60000;
7578
+ var FLAGS3 = [
7579
+ "--output-format",
7580
+ "streaming-messages-json",
7581
+ "--always-approve",
7582
+ "--no-auto-update"
7583
+ ];
7584
+ function parse3(line) {
7585
+ try {
7586
+ const e = JSON.parse(line);
7587
+ return typeof e === "object" && e !== null ? e : undefined;
7588
+ } catch {
7589
+ return;
7590
+ }
7591
+ }
7592
+ var isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
7593
+ function content2(e) {
7594
+ const message = e?.message;
7595
+ return Array.isArray(message?.content) ? message.content : [];
7596
+ }
7597
+ var PROMPT_KEYS = ["output_for_prompt", "tool_output_for_prompt", "content"];
7598
+ function promptText(v) {
7599
+ if (typeof v === "string")
7600
+ return v;
7601
+ if (!isObject(v))
7602
+ return;
7603
+ for (const k of PROMPT_KEYS)
7604
+ if (typeof v[k] === "string")
7605
+ return v[k];
7606
+ for (const [k, x] of Object.entries(v)) {
7607
+ if (k === "type")
7608
+ continue;
7609
+ const t = promptText(x);
7610
+ if (t !== undefined)
7611
+ return t;
7612
+ }
7613
+ return;
7614
+ }
7615
+ function toolResult(b) {
7616
+ const raw = b.content;
7617
+ let json;
7618
+ if (typeof raw === "string")
7619
+ try {
7620
+ json = JSON.parse(raw);
7621
+ } catch {}
7622
+ const failed = isObject(json) && (typeof json.exit_code === "number" && json.exit_code !== 0 || json.timed_out === true);
7623
+ const text = isObject(json) ? promptText(json) ?? raw : typeof raw === "string" ? raw : Array.isArray(raw) ? raw.map((c) => typeof c.text === "string" ? c.text : `[${c.type}]`).join(`
7624
+ `) : "";
7625
+ return {
7626
+ kind: "tool_result",
7627
+ isError: b.is_error === true || failed,
7628
+ text: text.trimEnd()
7629
+ };
7630
+ }
7631
+ function steps3(events) {
7632
+ const out = [];
7633
+ for (const e of events) {
7634
+ if (e?.type === "assistant")
7635
+ for (const b of content2(e)) {
7636
+ if (b.type === "tool_use")
7637
+ out.push({
7638
+ kind: "tool_call",
7639
+ name: typeof b.name === "string" ? b.name : "",
7640
+ input: isObject(b.input) ? b.input : null
7641
+ });
7642
+ else if (b.type === "text" && typeof b.text === "string")
7643
+ out.push({ kind: "text", text: b.text });
7644
+ }
7645
+ else if (e?.type === "user") {
7646
+ for (const b of content2(e))
7647
+ if (b.type === "tool_result")
7648
+ out.push(toolResult(b));
7649
+ }
7650
+ }
7651
+ return out;
7652
+ }
7653
+ function resultError(result) {
7654
+ const errors = Array.isArray(result.errors) ? result.errors.filter((x) => typeof x === "string").join("; ") : "";
7655
+ const said = errors || result.result || "";
7656
+ return `grok result is an error (subtype=${result.subtype})${said ? `: ${said.slice(0, 300)}` : ""}`;
7657
+ }
7658
+ function read3(stdout, exited, timeoutMs) {
7659
+ const events = stdout.split(`
7660
+ `).filter((l) => l.trim() !== "").map(parse3);
7661
+ const result = events.findLast((e) => e?.type === "result");
7662
+ const trace = {
7663
+ steps: steps3(events),
7664
+ final: typeof result?.result === "string" ? result.result : "",
7665
+ turns: result?.num_turns ?? null,
7666
+ cost: result?.total_cost_usd ?? null
7667
+ };
7668
+ const init = events.find((e) => e?.type === "system" && e.subtype === "init");
7669
+ const reportedModel = typeof init?.model === "string" && init.model !== "unknown" ? init.model : null;
7670
+ const sessionId = typeof init?.session_id === "string" && init.session_id !== "" ? init.session_id : undefined;
7671
+ const fail = (reason) => ({
7672
+ ...trace,
7673
+ reportedModel,
7674
+ version: null,
7675
+ ok: false,
7676
+ reason,
7677
+ ...sessionId === undefined ? {} : { sessionId }
7678
+ });
7679
+ if (exited.timedOut)
7680
+ return fail(`timed out after ${timeoutMs / 1000}s`);
7681
+ if (!init) {
7682
+ const said = stdout.trim().split(`
7683
+ `)[0]?.slice(0, 300);
7684
+ return fail(`grok did not start a session (exit ${exited.code ?? "by signal"})${said ? `: ${said}` : ", see stderr.txt"}`);
7685
+ }
7686
+ if (result && (result.is_error === true || result.subtype !== "success"))
7687
+ return fail(resultError(result));
7688
+ if (sessionId === undefined)
7689
+ return fail("grok's init line has no session_id");
7690
+ if (!result)
7691
+ return fail(`grok exited ${exited.code ?? "by signal"} without a result, see stderr.txt`);
7692
+ return { ...trace, reportedModel, version: null, ok: true, sessionId };
7693
+ }
7694
+ function progress3(line) {
7695
+ const e = parse3(line);
7696
+ if (e?.type !== "assistant")
7697
+ return;
7698
+ const p = {};
7699
+ for (const b of content2(e)) {
7700
+ const cmd = b.input?.command;
7701
+ if (b.type === "tool_use" && typeof cmd === "string")
7702
+ p.command = cmd.split(`
7703
+ `)[0];
7704
+ }
7705
+ return p;
7706
+ }
7707
+ function isScript(bin) {
7708
+ const fd = openSync(bin, "r");
7709
+ try {
7710
+ const head = Buffer.alloc(2);
7711
+ return readSync(fd, head, 0, 2, 0) === 2 && head.toString() === "#!";
7712
+ } finally {
7713
+ closeSync(fd);
7714
+ }
7715
+ }
7716
+ function loginProblem() {
7717
+ const relogin = "Run `grok login`, then run eval again";
7718
+ if (!existsSync3(USER_AUTH))
7719
+ return {
7720
+ loggedOut: true,
7721
+ reason: `grok is not logged in (no ${USER_AUTH}). ${relogin}. Headless grok needs that login: XAI_API_KEY alone is not enough`
7722
+ };
7723
+ let auth;
7724
+ try {
7725
+ auth = JSON.parse(readFileSync2(USER_AUTH, "utf8"));
7726
+ } catch {
7727
+ return {
7728
+ loggedOut: false,
7729
+ reason: `${USER_AUTH} is not valid JSON. ${relogin}`
7730
+ };
7731
+ }
7732
+ const logins = isObject(auth) ? Object.values(auth).filter((v) => isObject(v) && typeof v.key === "string" && v.key !== "") : [];
7733
+ if (logins.length === 0)
7734
+ return {
7735
+ loggedOut: true,
7736
+ reason: `grok is not logged in (${USER_AUTH} holds no login). ${relogin}`
7737
+ };
7738
+ const expiries = logins.map((l) => l.expires_at);
7739
+ if (expiries.some((x) => x === undefined))
7740
+ return;
7741
+ const times = expiries.map((x) => Date.parse(String(x)));
7742
+ if (times.some(Number.isNaN))
7743
+ return {
7744
+ loggedOut: false,
7745
+ reason: `${USER_AUTH} has an expires_at that is not a date (${expiries.join(", ")}). ${relogin}`
7746
+ };
7747
+ const latest = Math.max(...times);
7748
+ if (latest - Date.now() > REFRESH_WINDOW_MS)
7749
+ return;
7750
+ return {
7751
+ loggedOut: false,
7752
+ reason: `grok's login ${latest < Date.now() ? "expired" : "expires"} at ${new Date(latest).toISOString()}. Run grok once (e.g. \`grok -p hi\`) so it refreshes the login in ${USER_AUTH}, then run eval again: eval's runs must not refresh it themselves`
7753
+ };
7754
+ }
7755
+ function linkAuth(configDir) {
7756
+ const link = join4(configDir, "auth.json");
7757
+ let st;
7758
+ try {
7759
+ st = lstatSync2(link);
7760
+ } catch (e) {
7761
+ if (e.code !== "ENOENT")
7762
+ throw e;
7763
+ }
7764
+ if (st && !st.isSymbolicLink())
7765
+ return {
7766
+ loggedOut: false,
7767
+ reason: `${link} is a file, not a link to ${USER_AUTH}: a grok run refreshed its login there, so it is newer than ${USER_AUTH}. Move it back with \`mv ${link} ${USER_AUTH}\`, then run eval again`
7768
+ };
7769
+ if (st && readlinkSync(link) === USER_AUTH)
7770
+ return;
7771
+ if (st)
7772
+ unlinkSync2(link);
7773
+ symlinkSync2(USER_AUTH, link);
7774
+ return;
7775
+ }
7776
+ function checkLogin3(opts) {
7777
+ if (isScript(opts.bin))
7778
+ return {
7779
+ loggedOut: false,
7780
+ reason: `${opts.bin} is a script, not the grok executable (cmux, for one, puts a bash wrapper named grok on PATH). Pass grok's own binary with --harness-bin, ${BIN} by default`
7781
+ };
7782
+ return loginProblem() ?? linkAuth(opts.configDir);
7783
+ }
7784
+ var grok = {
7785
+ name: "grok-cli",
7786
+ bin: BIN,
7787
+ installHint: "Install grok (curl -fsSL https://x.ai/cli/install.sh | bash) or pass --harness-bin PATH.",
7788
+ configDirName: ".grok",
7789
+ reservedArgs: [
7790
+ [
7791
+ [
7792
+ "-p",
7793
+ "--single",
7794
+ "--prompt-file",
7795
+ "--prompt-json",
7796
+ "--output-format",
7797
+ "--json-schema",
7798
+ "--always-approve",
7799
+ "--permission-mode",
7800
+ "--no-auto-update",
7801
+ "--cwd",
7802
+ "-w",
7803
+ "--worktree"
7804
+ ],
7805
+ "firegoat sets it for every run"
7806
+ ],
7807
+ [
7808
+ ["-m", "--model"],
7809
+ "choose the model with top-level model: (or eval --model)"
7810
+ ],
7811
+ [
7812
+ [
7813
+ "-r",
7814
+ "--resume",
7815
+ "-c",
7816
+ "--continue",
7817
+ "-s",
7818
+ "--session-id",
7819
+ "--fork-session"
7820
+ ],
7821
+ "every run is a fresh session"
7822
+ ]
7823
+ ],
7824
+ uploads: false,
7825
+ args: ({ prompt, model, extraArgs }) => [
7826
+ "-p",
7827
+ prompt,
7828
+ ...FLAGS3,
7829
+ ...model === undefined ? [] : ["-m", model],
7830
+ ...extraArgs
7831
+ ],
7832
+ env: ({ configDir, run }) => ({
7833
+ GROK_HOME: configDir,
7834
+ GROK_DISABLE_AUTOUPDATER: "1",
7835
+ ...run === undefined ? {} : homeEnv(run.home)
7836
+ }),
7837
+ authLabel: () => `your grok login (${USER_AUTH})`,
7838
+ checkLogin: checkLogin3,
7839
+ progress: progress3,
7840
+ read: read3,
7841
+ sessionsDir: (configDir) => join4(configDir, "sessions"),
7842
+ findSession(configDir, sessionId) {
7843
+ const sessions = join4(configDir, "sessions");
7844
+ if (!existsSync3(sessions))
7845
+ return null;
7846
+ for (const d of readdirSync3(sessions)) {
7847
+ const dir = join4(sessions, d, sessionId);
7848
+ if (existsSync3(dir) && statSync(dir).isDirectory())
7849
+ return dir;
7850
+ }
7851
+ return null;
7852
+ }
7853
+ };
7854
+
7855
+ // src/harnesses/index.ts
7856
+ var HARNESSES = new Map([claude, codex, grok].map((h) => [h.name, h]));
7857
+ var DEFAULT_HARNESS = claude.name;
7858
+ function validHarnesses() {
7859
+ return `(valid: ${[...HARNESSES.keys()].join(", ")})`;
7860
+ }
7861
+
7038
7862
  // src/config.ts
7039
7863
  var CONFIG_FILE = "firegoat.yaml";
7040
7864
  var DEFAULT_TIMEOUT = 600;
@@ -7047,7 +7871,7 @@ function fail(path, msg) {
7047
7871
  function isRecord(v) {
7048
7872
  return typeof v === "object" && v !== null && !Array.isArray(v);
7049
7873
  }
7050
- function str(v, path) {
7874
+ function str2(v, path) {
7051
7875
  if (typeof v !== "string" || v.trim() === "")
7052
7876
  fail(path, "must be a non-empty string");
7053
7877
  return v;
@@ -7075,23 +7899,29 @@ function parseConfig(text) {
7075
7899
  fail("(root)", "must be a mapping");
7076
7900
  onlyKeys(raw, "(root)", [
7077
7901
  "runs",
7902
+ "harness",
7078
7903
  "model",
7079
7904
  "build",
7080
7905
  "install",
7081
7906
  "cleanup",
7082
7907
  "vars",
7083
7908
  "env",
7084
- "claude_args",
7909
+ "harness_args",
7085
7910
  "tests"
7086
7911
  ]);
7087
7912
  const runs = posInt(raw.runs, "runs", 1);
7088
- const models = parseModels(raw.model);
7089
- const build = commands(raw.build, "build");
7090
- const install = commands(raw.install, "install");
7091
- const cleanup = commands(raw.cleanup, "cleanup");
7913
+ const harnesses = raw.harness === undefined ? [DEFAULT_HARNESS] : nameList(raw.harness, "harness", "a harness name");
7914
+ harnesses.forEach((h, i) => {
7915
+ if (!HARNESSES.has(h))
7916
+ fail(Array.isArray(raw.harness) ? `harness[${i}]` : "harness", `unknown harness ${JSON.stringify(h)} ${validHarnesses()}`);
7917
+ });
7918
+ const models = raw.model === undefined ? [] : nameList(raw.model, "model", "a model name");
7919
+ const build = commands(raw.build, "build", EVAL_PLACEHOLDERS);
7920
+ const install = commands(raw.install, "install", RUN_PLACEHOLDERS);
7921
+ const cleanup = commands(raw.cleanup, "cleanup", RUN_PLACEHOLDERS);
7092
7922
  const vars = parseVars(raw.vars);
7093
7923
  const env = parseEnv(raw.env, "env");
7094
- const claudeArgs = parseClaudeArgs(raw.claude_args, "claude_args");
7924
+ const harnessArgs = parseHarnessArgs(raw.harness_args, "harness_args");
7095
7925
  if (!Array.isArray(raw.tests) || raw.tests.length === 0)
7096
7926
  fail("tests", "must be a non-empty list");
7097
7927
  const names = new Set;
@@ -7108,9 +7938,9 @@ function parseConfig(text) {
7108
7938
  "env",
7109
7939
  "install",
7110
7940
  "cleanup",
7111
- "claude_args"
7941
+ "harness_args"
7112
7942
  ]);
7113
- const name = str(t.name, `${p}.name`);
7943
+ const name = str2(t.name, `${p}.name`);
7114
7944
  if (!NAME.test(name))
7115
7945
  fail(`${p}.name`, "may only contain letters, digits, '.', '_' and '-'");
7116
7946
  if (names.has(name))
@@ -7125,25 +7955,28 @@ function parseConfig(text) {
7125
7955
  if (!isRecord(c))
7126
7956
  fail(cp, "must be a mapping");
7127
7957
  onlyKeys(c, cp, ["run"]);
7128
- checks.push({ run: str(c.run, `${cp}.run`) });
7958
+ const run = str2(c.run, `${cp}.run`);
7959
+ checkPlaceholders(run, `${cp}.run`, RUN_PLACEHOLDERS);
7960
+ checks.push({ run });
7129
7961
  });
7130
7962
  return {
7131
7963
  name,
7132
- prompt: str(t.prompt, `${p}.prompt`),
7133
- end_goal: str(t.end_goal, `${p}.end_goal`),
7964
+ prompt: str2(t.prompt, `${p}.prompt`),
7965
+ end_goal: str2(t.end_goal, `${p}.end_goal`),
7134
7966
  checks,
7135
7967
  timeout: posInt(t.timeout, `${p}.timeout`, DEFAULT_TIMEOUT),
7136
7968
  env: { ...env, ...parseEnv(t.env, `${p}.env`) },
7137
- install: commands(t.install, `${p}.install`),
7138
- cleanup: commands(t.cleanup, `${p}.cleanup`),
7139
- claudeArgs: [
7140
- ...claudeArgs,
7141
- ...parseClaudeArgs(t.claude_args, `${p}.claude_args`)
7969
+ install: commands(t.install, `${p}.install`, RUN_PLACEHOLDERS),
7970
+ cleanup: commands(t.cleanup, `${p}.cleanup`, RUN_PLACEHOLDERS),
7971
+ harnessArgs: [
7972
+ ...harnessArgs,
7973
+ ...parseHarnessArgs(t.harness_args, `${p}.harness_args`)
7142
7974
  ]
7143
7975
  };
7144
7976
  });
7145
7977
  return {
7146
7978
  runs,
7979
+ harnesses,
7147
7980
  models,
7148
7981
  build,
7149
7982
  install,
@@ -7154,67 +7987,53 @@ function parseConfig(text) {
7154
7987
  };
7155
7988
  }
7156
7989
  var NAME = /^[A-Za-z0-9._-]+$/;
7157
- function commands(raw, path) {
7990
+ function commands(raw, path, allowed) {
7158
7991
  if (raw === undefined)
7159
7992
  return [];
7160
- if (typeof raw === "string")
7161
- return [str(raw, path)];
7162
- if (!Array.isArray(raw))
7993
+ if (typeof raw !== "string" && !Array.isArray(raw))
7163
7994
  fail(path, "must be a shell command or a list of them");
7164
- return raw.map((c, i) => str(c, `${path}[${i}]`));
7995
+ const list = typeof raw === "string" ? [raw] : raw;
7996
+ return list.map((c, i) => {
7997
+ const p = typeof raw === "string" ? path : `${path}[${i}]`;
7998
+ const cmd = str2(c, p);
7999
+ checkPlaceholders(cmd, p, allowed);
8000
+ return cmd;
8001
+ });
7165
8002
  }
7166
- function parseModels(raw) {
7167
- if (raw === undefined)
7168
- return [];
8003
+ function nameList(raw, key, what) {
7169
8004
  if (typeof raw === "string")
7170
- return [str(raw, "model")];
8005
+ return [str2(raw, key)];
7171
8006
  if (!Array.isArray(raw) || raw.length === 0)
7172
- fail("model", "must be a model name or a non-empty list of them");
8007
+ fail(key, `must be ${what} or a non-empty list of them`);
7173
8008
  const seen = new Set;
7174
8009
  return raw.map((m, i) => {
7175
- const name = str(m, `model[${i}]`);
8010
+ const name = str2(m, `${key}[${i}]`);
7176
8011
  if (seen.has(name))
7177
- fail(`model[${i}]`, `duplicate model ${JSON.stringify(name)}`);
8012
+ fail(`${key}[${i}]`, `duplicate ${key} ${JSON.stringify(name)}`);
7178
8013
  seen.add(name);
7179
8014
  return name;
7180
8015
  });
7181
8016
  }
7182
- var OWNED_FLAGS = [
7183
- [
7184
- [
7185
- "-p",
7186
- "--print",
7187
- "--output-format",
7188
- "--verbose",
7189
- "--setting-sources",
7190
- "--strict-mcp-config",
7191
- "--disable-slash-commands",
7192
- "--dangerously-skip-permissions"
7193
- ],
7194
- "firegoat sets it for every run"
7195
- ],
7196
- [["--model"], "choose the model with top-level model: (or eval --model)"],
7197
- [
7198
- ["--session-id", "--resume", "-r", "--continue", "-c"],
7199
- "every run is a fresh session"
7200
- ]
7201
- ];
7202
- function parseClaudeArgs(raw, path) {
8017
+ function parseHarnessArgs(raw, path) {
7203
8018
  if (raw === undefined)
7204
8019
  return [];
7205
8020
  if (!Array.isArray(raw))
7206
- fail(path, "must be a list of claude arguments, one per entry, e.g. [--chrome]");
8021
+ fail(path, "must be a list of harness arguments, one per entry, e.g. [--chrome]");
7207
8022
  return raw.map((a, i) => {
7208
8023
  const p = `${path}[${i}]`;
7209
- const arg = str(a, p);
7210
- for (const [flags, why] of OWNED_FLAGS)
7211
- for (const f of flags)
7212
- if (arg === f || arg.startsWith(`${f}=`))
7213
- fail(p, `${f} is not allowed: ${why}`);
7214
- checkPlaceholders(arg, p);
7215
- return arg;
8024
+ const arg = str2(a, p);
8025
+ checkPlaceholders(arg, p, RUN_PLACEHOLDERS);
8026
+ return { arg, path: p };
7216
8027
  });
7217
8028
  }
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
+ }
7218
8037
  var VAR_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
7219
8038
  function parseVars(raw) {
7220
8039
  if (raw === undefined)
@@ -7234,7 +8053,10 @@ function parseVars(raw) {
7234
8053
  var RESERVED_ENV = new Set([
7235
8054
  "PATH",
7236
8055
  "CLAUDE_CONFIG_DIR",
7237
- "CLAUDE_SECURESTORAGE_CONFIG_DIR"
8056
+ "CLAUDE_SECURESTORAGE_CONFIG_DIR",
8057
+ "CODEX_HOME",
8058
+ "GROK_HOME",
8059
+ "GROK_DISABLE_AUTOUPDATER"
7238
8060
  ]);
7239
8061
  var HOME_ENV = new Set([
7240
8062
  "HOME",
@@ -7264,19 +8086,19 @@ function parseEnv(raw, path) {
7264
8086
  if (HOME_ENV.has(k))
7265
8087
  fail(p, "Reserved: every run gets its own home ($FIREGOAT_HOME)");
7266
8088
  if (RESERVED_ENV.has(k) || k.startsWith("FIREGOAT_"))
7267
- fail(p, "is set by firegoat and cannot be overridden (reserved: PATH, CLAUDE_CONFIG_DIR, CLAUDE_SECURESTORAGE_CONFIG_DIR, FIREGOAT_*)");
8089
+ fail(p, "is set by firegoat and cannot be overridden (reserved: PATH, CLAUDE_CONFIG_DIR, CLAUDE_SECURESTORAGE_CONFIG_DIR, CODEX_HOME, GROK_HOME, GROK_DISABLE_AUTOUPDATER, FIREGOAT_*)");
7268
8090
  if (typeof v !== "string")
7269
8091
  fail(p, "must be a string (quote numbers and booleans)");
7270
- checkPlaceholders(v, p);
8092
+ checkPlaceholders(v, p, RUN_PLACEHOLDERS);
7271
8093
  out[k] = v;
7272
8094
  }
7273
8095
  return out;
7274
8096
  }
7275
- function checkPlaceholders(v, p) {
8097
+ function checkPlaceholders(v, p, allowed) {
7276
8098
  for (const m of v.matchAll(ANY_PLACEHOLDER)) {
7277
8099
  const name = m[1];
7278
- if (!/^vars\.[A-Za-z0-9_]+$/.test(name) && !RUN_PLACEHOLDERS.includes(name))
7279
- fail(p, `unknown placeholder ${m[0]} (allowed: {{vars.KEY}}, ${RUN_PLACEHOLDERS.map((n) => `{{${n}}}`).join(", ")})`);
8100
+ if (!/^vars\.[A-Za-z0-9_]+$/.test(name) && !allowed.includes(name))
8101
+ fail(p, `unknown placeholder ${m[0]} (allowed: {{vars.KEY}}, ${allowed.map((n) => `{{${n}}}`).join(", ")})`);
7280
8102
  }
7281
8103
  }
7282
8104
  function isEvalLevel(value) {
@@ -7287,13 +8109,16 @@ function isEvalLevel(value) {
7287
8109
  });
7288
8110
  }
7289
8111
  function renderValue(value, vars, run, where) {
7290
- return value.replace(ENV_REF, (whole, name, braced, bare) => {
7291
- const key = name ?? braced ?? bare;
7292
- const filled = key.startsWith("vars.") ? vars[key.slice("vars.".length)] : run[key];
7293
- if (filled === undefined)
7294
- throw new ConfigError(`${where}: no value for ${whole}`);
7295
- return filled;
7296
- });
8112
+ return value.replace(ENV_REF, (whole, name, braced, bare) => lookup(whole, name ?? braced ?? bare, vars, run, where));
8113
+ }
8114
+ function renderStep(cmd, vars, run, where) {
8115
+ return cmd.replace(ANY_PLACEHOLDER, (whole, name) => lookup(whole, name, vars, run, where));
8116
+ }
8117
+ function lookup(whole, key, vars, run, where) {
8118
+ const filled = key.startsWith("vars.") ? vars[key.slice("vars.".length)] : run[key];
8119
+ if (filled === undefined)
8120
+ throw new ConfigError(`${where}: no value for ${whole}`);
8121
+ return filled;
7297
8122
  }
7298
8123
  function renderEnv(env, vars, run) {
7299
8124
  const out = {};
@@ -7315,9 +8140,9 @@ function render(text, vars) {
7315
8140
  }
7316
8141
  function loadConfig(dir) {
7317
8142
  const file = `${dir}/${CONFIG_FILE}`;
7318
- if (!existsSync(file))
8143
+ if (!existsSync4(file))
7319
8144
  throw new ConfigError(`${CONFIG_FILE} not found in ${dir}. Run: npx firegoat init`);
7320
- return parseConfig(readFileSync(file, "utf8"));
8145
+ return parseConfig(readFileSync3(file, "utf8"));
7321
8146
  }
7322
8147
 
7323
8148
  // src/eval.ts
@@ -7328,24 +8153,24 @@ import {
7328
8153
  mkdirSync as mkdirSync4,
7329
8154
  writeFileSync as writeFileSync4
7330
8155
  } from "node:fs";
7331
- import { basename as basename3, delimiter as delimiter2, join as join7 } from "node:path";
8156
+ import { basename as basename3, delimiter as delimiter2, join as join9 } from "node:path";
7332
8157
 
7333
8158
  // src/credentials.ts
7334
8159
  import { randomBytes } from "node:crypto";
7335
8160
  import {
7336
8161
  chmodSync,
7337
- closeSync,
8162
+ closeSync as closeSync2,
7338
8163
  fsyncSync,
7339
- lstatSync,
7340
- mkdirSync,
7341
- openSync,
7342
- readFileSync as readFileSync2,
8164
+ lstatSync as lstatSync3,
8165
+ mkdirSync as mkdirSync2,
8166
+ openSync as openSync2,
8167
+ readFileSync as readFileSync4,
7343
8168
  renameSync,
7344
- unlinkSync,
8169
+ unlinkSync as unlinkSync3,
7345
8170
  writeSync
7346
8171
  } from "node:fs";
7347
- import { homedir } from "node:os";
7348
- import { join } from "node:path";
8172
+ import { homedir as homedir3 } from "node:os";
8173
+ import { join as join5 } from "node:path";
7349
8174
 
7350
8175
  // src/firegoat-api.ts
7351
8176
  var DEFAULT_BASE_URL = "https://firegoat.ai/evals";
@@ -7497,10 +8322,10 @@ async function whoami(base, key) {
7497
8322
  // src/credentials.ts
7498
8323
  var KEY = /^fire_[\x21-\x7e]+$/;
7499
8324
  function credentialsDir() {
7500
- return join(homedir(), ".firegoat");
8325
+ return join5(homedir3(), ".firegoat");
7501
8326
  }
7502
8327
  function credentialsPath() {
7503
- return join(credentialsDir(), "credentials.json");
8328
+ return join5(credentialsDir(), "credentials.json");
7504
8329
  }
7505
8330
  function uid() {
7506
8331
  if (!process.getuid)
@@ -7509,7 +8334,7 @@ function uid() {
7509
8334
  }
7510
8335
  function lstatOrNull(p) {
7511
8336
  try {
7512
- return lstatSync(p);
8337
+ return lstatSync3(p);
7513
8338
  } catch (e) {
7514
8339
  if (e.code === "ENOENT")
7515
8340
  return null;
@@ -7539,7 +8364,7 @@ function loadCredentials() {
7539
8364
  throw new ConfigError(`${path} is readable by others (mode ${(st.mode & 511).toString(8)}): chmod 600 it or run firegoat login again`);
7540
8365
  let c;
7541
8366
  try {
7542
- c = JSON.parse(readFileSync2(path, "utf8"));
8367
+ c = JSON.parse(readFileSync4(path, "utf8"));
7543
8368
  } catch {
7544
8369
  throw new ConfigError(`${path} is not valid JSON: run firegoat logout, then firegoat login`);
7545
8370
  }
@@ -7563,20 +8388,20 @@ function saveCredentials(c) {
7563
8388
  if (st)
7564
8389
  assertOwned(dir, st, "dir");
7565
8390
  else
7566
- mkdirSync(dir, { mode: 448 });
8391
+ mkdirSync2(dir, { mode: 448 });
7567
8392
  chmodSync(dir, 448);
7568
8393
  const tmp = `${credentialsPath()}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
7569
- const fd = openSync(tmp, "wx", 384);
8394
+ const fd = openSync2(tmp, "wx", 384);
7570
8395
  try {
7571
8396
  writeSync(fd, `${JSON.stringify(c, null, 2)}
7572
8397
  `);
7573
8398
  fsyncSync(fd);
7574
8399
  } catch (e) {
7575
- closeSync(fd);
7576
- unlinkSync(tmp);
8400
+ closeSync2(fd);
8401
+ unlinkSync3(tmp);
7577
8402
  throw e;
7578
8403
  }
7579
- closeSync(fd);
8404
+ closeSync2(fd);
7580
8405
  renameSync(tmp, credentialsPath());
7581
8406
  }
7582
8407
  function removeCredentials() {
@@ -7597,7 +8422,7 @@ function removeCredentials() {
7597
8422
  if (!(e instanceof ConfigError))
7598
8423
  throw e;
7599
8424
  }
7600
- unlinkSync(path);
8425
+ unlinkSync3(path);
7601
8426
  return { saved };
7602
8427
  }
7603
8428
  function resolveKey(base) {
@@ -7619,15 +8444,15 @@ function resolveKey(base) {
7619
8444
  var EXIT = { pass: 0, fail: 1, notGraded: 2, usage: 3 };
7620
8445
 
7621
8446
  // src/firegoat-dir.ts
7622
- import { mkdirSync as mkdirSync2, writeFileSync } from "node:fs";
7623
- import { join as join2 } from "node:path";
8447
+ import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "node:fs";
8448
+ import { join as join6 } from "node:path";
7624
8449
  var GITIGNORE = `*
7625
8450
  `;
7626
8451
  function ensureFiregoatDir(root) {
7627
- const dir = join2(root, ".firegoat");
7628
- mkdirSync2(dir, { recursive: true });
8452
+ const dir = join6(root, ".firegoat");
8453
+ mkdirSync3(dir, { recursive: true });
7629
8454
  try {
7630
- writeFileSync(join2(dir, ".gitignore"), GITIGNORE, { flag: "wx" });
8455
+ writeFileSync2(join6(dir, ".gitignore"), GITIGNORE, { flag: "wx" });
7631
8456
  } catch (e) {
7632
8457
  if (e.code !== "EEXIST")
7633
8458
  throw e;
@@ -7636,17 +8461,48 @@ function ensureFiregoatDir(root) {
7636
8461
  }
7637
8462
 
7638
8463
  // src/git.ts
7639
- import { spawnSync } from "node:child_process";
8464
+ import { spawnSync as spawnSync3 } from "node:child_process";
7640
8465
  import { basename, dirname } from "node:path";
7641
8466
 
7642
8467
  // src/process.ts
7643
- import { spawn } from "node:child_process";
8468
+ import { execFileSync, spawn } from "node:child_process";
7644
8469
  var NPM_INJECTED = /^(npm_config_|npm_lifecycle_|npm_package_)|^(npm_execpath|npm_node_execpath|npm_command|INIT_CWD)$/;
7645
8470
  var CLAUDE_SESSION = /^(CLAUDECODE|CLAUDE_CODE_.+|CLAUDE_PID|CLAUDE_ENV_FILE|CLAUDE_SESSION_ID|CLAUDE_PROJECT_DIR|CLAUDE_EFFORT|CLAUDE_AGENT_SDK_.+|CLAUDE_PLUGIN_ROOT|CLAUDE_PLUGIN_DATA|CLAUDE_SKILL_DIR)$/;
7646
8471
  var CLAUDE_AUTH = /^CLAUDE_CODE_(OAUTH_TOKEN|OAUTH_REFRESH_TOKEN|USE_[A-Z_]+|SKIP_[A-Z]+_AUTH|CLIENT_CERT|CLIENT_KEY|CLIENT_KEY_PASSPHRASE)$/;
7647
8472
  function hostEnv() {
7648
8473
  return Object.fromEntries(Object.entries(process.env).filter(([k]) => !NPM_INJECTED.test(k) && !k.startsWith("FIREGOAT_") && (!CLAUDE_SESSION.test(k) || CLAUDE_AUTH.test(k))));
7649
8474
  }
8475
+ function processTree(pid) {
8476
+ const children = new Map;
8477
+ const ps = execFileSync("ps", ["-A", "-o", "pid=,ppid="], {
8478
+ encoding: "utf8"
8479
+ });
8480
+ for (const line of ps.split(`
8481
+ `)) {
8482
+ const [child, parent] = line.trim().split(/\s+/).map(Number);
8483
+ if (!child || parent === undefined)
8484
+ continue;
8485
+ children.set(parent, [...children.get(parent) ?? [], child]);
8486
+ }
8487
+ const tree = [pid];
8488
+ for (let i = 0;i < tree.length; i++)
8489
+ tree.push(...children.get(tree[i]) ?? []);
8490
+ return tree;
8491
+ }
8492
+ function killTree(pid) {
8493
+ let tree = [pid];
8494
+ let problem;
8495
+ try {
8496
+ tree = processTree(pid);
8497
+ } catch (e) {
8498
+ problem = e.message;
8499
+ }
8500
+ for (const p of tree)
8501
+ try {
8502
+ process.kill(p, "SIGKILL");
8503
+ } catch {}
8504
+ return problem;
8505
+ }
7650
8506
  function run(opts) {
7651
8507
  return new Promise((resolve) => {
7652
8508
  const child = spawn(opts.cmd, opts.args, {
@@ -7658,7 +8514,12 @@ function run(opts) {
7658
8514
  let settled = false;
7659
8515
  const timer = setTimeout(() => {
7660
8516
  timedOut = true;
7661
- child.kill("SIGKILL");
8517
+ if (child.pid === undefined)
8518
+ return;
8519
+ const problem = killTree(child.pid);
8520
+ if (problem)
8521
+ opts.onStderr(Buffer.from(`firegoat: timed out; could not list ${opts.cmd}'s child processes (${problem}), killed only ${opts.cmd}
8522
+ `));
7662
8523
  }, opts.timeoutMs);
7663
8524
  const done = (r) => {
7664
8525
  if (settled)
@@ -7676,335 +8537,133 @@ function run(opts) {
7676
8537
  child.stderr.destroy();
7677
8538
  done({ code, timedOut });
7678
8539
  }, 2000).unref());
7679
- });
7680
- }
7681
- async function runShell(opts) {
7682
- opts.log.write(`$ ${opts.run}
7683
- `);
7684
- const r = await run({
7685
- cmd: "/bin/sh",
7686
- args: ["-c", opts.run],
7687
- cwd: opts.cwd,
7688
- env: opts.env,
7689
- timeoutMs: opts.timeoutMs,
7690
- onStdout: (c) => opts.log.write(c),
7691
- onStderr: (c) => opts.log.write(c)
7692
- });
7693
- let error;
7694
- if (r.error)
7695
- error = `could not start /bin/sh: ${r.error.message}`;
7696
- else if (r.timedOut)
7697
- error = `timed out after ${opts.timeoutMs / 1000}s`;
7698
- else if (r.code === null)
7699
- error = "killed by a signal";
7700
- opts.log.write(`[exit ${r.code ?? "none"}${error ? `: ${error}` : ""}]
7701
-
7702
- `);
7703
- return error ? { run: opts.run, code: r.code, error } : { run: opts.run, code: r.code };
7704
- }
7705
-
7706
- // src/git.ts
7707
- function git(dir, args) {
7708
- const r = spawnSync("git", args, {
7709
- cwd: dir,
7710
- env: hostEnv(),
7711
- encoding: "utf8"
7712
- });
7713
- if (r.error)
7714
- throw r.error;
7715
- return { code: r.status ?? -1, stdout: r.stdout.trim() };
7716
- }
7717
- function gitRepo(dir) {
7718
- const r = spawnSync("git", [
7719
- "rev-parse",
7720
- "--path-format=absolute",
7721
- "--git-dir",
7722
- "--git-common-dir",
7723
- "--show-toplevel"
7724
- ], { cwd: dir, env: hostEnv(), encoding: "utf8" });
7725
- if (r.error?.code === "ENOENT")
7726
- return null;
7727
- if (r.error)
7728
- throw r.error;
7729
- if (r.status !== 0) {
7730
- if (/not a git repository/.test(r.stderr))
7731
- return null;
7732
- throw new Error(`git rev-parse in ${dir} failed: ${r.stderr.trim()}`);
7733
- }
7734
- const [gitDir, commonDir, toplevel] = r.stdout.trim().split(`
7735
- `);
7736
- if (!gitDir || !commonDir || !toplevel)
7737
- throw new Error(`git rev-parse in ${dir} printed ${r.stdout}`);
7738
- let mainCheckout = null;
7739
- if (gitDir !== commonDir) {
7740
- if (basename(commonDir) !== ".git")
7741
- throw new Error(`${dir} is a linked git worktree, but its common git dir ${commonDir} is not a checkout's .git (a bare repo?), so there is no main checkout to keep .firegoat/runs in`);
7742
- mainCheckout = dirname(commonDir);
7743
- }
7744
- const branch = git(dir, ["symbolic-ref", "-q", "--short", "HEAD"]);
7745
- const commit = git(dir, ["rev-parse", "-q", "--verify", "HEAD"]);
7746
- const status = git(dir, ["status", "--porcelain"]);
7747
- if (status.code !== 0)
7748
- throw new Error(`git status in ${dir} failed`);
7749
- return {
7750
- info: {
7751
- branch: branch.code === 0 ? branch.stdout : null,
7752
- commit: commit.code === 0 ? commit.stdout : null,
7753
- dirty: status.stdout !== ""
7754
- },
7755
- mainCheckout,
7756
- toplevel
7757
- };
7758
- }
7759
-
7760
- // src/harness.ts
7761
- async function runAgent(harness, opts) {
7762
- let stdout = "";
7763
- let pending = "";
7764
- const exited = await run({
7765
- cmd: opts.bin,
7766
- args: harness.args(opts),
7767
- cwd: opts.cwd,
7768
- env: opts.env,
7769
- timeoutMs: opts.timeoutMs,
7770
- onStdout: (c) => {
7771
- const text = c.toString();
7772
- stdout += text;
7773
- if (!opts.onProgress)
7774
- return;
7775
- pending += text;
7776
- const lines = pending.split(`
7777
- `);
7778
- pending = lines.pop() ?? "";
7779
- for (const line of lines) {
7780
- const p = harness.progress(line);
7781
- if (p)
7782
- opts.onProgress(p);
7783
- }
7784
- },
7785
- onStderr: (c) => opts.stderr.write(c)
7786
- });
7787
- if (exited.error)
7788
- return {
7789
- ok: false,
7790
- reason: `could not start ${opts.bin}: ${exited.error.message}`,
7791
- steps: [],
7792
- final: "",
7793
- reportedModel: null,
7794
- version: null,
7795
- turns: null,
7796
- cost: null
7797
- };
7798
- return harness.read(stdout, exited, opts.timeoutMs);
7799
- }
7800
-
7801
- // src/harnesses/claude.ts
7802
- import { spawnSync as spawnSync2 } from "node:child_process";
7803
- import { existsSync as existsSync2, readdirSync } from "node:fs";
7804
- import { join as join3 } from "node:path";
7805
- var FLAGS = [
7806
- "--output-format",
7807
- "stream-json",
7808
- "--verbose",
7809
- "--dangerously-skip-permissions",
7810
- "--strict-mcp-config",
7811
- "--setting-sources",
7812
- "",
7813
- "--disable-slash-commands"
7814
- ];
7815
- function parse(line) {
7816
- try {
7817
- const e = JSON.parse(line);
7818
- return typeof e === "object" && e !== null ? e : undefined;
7819
- } catch {
7820
- return;
7821
- }
7822
- }
7823
- function content(e) {
7824
- const message = e?.message;
7825
- return Array.isArray(message?.content) ? message.content : [];
7826
- }
7827
- function resultText(content) {
7828
- if (typeof content === "string")
7829
- return content;
7830
- if (Array.isArray(content))
7831
- return content.map((c) => typeof c.text === "string" ? c.text : `[${c.type}]`).join(`
7832
- `);
7833
- return "";
7834
- }
7835
- function steps(events) {
7836
- const out = [];
7837
- for (const e of events) {
7838
- if (e?.type === "assistant")
7839
- for (const b of content(e)) {
7840
- if (b.type === "tool_use")
7841
- out.push({
7842
- kind: "tool_call",
7843
- name: typeof b.name === "string" ? b.name : "",
7844
- input: typeof b.input === "object" && b.input !== null ? b.input : null
7845
- });
7846
- else if (b.type === "text" && typeof b.text === "string")
7847
- out.push({ kind: "text", text: b.text });
7848
- }
7849
- else if (e?.type === "user") {
7850
- for (const b of content(e))
7851
- if (b.type === "tool_result")
7852
- out.push({
7853
- kind: "tool_result",
7854
- isError: b.is_error === true,
7855
- text: resultText(b.content).trimEnd()
7856
- });
7857
- }
7858
- }
7859
- return out;
7860
- }
7861
- function read(stdout, exited, timeoutMs) {
7862
- const events = stdout.split(`
7863
- `).filter((l) => l.trim() !== "").map(parse);
7864
- let final = "";
7865
- for (const e of events)
7866
- if (e?.type === "result" && typeof e.result === "string")
7867
- final = e.result;
7868
- const result = events.findLast((e) => e?.type === "result");
7869
- const trace = {
7870
- steps: steps(events),
7871
- final,
7872
- turns: result?.num_turns ?? null,
7873
- cost: result?.total_cost_usd ?? null
7874
- };
7875
- const init = events.find((e) => e?.type === "system" && e.subtype === "init");
7876
- if (!init) {
7877
- const said = stdout.trim().split(`
7878
- `)[0]?.slice(0, 300);
7879
- return {
7880
- ...trace,
7881
- reportedModel: null,
7882
- version: null,
7883
- ok: false,
7884
- reason: `claude did not start a session (exit ${exited.code ?? "by signal"})${said ? `: ${said}` : ", see stderr.txt"}`
7885
- };
7886
- }
7887
- const reportedModel = typeof init.model === "string" ? init.model : null;
7888
- const version = typeof init.claude_code_version === "string" ? init.claude_code_version : null;
7889
- if (typeof init.session_id !== "string" || init.session_id === "")
7890
- return {
7891
- ...trace,
7892
- reportedModel,
7893
- version,
7894
- ok: false,
7895
- reason: "claude's init line has no session_id"
7896
- };
7897
- const sessionId = init.session_id;
7898
- if (exited.timedOut)
7899
- return {
7900
- ...trace,
7901
- reportedModel,
7902
- version,
7903
- ok: false,
7904
- reason: `timed out after ${timeoutMs / 1000}s`,
7905
- sessionId
7906
- };
7907
- if (!result)
7908
- return {
7909
- ...trace,
7910
- reportedModel,
7911
- version,
7912
- ok: false,
7913
- reason: `claude exited ${exited.code ?? "by signal"} without a result, see stderr.txt`,
7914
- sessionId
7915
- };
7916
- if (result.is_error === true || result.subtype !== "success")
7917
- return {
7918
- ...trace,
7919
- reportedModel,
7920
- version,
7921
- ok: false,
7922
- reason: `claude result is an error (subtype=${result.subtype})${result.result ? `: ${result.result.slice(0, 300)}` : ""}`,
7923
- sessionId
7924
- };
7925
- return { ...trace, reportedModel, version, ok: true, sessionId };
8540
+ });
7926
8541
  }
7927
- function progress(line) {
7928
- const e = parse(line);
7929
- if (e?.type !== "assistant")
7930
- return;
7931
- const p = {};
7932
- for (const b of content(e)) {
7933
- const cmd = b.input?.command;
7934
- if (b.type === "tool_use" && typeof cmd === "string")
7935
- p.command = cmd.split(`
7936
- `)[0];
8542
+ async function runShell(opts) {
8543
+ opts.log.write(`$ ${opts.run}
8544
+ `);
8545
+ const r = await run({
8546
+ cmd: "/bin/sh",
8547
+ args: ["-c", opts.run],
8548
+ cwd: opts.cwd,
8549
+ env: opts.env,
8550
+ timeoutMs: opts.timeoutMs,
8551
+ onStdout: (c) => opts.log.write(c),
8552
+ onStderr: (c) => opts.log.write(c)
8553
+ });
8554
+ let error;
8555
+ if (r.error)
8556
+ error = `could not start /bin/sh: ${r.error.message}`;
8557
+ else if (r.timedOut)
8558
+ error = `timed out after ${opts.timeoutMs / 1000}s`;
8559
+ else if (r.code === null)
8560
+ error = "killed by a signal";
8561
+ opts.log.write(`[exit ${r.code ?? "none"}${error ? `: ${error}` : ""}]
8562
+
8563
+ `);
8564
+ return error ? { run: opts.run, code: r.code, error } : { run: opts.run, code: r.code };
8565
+ }
8566
+
8567
+ // src/git.ts
8568
+ function git(dir, args) {
8569
+ const r = spawnSync3("git", args, {
8570
+ cwd: dir,
8571
+ env: hostEnv(),
8572
+ encoding: "utf8"
8573
+ });
8574
+ if (r.error)
8575
+ throw r.error;
8576
+ return { code: r.status ?? -1, stdout: r.stdout.trim() };
8577
+ }
8578
+ function gitRepo(dir) {
8579
+ const r = spawnSync3("git", [
8580
+ "rev-parse",
8581
+ "--path-format=absolute",
8582
+ "--git-dir",
8583
+ "--git-common-dir",
8584
+ "--show-toplevel"
8585
+ ], { cwd: dir, env: hostEnv(), encoding: "utf8" });
8586
+ if (r.error?.code === "ENOENT")
8587
+ return null;
8588
+ if (r.error)
8589
+ throw r.error;
8590
+ if (r.status !== 0) {
8591
+ if (/not a git repository/.test(r.stderr))
8592
+ return null;
8593
+ throw new Error(`git rev-parse in ${dir} failed: ${r.stderr.trim()}`);
7937
8594
  }
7938
- return p;
8595
+ const [gitDir, commonDir, toplevel] = r.stdout.trim().split(`
8596
+ `);
8597
+ if (!gitDir || !commonDir || !toplevel)
8598
+ throw new Error(`git rev-parse in ${dir} printed ${r.stdout}`);
8599
+ let mainCheckout = null;
8600
+ if (gitDir !== commonDir) {
8601
+ if (basename(commonDir) !== ".git")
8602
+ throw new Error(`${dir} is a linked git worktree, but its common git dir ${commonDir} is not a checkout's .git (a bare repo?), so there is no main checkout to keep .firegoat/runs in`);
8603
+ mainCheckout = dirname(commonDir);
8604
+ }
8605
+ const branch = git(dir, ["symbolic-ref", "-q", "--short", "HEAD"]);
8606
+ const commit = git(dir, ["rev-parse", "-q", "--verify", "HEAD"]);
8607
+ const status = git(dir, ["status", "--porcelain"]);
8608
+ if (status.code !== 0)
8609
+ throw new Error(`git status in ${dir} failed`);
8610
+ return {
8611
+ info: {
8612
+ branch: branch.code === 0 ? branch.stdout : null,
8613
+ commit: commit.code === 0 ? commit.stdout : null,
8614
+ dirty: status.stdout !== ""
8615
+ },
8616
+ mainCheckout,
8617
+ toplevel
8618
+ };
7939
8619
  }
7940
- function checkLogin(opts) {
7941
- const r = spawnSync2(opts.bin, ["auth", "status"], {
8620
+
8621
+ // src/harness.ts
8622
+ async function runAgent(harness, opts) {
8623
+ let stdout = "";
8624
+ let pending = "";
8625
+ const exited = await run({
8626
+ cmd: opts.bin,
8627
+ args: harness.args(opts),
7942
8628
  cwd: opts.cwd,
7943
8629
  env: opts.env,
7944
- encoding: "utf8",
7945
- timeout: 30000
8630
+ timeoutMs: opts.timeoutMs,
8631
+ onStdout: (c) => {
8632
+ const text = c.toString();
8633
+ stdout += text;
8634
+ if (!opts.onProgress)
8635
+ return;
8636
+ pending += text;
8637
+ const lines = pending.split(`
8638
+ `);
8639
+ pending = lines.pop() ?? "";
8640
+ for (const line of lines) {
8641
+ const p = harness.progress(line);
8642
+ if (p)
8643
+ opts.onProgress(p);
8644
+ }
8645
+ },
8646
+ onStderr: (c) => opts.stderr.write(c)
7946
8647
  });
7947
- if (r.error)
7948
- return {
7949
- loggedOut: false,
7950
- reason: `could not run ${opts.bin}: ${r.error.message}`
7951
- };
7952
- let status;
7953
- try {
7954
- status = JSON.parse(r.stdout);
7955
- } catch {}
7956
- if (status?.loggedIn === true)
7957
- return;
7958
- if (status?.loggedIn === false)
8648
+ if (exited.error)
7959
8649
  return {
7960
- loggedOut: true,
7961
- reason: "claude is not logged in. Run `claude` once and log in (or set ANTHROPIC_API_KEY), then run eval again"
8650
+ ok: false,
8651
+ reason: `could not start ${opts.bin}: ${exited.error.message}`,
8652
+ steps: [],
8653
+ final: "",
8654
+ reportedModel: null,
8655
+ version: null,
8656
+ turns: null,
8657
+ cost: null
7962
8658
  };
7963
- return {
7964
- loggedOut: false,
7965
- reason: `claude auth status gave no login state (exit ${r.status}): ${(r.stdout + r.stderr).trim().slice(0, 300)}`
7966
- };
8659
+ return harness.read(stdout, exited, opts.timeoutMs);
7967
8660
  }
7968
- var claude = {
7969
- name: "claude",
7970
- bin: "claude",
7971
- installHint: "Install Claude Code (https://claude.com/claude-code) or pass --claude-bin PATH.",
7972
- configDirName: ".claude",
7973
- args: ({ prompt, model, extraArgs }) => [
7974
- "-p",
7975
- prompt,
7976
- ...FLAGS,
7977
- ...model === undefined ? [] : ["--model", model],
7978
- ...extraArgs
7979
- ],
7980
- env: ({ configDir, homeEnvFile }) => ({
7981
- CLAUDE_CONFIG_DIR: configDir,
7982
- CLAUDE_SECURESTORAGE_CONFIG_DIR: "",
7983
- ...homeEnvFile === undefined ? {} : { CLAUDE_ENV_FILE: homeEnvFile }
7984
- }),
7985
- authLabel: () => process.env.ANTHROPIC_API_KEY ? "ANTHROPIC_API_KEY (claude bills the API key; unset it to use your claude login)" : "your claude login",
7986
- checkLogin,
7987
- progress,
7988
- read,
7989
- sessionsDir: (configDir) => join3(configDir, "projects"),
7990
- findSession(configDir, sessionId) {
7991
- const projects = join3(configDir, "projects");
7992
- if (!existsSync2(projects))
7993
- return null;
7994
- for (const d of readdirSync(projects)) {
7995
- const f = join3(projects, d, `${sessionId}.jsonl`);
7996
- if (existsSync2(f))
7997
- return f;
7998
- }
7999
- return null;
8000
- }
8001
- };
8002
8661
 
8003
8662
  // src/help.ts
8004
8663
  var EXIT_CODES = `Exit codes:
8005
8664
  0 Every run's script checks passed
8006
8665
  1 A script check failed
8007
- 2 At least one run was not graded (claude missing or logged out, crash, timeout,
8666
+ 2 At least one run was not graded (the harness missing or logged out, crash, timeout,
8008
8667
  build failed); wins over 1
8009
8668
  3 Usage or config error (bad flag, unknown test, invalid or missing firegoat.yaml,
8010
8669
  firegoat.ai answered 401/403 for the upload key); any other failure of
@@ -8058,10 +8717,18 @@ firegoat.yaml, committed.
8058
8717
 
8059
8718
  firegoat.yaml reference:
8060
8719
  runs: 1 Runs per test
8720
+ harness: <name> | [<name>, ...]
8721
+ The agent CLI: claude-code (default), codex-cli
8722
+ or grok-cli.
8723
+ A list runs every test runs times per harness;
8724
+ eval --harness overrides
8061
8725
  model: <name> | [<name>, ...]
8062
- claude --model; a list runs every test runs times
8063
- per model (n counts 1..runs x models). Unset: no
8064
- --model, claude's default. eval --model overrides
8726
+ The harness's --model (-m for codex, grok); a list
8727
+ runs
8728
+ every test runs times per model, on each harness
8729
+ (n counts 1..runs x harnesses x models, harness by
8730
+ harness, then model). Unset: no --model, the
8731
+ harness's default. eval --model overrides
8065
8732
  build: <sh> | [<sh>, ...] Once per eval, in this folder; what it writes to
8066
8733
  $FIREGOAT_OUT is first on the agent's PATH
8067
8734
  install: <sh> | [<sh>, ...] In each fresh folder, before the agent starts; a
@@ -8071,8 +8738,9 @@ firegoat.yaml reference:
8071
8738
  what the agent left running (servers, daemons).
8072
8739
  Every step runs; a failure is a warning (run.json
8073
8740
  cleanup, <test>/<n>/cleanup.txt), never the verdict
8074
- vars: {KEY: value} Defaults for {{vars.KEY}} in prompts and checks;
8075
- --var KEY=value overrides
8741
+ vars: {KEY: value} Defaults for {{vars.KEY}} in prompts, checks and
8742
+ build, install and cleanup steps; --var KEY=value
8743
+ overrides. A missing value is a config error
8076
8744
  env: {NAME: value} Env vars for the agent, and for install, checks
8077
8745
  and cleanup of the same run; values are strings and can
8078
8746
  use {{vars.KEY}}, {{run.id}}, {{run.n}},
@@ -8080,7 +8748,8 @@ firegoat.yaml reference:
8080
8748
  $FIREGOAT_HOME (no other $ expansion: $HOME stays
8081
8749
  literal). The build step gets only the entries that
8082
8750
  use only {{vars.*}}, {{run.id}} and $FIREGOAT_OUT.
8083
- PATH, CLAUDE_CONFIG_DIR, FIREGOAT_* and
8751
+ PATH, CLAUDE_CONFIG_DIR, CODEX_HOME, GROK_HOME,
8752
+ GROK_DISABLE_AUTOUPDATER, FIREGOAT_* and
8084
8753
  ANTHROPIC_MODEL (use model:) are reserved, and so
8085
8754
  are HOME, XDG_CONFIG_HOME, XDG_CACHE_HOME,
8086
8755
  XDG_DATA_HOME, XDG_STATE_HOME and CLAUDE_ENV_FILE:
@@ -8088,20 +8757,33 @@ firegoat.yaml reference:
8088
8757
  tool that keeps state in ~ starts fresh in each
8089
8758
  run. Only a tool that ignores HOME needs its own
8090
8759
  var, e.g. MYTOOL_HOME: "$FIREGOAT_HOME/.mytool"
8091
- claude_args: [<arg>, ...] Extra claude arguments, after firegoat's own flags
8092
- and --model. Each entry is one argument, passed
8093
- as-is (no shell splitting): [--chrome] (Claude in
8094
- Chrome, for tests that need a browser),
8095
- [--plugin-dir, ./plugins/x], --mcp-config, --agents,
8096
- --append-system-prompt. Entries expand like env:
8097
- values ($FIREGOAT_WORKDIR, $FIREGOAT_HOME,
8098
- $FIREGOAT_OUT, {{vars.KEY}}, {{run.*}}); run.json
8099
- claudeArgs has the result. Flags firegoat sets are
8100
- errors, also as --flag=value: -p, --print,
8760
+ harness_args: [<arg>, ...] Extra harness arguments, after firegoat's own flags
8761
+ and --model (for codex, before the prompt), to
8762
+ every selected harness. Each entry is one
8763
+ argument, passed as-is (no shell splitting):
8764
+ claude: [--chrome] (Claude in Chrome, for tests
8765
+ that need a browser), [--plugin-dir, ./plugins/x],
8766
+ --mcp-config, --agents, --append-system-prompt;
8767
+ codex: [-c, key=value], --enable, --add-dir.
8768
+ Entries expand like env: values
8769
+ ($FIREGOAT_WORKDIR, $FIREGOAT_HOME, $FIREGOAT_OUT,
8770
+ {{vars.KEY}}, {{run.*}}); run.json harnessArgs has
8771
+ the result. Flags firegoat sets are errors, also
8772
+ as --flag=value. claude-code: -p, --print,
8101
8773
  --output-format, --verbose, --setting-sources,
8102
8774
  --strict-mcp-config, --disable-slash-commands,
8103
8775
  --dangerously-skip-permissions, --model (use
8104
- model:), --session-id, --resume, -r, --continue, -c
8776
+ model:), --session-id, --resume, -r, --continue,
8777
+ -c. codex-cli: --json, --skip-git-repo-check,
8778
+ --dangerously-bypass-approvals-and-sandbox, -C,
8779
+ --cd, -m, --model (use model:), resume, fork,
8780
+ --ephemeral. grok-cli: -p, --single,
8781
+ --prompt-file, --prompt-json, --output-format,
8782
+ --json-schema, --always-approve,
8783
+ --permission-mode, --no-auto-update, --cwd, -w,
8784
+ --worktree, -m, --model (use model:), -r,
8785
+ --resume, -c, --continue, -s, --session-id,
8786
+ --fork-session
8105
8787
  tests:
8106
8788
  - name: quickstart Letters, digits, '.', '_', '-'; unique
8107
8789
  prompt: ... What a user would ask the agent; one concrete outcome
@@ -8113,7 +8795,7 @@ firegoat.yaml reference:
8113
8795
  env: {NAME: value} Optional, on top of the top-level env:
8114
8796
  install: <sh> | [...] Optional, runs after the top-level install
8115
8797
  cleanup: <sh> | [...] Optional, runs before the top-level cleanup
8116
- claude_args: [...] Optional, after the top-level claude_args
8798
+ harness_args: [...] Optional, after the top-level harness_args
8117
8799
 
8118
8800
  Step order: build once per eval; then per run: install (top-level, then the
8119
8801
  test's), the agent, checks, cleanup (the test's, then top-level).
@@ -8123,13 +8805,18 @@ firegoat.yaml reference:
8123
8805
  from a template project, commit it as e.g. fixtures/starter-app/, then:
8124
8806
  build: cp -R fixtures/starter-app "$FIREGOAT_OUT/starter-app"
8125
8807
  install: cp -R "$FIREGOAT_OUT/starter-app/." .
8808
+ Placeholders in build, install, cleanup and checks: {{vars.KEY}}, {{run.id}},
8809
+ {{run.n}}, {{test.name}} (build: only {{vars.KEY}} and {{run.id}}); others are
8810
+ config errors. Values are pasted into the shell text as-is, unquoted: quote
8811
+ them yourself ('{{vars.KEY}}'), or use the env var below ("$KEY"), which is safe
8812
+ for any value.
8126
8813
  The build, install and cleanup commands and the checks get the vars as env
8127
8814
  vars, plus $FIREGOAT_OUT and $FIREGOAT_WORKDIR; install, checks and cleanup also
8128
8815
  get $FIREGOAT_HOME, the run's own home, as HOME (build keeps your real HOME).
8129
8816
  Checks also get $FIREGOAT_TRANSCRIPT, a short plain-text
8130
8817
  transcript, greppable line by line (format: eval --help, Transcript). So
8131
8818
  '^\\[[0-9]+\\] \\$ .*foo' matches only commands. Checks also get
8132
- $FIREGOAT_SESSION (claude's full session file, JSON lines).
8819
+ $FIREGOAT_SESSION (the harness's full session file, JSON lines).
8133
8820
 
8134
8821
  The agent never gets vars, only env:. The vars npm/npx set for firegoat itself
8135
8822
  (npm_config_*, npm_lifecycle_*, npm_package_*, npm_execpath, npm_node_execpath,
@@ -8140,7 +8827,7 @@ firegoat.yaml reference:
8140
8827
  so the agent's commands see them too.
8141
8828
 
8142
8829
  Testing an MCP server: commit its config as fixtures/mcp.json, copy it in build:
8143
- and pass it with claude_args. Only the configs you pass load: --strict-mcp-config
8830
+ and pass it with harness_args (claude-code). Only the configs you pass load: --strict-mcp-config
8144
8831
  is always on. An MCP server inherits claude's env, which keeps your real HOME
8145
8832
  (claude's login is there), so set the server's HOME to the run's home, or it
8146
8833
  writes to yours. claude gets $FIREGOAT_HOME, $FIREGOAT_WORKDIR and $FIREGOAT_OUT
@@ -8151,7 +8838,7 @@ relative paths in it are from the run's folder, claude's cwd:
8151
8838
  "env": {"HOME": "\${FIREGOAT_HOME}"}}}}
8152
8839
  firegoat.yaml:
8153
8840
  build: cp -R fixtures/. "$FIREGOAT_OUT"
8154
- claude_args: [--mcp-config, $FIREGOAT_OUT/mcp.json]
8841
+ harness_args: [--mcp-config, $FIREGOAT_OUT/mcp.json]
8155
8842
 
8156
8843
  Example:
8157
8844
  build: npm pack --pack-destination "$FIREGOAT_OUT"
@@ -8175,7 +8862,7 @@ Example:
8175
8862
  Get an acme API key from the acme dashboard (I'm logged in in Chrome) and put
8176
8863
  it in .env as ACME_API_KEY.
8177
8864
  end_goal: .env holds a key copied from the dashboard, not a made-up one.
8178
- claude_args: [--chrome]
8865
+ harness_args: [--chrome]
8179
8866
 
8180
8867
  init also says that firegoat sends anonymous usage stats; FIREGOAT_TELEMETRY=0
8181
8868
  (or DO_NOT_TRACK=1) turns them off. Fields: firegoat --help, Telemetry.
@@ -8220,9 +8907,10 @@ Exit codes: 0 logged out (or was not logged in), 3 the file or ~/.firegoat is a
8220
8907
  symlink, not a regular file/directory, or someone else's (left alone).`;
8221
8908
  var EVAL_HELP = `Usage: firegoat eval [test..] [--runs N] [--model NAME].. [--parallel N]
8222
8909
  [--var KEY=value].. [--json] [--no-upload] [--dry-run]
8223
- [--claude-bin PATH]
8910
+ [--harness NAME].. [--harness-bin PATH]
8224
8911
 
8225
- Runs each test in firegoat.yaml (in the current folder): your claude gets the prompt
8912
+ Runs each test in firegoat.yaml (in the current folder): your agent harness
8913
+ (claude-code by default, codex-cli or grok-cli; harness: in firegoat.yaml) gets the prompt
8226
8914
  in a fresh, empty folder, .firegoat/runs/<runId>/<test>/<n>/work, with build:'s
8227
8915
  outputs on PATH. Then the test's checks run in that folder.
8228
8916
 
@@ -8234,10 +8922,32 @@ changes the run's status or the exit code.
8234
8922
 
8235
8923
  claude runs on its own login (or ANTHROPIC_API_KEY if set, as claude always does)
8236
8924
  with a fresh config dir, .firegoat/.claude: none of your settings, CLAUDE.md,
8237
- plugins, skills or MCP servers (claude_args: in firegoat.yaml adds claude flags,
8925
+ plugins, skills or MCP servers (harness_args: in firegoat.yaml adds claude flags,
8238
8926
  e.g. --chrome or --plugin-dir). claude saves its session there. There is no
8239
8927
  sandbox: the agent can reach the rest of your machine, including the repo.
8240
8928
 
8929
+ codex-cli runs codex exec --json --dangerously-bypass-approvals-and-sandbox with a
8930
+ fresh CODEX_HOME, .firegoat/.codex, holding a link to your codex login
8931
+ (~/.codex/auth.json, or $CODEX_HOME/auth.json; run codex login first), and with
8932
+ -c project_doc_max_bytes=0: no AGENTS.md, config.toml, skills or plugins of
8933
+ yours. codex's own HOME is the run's home (below), also for its Read/Write. It
8934
+ saves its session (rollout) under .firegoat/.codex/sessions/. firegoat.ai doesn't
8935
+ read codex sessions yet: those runs are not uploaded. codex reports no turns or
8936
+ cost; its model comes from the session file.
8937
+
8938
+ grok-cli runs ~/.grok/bin/grok (where grok's installer puts it; never a grok on
8939
+ PATH, which may be a wrapper such as cmux's: a #! script is refused) as grok -p
8940
+ --output-format streaming-messages-json --always-approve --no-auto-update, with a
8941
+ fresh GROK_HOME, .firegoat/.grok, holding a link to your grok login
8942
+ ($GROK_HOME/auth.json or ~/.grok/auth.json; run grok login first; XAI_API_KEY alone
8943
+ does not log headless grok in). grok's own HOME is the run's home (below): with
8944
+ your real one it loads Claude Code's skills, plugins, MCP servers and hooks. eval
8945
+ refuses a login that expires within 5 minutes (run grok -p hi once to refresh it):
8946
+ a refresh inside a run would replace the link with a file, which the next eval
8947
+ asks you to move back over ~/.grok/auth.json. The session is a directory,
8948
+ .firegoat/.grok/sessions/<cwd>/<id>/ ($FIREGOAT_SESSION). firegoat.ai doesn't read
8949
+ grok sessions yet: those runs are not uploaded.
8950
+
8241
8951
  Every run gets its own HOME, .firegoat/runs/<runId>/<test>/<n>/home (with
8242
8952
  XDG_CONFIG_HOME, XDG_CACHE_HOME, XDG_DATA_HOME, XDG_STATE_HOME inside it), as
8243
8953
  $FIREGOAT_HOME: tools that keep state in ~ start fresh and parallel runs don't
@@ -8258,35 +8968,47 @@ nested session. Login and provider vars stay: ANTHROPIC_*, CLAUDE_CODE_OAUTH_TOK
8258
8968
  CLAUDE_CODE_OAUTH_REFRESH_TOKEN, CLAUDE_CODE_USE_*, CLAUDE_CODE_SKIP_*_AUTH,
8259
8969
  CLAUDE_CODE_CLIENT_CERT, CLAUDE_CODE_CLIENT_KEY, CLAUDE_CODE_CLIENT_KEY_PASSPHRASE.
8260
8970
 
8261
- In a linked git worktree (git worktree add), .firegoat/runs/ and .firegoat/.claude
8262
- live in the main checkout instead, so runs outlive the worktree; firegoat.yaml,
8971
+ In a linked git worktree (git worktree add), .firegoat/runs/ and the harness
8972
+ config dirs (.firegoat/.claude, .firegoat/.codex, .firegoat/.grok) live in the main checkout instead, so runs outlive the worktree; firegoat.yaml,
8263
8973
  fixtures and build: stay in the current folder.
8264
8974
 
8265
8975
  test.. Only these tests (by name)
8266
8976
  --runs N Runs per test (overrides runs: in firegoat.yaml)
8267
- --model NAME claude --model, replacing model: in firegoat.yaml; repeat it
8268
- (--model a --model b) to run each test runs times per model
8977
+ --harness NAME claude-code, codex-cli or grok-cli, replacing harness: in
8978
+ firegoat.yaml;
8979
+ repeat it to run each test on each harness
8980
+ --model NAME The harness's --model, replacing model: in firegoat.yaml;
8981
+ repeat it (--model a --model b) to run each test runs times
8982
+ per model
8269
8983
  --parallel N Runs at once (default 4; 1 runs them one after another).
8270
8984
  Each run has its own HOME; state outside it (a fixed port,
8271
8985
  /tmp paths) can still collide: use 1 then
8272
- --var KEY=value Fills {{vars.KEY}}; env for build, install, checks and
8273
- cleanup
8986
+ --var KEY=value Fills {{vars.KEY}} (prompts, checks, build, install,
8987
+ cleanup); env for build, install, checks and cleanup
8274
8988
  --json The summary as JSON on stdout (or {error, exitCode} if eval
8275
- stops early); progress goes to stderr
8989
+ stops early); progress goes to stderr. The verdict comes
8990
+ last, after runs: totals {pass, fail, error}, status
8991
+ ("pass", "fail" or "error" = not graded) and exitCode
8276
8992
  --no-upload Keep runs local even when logged in to firegoat.ai
8277
8993
  --dry-run Check firegoat.yaml as eval would (test names, --var,
8278
- --model, missing vars), print each planned run
8279
- ("<test>#<n> [model]" with its install, checks and cleanup
8280
- counts and rendered claude_args, paths under
8994
+ --model, --harness, missing vars), print each planned
8995
+ run ("<test>#<n> [harness model]" with its install, checks
8996
+ and cleanup counts and rendered harness_args, paths under
8281
8997
  runs/<runId>/) and exit 0. Builds nothing, starts no
8282
- claude, uploads nothing, writes no files. Config errors
8998
+ agent, uploads nothing, writes no files. Config errors
8283
8999
  exit 3. With --json: {dryRun, build, runs: [{test, n,
8284
- model, claudeArgs, install, checks, cleanup}]}
8285
- --claude-bin PATH Path to claude (default: claude on PATH)
8286
-
8287
- Upload: after firegoat login (or with FIREGOAT_API_KEY), each run's claude session,
8288
- checks.txt and stderr.txt go to firegoat.ai as a private trace, and its URL is
8289
- printed ("✓ <test>#<n> <url>", or "✗ ... Upload failed: <why>") and saved as
9000
+ harness, model, harnessArgs, install, checks,
9001
+ cleanup}]}, steps and checks with their placeholders
9002
+ filled
9003
+ --harness-bin PATH Path to the harness binary (default: claude or codex on
9004
+ PATH, ~/.grok/bin/grok); only with exactly one harness
9005
+
9006
+ Run labels: "PASS <test>#<n> [<harness> <model>]": the harness shows when the eval
9007
+ runs more than one, or not claude-code; the model when one is set.
9008
+
9009
+ Upload: after firegoat login (or with FIREGOAT_API_KEY), each claude-code run's
9010
+ session, checks.txt and stderr.txt go to firegoat.ai as a private trace, and its
9011
+ URL is printed ("✓ <test>#<n> <url>", or "✗ ... Upload failed: <why>") and saved as
8290
9012
  upload in run.json. Before any run, eval checks the key: a 401/403 exits 3, any
8291
9013
  other failure (unreachable, 429, 5xx, wrong FIREGOAT_BASE_URL) exits 2 (fix it, or
8292
9014
  --no-upload). A failed upload never changes a run's status or the exit code; eval
@@ -8295,19 +9017,21 @@ Runs without a session file are not uploaded. FIREGOAT_BASE_URL points elsewhere
8295
9017
  (default https://firegoat.ai/evals). See firegoat login --help.
8296
9018
 
8297
9019
  Output: .firegoat/runs/<runId>/
8298
- summary.json Every run, plus totals, dir (where eval ran),
8299
- git {branch, commit, dirty} (no git outside a repo)
8300
- and upload {origin, email} (null when not uploading)
9020
+ summary.json firegoatVersion, runId, runsDir, dir (where eval
9021
+ ran), git {branch, commit, dirty} (no git outside a
9022
+ repo), harnesses, upload {origin, email} (null when
9023
+ not uploading), runs (every run.json), then the
9024
+ verdict: totals, status and exitCode
8301
9025
  ../index.jsonl One line per eval: runId, startedAt, dir, branch,
8302
- commit, passed, failed, notGraded, cost (null when
9026
+ commit, harnesses, passed, failed, notGraded, cost (null when
8303
9027
  any run's cost is unknown), uploaded, uploadFailed
8304
- <test>/<n>/run.json One run: status and reason (from the checks), model
8305
- (asked for, or null) and claudeModel (what claude's
8306
- init line reported), claudeArgs (claude_args
8307
- passed), prompt, end_goal,
9028
+ <test>/<n>/run.json One run: status and reason (from the checks), harness,
9029
+ model (asked for, or null) and reportedModel (what
9030
+ the harness reported running, or null), harnessArgs
9031
+ (harness_args passed), prompt, end_goal,
8308
9032
  checks [{run, code}], cleanup [{run, code}],
8309
9033
  and paths: workdir, home (the run's HOME),
8310
- transcript, session (claude's session file), stderr,
9034
+ transcript, session (the harness's session file), stderr,
8311
9035
  checksLog, cleanupLog; startedAt; upload ({status: "uploaded",
8312
9036
  url} | {status: "failed", error} | {status:
8313
9037
  "skipped", reason}, null when not uploading)
@@ -8339,6 +9063,8 @@ Examples:
8339
9063
  npx firegoat eval quickstart --runs 3 One test, three runs
8340
9064
  npx firegoat eval --model claude-haiku-5-5 --model claude-sonnet-5-5
8341
9065
  Each test on both models
9066
+ npx firegoat eval --harness claude-code --harness codex-cli
9067
+ Each test on claude and on codex
8342
9068
  npx firegoat eval --var SHA=$(git rev-parse HEAD)
8343
9069
  npx firegoat eval --dry-run Validate firegoat.yaml, list the runs
8344
9070
 
@@ -8367,30 +9093,6 @@ Example:
8367
9093
 
8368
9094
  Exit codes: 0 sent, 2 the service failed (or rate limit), 3 usage error.`;
8369
9095
 
8370
- // src/home.ts
8371
- import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "node:fs";
8372
- import { join as join4 } from "node:path";
8373
- function homeEnv(home) {
8374
- return {
8375
- HOME: home,
8376
- XDG_CONFIG_HOME: join4(home, ".config"),
8377
- XDG_CACHE_HOME: join4(home, ".cache"),
8378
- XDG_DATA_HOME: join4(home, ".local", "share"),
8379
- XDG_STATE_HOME: join4(home, ".local", "state")
8380
- };
8381
- }
8382
- var shellQuote = (s) => `'${s.replaceAll("'", `'\\''`)}'`;
8383
- function makeRunHome(dir) {
8384
- const home = join4(dir, "home");
8385
- const env = homeEnv(home);
8386
- for (const d of Object.values(env))
8387
- mkdirSync3(d, { recursive: true });
8388
- const envFile = join4(dir, "home.env");
8389
- writeFileSync2(envFile, Object.entries(env).map(([k, v]) => `export ${k}=${shellQuote(v)}
8390
- `).join(""));
8391
- return { home, envFile };
8392
- }
8393
-
8394
9096
  // src/steps.ts
8395
9097
  import { createWriteStream } from "node:fs";
8396
9098
  var STEP_TIMEOUT_MS = 600000;
@@ -8525,8 +9227,8 @@ async function sendTelemetry(event, exitCode, startedAt) {
8525
9227
  }
8526
9228
 
8527
9229
  // src/transcript.ts
8528
- import { readdirSync as readdirSync2, statSync, writeFileSync as writeFileSync3 } from "node:fs";
8529
- import { join as join5, relative } from "node:path";
9230
+ import { readdirSync as readdirSync4, statSync as statSync2, writeFileSync as writeFileSync3 } from "node:fs";
9231
+ import { join as join7, relative } from "node:path";
8530
9232
  var OUTPUT_LINES = 15;
8531
9233
  var OUTPUT_CHARS = 1500;
8532
9234
  var TEXT_CHARS = 1500;
@@ -8552,7 +9254,15 @@ var prose = (text) => prefixLines("agent: ", text);
8552
9254
  function describeCall(step, name, input) {
8553
9255
  if (typeof input.command === "string")
8554
9256
  return prefixLines(`[${step}] $ `, input.command);
8555
- for (const key of ["file_path", "path", "url", "pattern", "query"])
9257
+ for (const key of [
9258
+ "file_path",
9259
+ "path",
9260
+ "target_file",
9261
+ "target_directory",
9262
+ "url",
9263
+ "pattern",
9264
+ "query"
9265
+ ])
8556
9266
  if (typeof input[key] === "string")
8557
9267
  return `[${step}] ${name} ${input[key]}`;
8558
9268
  return `[${step}] ${name} ${truncate(JSON.stringify(input), 1, INPUT_CHARS)}`;
@@ -8567,11 +9277,11 @@ function describeToolCall(step, c) {
8567
9277
  function workdirFiles(dir) {
8568
9278
  const out = [];
8569
9279
  const walk = (d) => {
8570
- for (const name of readdirSync2(d)) {
9280
+ for (const name of readdirSync4(d)) {
8571
9281
  if (SKIP_DIRS.has(name))
8572
9282
  continue;
8573
- const p = join5(d, name);
8574
- if (statSync(p).isDirectory())
9283
+ const p = join7(d, name);
9284
+ if (statSync2(p).isDirectory())
8575
9285
  walk(p);
8576
9286
  else
8577
9287
  out.push(relative(dir, p));
@@ -8645,6 +9355,10 @@ function tier(rows) {
8645
9355
  const share = graded.filter((r) => r.state === "pass").length / graded.length;
8646
9356
  return share === 1 ? "S" : share >= 0.75 ? "A" : share >= 0.5 ? "B" : "C";
8647
9357
  }
9358
+ function runTag(harness, model) {
9359
+ const parts = [harness, model].filter((x) => x !== null);
9360
+ return parts.length > 0 ? ` [${parts.join(" ")}]` : "";
9361
+ }
8648
9362
  function notGradedWhy(reason) {
8649
9363
  const said = /claude result is an error \(subtype=[^)]*\): (.*)$/s.exec(reason);
8650
9364
  if (said)
@@ -8730,8 +9444,7 @@ function plainUI(write) {
8730
9444
  printed.add(r);
8731
9445
  const label = r.state === "error" ? "ERROR" : r.state.toUpperCase();
8732
9446
  const u = usageParts(r).join(", ");
8733
- const model = r.model === null ? "" : ` [${r.model}]`;
8734
- write(`${label} ${r.test}#${r.n}${model} ${r.reason}${u ? ` (${u})` : ""}`);
9447
+ write(`${label} ${r.test}#${r.n}${runTag(r.harness, r.model)} ${r.reason}${u ? ` (${u})` : ""}`);
8735
9448
  }
8736
9449
  for (const r of rows) {
8737
9450
  if (uploadPrinted.has(r) || !r.upload || r.upload === "uploading")
@@ -8806,11 +9519,14 @@ function ttyUI(out, opts) {
8806
9519
  const mark = build.state === "ok" ? p(C.green, "✓") : build.state === "fail" ? p(C.red, "✗") : p(C.ember, SPIN[frame % SPIN.length]);
8807
9520
  out.push(` ${build.state === "running" ? p(C.ember, "◐") : p(C.green, "●")} Build${" ".repeat(Math.max(1, w - 20))}${mark}${build.seconds !== undefined ? ` ${build.seconds}s` : ""}`);
8808
9521
  }
8809
- const modelCols = Math.min(24, Math.max(0, ...rows.map((r) => r.model?.length ?? 0)));
9522
+ const column = (width) => Math.min(24, Math.max(0, ...rows.map(width)));
9523
+ const harnessCols = column((r) => r.harness?.length ?? 0);
9524
+ const modelCols = column((r) => r.model?.length ?? 0);
8810
9525
  rows.forEach((r, i) => {
8811
9526
  out.push(p(C.gray, " │"));
8812
9527
  const num = String(i + 1).padStart(2, "0");
8813
9528
  const name = cut(`${r.test}${r.n > 1 ? `#${r.n}` : ""}`, 16).padEnd(16);
9529
+ const harness = harnessCols > 0 ? ` ${p(C.cyan, cut(r.harness ?? "", harnessCols).padEnd(harnessCols))}` : "";
8814
9530
  const model = modelCols > 0 ? ` ${p(C.cyan, cut(r.model ?? "", modelCols).padEnd(modelCols))}` : "";
8815
9531
  let detail = "";
8816
9532
  if (r.state === "running")
@@ -8819,7 +9535,7 @@ function ttyUI(out, opts) {
8819
9535
  detail = `${checksPassed(r).padEnd(11)} ${p(C.gray, usage(r))}`;
8820
9536
  else if (r.state === "error")
8821
9537
  detail = p(C.gray, usage(r));
8822
- out.push(` ${glyph(r)} ${p(C.gray, num)} ${p(C.bold, name)}${model} ${p(C.ember, ramp(r))} ${statusWord(r)} ${detail}`);
9538
+ out.push(` ${glyph(r)} ${p(C.gray, num)} ${p(C.bold, name)}${harness}${model} ${p(C.ember, ramp(r))} ${statusWord(r)} ${detail}`);
8823
9539
  if (r.state === "running" && r.command)
8824
9540
  out.push(p(C.gray, ` ┊ └ $ ${cut(r.command, w - 16)}`));
8825
9541
  if (r.state === "fail")
@@ -8899,7 +9615,7 @@ function ttyUI(out, opts) {
8899
9615
  }
8900
9616
 
8901
9617
  // src/upload.ts
8902
- import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
9618
+ import { existsSync as existsSync5, readFileSync as readFileSync5 } from "node:fs";
8903
9619
  import { basename as basename2 } from "node:path";
8904
9620
  var MAX_FILE = 20 * 1024 * 1024;
8905
9621
  var MAX_CHECKS = 200;
@@ -8927,7 +9643,7 @@ function projectName(v) {
8927
9643
  function runModel(r) {
8928
9644
  return {
8929
9645
  requested: label(r.model, "Model"),
8930
- reported: label(r.claudeModel, "Claude's reported model")
9646
+ reported: label(r.reportedModel, "Reported model")
8931
9647
  };
8932
9648
  }
8933
9649
  function runGit(g) {
@@ -8981,7 +9697,7 @@ async function uploadRun(t, r) {
8981
9697
  };
8982
9698
  const bytes = {};
8983
9699
  for (const name of FILES) {
8984
- const b = existsSync3(paths[name]) ? readFileSync3(paths[name]) : Buffer.alloc(0);
9700
+ const b = existsSync5(paths[name]) ? readFileSync5(paths[name]) : Buffer.alloc(0);
8985
9701
  if (b.byteLength > MAX_FILE)
8986
9702
  return {
8987
9703
  status: "failed",
@@ -9063,13 +9779,13 @@ async function uploadRun(t, r) {
9063
9779
  }
9064
9780
 
9065
9781
  // src/which.ts
9066
- import { accessSync, constants, statSync as statSync2 } from "node:fs";
9067
- import { delimiter, join as join6, resolve } from "node:path";
9782
+ import { accessSync, constants, statSync as statSync3 } from "node:fs";
9783
+ import { delimiter, join as join8, resolve } from "node:path";
9068
9784
  function resolveBin(bin) {
9069
9785
  const executable = (p) => {
9070
9786
  try {
9071
9787
  accessSync(p, constants.X_OK);
9072
- return statSync2(p).isFile();
9788
+ return statSync3(p).isFile();
9073
9789
  } catch {
9074
9790
  return false;
9075
9791
  }
@@ -9077,8 +9793,8 @@ function resolveBin(bin) {
9077
9793
  if (bin.includes("/"))
9078
9794
  return executable(bin) ? resolve(bin) : undefined;
9079
9795
  for (const dir of (process.env.PATH ?? "").split(delimiter)) {
9080
- if (dir && executable(join6(dir, bin)))
9081
- return join6(dir, bin);
9796
+ if (dir && executable(join8(dir, bin)))
9797
+ return join8(dir, bin);
9082
9798
  }
9083
9799
  return;
9084
9800
  }
@@ -9092,12 +9808,12 @@ function parseArgs(args) {
9092
9808
  const opts = {
9093
9809
  names: [],
9094
9810
  models: [],
9811
+ harnesses: [],
9095
9812
  parallel: 4,
9096
9813
  json: false,
9097
9814
  noUpload: false,
9098
9815
  dryRun: false,
9099
- vars: {},
9100
- claudeBin: "claude"
9816
+ vars: {}
9101
9817
  };
9102
9818
  for (let i = 0;i < args.length; i++) {
9103
9819
  const a = args[i];
@@ -9138,14 +9854,21 @@ function parseArgs(args) {
9138
9854
  if (opts.models.includes(v))
9139
9855
  throw new UsageError(`--model ${v} given twice`);
9140
9856
  opts.models.push(v);
9857
+ } else if (flag === "--harness") {
9858
+ const v = value();
9859
+ if (!HARNESSES.has(v))
9860
+ throw new UsageError(`Unknown harness ${JSON.stringify(v)} ${validHarnesses()}`);
9861
+ if (opts.harnesses.includes(v))
9862
+ throw new UsageError(`--harness ${v} given twice`);
9863
+ opts.harnesses.push(v);
9141
9864
  } else if (flag === "--parallel") {
9142
9865
  const v = value();
9143
9866
  const n = Number(v);
9144
9867
  if (!/^\d+$/.test(v) || n < 1)
9145
9868
  throw new UsageError(`--parallel must be a positive integer, got ${v}`);
9146
9869
  opts.parallel = n;
9147
- } else if (flag === "--claude-bin")
9148
- opts.claudeBin = value();
9870
+ } else if (flag === "--harness-bin")
9871
+ opts.harnessBin = value();
9149
9872
  else if (a.startsWith("-"))
9150
9873
  throw new UsageError(`Unknown flag: ${a}`);
9151
9874
  else
@@ -9158,7 +9881,7 @@ function newRunId() {
9158
9881
  return `${ts}-${randomBytes2(2).toString("hex")}`;
9159
9882
  }
9160
9883
  function writeRunJson(r) {
9161
- writeFileSync4(join7(r.dir, "run.json"), `${JSON.stringify(r, null, 2)}
9884
+ writeFileSync4(join9(r.dir, "run.json"), `${JSON.stringify(r, null, 2)}
9162
9885
  `);
9163
9886
  }
9164
9887
  async function uploadPreflight(opts, ui, project, runGroup, git) {
@@ -9205,59 +9928,76 @@ function runFill(runId, n, testName, out, dir) {
9205
9928
  "run.n": String(n),
9206
9929
  "test.name": testName,
9207
9930
  FIREGOAT_OUT: out,
9208
- FIREGOAT_WORKDIR: join7(dir, "work"),
9209
- FIREGOAT_HOME: join7(dir, "home")
9931
+ FIREGOAT_WORKDIR: join9(dir, "work"),
9932
+ FIREGOAT_HOME: join9(dir, "home")
9933
+ };
9934
+ }
9935
+ function renderHarnessArgs(test, vars, fill) {
9936
+ return test.harnessArgs.map((a) => renderValue(a.arg, vars, fill, a.path));
9937
+ }
9938
+ function renderSteps(what, commands, vars, fill) {
9939
+ return commands.map((c, i) => renderStep(c, vars, fill, `${what}[${i}]`));
9940
+ }
9941
+ function renderRunSteps(test, top, vars, fill) {
9942
+ return {
9943
+ install: renderSteps("install", [...top.install, ...test.install], vars, fill),
9944
+ checks: renderSteps("checks", test.checks.map((c) => c.run), vars, fill),
9945
+ cleanup: renderSteps("cleanup", [...test.cleanup, ...top.cleanup], vars, fill)
9210
9946
  };
9211
9947
  }
9212
- function renderClaudeArgs(test, vars, fill) {
9213
- return test.claudeArgs.map((a, i) => renderValue(a, vars, fill, `claude_args[${i}]`));
9948
+ function renderBuild(config, vars, runId) {
9949
+ return renderSteps("build", config.build, vars, { "run.id": runId });
9214
9950
  }
9215
- function planRuns(tests, models, runs) {
9216
- const matrix = models.length > 0 ? models : [null];
9217
- return tests.flatMap((t) => matrix.flatMap((model, m) => Array.from({ length: runs }, (_, i) => ({
9951
+ function planRuns(tests, harnesses, models, runs) {
9952
+ const modelList = models.length > 0 ? models : [null];
9953
+ const matrix = harnesses.flatMap((harness) => modelList.map((model) => ({ harness, model })));
9954
+ return tests.flatMap((t) => matrix.flatMap((cell, c) => Array.from({ length: runs }, (_, i) => ({
9218
9955
  test: t.name,
9219
- n: m * runs + i + 1,
9220
- model
9956
+ n: c * runs + i + 1,
9957
+ ...cell
9221
9958
  }))));
9222
9959
  }
9960
+ function showsHarness(harnesses) {
9961
+ return harnesses.length > 1 || harnesses[0] !== DEFAULT_HARNESS;
9962
+ }
9223
9963
  var DRY_RUN_ID = "<runId>";
9224
9964
  function dryRun(opts) {
9225
- const out = join7(opts.runsRoot, DRY_RUN_ID, "out");
9965
+ const out = join9(opts.runsRoot, DRY_RUN_ID, "out");
9226
9966
  const runs = opts.plan.map((p) => {
9227
9967
  const test = opts.tests.find((t) => t.name === p.test);
9228
- const fill = runFill(DRY_RUN_ID, p.n, test.name, out, join7(opts.runsRoot, DRY_RUN_ID, test.name, String(p.n)));
9968
+ const fill = runFill(DRY_RUN_ID, p.n, test.name, out, join9(opts.runsRoot, DRY_RUN_ID, test.name, String(p.n)));
9229
9969
  renderEnv(test.env, opts.vars, fill);
9230
9970
  return {
9231
9971
  ...p,
9232
- claudeArgs: renderClaudeArgs(test, opts.vars, fill),
9233
- install: [...opts.config.install, ...test.install],
9234
- checks: test.checks.map((c) => render(c.run, opts.vars)),
9235
- cleanup: [...test.cleanup, ...opts.config.cleanup]
9972
+ harnessArgs: renderHarnessArgs(test, opts.vars, fill),
9973
+ ...renderRunSteps(test, opts.config, opts.vars, fill)
9236
9974
  };
9237
9975
  });
9976
+ const build = renderBuild(opts.config, opts.vars, DRY_RUN_ID);
9238
9977
  if (opts.json) {
9239
- console.log(JSON.stringify({ dryRun: true, build: opts.config.build, runs }, null, 2));
9978
+ console.log(JSON.stringify({ dryRun: true, build, runs }, null, 2));
9240
9979
  return;
9241
9980
  }
9242
9981
  const count = (n, what) => `${n} ${what}${n === 1 ? "" : "s"}`;
9243
9982
  opts.say(`Dry run: ${count(runs.length, "run")}, build ${count(opts.config.build.length, "step")}. Nothing was started.`);
9244
9983
  for (const r of runs)
9245
- opts.say(`${r.test}#${r.n}${r.model === null ? "" : ` [${r.model}]`} install ${r.install.length}, checks ${r.checks.length}, cleanup ${r.cleanup.length}${r.claudeArgs.length > 0 ? `, claude_args ${JSON.stringify(r.claudeArgs)}` : ""}`);
9984
+ 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)}` : ""}`);
9246
9985
  }
9247
9986
  async function runOne(opts) {
9248
9987
  const { test, n } = opts;
9249
9988
  const started = Date.now();
9250
- const dir = join7(opts.runDir, test.name, String(n));
9251
- const workdir = join7(dir, "work");
9989
+ const dir = join9(opts.runDir, test.name, String(n));
9990
+ const workdir = join9(dir, "work");
9252
9991
  mkdirSync4(workdir, { recursive: true });
9253
9992
  const { home, envFile } = makeRunHome(dir);
9254
9993
  const fill = runFill(opts.runId, n, test.name, opts.out, dir);
9255
9994
  const r = {
9256
9995
  test: test.name,
9257
9996
  n,
9997
+ harness: opts.harness.name,
9258
9998
  model: opts.model,
9259
- claudeModel: null,
9260
- claudeArgs: renderClaudeArgs(test, opts.vars, fill),
9999
+ reportedModel: null,
10000
+ harnessArgs: renderHarnessArgs(test, opts.vars, fill),
9261
10001
  status: "error",
9262
10002
  reason: "",
9263
10003
  prompt: render(test.prompt, opts.vars),
@@ -9267,11 +10007,11 @@ async function runOne(opts) {
9267
10007
  workdir,
9268
10008
  home,
9269
10009
  session: null,
9270
- transcript: join7(dir, "transcript.txt"),
9271
- stderr: join7(dir, "stderr.txt"),
9272
- checksLog: join7(dir, "checks.txt"),
10010
+ transcript: join9(dir, "transcript.txt"),
10011
+ stderr: join9(dir, "stderr.txt"),
10012
+ checksLog: join9(dir, "checks.txt"),
9273
10013
  cleanup: [],
9274
- cleanupLog: join7(dir, "cleanup.txt"),
10014
+ cleanupLog: join9(dir, "cleanup.txt"),
9275
10015
  num_turns: null,
9276
10016
  cost: null,
9277
10017
  duration_ms: 0,
@@ -9286,11 +10026,11 @@ async function runOne(opts) {
9286
10026
  FIREGOAT_WORKDIR: workdir,
9287
10027
  FIREGOAT_HOME: home
9288
10028
  };
9289
- const [status, reason] = await attempt(opts, r, env, stepEnv, envFile);
9290
- const cleanup = [...test.cleanup, ...opts.cleanup];
9291
- if (cleanup.length > 0)
10029
+ const steps = renderRunSteps(test, opts, opts.vars, fill);
10030
+ const [status, reason] = await attempt(opts, r, steps, env, stepEnv, envFile);
10031
+ if (steps.cleanup.length > 0)
9292
10032
  r.cleanup = await runAllSteps({
9293
- commands: cleanup,
10033
+ commands: steps.cleanup,
9294
10034
  cwd: workdir,
9295
10035
  env: stepEnv,
9296
10036
  logPath: r.cleanupLog
@@ -9301,17 +10041,16 @@ async function runOne(opts) {
9301
10041
  writeRunJson(r);
9302
10042
  return r;
9303
10043
  }
9304
- async function attempt(opts, r, env, stepEnv, homeEnvFile) {
10044
+ async function attempt(opts, r, steps, env, stepEnv, homeEnvFile) {
9305
10045
  const { test, n } = opts;
9306
10046
  const { dir, workdir } = r;
9307
- const install = [...opts.install, ...test.install];
9308
- if (install.length > 0) {
10047
+ if (steps.install.length > 0) {
9309
10048
  const error = await runSteps({
9310
10049
  what: "install",
9311
- commands: install,
10050
+ commands: steps.install,
9312
10051
  cwd: workdir,
9313
10052
  env: stepEnv,
9314
- logPath: join7(dir, "install.txt")
10053
+ logPath: join9(dir, "install.txt")
9315
10054
  });
9316
10055
  if (error)
9317
10056
  return ["error", `not graded: ${error}`];
@@ -9325,7 +10064,10 @@ async function attempt(opts, r, env, stepEnv, homeEnvFile) {
9325
10064
  env: {
9326
10065
  ...hostEnv(),
9327
10066
  ...env,
9328
- ...harness.env({ configDir: opts.configDir, homeEnvFile }),
10067
+ ...harness.env({
10068
+ configDir: opts.configDir,
10069
+ run: { home: r.home, homeEnvFile }
10070
+ }),
9329
10071
  PATH: opts.stepEnv.PATH,
9330
10072
  FIREGOAT_OUT: opts.out,
9331
10073
  FIREGOAT_WORKDIR: workdir,
@@ -9334,18 +10076,22 @@ async function attempt(opts, r, env, stepEnv, homeEnvFile) {
9334
10076
  stderr,
9335
10077
  timeoutMs: test.timeout * 1000,
9336
10078
  model: opts.model ?? undefined,
9337
- extraArgs: r.claudeArgs,
10079
+ extraArgs: r.harnessArgs,
9338
10080
  onProgress: opts.onProgress
9339
10081
  });
9340
- r.claudeModel = outcome.reportedModel;
9341
10082
  if (outcome.version !== null)
9342
10083
  evalStats({ claudeVersion: claudeVersion(outcome.version) });
9343
10084
  await new Promise((res) => stderr.end(res));
9344
10085
  if (outcome.sessionId)
9345
10086
  r.session = harness.findSession(opts.configDir, outcome.sessionId);
10087
+ r.reportedModel = outcome.reportedModel ?? (r.session && harness.sessionModel ? harness.sessionModel(r.session) : null);
10088
+ const about = [
10089
+ opts.shownHarness,
10090
+ opts.model === null ? null : `model ${opts.model}`
10091
+ ].filter((x) => x !== null);
9346
10092
  writeTranscript({
9347
10093
  path: r.transcript,
9348
- title: `${test.name}#${n}${opts.model ? ` (model ${opts.model})` : ""}`,
10094
+ title: `${test.name}#${n}${about.length > 0 ? ` (${about.join(", ")})` : ""}`,
9349
10095
  prompt: r.prompt,
9350
10096
  steps: outcome.steps,
9351
10097
  final: outcome.final,
@@ -9362,9 +10108,9 @@ async function attempt(opts, r, env, stepEnv, homeEnvFile) {
9362
10108
  ];
9363
10109
  const log = createWriteStream2(r.checksLog);
9364
10110
  try {
9365
- for (const c of test.checks)
10111
+ for (const run of steps.checks)
9366
10112
  r.checks.push(await runShell({
9367
- run: render(c.run, opts.vars),
10113
+ run,
9368
10114
  cwd: workdir,
9369
10115
  env: {
9370
10116
  ...stepEnv,
@@ -9388,6 +10134,9 @@ async function attempt(opts, r, env, stepEnv, homeEnvFile) {
9388
10134
  test.checks.length === 0 ? "no script checks" : `${r.checks.length}/${r.checks.length} checks passed`
9389
10135
  ];
9390
10136
  }
10137
+ function notUploaded(h) {
10138
+ return `firegoat.ai doesn't read ${h.name} sessions yet: its runs stay local`;
10139
+ }
9391
10140
  var MAX_LISTED_URLS = 5;
9392
10141
  function uploadedSummary(origin, results, runDir) {
9393
10142
  const urls = results.flatMap((r) => r.upload?.status === "uploaded" ? [r.upload.url] : []);
@@ -9395,7 +10144,7 @@ function uploadedSummary(origin, results, runDir) {
9395
10144
  return;
9396
10145
  const head = `Uploaded ${urls.length} session${urls.length === 1 ? "" : "s"} to ${new URL(origin).host} (private)`;
9397
10146
  if (urls.length > MAX_LISTED_URLS) {
9398
- console.log(`${head}: each run's upload.url in ${join7(runDir, "summary.json")}`);
10147
+ console.log(`${head}: each run's upload.url in ${join9(runDir, "summary.json")}`);
9399
10148
  return;
9400
10149
  }
9401
10150
  console.log(`${head}:`);
@@ -9450,9 +10199,22 @@ async function evalCommand(args, root) {
9450
10199
  }
9451
10200
  const runs = opts.runs ?? config.runs;
9452
10201
  const models = opts.models.length > 0 ? opts.models : config.models;
10202
+ const harnessNames = opts.harnesses.length > 0 ? opts.harnesses : config.harnesses;
10203
+ if (opts.harnessBin !== undefined && harnessNames.length !== 1)
10204
+ return fatal(EXIT.usage, `--harness-bin needs exactly one harness, this eval runs ${harnessNames.length} (${harnessNames.join(", ")}): pick one with --harness`, "usage");
10205
+ const harnesses = harnessNames.map((h) => HARNESSES.get(h));
10206
+ const showHarness = showsHarness(harnessNames);
10207
+ try {
10208
+ for (const h of harnesses)
10209
+ checkHarnessArgs(tests, h);
10210
+ } catch (e) {
10211
+ if (e instanceof ConfigError)
10212
+ return fatal(EXIT.usage, e.message, "config");
10213
+ throw e;
10214
+ }
9453
10215
  evalStats({
9454
10216
  testCount: tests.length,
9455
- runCount: tests.length * runs * Math.max(models.length, 1),
10217
+ runCount: tests.length * runs * Math.max(models.length, 1) * harnesses.length,
9456
10218
  modelCount: models.length,
9457
10219
  parallel: opts.parallel
9458
10220
  });
@@ -9462,8 +10224,10 @@ async function evalCommand(args, root) {
9462
10224
  t.prompt,
9463
10225
  ...t.checks.map((c) => c.run),
9464
10226
  ...Object.values(t.env),
9465
- ...t.claudeArgs
9466
- ]).flatMap(varRefs).filter((k) => !(k in vars)))
10227
+ ...t.harnessArgs.map((a) => a.arg),
10228
+ ...t.install,
10229
+ ...t.cleanup
10230
+ ]).concat(config.build, config.install, config.cleanup).flatMap(varRefs).filter((k) => !(k in vars)))
9467
10231
  ];
9468
10232
  if (missing.length > 0)
9469
10233
  return fatal(EXIT.usage, `No value for ${missing.map((k) => `{{vars.${k}}}`).join(", ")}: pass --var ${missing[0]}=... or set it under vars: in firegoat.yaml`, "config");
@@ -9478,9 +10242,10 @@ async function evalCommand(args, root) {
9478
10242
  dryRun({
9479
10243
  config,
9480
10244
  tests,
9481
- plan: planRuns(tests, models, runs),
10245
+ plan: planRuns(tests, harnessNames, models, runs),
10246
+ showHarness,
9482
10247
  vars,
9483
- runsRoot: join7(repo?.mainCheckout ?? root, ".firegoat", "runs"),
10248
+ runsRoot: join9(repo?.mainCheckout ?? root, ".firegoat", "runs"),
9484
10249
  json: opts.json,
9485
10250
  say
9486
10251
  });
@@ -9491,11 +10256,15 @@ async function evalCommand(args, root) {
9491
10256
  }
9492
10257
  return EXIT.pass;
9493
10258
  }
9494
- const harness = claude;
9495
- evalStats({ harness: harness.name });
9496
- const binPath = resolveBin(opts.claudeBin);
9497
- if (!binPath)
9498
- return fatal(EXIT.notGraded, `Not graded: ${harness.name} binary ${JSON.stringify(opts.claudeBin)} not found. ${harness.installHint}`, "claude_missing");
10259
+ evalStats({ harness: harnessNames.join(",") });
10260
+ const bins = new Map;
10261
+ for (const h of harnesses) {
10262
+ const bin = opts.harnessBin ?? h.bin;
10263
+ const path = resolveBin(bin);
10264
+ if (!path)
10265
+ return fatal(EXIT.notGraded, `Not graded: ${h.name} binary ${JSON.stringify(bin)} not found. ${h.installHint}`, "harness_missing");
10266
+ bins.set(h, path);
10267
+ }
9499
10268
  let repo;
9500
10269
  try {
9501
10270
  repo = gitRepo(root);
@@ -9503,16 +10272,24 @@ async function evalCommand(args, root) {
9503
10272
  return fatal(EXIT.usage, e.message, "config");
9504
10273
  }
9505
10274
  const firegoatDir = ensureFiregoatDir(repo?.mainCheckout ?? root);
9506
- const configDir = join7(firegoatDir, harness.configDirName);
9507
- mkdirSync4(configDir, { recursive: true });
9508
- ui.auth(harness.authLabel());
9509
- const loginProblem = harness.checkLogin({
9510
- bin: binPath,
9511
- env: { ...hostEnv(), ...harness.env({ configDir }) },
9512
- cwd: root
9513
- });
9514
- if (loginProblem)
9515
- return fatal(EXIT.notGraded, `Not graded: ${loginProblem.reason}`, loginProblem.loggedOut ? "claude_logged_out" : "claude_auth_status");
10275
+ const configDirs = new Map;
10276
+ for (const h of harnesses) {
10277
+ const configDir = join9(firegoatDir, h.configDirName);
10278
+ mkdirSync4(configDir, { recursive: true });
10279
+ configDirs.set(h, configDir);
10280
+ }
10281
+ ui.auth(harnesses.length === 1 ? harnesses[0].authLabel() : harnesses.map((h) => `${h.name}: ${h.authLabel()}`).join("; "));
10282
+ for (const h of harnesses) {
10283
+ const configDir = configDirs.get(h);
10284
+ const loginProblem = h.checkLogin({
10285
+ bin: bins.get(h),
10286
+ env: { ...hostEnv(), ...h.env({ configDir }) },
10287
+ cwd: root,
10288
+ configDir
10289
+ });
10290
+ if (loginProblem)
10291
+ return fatal(EXIT.notGraded, `Not graded: ${loginProblem.reason}`, loginProblem.loggedOut ? "harness_logged_out" : "harness_auth_status");
10292
+ }
9516
10293
  const evalStarted = Date.now();
9517
10294
  const runId = newRunId();
9518
10295
  let upload;
@@ -9521,14 +10298,19 @@ async function evalCommand(args, root) {
9521
10298
  if (pre && "exitCode" in pre)
9522
10299
  return fatal(pre.exitCode, pre.error, pre.code);
9523
10300
  upload = pre;
10301
+ if (upload) {
10302
+ for (const h of harnesses)
10303
+ if (!h.uploads)
10304
+ ui.note(notUploaded(h));
10305
+ }
9524
10306
  } catch (e) {
9525
10307
  if (e instanceof ConfigError)
9526
10308
  return fatal(EXIT.usage, e.message, "config");
9527
10309
  throw e;
9528
10310
  }
9529
- const runsRoot = join7(firegoatDir, "runs");
9530
- const runDir = join7(runsRoot, runId);
9531
- const out = join7(runDir, "out");
10311
+ const runsRoot = join9(firegoatDir, "runs");
10312
+ const runDir = join9(runsRoot, runId);
10313
+ const out = join9(runDir, "out");
9532
10314
  mkdirSync4(out, { recursive: true });
9533
10315
  const stepEnv = {
9534
10316
  ...hostEnv(),
@@ -9536,8 +10318,10 @@ async function evalCommand(args, root) {
9536
10318
  FIREGOAT_OUT: out,
9537
10319
  PATH: [out, process.env.PATH ?? ""].join(delimiter2)
9538
10320
  };
9539
- const rows = planRuns(tests, models, runs).map((p) => ({
10321
+ const plan = planRuns(tests, harnessNames, models, runs);
10322
+ const rows = plan.map((p) => ({
9540
10323
  ...p,
10324
+ harness: showHarness ? p.harness : null,
9541
10325
  state: "queued",
9542
10326
  turns: 0
9543
10327
  }));
@@ -9546,13 +10330,13 @@ async function evalCommand(args, root) {
9546
10330
  ui.build("running");
9547
10331
  const error = await runSteps({
9548
10332
  what: "build",
9549
- commands: config.build,
10333
+ commands: renderBuild(config, vars, runId),
9550
10334
  cwd: root,
9551
10335
  env: {
9552
10336
  ...stepEnv,
9553
10337
  ...renderEnv(Object.fromEntries(Object.entries(config.env).filter(([, v]) => isEvalLevel(v))), vars, { "run.id": runId, FIREGOAT_OUT: out })
9554
10338
  },
9555
- logPath: join7(runDir, "build.txt")
10339
+ logPath: join9(runDir, "build.txt")
9556
10340
  });
9557
10341
  const secs = Math.round((Date.now() - buildStarted) / 1000);
9558
10342
  ui.build(error ? "fail" : "ok", secs);
@@ -9564,6 +10348,7 @@ async function evalCommand(args, root) {
9564
10348
  const runAt = async (i) => {
9565
10349
  const row = rows[i];
9566
10350
  const test = tests.find((t) => t.name === row.test);
10351
+ const harness = HARNESSES.get(plan[i].harness);
9567
10352
  row.state = "running";
9568
10353
  ui.update();
9569
10354
  const r = await runOne({
@@ -9575,11 +10360,12 @@ async function evalCommand(args, root) {
9575
10360
  test,
9576
10361
  n: row.n,
9577
10362
  model: row.model,
10363
+ shownHarness: row.harness,
9578
10364
  runId,
9579
10365
  runDir,
9580
10366
  harness,
9581
- bin: binPath,
9582
- configDir,
10367
+ bin: bins.get(harness),
10368
+ configDir: configDirs.get(harness),
9583
10369
  install: config.install,
9584
10370
  cleanup: config.cleanup,
9585
10371
  out,
@@ -9597,7 +10383,10 @@ async function evalCommand(args, root) {
9597
10383
  command: undefined
9598
10384
  });
9599
10385
  ui.update();
9600
- if (upload) {
10386
+ if (upload && !harness.uploads) {
10387
+ r.upload = { status: "skipped", reason: notUploaded(harness) };
10388
+ writeRunJson(r);
10389
+ } else if (upload) {
9601
10390
  row.upload = "uploading";
9602
10391
  ui.update();
9603
10392
  r.upload = await uploadRun(upload, r);
@@ -9624,24 +10413,34 @@ async function evalCommand(args, root) {
9624
10413
  uploaded,
9625
10414
  uploadFailed: uploadsFailed
9626
10415
  });
10416
+ const status = totals.error > 0 ? "error" : totals.fail > 0 ? "fail" : "pass";
10417
+ const exitCode = {
10418
+ error: EXIT.notGraded,
10419
+ fail: EXIT.fail,
10420
+ pass: EXIT.pass
10421
+ }[status];
9627
10422
  const doc = {
9628
10423
  firegoatVersion: package_default.version,
9629
10424
  runId,
9630
10425
  runsDir: runDir,
9631
10426
  dir: root,
9632
10427
  ...repo && { git: repo.info },
10428
+ harnesses: harnessNames,
9633
10429
  upload: upload && { origin: upload.origin, email: upload.email },
10430
+ runs: results,
9634
10431
  totals,
9635
- runs: results
10432
+ status,
10433
+ exitCode
9636
10434
  };
9637
- writeFileSync4(join7(runDir, "summary.json"), `${JSON.stringify(doc, null, 2)}
10435
+ writeFileSync4(join9(runDir, "summary.json"), `${JSON.stringify(doc, null, 2)}
9638
10436
  `);
9639
- appendFileSync(join7(runsRoot, "index.jsonl"), `${JSON.stringify({
10437
+ appendFileSync(join9(runsRoot, "index.jsonl"), `${JSON.stringify({
9640
10438
  runId,
9641
10439
  startedAt: new Date(evalStarted).toISOString(),
9642
10440
  dir: root,
9643
10441
  branch: repo?.info.branch ?? null,
9644
10442
  commit: repo?.info.commit ?? null,
10443
+ harnesses: harnessNames,
9645
10444
  passed: totals.pass,
9646
10445
  failed: totals.fail,
9647
10446
  notGraded: totals.error,
@@ -9666,15 +10465,15 @@ async function evalCommand(args, root) {
9666
10465
  console.error(`firegoat eval: ${uploadsFailed} upload${uploadsFailed === 1 ? "" : "s"} failed (each run's upload.error in summary.json)`);
9667
10466
  if (opts.json)
9668
10467
  console.log(JSON.stringify(doc, null, 2));
9669
- if (totals.error > 0)
10468
+ if (status === "error")
9670
10469
  failWith("not_graded");
9671
- else if (totals.fail > 0)
10470
+ else if (status === "fail")
9672
10471
  failWith("checks_failed");
9673
- return totals.error > 0 ? EXIT.notGraded : totals.fail > 0 ? EXIT.fail : EXIT.pass;
10472
+ return exitCode;
9674
10473
  }
9675
10474
 
9676
10475
  // src/feedback.ts
9677
- import { readFileSync as readFileSync4 } from "node:fs";
10476
+ import { readFileSync as readFileSync6 } from "node:fs";
9678
10477
 
9679
10478
  // node_modules/@firegoat/feedback/dist/index.js
9680
10479
  function memoryDeviceStore() {
@@ -9893,7 +10692,7 @@ async function feedbackCommand(args) {
9893
10692
  try {
9894
10693
  if (a === "--body") {
9895
10694
  const v = value();
9896
- body = v === "-" ? readFileSync4(0, "utf8") : v;
10695
+ body = v === "-" ? readFileSync6(0, "utf8") : v;
9897
10696
  } else if (a === "--type") {
9898
10697
  const v = value();
9899
10698
  if (!TYPES.includes(v))
@@ -9946,10 +10745,9 @@ firegoat ${package_default.version}` : `firegoat ${package_default.version}`,
9946
10745
  }
9947
10746
 
9948
10747
  // src/init.ts
9949
- import { spawnSync as spawnSync3 } from "node:child_process";
9950
- import { existsSync as existsSync4, writeFileSync as writeFileSync5 } from "node:fs";
9951
- import { basename as basename4, join as join8 } from "node:path";
9952
- import { createInterface } from "node:readline/promises";
10748
+ import { spawn as spawn3, spawnSync as spawnSync4 } from "node:child_process";
10749
+ import { existsSync as existsSync6, writeFileSync as writeFileSync5 } from "node:fs";
10750
+ import { basename as basename4, join as join10 } from "node:path";
9953
10751
 
9954
10752
  // src/login.ts
9955
10753
  import { spawn as spawn2 } from "node:child_process";
@@ -9960,7 +10758,7 @@ var FORM = { "Content-Type": "application/x-www-form-urlencoded" };
9960
10758
  var UPLOADS_ON = "Eval uploads each session to firegoat.ai as a private trace; --no-upload keeps runs local.";
9961
10759
  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).`;
9962
10760
  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).";
9963
- function str2(o, k, step) {
10761
+ function str3(o, k, step) {
9964
10762
  const v = o?.[k];
9965
10763
  if (typeof v !== "string" || v === "")
9966
10764
  throw new ApiError(`${step}: response has no ${k}`);
@@ -9979,7 +10777,7 @@ function openBrowser(url) {
9979
10777
  } catch {}
9980
10778
  }
9981
10779
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
9982
- async function loginFlow(say, interactive) {
10780
+ async function loginFlow(show, interactive) {
9983
10781
  const base = baseUrl();
9984
10782
  const origin = new URL(base).origin;
9985
10783
  const cfgStep = "GET /api/traces/oauth/config";
@@ -9991,9 +10789,9 @@ async function loginFlow(say, interactive) {
9991
10789
  });
9992
10790
  if (cfg.status !== 200)
9993
10791
  throw replyError(cfgStep, cfg);
9994
- const clientId = str2(cfg.json, "client_id", cfgStep);
9995
- const deviceUrl = assertSafeUrl(str2(cfg.json, "device_authorization_endpoint", cfgStep), "device_authorization_endpoint");
9996
- const tokenUrl = assertSafeUrl(str2(cfg.json, "token_endpoint", cfgStep), "token_endpoint");
10792
+ const clientId = str3(cfg.json, "client_id", cfgStep);
10793
+ const deviceUrl = assertSafeUrl(str3(cfg.json, "device_authorization_endpoint", cfgStep), "device_authorization_endpoint");
10794
+ const tokenUrl = assertSafeUrl(str3(cfg.json, "token_endpoint", cfgStep), "token_endpoint");
9997
10795
  if (tokenUrl.origin !== origin)
9998
10796
  throw new ApiError(`token_endpoint must be on ${origin}, got ${printable(tokenUrl.origin, 100)}`);
9999
10797
  const devStep = "Device authorization";
@@ -10007,11 +10805,11 @@ async function loginFlow(say, interactive) {
10007
10805
  });
10008
10806
  if (dev.status !== 200)
10009
10807
  throw replyError(devStep, dev);
10010
- const deviceCode = str2(dev.json, "device_code", devStep);
10011
- const userCode = str2(dev.json, "user_code", devStep);
10808
+ const deviceCode = str3(dev.json, "device_code", devStep);
10809
+ const userCode = str3(dev.json, "user_code", devStep);
10012
10810
  if (!/^[A-Za-z0-9-]{1,64}$/.test(userCode))
10013
10811
  throw new ApiError(`${devStep}: user_code has unexpected characters`);
10014
- const pageRaw = typeof dev.json?.verification_uri_complete === "string" ? dev.json.verification_uri_complete : str2(dev.json, "verification_uri", devStep);
10812
+ const pageRaw = typeof dev.json?.verification_uri_complete === "string" ? dev.json.verification_uri_complete : str3(dev.json, "verification_uri", devStep);
10015
10813
  const page = assertSafeUrl(pageRaw, "verification_uri");
10016
10814
  if (page.protocol !== "https:" || pageRaw.startsWith("-"))
10017
10815
  throw new ApiError("verification_uri must be https");
@@ -10024,8 +10822,11 @@ async function loginFlow(say, interactive) {
10024
10822
  let interval = Math.min(Math.max(rawInterval ?? 5, 5), MAX_INTERVAL_S);
10025
10823
  const expiresS = Math.min(expiresIn, MAX_EXPIRES_S);
10026
10824
  const deadline = Date.now() + expiresS * 1000;
10027
- say(TERMS);
10028
- say(`Open ${printable(page.href, 500)} and enter code ${userCode} (expires in ${Math.max(1, Math.round(expiresS / 60))}m).`);
10825
+ show({
10826
+ page: printable(page.href, 500),
10827
+ code: userCode,
10828
+ minutes: Math.max(1, Math.round(expiresS / 60))
10829
+ });
10029
10830
  if (interactive)
10030
10831
  openBrowser(page.href);
10031
10832
  const tokStep = "Token request";
@@ -10093,7 +10894,10 @@ ${LOGIN_HELP}`);
10093
10894
  console.error("Note: FIREGOAT_API_KEY is set, so eval uses it instead of this login.");
10094
10895
  try {
10095
10896
  console.log(UPLOAD_NOTICE);
10096
- const email = await loginFlow((l) => console.log(l), Boolean(process.stdout.isTTY));
10897
+ const email = await loginFlow(({ page, code, minutes }) => {
10898
+ console.log(TERMS);
10899
+ console.log(`Open ${page} and enter code ${code} (expires in ${minutes}m).`);
10900
+ }, Boolean(process.stdout.isTTY));
10097
10901
  console.log(`Logged in as ${email}. Key saved to ${credentialsPath()}.`);
10098
10902
  return EXIT.pass;
10099
10903
  } catch (e) {
@@ -10130,6 +10934,102 @@ ${LOGOUT_HELP}`);
10130
10934
  return EXIT.pass;
10131
10935
  }
10132
10936
 
10937
+ // src/tty.ts
10938
+ import { emitKeypressEvents } from "node:readline";
10939
+ var ANSI2 = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
10940
+ var visible = (s) => s.replace(ANSI2, "");
10941
+ function liveBlock(out = process.stdout) {
10942
+ let drawn = 0;
10943
+ const rowsOf = (line) => Math.max(1, Math.ceil(visible(line).length / (out.columns || 80)));
10944
+ return {
10945
+ draw(lines) {
10946
+ out.write(`${drawn > 0 ? `\x1B[${drawn}A\x1B[J` : ""}${lines.join(`
10947
+ `)}
10948
+ `);
10949
+ drawn = lines.reduce((n, l) => n + rowsOf(l), 0);
10950
+ }
10951
+ };
10952
+ }
10953
+ function animate(block, render) {
10954
+ let frame = 0;
10955
+ const draw = () => block.draw(render(SPIN[frame++ % SPIN.length]));
10956
+ draw();
10957
+ const timer = setInterval(draw, 120);
10958
+ return () => clearInterval(timer);
10959
+ }
10960
+ function wrap(text, indent, width) {
10961
+ const room = Math.max(10, width - indent.length);
10962
+ const lines = [];
10963
+ let line = "";
10964
+ for (const word of text.split(" ")) {
10965
+ for (let w = word;w.length > 0; ) {
10966
+ const next = line ? `${line} ${w}` : w;
10967
+ if (next.length <= room) {
10968
+ line = next;
10969
+ w = "";
10970
+ } else if (line) {
10971
+ lines.push(line);
10972
+ line = "";
10973
+ } else {
10974
+ lines.push(w.slice(0, room));
10975
+ w = w.slice(room);
10976
+ }
10977
+ }
10978
+ }
10979
+ if (line)
10980
+ lines.push(line);
10981
+ return lines.map((l) => indent + l);
10982
+ }
10983
+ function abortError() {
10984
+ return Object.assign(new Error("Aborted"), { code: "ABORT_ERR" });
10985
+ }
10986
+ function select(block, count, render, initial = 0) {
10987
+ const input = process.stdin;
10988
+ let current = initial;
10989
+ block.draw(render(current));
10990
+ return new Promise((resolve, reject) => {
10991
+ const restore = () => {
10992
+ input.off("keypress", onKey);
10993
+ input.off("end", onEnd);
10994
+ process.off("exit", showCursor);
10995
+ input.setRawMode(false);
10996
+ input.pause();
10997
+ showCursor();
10998
+ };
10999
+ const onKey = (_, key) => {
11000
+ if (!key)
11001
+ return;
11002
+ if (key.ctrl && (key.name === "c" || key.name === "d")) {
11003
+ restore();
11004
+ reject(abortError());
11005
+ } else if (key.name === "return" || key.name === "enter") {
11006
+ restore();
11007
+ resolve(current);
11008
+ } else if (key.name === "up" || key.name === "k") {
11009
+ current = (current + count - 1) % count;
11010
+ block.draw(render(current));
11011
+ } else if (key.name === "down" || key.name === "j") {
11012
+ current = (current + 1) % count;
11013
+ block.draw(render(current));
11014
+ }
11015
+ };
11016
+ const onEnd = () => {
11017
+ restore();
11018
+ reject(abortError());
11019
+ };
11020
+ emitKeypressEvents(input);
11021
+ input.setRawMode(true);
11022
+ input.resume();
11023
+ process.stdout.write("\x1B[?25l");
11024
+ process.on("exit", showCursor);
11025
+ input.on("keypress", onKey);
11026
+ input.on("end", onEnd);
11027
+ });
11028
+ }
11029
+ function showCursor() {
11030
+ process.stdout.write("\x1B[?25h");
11031
+ }
11032
+
10133
11033
  // src/init.ts
10134
11034
  var SKILLS_ARGV = [
10135
11035
  "skills",
@@ -10146,18 +11046,23 @@ function skillsDir(scope) {
10146
11046
  if (scope === "local")
10147
11047
  return ".claude/skills/";
10148
11048
  const config = process.env.CLAUDE_CONFIG_DIR?.trim();
10149
- return config ? `${join8(config, "skills")}/` : "~/.claude/skills/";
11049
+ return config ? `${join10(config, "skills")}/` : "~/.claude/skills/";
10150
11050
  }
10151
11051
  function template(product) {
10152
11052
  return `# firegoat.yaml: evals for how well coding agents use ${product}.
10153
- # Each test runs your claude in an empty folder (.firegoat/runs/...) with \`prompt\`
10154
- # as the first message, then runs \`checks\` there. Reference: npx firegoat init --help
11053
+ # Each test runs your agent (claude by default) in an empty folder
11054
+ # (.firegoat/runs/...) with \`prompt\` as the first message, then runs \`checks\`
11055
+ # there. Reference: npx firegoat init --help
10155
11056
 
10156
11057
  # How many times to run each test.
10157
11058
  runs: 1
10158
11059
 
10159
- # Optional: claude's --model. A list runs every test \`runs\` times per model.
10160
- # Unset: claude's default. eval --model NAME overrides it.
11060
+ # Optional: the agent CLI each test runs on: claude-code (default), codex-cli or grok-cli.
11061
+ # A list runs every test \`runs\` times per harness. eval --harness NAME overrides it.
11062
+ # harness: [claude-code, codex-cli]
11063
+
11064
+ # Optional: the harness's --model. A list runs every test \`runs\` times per model.
11065
+ # Unset: the harness's default. eval --model NAME overrides it.
10161
11066
  # model: [claude-haiku-5-5, claude-sonnet-5-5]
10162
11067
 
10163
11068
  # Optional: test your local build instead of the published package.
@@ -10204,12 +11109,18 @@ runs: 1
10204
11109
  # MYTOOL_HOME: "$FIREGOAT_HOME/.mytool"
10205
11110
  # UV_FIND_LINKS: "$FIREGOAT_OUT"
10206
11111
 
11112
+ # Optional: values for {{vars.KEY}} in prompts, checks and build/install/cleanup
11113
+ # steps (pasted in as-is, unquoted); eval --var KEY=value overrides them. Steps
11114
+ # and checks also get each one as an env var ("$KEY": safe for any value).
11115
+ # vars:
11116
+ # ACCOUNT: acme-test
11117
+
10207
11118
  tests:
10208
11119
  # A smoke test that firegoat and claude work. Replace it with tests for your
10209
11120
  # product: the firegoat-onboarding skill writes them.
10210
11121
  - name: goat
10211
11122
  # What a user would ask their agent.
10212
- prompt: Who's the GOAT? Answer in one short sentence.
11123
+ prompt: "Who's the GOAT? Hint: it's me, the person typing this. Answer in one short sentence with the word GOAT in it."
10213
11124
  # True when the user's problem is solved. Not graded by firegoat: it goes to
10214
11125
  # your agent (eval --json, run.json) to judge.
10215
11126
  end_goal: Claude answers that you (the user) are the goat, e.g. YOU'RE THE GOAT.
@@ -10218,7 +11129,7 @@ tests:
10218
11129
  checks:
10219
11130
  - run: >-
10220
11131
  test -s "$FIREGOAT_TRANSCRIPT" &&
10221
- grep -Eiq '^agent: .*goat' "$FIREGOAT_TRANSCRIPT"
11132
+ grep -Eiq '^agent: .*(you.*goat|goat.*you)' "$FIREGOAT_TRANSCRIPT"
10222
11133
  # Seconds before the agent run is stopped.
10223
11134
  timeout: 600
10224
11135
  `;
@@ -10232,65 +11143,119 @@ var CLIPBOARD = [
10232
11143
  ];
10233
11144
  function copyToClipboard(text) {
10234
11145
  return CLIPBOARD.some(([cmd, args]) => {
10235
- const r = spawnSync3(cmd, args, { input: text, env: hostEnv() });
11146
+ const r = spawnSync4(cmd, args, { input: text, env: hostEnv() });
10236
11147
  return !r.error && r.status === 0;
10237
11148
  });
10238
11149
  }
10239
- async function ask(question) {
10240
- const rl = createInterface({ input: process.stdin, output: process.stdout });
10241
- try {
10242
- return (await rl.question(question)).trim();
10243
- } finally {
10244
- rl.close();
11150
+ var LOGIN_HINT = "Runs stay local: npx firegoat login uploads them to firegoat.ai";
11151
+ function cut(s, n) {
11152
+ return s.length <= n ? s : `${s.slice(0, Math.max(0, n - 1))}~`;
11153
+ }
11154
+
11155
+ class Timeline {
11156
+ paint;
11157
+ width;
11158
+ static INDENT = " ".repeat(9);
11159
+ constructor(paint, width) {
11160
+ this.paint = paint;
11161
+ this.width = width;
11162
+ }
11163
+ line(mark, n, label, status = "") {
11164
+ const head = ` ${mark} ${this.paint("90", String(n).padStart(2, "0"))} `;
11165
+ if (!status)
11166
+ return head + cut(label, this.width - 9);
11167
+ return `${head}${label.padEnd(28)}${this.paint("90", cut(status, this.width - 37))}`;
11168
+ }
11169
+ done(n, label, status) {
11170
+ return this.line(this.paint("32", "●"), n, label, status);
11171
+ }
11172
+ failed(n, label) {
11173
+ return this.line(this.paint("31", "●"), n, label, "Failed");
11174
+ }
11175
+ busy(spin, n, label, status) {
11176
+ return this.line(this.paint("38;5;208", spin), n, label, status);
11177
+ }
11178
+ detail(text) {
11179
+ return wrap(text, Timeline.INDENT, this.width).map((l) => this.paint("90", l));
11180
+ }
11181
+ gap() {
11182
+ console.log(this.paint("90", " │"));
11183
+ }
11184
+ ask(block, n, question, options) {
11185
+ const cols = Math.max(...options.map(([l]) => l.length)) + 3;
11186
+ const room = this.width - Timeline.INDENT.length - 2;
11187
+ return select(block, options.length, (current) => [
11188
+ this.line(this.paint("38;5;208", "◉"), n, question),
11189
+ ...options.map(([label, note], i) => {
11190
+ const text = cut(`${label.padEnd(cols)}${note}`, room);
11191
+ return i === current ? `${Timeline.INDENT}${this.paint("38;5;208", `● ${text}`)}` : this.paint("90", `${Timeline.INDENT}○ ${text}`);
11192
+ }),
11193
+ this.paint("90", `${Timeline.INDENT}Up/down to move, enter to pick`)
11194
+ ]);
10245
11195
  }
10246
11196
  }
10247
- async function askYes(question) {
10248
- const a = await ask(`${question} [Y/n] `);
10249
- return a === "" || /^y(es)?$/i.test(a);
10250
- }
10251
- async function askScope(question) {
10252
- for (;; ) {
10253
- const a = await ask(`${question} [G/l/n] `);
10254
- if (a === "" || /^g(lobal)?$/i.test(a))
10255
- return "global";
10256
- if (/^l(ocal)?$/i.test(a))
10257
- return "local";
10258
- if (/^no?$/i.test(a))
10259
- return "none";
10260
- process.stdout.write("\x1B[1A\x1B[2K");
10261
- }
11197
+ function runQuiet(cmd, args, opts) {
11198
+ return new Promise((resolve) => {
11199
+ const child = spawn3(cmd, args, {
11200
+ ...opts,
11201
+ stdio: ["ignore", "pipe", "pipe"]
11202
+ });
11203
+ let output = "";
11204
+ const collect = (d) => {
11205
+ output += d;
11206
+ };
11207
+ child.stdout.on("data", collect);
11208
+ child.stderr.on("data", collect);
11209
+ child.on("error", (error) => resolve({ status: null, output, error }));
11210
+ child.on("close", (status) => resolve({ status, output }));
11211
+ });
10262
11212
  }
10263
- var LOGIN_HINT = "Runs stay local: npx firegoat login uploads them to firegoat.ai";
10264
- async function loginStep(interactive, done, paint) {
11213
+ async function loginStep(ui) {
10265
11214
  const label = "firegoat.ai login";
10266
- const uploadsOn = () => console.log(` ${paint("90", UPLOADS_ON)}`);
10267
11215
  const key = resolveKey(baseUrl());
10268
- if (key) {
10269
- const who = key.source === "env" ? "FIREGOAT_API_KEY is set" : `Logged in as ${key.email}`;
10270
- if (interactive) {
10271
- done(label, who);
10272
- uploadsOn();
10273
- } else
10274
- console.log(`${who}
10275
- ${UPLOADS_ON}`);
11216
+ const who = key ? key.source === "env" ? "FIREGOAT_API_KEY is set" : `Logged in as ${key.email}` : undefined;
11217
+ if (!ui) {
11218
+ console.log(who ? `${who}
11219
+ ${UPLOADS_ON}` : LOGIN_HINT);
10276
11220
  return EXIT.pass;
10277
11221
  }
10278
- if (!interactive) {
10279
- console.log(LOGIN_HINT);
11222
+ const block = liveBlock();
11223
+ if (who) {
11224
+ block.draw([ui.done(4, label, who), ...ui.detail(UPLOADS_ON)]);
10280
11225
  return EXIT.pass;
10281
11226
  }
10282
- console.log(` ${paint("90", UPLOAD_NOTICE)}`);
10283
- const yes = await askYes(` ${paint("38;5;208", "◉")} ${paint("90", "04")} Login to firegoat.ai to upload runs?`);
10284
- if (!yes) {
10285
- done(label, "Skipped (npx firegoat login later)");
11227
+ const picked = await ui.ask(block, 4, "Login to firegoat.ai to upload runs?", [
11228
+ ["Yes", "Opens firegoat.ai in your browser"],
11229
+ ["Not now", "Runs stay local"]
11230
+ ]);
11231
+ if (picked !== 0) {
11232
+ block.draw([ui.done(4, label, "Skipped (npx firegoat login later)")]);
10286
11233
  return EXIT.pass;
10287
11234
  }
11235
+ let status = "Connecting";
11236
+ let details = [];
11237
+ const stop = animate(block, (spin) => [
11238
+ ui.busy(spin, 4, label, status),
11239
+ ...details
11240
+ ]);
10288
11241
  try {
10289
- const email = await loginFlow((l) => console.log(` ${l}`), true);
10290
- done(label, `Logged in as ${email}`);
10291
- uploadsOn();
11242
+ const email = await loginFlow(({ page, code, minutes }) => {
11243
+ status = "Waiting for the browser";
11244
+ details = [
11245
+ ...ui.detail(TERMS),
11246
+ ...ui.detail(`Open ${page}`),
11247
+ ...ui.detail(`Code: ${code} (expires in ${minutes}m)`)
11248
+ ];
11249
+ }, true);
11250
+ stop();
11251
+ block.draw([
11252
+ ui.done(4, label, `Logged in as ${email}`),
11253
+ ...ui.detail(UPLOADS_ON)
11254
+ ]);
10292
11255
  return EXIT.pass;
10293
11256
  } catch (e) {
11257
+ stop();
11258
+ block.draw([ui.failed(4, label), ...details]);
10294
11259
  if (!(e instanceof ApiError))
10295
11260
  throw e;
10296
11261
  failWith(e instanceof NetworkError ? "network" : "login_failed");
@@ -10336,84 +11301,93 @@ ${INIT_HELP}`);
10336
11301
  const paint = (code, text) => color ? `\x1B[${code}m${text}\x1B[0m` : text;
10337
11302
  const width = Math.min(process.stdout.columns || 80, 80);
10338
11303
  const rule = paint("90", ` ${"─".repeat(width - 2)}`);
10339
- const step = (n, label, status) => console.log(` ${paint("32", "●")} ${paint("90", String(n).padStart(2, "0"))} ${label.padEnd(28)}${paint("90", status)}`);
10340
- if (interactive) {
11304
+ const ui = interactive ? new Timeline(paint, width) : undefined;
11305
+ if (ui) {
10341
11306
  for (const line of logoHeader(paint, "firegoat init", paint("90", "Get agent-ready."), width))
10342
11307
  console.log(line);
10343
11308
  console.log(rule);
10344
11309
  }
10345
11310
  ensureFiregoatDir(cwd);
10346
- const configPath = join8(cwd, CONFIG_FILE);
10347
- const keep = existsSync4(configPath) && !force;
11311
+ const configPath = join10(cwd, CONFIG_FILE);
11312
+ const keep = existsSync6(configPath) && !force;
10348
11313
  if (!keep)
10349
11314
  writeFileSync5(configPath, template(basename4(cwd)));
10350
- if (interactive) {
10351
- step(1, CONFIG_FILE, keep ? "Kept (--force replaces it)" : "Created");
10352
- console.log(paint("90", " │"));
10353
- step(2, ".firegoat/", "Ready");
11315
+ if (ui) {
11316
+ console.log(ui.done(1, CONFIG_FILE, keep ? "Kept (--force replaces it)" : "Created"));
11317
+ ui.gap();
11318
+ console.log(ui.done(2, ".firegoat/", "Ready"));
10354
11319
  } else
10355
11320
  console.log(keep ? `Kept ${CONFIG_FILE} (--force replaces it)` : `Wrote ${CONFIG_FILE}: replace the goat smoke test with tests for your product`);
10356
- if (interactive && !noSkills)
10357
- console.log(paint("90", " │"));
10358
- if (interactive && !noSkills && !localSkills) {
10359
- scope = await askScope(` ${paint("38;5;208", "◉")} ${paint("90", "03")} Install the firegoat-onboarding skill globally (~/.claude/skills) or in this repo (.claude/skills)?`);
10360
- process.stdout.write("\x1B[1A\x1B[2K");
11321
+ const skill = "firegoat-onboarding skill";
11322
+ const block = liveBlock();
11323
+ ui?.gap();
11324
+ if (ui && !noSkills && !localSkills) {
11325
+ const picked = await ui.ask(block, 3, "Install the firegoat-onboarding skill", [
11326
+ ["Global", skillsDir("global")],
11327
+ ["This repo", `${skillsDir("local")} (commit it for your team)`],
11328
+ ["Skip", "Don't install it"]
11329
+ ]);
11330
+ scope = ["global", "local", "none"][picked] ?? "none";
10361
11331
  initSkills(scope);
10362
11332
  }
10363
11333
  if (scope !== "none") {
10364
11334
  const argv = [...SKILLS_ARGV, ...scope === "global" ? ["-g"] : []];
10365
11335
  const where = skillsDir(scope);
10366
- if (!interactive)
11336
+ if (!ui)
10367
11337
  console.log(`Installing the skill: npx ${argv.join(" ")}`);
10368
11338
  const env = Object.fromEntries(Object.entries(process.env).filter(([k]) => k !== "npm_config_package" && !k.startsWith("FIREGOAT_")));
10369
- const r = spawnSync3("npx", ["-y", ...argv], {
10370
- cwd,
10371
- env,
10372
- stdio: "pipe",
10373
- encoding: "utf8"
10374
- });
11339
+ const stop = ui ? animate(block, (spin) => [
11340
+ ui.busy(spin, 3, skill, `Installing → ${where}`)
11341
+ ]) : undefined;
11342
+ const r = await runQuiet("npx", ["-y", ...argv], { cwd, env });
11343
+ stop?.();
10375
11344
  if (r.error || r.status !== 0) {
10376
11345
  failWith("skills_failed");
10377
- process.stderr.write(`${r.stdout ?? ""}${r.stderr ?? ""}`);
11346
+ if (ui)
11347
+ block.draw([ui.failed(3, skill)]);
11348
+ process.stderr.write(r.output);
10378
11349
  console.error(`firegoat init: Installing the skill failed (${r.error?.message ?? `exit ${r.status}`}). Run it yourself: npx ${argv.join(" ")}`);
10379
11350
  return EXIT.notGraded;
10380
11351
  }
10381
- if (interactive)
10382
- step(3, "firegoat-onboarding skill", `Installed → ${where}`);
11352
+ if (ui)
11353
+ block.draw([ui.done(3, skill, `Installed → ${where}`)]);
10383
11354
  else
10384
11355
  console.log(`Installed skill firegoat-onboarding in ${where}`);
10385
- const ignored = scope === "local" && spawnSync3("git", ["check-ignore", "-q", ".claude/skills"], {
11356
+ const ignored = scope === "local" && spawnSync4("git", ["check-ignore", "-q", ".claude/skills"], {
10386
11357
  cwd,
10387
11358
  env: hostEnv()
10388
11359
  }).status === 0;
10389
11360
  if (ignored)
10390
11361
  console.error("Warning: git ignores .claude/skills, so teammates and CI won't get the skill. Commit them with: git add -f .claude/skills");
10391
- } else if (interactive)
10392
- step(3, "firegoat-onboarding skill", "Skipped");
11362
+ } else if (ui)
11363
+ block.draw([ui.done(3, skill, "Skipped")]);
10393
11364
  if (login) {
10394
- if (interactive)
10395
- console.log(paint("90", " │"));
10396
- const loginExit = await loginStep(interactive, (label, status) => step(4, label, status), paint);
11365
+ ui?.gap();
11366
+ const loginExit = await loginStep(ui);
10397
11367
  if (loginExit !== EXIT.pass)
10398
11368
  return loginExit;
10399
11369
  }
10400
11370
  const telemetryOn = telemetryEnabled();
10401
- if (!interactive) {
11371
+ if (!ui) {
10402
11372
  if (telemetryOn)
10403
11373
  console.log(TELEMETRY_NOTICE);
10404
11374
  console.log(`
10405
11375
  Prompt for your coding agent: ${AGENT_PROMPT}`);
10406
11376
  return EXIT.pass;
10407
11377
  }
10408
- if (telemetryOn)
10409
- console.log(` ${paint("90", TELEMETRY_NOTICE)}`);
10410
11378
  console.log(rule);
11379
+ console.log("");
10411
11380
  console.log(` Next: hand this to your coding agent
10412
11381
  `);
10413
11382
  console.log(AGENT_PROMPT);
10414
11383
  console.log("");
10415
11384
  const copied = copyToClipboard(AGENT_PROMPT);
10416
11385
  console.log(` ${copied ? `${paint("32", "✓ Prompt copied!")} ` : ""}Then: ${paint("36", "npx firegoat@latest eval")}`);
11386
+ if (telemetryOn) {
11387
+ console.log("");
11388
+ for (const l of wrap(TELEMETRY_NOTICE, " ", width))
11389
+ console.log(paint("90", l));
11390
+ }
10417
11391
  return EXIT.pass;
10418
11392
  }
10419
11393