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