firegoat 0.19.0 → 0.21.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 +25 -4
- package/dist/cli.js +144 -57
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ npx firegoat@latest init # Creates firegoat.yaml and .firegoat/, installs th
|
|
|
12
12
|
npx firegoat@latest eval # Runs your claude on each test, in .firegoat/runs/<runId>/<test>/<n>/work
|
|
13
13
|
```
|
|
14
14
|
|
|
15
|
-
That's it: two commands. Use `npx firegoat@latest`: plain `npx firegoat` can keep running an old cached version. `eval` prints its version first (`firegoat
|
|
15
|
+
That's it: two commands. Use `npx firegoat@latest`: plain `npx firegoat` can keep running an old cached version. `eval` prints its version first (`firegoat X.Y.Z`), and `summary.json` records it as `firegoatVersion`. `claude` must be installed and logged in (run `claude` once).
|
|
16
16
|
|
|
17
17
|
`init` installs the firegoat-onboarding skill globally, into `~/.claude/skills/` (in a terminal it asks: global, this repo, or none). `--local-skills` puts it in this repo's `.claude/skills/` instead (commit it so teammates and CI get it); `--no-skills` skips it.
|
|
18
18
|
|
|
@@ -156,7 +156,28 @@ tests:
|
|
|
156
156
|
claude_args: [--chrome] # Claude in Chrome, this test only
|
|
157
157
|
```
|
|
158
158
|
|
|
159
|
-
`claude_args:` (top level, and per test after the top-level ones) is passed to claude after firegoat's own flags and `--model`. Each entry is one argument, as-is, with no shell splitting: `[--plugin-dir, ./plugins/acme]`, not `["--plugin-dir ./plugins/acme"]`. Use it for `--chrome`, `--plugin-dir`, `--mcp-config`, `--agents`, `--append-system-prompt`. The flags firegoat sets itself are config errors, also as `--flag=value`: `-p`, `--print`, `--output-format`, `--verbose`, `--setting-sources`, `--strict-mcp-config`, `--disable-slash-commands`, `--dangerously-skip-permissions`, `--model` (use `model:`), and `--session-id`, `--resume`, `-r`, `--continue`, `-c` (every run is a fresh session). Each run's `run.json` records `claudeArgs`.
|
|
159
|
+
`claude_args:` (top level, and per test after the top-level ones) is passed to claude after firegoat's own flags and `--model`. Each entry is one argument, as-is, with no shell splitting: `[--plugin-dir, ./plugins/acme]`, not `["--plugin-dir ./plugins/acme"]`. Use it for `--chrome`, `--plugin-dir`, `--mcp-config`, `--agents`, `--append-system-prompt`. The flags firegoat sets itself are config errors, also as `--flag=value`: `-p`, `--print`, `--output-format`, `--verbose`, `--setting-sources`, `--strict-mcp-config`, `--disable-slash-commands`, `--dangerously-skip-permissions`, `--model` (use `model:`), and `--session-id`, `--resume`, `-r`, `--continue`, `-c` (every run is a fresh session). Entries expand like `env:` values: `$FIREGOAT_WORKDIR`, `$FIREGOAT_HOME`, `$FIREGOAT_OUT` (also `${...}`), `{{vars.KEY}}`, `{{run.id}}`, `{{run.n}}`, `{{test.name}}`; no other `$` expansion. Each run's `run.json` records the expanded `claudeArgs`.
|
|
160
|
+
|
|
161
|
+
### Testing an MCP server
|
|
162
|
+
|
|
163
|
+
Commit the server's config as `fixtures/mcp.json`:
|
|
164
|
+
|
|
165
|
+
```json
|
|
166
|
+
{"mcpServers": {"acme": {"command": "./node_modules/.bin/acme-mcp",
|
|
167
|
+
"env": {"HOME": "${FIREGOAT_HOME}"}}}}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
Copy it in `build:` and pass it with `claude_args:`:
|
|
171
|
+
|
|
172
|
+
```yaml
|
|
173
|
+
build:
|
|
174
|
+
- npm pack --pack-destination "$FIREGOAT_OUT"
|
|
175
|
+
- cp -R fixtures/. "$FIREGOAT_OUT"
|
|
176
|
+
install: npm init -y >/dev/null && npm install "$FIREGOAT_OUT"/*.tgz
|
|
177
|
+
claude_args: [--mcp-config, $FIREGOAT_OUT/mcp.json]
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
claude gets `FIREGOAT_HOME`, `FIREGOAT_WORKDIR` and `FIREGOAT_OUT` in its own env, set per run, and expands `${VAR}` in an MCP config's `command`, `args`, `env`, `url` and `headers` from its env (an unset var stays the literal text). Relative paths in the config are from the run's folder, claude's cwd: `./node_modules/.bin/acme-mcp` is the build under test, installed by `install:`. MCP servers that claude starts inherit claude's own env, which keeps your real `HOME` (claude needs it for its login): set `"env": {"HOME": "${FIREGOAT_HOME}"}` on the server, as above, or it writes to your real home. Per-run state the server keeps elsewhere goes the same way, e.g. `"ACME_DB": "${FIREGOAT_WORKDIR}/acme.db"`. `--strict-mcp-config` is always on, so only the configs you pass load. To check that the agent used a tool, grep the transcript: `grep -Eq '^\[[0-9]+\] mcp__acme__search' "$FIREGOAT_TRANSCRIPT"`.
|
|
160
181
|
|
|
161
182
|
With `--chrome`, claude points the Claude in Chrome extension's native host (`~/Library/Application Support/<browser>/NativeMessagingHosts/com.anthropic.claude_code_browser_extension.json`) at `.firegoat/.claude`. If you delete `.firegoat/`, run a normal `claude --chrome` once to point it back.
|
|
162
183
|
|
|
@@ -198,9 +219,9 @@ Limits:
|
|
|
198
219
|
## What eval does
|
|
199
220
|
|
|
200
221
|
- claude runs on its own login (or `ANTHROPIC_API_KEY`, if set) with `CLAUDE_CONFIG_DIR=.firegoat/.claude` and `--setting-sources "" --strict-mcp-config --disable-slash-commands`: none of your settings, CLAUDE.md, plugins, skills or MCP servers (add flags with `claude_args:`, see above). claude saves its session there, in its own format.
|
|
201
|
-
- No sandbox: the agent's folder is inside `.firegoat/runs/`, and it can reach the rest of your machine. `.firegoat/` holds local run output and ignores itself in git. Keep test fixtures (scripts, sample data)
|
|
222
|
+
- No sandbox: the agent's folder is inside `.firegoat/runs/`, and it can reach the rest of your machine. `.firegoat/` holds local run output and ignores itself in git. Keep test fixtures (scripts, sample data) in `fixtures/` next to `firegoat.yaml`, committed.
|
|
202
223
|
- In a linked git worktree (`git worktree add`), `.firegoat/runs/` and `.firegoat/.claude/` are in the main checkout, so runs (and their session files) survive `git worktree remove`; `firegoat.yaml`, fixtures and `build:` stay in the worktree. Each `summary.json` records `dir` (where eval ran) and `git: {branch, commit, dirty}`, and `.firegoat/runs/index.jsonl` gets one line per eval (`runId, startedAt, dir, branch, commit, passed, failed, notGraded, cost, uploaded, uploadFailed`) to tell runs from different branches apart.
|
|
203
|
-
- The agent gets your environment plus `env:`, never `vars:`. Vars that npm/npx set for firegoat itself (`npm_config_*`, `npm_lifecycle_*`, `npm_package_*`, `npm_execpath`, `npm_node_execpath`, `npm_command`, `INIT_CWD`) are removed from every env firegoat starts (agent, build, install, checks): `npx -y firegoat` sets `npm_config_yes=true`, which would make the agent's `npm init` / `npx create-*` skip their prompts. Set one under `env:` to keep it. Every `FIREGOAT_*` var firegoat was started with (`FIREGOAT_API_KEY` above all) is removed too, and so are the vars a parent Claude Code session sets (when firegoat runs from claude, or its agent runs firegoat), so the claude under test doesn't act as a nested 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`. Login and provider vars stay: `ANTHROPIC_*`, `CLAUDE_CODE_OAUTH_TOKEN`, `CLAUDE_CODE_OAUTH_REFRESH_TOKEN`, `CLAUDE_CODE_USE_*` (Bedrock, Vertex, ...), `CLAUDE_CODE_SKIP_*_AUTH`, `CLAUDE_CODE_CLIENT_CERT`, `CLAUDE_CODE_CLIENT_KEY`, `CLAUDE_CODE_CLIENT_KEY_PASSPHRASE`.
|
|
224
|
+
- The agent gets your environment plus `env:`, never `vars:`. Vars that npm/npx set for firegoat itself (`npm_config_*`, `npm_lifecycle_*`, `npm_package_*`, `npm_execpath`, `npm_node_execpath`, `npm_command`, `INIT_CWD`) are removed from every env firegoat starts (agent, build, install, checks): `npx -y firegoat` sets `npm_config_yes=true`, which would make the agent's `npm init` / `npx create-*` skip their prompts. Set one under `env:` to keep it. Every `FIREGOAT_*` var firegoat was started with (`FIREGOAT_API_KEY` above all) is removed too (claude then gets the run's own `FIREGOAT_OUT`, `FIREGOAT_WORKDIR` and `FIREGOAT_HOME`, for MCP configs; the agent's commands see them too), and so are the vars a parent Claude Code session sets (when firegoat runs from claude, or its agent runs firegoat), so the claude under test doesn't act as a nested 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`. Login and provider vars stay: `ANTHROPIC_*`, `CLAUDE_CODE_OAUTH_TOKEN`, `CLAUDE_CODE_OAUTH_REFRESH_TOKEN`, `CLAUDE_CODE_USE_*` (Bedrock, Vertex, ...), `CLAUDE_CODE_SKIP_*_AUTH`, `CLAUDE_CODE_CLIENT_CERT`, `CLAUDE_CODE_CLIENT_KEY`, `CLAUDE_CODE_CLIENT_KEY_PASSPHRASE`.
|
|
204
225
|
- Runs (tests × models × `runs`) go 4 at a time, each in its own folder with its own session and HOME; `--parallel N` changes that, `--parallel 1` runs them one after another. `summary.json` lists them in `firegoat.yaml` order.
|
|
205
226
|
- Script checks run in the agent's folder afterwards and set the exit code. `end_goal` is not graded by firegoat: it's for you, or your coding agent, to judge from `run.json`, the session and the folder.
|
|
206
227
|
- Output per run, in `.firegoat/runs/<runId>/<test>/<n>/`: `transcript.txt` (each tool call with its command and short output, the final answer, files made; checks get it as `$FIREGOAT_TRANSCRIPT`; greppable line by line: every line of a shell call starts with `[n] $ `, every line of the agent's text and final answer with `agent: `, output is indented, so `grep -E '^\[[0-9]+\] \$ .*npm install'` matches only commands, also inside heredocs and multi-line scripts), `run.json` (status, prompt, end_goal, check results, paths), `work/`, `home/` and `home.env` (the run's HOME), logs. Read the transcript to judge `end_goal`; claude's full session file is ~50x larger. `summary.json` covers the whole eval; `--json` prints it.
|
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.21.0",
|
|
6951
6951
|
repository: {
|
|
6952
6952
|
type: "git",
|
|
6953
6953
|
url: "git+https://github.com/firegoat-ai/firegoat-cli.git"
|
|
@@ -7211,6 +7211,7 @@ function parseClaudeArgs(raw, path) {
|
|
|
7211
7211
|
for (const f of flags)
|
|
7212
7212
|
if (arg === f || arg.startsWith(`${f}=`))
|
|
7213
7213
|
fail(p, `${f} is not allowed: ${why}`);
|
|
7214
|
+
checkPlaceholders(arg, p);
|
|
7214
7215
|
return arg;
|
|
7215
7216
|
});
|
|
7216
7217
|
}
|
|
@@ -7266,15 +7267,18 @@ function parseEnv(raw, path) {
|
|
|
7266
7267
|
fail(p, "is set by firegoat and cannot be overridden (reserved: PATH, CLAUDE_CONFIG_DIR, CLAUDE_SECURESTORAGE_CONFIG_DIR, FIREGOAT_*)");
|
|
7267
7268
|
if (typeof v !== "string")
|
|
7268
7269
|
fail(p, "must be a string (quote numbers and booleans)");
|
|
7269
|
-
|
|
7270
|
-
const name = m[1];
|
|
7271
|
-
if (!/^vars\.[A-Za-z0-9_]+$/.test(name) && !RUN_PLACEHOLDERS.includes(name))
|
|
7272
|
-
fail(p, `unknown placeholder ${m[0]} (allowed: {{vars.KEY}}, ${RUN_PLACEHOLDERS.map((n) => `{{${n}}}`).join(", ")})`);
|
|
7273
|
-
}
|
|
7270
|
+
checkPlaceholders(v, p);
|
|
7274
7271
|
out[k] = v;
|
|
7275
7272
|
}
|
|
7276
7273
|
return out;
|
|
7277
7274
|
}
|
|
7275
|
+
function checkPlaceholders(v, p) {
|
|
7276
|
+
for (const m of v.matchAll(ANY_PLACEHOLDER)) {
|
|
7277
|
+
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(", ")})`);
|
|
7280
|
+
}
|
|
7281
|
+
}
|
|
7278
7282
|
function isEvalLevel(value) {
|
|
7279
7283
|
const dirsOk = [...value.matchAll(DIR_REF)].every((m) => (m[1] ?? m[2]) === "FIREGOAT_OUT");
|
|
7280
7284
|
return dirsOk && [...value.matchAll(ANY_PLACEHOLDER)].every((m) => {
|
|
@@ -7282,16 +7286,19 @@ function isEvalLevel(value) {
|
|
|
7282
7286
|
return name.startsWith("vars.") || EVAL_PLACEHOLDERS.includes(name);
|
|
7283
7287
|
});
|
|
7284
7288
|
}
|
|
7289
|
+
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
|
+
});
|
|
7297
|
+
}
|
|
7285
7298
|
function renderEnv(env, vars, run) {
|
|
7286
7299
|
const out = {};
|
|
7287
7300
|
for (const [k, v] of Object.entries(env))
|
|
7288
|
-
out[k] = v
|
|
7289
|
-
const key = name ?? braced ?? bare;
|
|
7290
|
-
const filled = key.startsWith("vars.") ? vars[key.slice("vars.".length)] : run[key];
|
|
7291
|
-
if (filled === undefined)
|
|
7292
|
-
throw new ConfigError(`env.${k}: no value for ${whole}`);
|
|
7293
|
-
return filled;
|
|
7294
|
-
});
|
|
7301
|
+
out[k] = renderValue(v, vars, run, `env.${k}`);
|
|
7295
7302
|
return out;
|
|
7296
7303
|
}
|
|
7297
7304
|
var PLACEHOLDER = /\{\{\s*vars\.([A-Za-z0-9_]+)\s*\}\}/g;
|
|
@@ -7441,8 +7448,15 @@ async function runClaude(opts) {
|
|
|
7441
7448
|
reason: `claude did not start a session (exit ${exited.code ?? "by signal"})${said ? `: ${said}` : ", see stderr.txt"}`
|
|
7442
7449
|
};
|
|
7443
7450
|
}
|
|
7444
|
-
const sessionId = String(init.session_id);
|
|
7445
7451
|
const claudeModel = typeof init.model === "string" ? init.model : null;
|
|
7452
|
+
if (typeof init.session_id !== "string" || init.session_id === "")
|
|
7453
|
+
return {
|
|
7454
|
+
events,
|
|
7455
|
+
claudeModel,
|
|
7456
|
+
ok: false,
|
|
7457
|
+
reason: "claude's init line has no session_id"
|
|
7458
|
+
};
|
|
7459
|
+
const sessionId = init.session_id;
|
|
7446
7460
|
const result = events.findLast((e) => e?.type === "result");
|
|
7447
7461
|
if (exited.timedOut)
|
|
7448
7462
|
return {
|
|
@@ -7639,7 +7653,7 @@ async function send(opts) {
|
|
|
7639
7653
|
function replyError(step, r) {
|
|
7640
7654
|
const j = r.json ?? {};
|
|
7641
7655
|
const error = typeof j.error === "string" ? j.error : "no error message";
|
|
7642
|
-
const more = j.error_description ?? j.details
|
|
7656
|
+
const more = j.error_description ?? j.details;
|
|
7643
7657
|
const details = more === undefined ? "" : ` ${typeof more === "string" ? more : JSON.stringify(more)}`;
|
|
7644
7658
|
return new ApiError(printable(`${step} ${r.status}: ${error}${details}`), r.status);
|
|
7645
7659
|
}
|
|
@@ -7809,7 +7823,12 @@ var GITIGNORE = `*
|
|
|
7809
7823
|
function ensureFiregoatDir(root) {
|
|
7810
7824
|
const dir = join2(root, ".firegoat");
|
|
7811
7825
|
mkdirSync2(dir, { recursive: true });
|
|
7812
|
-
|
|
7826
|
+
try {
|
|
7827
|
+
writeFileSync(join2(dir, ".gitignore"), GITIGNORE, { flag: "wx" });
|
|
7828
|
+
} catch (e) {
|
|
7829
|
+
if (e.code !== "EEXIST")
|
|
7830
|
+
throw e;
|
|
7831
|
+
}
|
|
7813
7832
|
return dir;
|
|
7814
7833
|
}
|
|
7815
7834
|
|
|
@@ -7911,8 +7930,8 @@ firegoat-onboarding skill globally, into ~/.claude/skills/ (npx skills add
|
|
|
7911
7930
|
firegoat-ai/skills -g; in a terminal it asks: global, this repo, or none), offers
|
|
7912
7931
|
firegoat login (in a terminal only; otherwise prints how), then prints a prompt to
|
|
7913
7932
|
paste into your coding agent. .firegoat/ holds local run output and ignores itself
|
|
7914
|
-
in git. Keep test fixtures (helper scripts, sample data)
|
|
7915
|
-
|
|
7933
|
+
in git. Keep test fixtures (helper scripts, sample data) in fixtures/ next to
|
|
7934
|
+
firegoat.yaml, committed.
|
|
7916
7935
|
|
|
7917
7936
|
--force Replace an existing firegoat.yaml
|
|
7918
7937
|
--local-skills Install the skill into this repo's .claude/skills/ instead
|
|
@@ -7957,7 +7976,10 @@ firegoat.yaml reference:
|
|
|
7957
7976
|
as-is (no shell splitting): [--chrome] (Claude in
|
|
7958
7977
|
Chrome, for tests that need a browser),
|
|
7959
7978
|
[--plugin-dir, ./plugins/x], --mcp-config, --agents,
|
|
7960
|
-
--append-system-prompt.
|
|
7979
|
+
--append-system-prompt. Entries expand like env:
|
|
7980
|
+
values ($FIREGOAT_WORKDIR, $FIREGOAT_HOME,
|
|
7981
|
+
$FIREGOAT_OUT, {{vars.KEY}}, {{run.*}}); run.json
|
|
7982
|
+
claudeArgs has the result. Flags firegoat sets are
|
|
7961
7983
|
errors, also as --flag=value: -p, --print,
|
|
7962
7984
|
--output-format, --verbose, --setting-sources,
|
|
7963
7985
|
--strict-mcp-config, --disable-slash-commands,
|
|
@@ -7998,7 +8020,23 @@ firegoat.yaml reference:
|
|
|
7998
8020
|
(npm_config_*, npm_lifecycle_*, npm_package_*, npm_execpath, npm_node_execpath,
|
|
7999
8021
|
npm_command, INIT_CWD; e.g. npm_config_yes=true from npx -y) are removed from
|
|
8000
8022
|
every env firegoat starts, unless env: sets them. So is every FIREGOAT_* var
|
|
8001
|
-
(FIREGOAT_API_KEY above all) firegoat itself was started with.
|
|
8023
|
+
(FIREGOAT_API_KEY above all) firegoat itself was started with. claude itself
|
|
8024
|
+
gets $FIREGOAT_OUT, $FIREGOAT_WORKDIR and $FIREGOAT_HOME (for MCP configs, below),
|
|
8025
|
+
so the agent's commands see them too.
|
|
8026
|
+
|
|
8027
|
+
Testing an MCP server: commit its config as fixtures/mcp.json, copy it in build:
|
|
8028
|
+
and pass it with claude_args. Only the configs you pass load: --strict-mcp-config
|
|
8029
|
+
is always on. An MCP server inherits claude's env, which keeps your real HOME
|
|
8030
|
+
(claude's login is there), so set the server's HOME to the run's home, or it
|
|
8031
|
+
writes to yours. claude gets $FIREGOAT_HOME, $FIREGOAT_WORKDIR and $FIREGOAT_OUT
|
|
8032
|
+
in its env and expands \${VAR} in the config (an unset one stays literal);
|
|
8033
|
+
relative paths in it are from the run's folder, claude's cwd:
|
|
8034
|
+
fixtures/mcp.json:
|
|
8035
|
+
{"mcpServers": {"acme": {"command": "./node_modules/.bin/acme-mcp",
|
|
8036
|
+
"env": {"HOME": "\${FIREGOAT_HOME}"}}}}
|
|
8037
|
+
firegoat.yaml:
|
|
8038
|
+
build: cp -R fixtures/. "$FIREGOAT_OUT"
|
|
8039
|
+
claude_args: [--mcp-config, $FIREGOAT_OUT/mcp.json]
|
|
8002
8040
|
|
|
8003
8041
|
Example:
|
|
8004
8042
|
build: npm pack --pack-destination "$FIREGOAT_OUT"
|
|
@@ -8114,8 +8152,7 @@ fixtures and build: stay in the current folder.
|
|
|
8114
8152
|
--json The summary as JSON on stdout (or {error, exitCode} if eval
|
|
8115
8153
|
stops early); progress goes to stderr
|
|
8116
8154
|
--no-upload Keep runs local even when logged in to firegoat.ai
|
|
8117
|
-
--claude-bin PATH Path to claude (
|
|
8118
|
-
PATH)
|
|
8155
|
+
--claude-bin PATH Path to claude (default: claude on PATH)
|
|
8119
8156
|
|
|
8120
8157
|
Upload: after firegoat login (or with FIREGOAT_API_KEY), each run's claude session,
|
|
8121
8158
|
checks.txt and stderr.txt go to firegoat.ai as a private trace, and its URL is
|
|
@@ -8132,8 +8169,8 @@ Output: .firegoat/runs/<runId>/
|
|
|
8132
8169
|
git {branch, commit, dirty} (no git outside a repo)
|
|
8133
8170
|
and upload {origin, email} (null when not uploading)
|
|
8134
8171
|
../index.jsonl One line per eval: runId, startedAt, dir, branch,
|
|
8135
|
-
commit, passed, failed, notGraded, cost
|
|
8136
|
-
uploadFailed
|
|
8172
|
+
commit, passed, failed, notGraded, cost (null when
|
|
8173
|
+
any run's cost is unknown), uploaded, uploadFailed
|
|
8137
8174
|
<test>/<n>/run.json One run: status and reason (from the checks), model
|
|
8138
8175
|
(asked for, or null) and claudeModel (what claude's
|
|
8139
8176
|
init line reported), claudeArgs (claude_args
|
|
@@ -8284,6 +8321,13 @@ function describeCall(step, name, input) {
|
|
|
8284
8321
|
return `[${step}] ${name} ${input[key]}`;
|
|
8285
8322
|
return `[${step}] ${name} ${truncate(JSON.stringify(input), 1, 300)}`;
|
|
8286
8323
|
}
|
|
8324
|
+
function describeToolUse(step, b) {
|
|
8325
|
+
if (typeof b.name !== "string" || b.name === "")
|
|
8326
|
+
return `[${step}] (tool call without a name)`;
|
|
8327
|
+
if (typeof b.input !== "object" || b.input === null)
|
|
8328
|
+
return `[${step}] ${b.name} (no input)`;
|
|
8329
|
+
return describeCall(step, b.name, b.input);
|
|
8330
|
+
}
|
|
8287
8331
|
function resultText(content) {
|
|
8288
8332
|
if (typeof content === "string")
|
|
8289
8333
|
return content;
|
|
@@ -8319,7 +8363,7 @@ function writeTranscript(opts) {
|
|
|
8319
8363
|
if (e?.type === "assistant")
|
|
8320
8364
|
for (const b of content) {
|
|
8321
8365
|
if (b.type === "tool_use")
|
|
8322
|
-
out.push(
|
|
8366
|
+
out.push(describeToolUse(++step, b));
|
|
8323
8367
|
else if (b.type === "text" && typeof b.text === "string") {
|
|
8324
8368
|
lastText = out.length;
|
|
8325
8369
|
out.push(prose(truncate(b.text, 30, TEXT_CHARS)));
|
|
@@ -8396,23 +8440,38 @@ function ramp(r) {
|
|
|
8396
8440
|
const end = r.state === "pass" ? "▲" : r.state === "fail" ? "╳" : r.state === "error" ? "╌" : r.state === "running" ? "●" : "";
|
|
8397
8441
|
return (blocks + end).padEnd(8);
|
|
8398
8442
|
}
|
|
8399
|
-
function
|
|
8443
|
+
function usageParts(r) {
|
|
8400
8444
|
const parts = [];
|
|
8401
|
-
if (
|
|
8445
|
+
if (typeof r.cost === "number")
|
|
8402
8446
|
parts.push(`$${r.cost.toFixed(2)}`);
|
|
8403
|
-
|
|
8447
|
+
if (typeof r.reportedTurns === "number")
|
|
8448
|
+
parts.push(`${r.reportedTurns} turns`);
|
|
8404
8449
|
if (r.seconds !== undefined)
|
|
8405
8450
|
parts.push(`${r.seconds}s`);
|
|
8406
|
-
return parts
|
|
8451
|
+
return parts;
|
|
8452
|
+
}
|
|
8453
|
+
function usage(r) {
|
|
8454
|
+
return usageParts(r).join(" ");
|
|
8407
8455
|
}
|
|
8408
8456
|
function checksPassed(r) {
|
|
8409
8457
|
const all = r.checks ?? [];
|
|
8410
8458
|
return `${all.filter((c) => c.code === 0).length}/${all.length} checks`;
|
|
8411
8459
|
}
|
|
8412
|
-
function
|
|
8413
|
-
const
|
|
8414
|
-
const
|
|
8415
|
-
|
|
8460
|
+
function totalCost(rows) {
|
|
8461
|
+
const done = rows.filter((r) => r.state !== "queued" && r.state !== "running");
|
|
8462
|
+
const unknown = done.filter((r) => typeof r.cost !== "number").length;
|
|
8463
|
+
if (unknown > 0)
|
|
8464
|
+
return `cost unknown for ${unknown} run${unknown === 1 ? "" : "s"}`;
|
|
8465
|
+
return `$${done.reduce((s, r) => s + r.cost, 0).toFixed(2)}`;
|
|
8466
|
+
}
|
|
8467
|
+
function totalTurns(rows) {
|
|
8468
|
+
let sum = 0;
|
|
8469
|
+
for (const r of rows) {
|
|
8470
|
+
if (typeof r.reportedTurns !== "number")
|
|
8471
|
+
return null;
|
|
8472
|
+
sum += r.reportedTurns;
|
|
8473
|
+
}
|
|
8474
|
+
return sum;
|
|
8416
8475
|
}
|
|
8417
8476
|
function duration(seconds) {
|
|
8418
8477
|
return seconds >= 60 ? `${Math.floor(seconds / 60)}m ${seconds % 60}s` : `${seconds}s`;
|
|
@@ -8454,9 +8513,9 @@ function plainUI(write) {
|
|
|
8454
8513
|
continue;
|
|
8455
8514
|
printed.add(r);
|
|
8456
8515
|
const label = r.state === "error" ? "ERROR" : r.state.toUpperCase();
|
|
8457
|
-
const u =
|
|
8516
|
+
const u = usageParts(r).join(", ");
|
|
8458
8517
|
const model = r.model === null ? "" : ` [${r.model}]`;
|
|
8459
|
-
write(`${label} ${r.test}#${r.n}${model} ${r.reason} (${u})`);
|
|
8518
|
+
write(`${label} ${r.test}#${r.n}${model} ${r.reason}${u ? ` (${u})` : ""}`);
|
|
8460
8519
|
}
|
|
8461
8520
|
for (const r of rows) {
|
|
8462
8521
|
if (uploadPrinted.has(r) || !r.upload || r.upload === "uploading")
|
|
@@ -8467,10 +8526,9 @@ function plainUI(write) {
|
|
|
8467
8526
|
},
|
|
8468
8527
|
done: ({ runsDir, rows: all }) => {
|
|
8469
8528
|
const c = counts(all);
|
|
8470
|
-
const t = total(all);
|
|
8471
8529
|
const tr = tier(all);
|
|
8472
8530
|
write("");
|
|
8473
|
-
write(`${c.pass} passed, ${c.fail} failed, ${c.error} not graded (${all.length} runs,
|
|
8531
|
+
write(`${c.pass} passed, ${c.fail} failed, ${c.error} not graded (${all.length} runs, ${totalCost(all)})${tr ? `, tier ${tr}` : ""}`);
|
|
8474
8532
|
write(`Runs: ${runsDir} (end_goal is for you to judge: read <test>/<n>/transcript.txt)`);
|
|
8475
8533
|
}
|
|
8476
8534
|
};
|
|
@@ -8563,12 +8621,11 @@ function ttyUI(out, opts) {
|
|
|
8563
8621
|
out.push(p(C.amber, ` │ ${cut(r.upload.status === "failed" ? `↑ Upload failed: ${r.upload.error}` : `↑ Not uploaded: ${r.upload.reason}`, w - 12)}`));
|
|
8564
8622
|
});
|
|
8565
8623
|
out.push(rule);
|
|
8566
|
-
const t = total(rows);
|
|
8567
8624
|
if (!finished) {
|
|
8568
8625
|
const track = Math.max(10, w - 34);
|
|
8569
8626
|
const at = rows.length ? Math.round(doneCount / rows.length * (track - 1)) : 0;
|
|
8570
8627
|
const progress = `${"·".repeat(at)}▶${"·".repeat(Math.max(0, track - at - 1))}`;
|
|
8571
|
-
out.push(` ${p(C.ember, progress)} ${p(C.gray, `${doneCount}/${rows.length} ·
|
|
8628
|
+
out.push(` ${p(C.ember, progress)} ${p(C.gray, `${doneCount}/${rows.length} · ${totalCost(rows)} so far`)}`);
|
|
8572
8629
|
return out;
|
|
8573
8630
|
}
|
|
8574
8631
|
const tr = tier(rows);
|
|
@@ -8577,7 +8634,8 @@ function ttyUI(out, opts) {
|
|
|
8577
8634
|
const left = goated ? `${rows.length}/${rows.length} runs passed their checks` : tally;
|
|
8578
8635
|
const right = goated ? `GOATED · tier ${tr}` : tr ? `Tier ${tr}` : "";
|
|
8579
8636
|
out.push(` ${goated ? p(C.green, left) : left}${" ".repeat(Math.max(2, w - left.length - right.length - 3))}${goated ? p(C.ember, right) : right}`);
|
|
8580
|
-
|
|
8637
|
+
const turns = totalTurns(rows);
|
|
8638
|
+
out.push(p(C.gray, ` Total ${totalCost(rows)}${turns === null ? "" : ` · ${turns} turns`} · ${duration(finished.seconds)}`));
|
|
8581
8639
|
out.push(p(C.gray, ` Runs ${shortPath(finished.runsDir)}/`));
|
|
8582
8640
|
out.push(` Checks are a score, not the verdict: judge each end_goal from ${p(C.cyan, "<test>/<n>/transcript.txt")}`);
|
|
8583
8641
|
return out;
|
|
@@ -8644,6 +8702,12 @@ function label(v, what) {
|
|
|
8644
8702
|
throw new Error(`${what} is ${v.length} characters, the upload takes at most ${MAX_LABEL}`);
|
|
8645
8703
|
return v;
|
|
8646
8704
|
}
|
|
8705
|
+
function projectName(v) {
|
|
8706
|
+
const name = label(v, "Project name");
|
|
8707
|
+
if (name === null)
|
|
8708
|
+
throw new Error("Project name is empty");
|
|
8709
|
+
return name;
|
|
8710
|
+
}
|
|
8647
8711
|
function runModel(r) {
|
|
8648
8712
|
return {
|
|
8649
8713
|
requested: label(r.model, "Model"),
|
|
@@ -8709,9 +8773,11 @@ async function uploadRun(t, r) {
|
|
|
8709
8773
|
};
|
|
8710
8774
|
bytes[name] = b;
|
|
8711
8775
|
}
|
|
8776
|
+
let project;
|
|
8712
8777
|
let model;
|
|
8713
8778
|
let git;
|
|
8714
8779
|
try {
|
|
8780
|
+
project = projectName(t.project);
|
|
8715
8781
|
model = runModel(r);
|
|
8716
8782
|
git = t.git ? runGit(t.git) : undefined;
|
|
8717
8783
|
} catch (e) {
|
|
@@ -8720,7 +8786,7 @@ async function uploadRun(t, r) {
|
|
|
8720
8786
|
const body = {
|
|
8721
8787
|
sessionId,
|
|
8722
8788
|
runGroup: t.runGroup,
|
|
8723
|
-
project
|
|
8789
|
+
project,
|
|
8724
8790
|
timestamp: r.startedAt,
|
|
8725
8791
|
run: {
|
|
8726
8792
|
test: r.test,
|
|
@@ -8814,7 +8880,7 @@ function parseArgs(args) {
|
|
|
8814
8880
|
json: false,
|
|
8815
8881
|
noUpload: false,
|
|
8816
8882
|
vars: {},
|
|
8817
|
-
claudeBin:
|
|
8883
|
+
claudeBin: "claude"
|
|
8818
8884
|
};
|
|
8819
8885
|
for (let i = 0;i < args.length; i++) {
|
|
8820
8886
|
const a = args[i];
|
|
@@ -8930,12 +8996,20 @@ async function runOne(opts) {
|
|
|
8930
8996
|
const workdir = join6(dir, "work");
|
|
8931
8997
|
mkdirSync4(workdir, { recursive: true });
|
|
8932
8998
|
const { home, envFile } = makeRunHome(dir);
|
|
8999
|
+
const fill = {
|
|
9000
|
+
"run.id": opts.runId,
|
|
9001
|
+
"run.n": String(n),
|
|
9002
|
+
"test.name": test.name,
|
|
9003
|
+
FIREGOAT_OUT: opts.out,
|
|
9004
|
+
FIREGOAT_WORKDIR: workdir,
|
|
9005
|
+
FIREGOAT_HOME: home
|
|
9006
|
+
};
|
|
8933
9007
|
const r = {
|
|
8934
9008
|
test: test.name,
|
|
8935
9009
|
n,
|
|
8936
9010
|
model: opts.model,
|
|
8937
9011
|
claudeModel: null,
|
|
8938
|
-
claudeArgs: test.claudeArgs,
|
|
9012
|
+
claudeArgs: test.claudeArgs.map((a, i) => renderValue(a, opts.vars, fill, `claude_args[${i}]`)),
|
|
8939
9013
|
status: "error",
|
|
8940
9014
|
reason: "",
|
|
8941
9015
|
prompt: render(test.prompt, opts.vars),
|
|
@@ -8956,14 +9030,7 @@ async function runOne(opts) {
|
|
|
8956
9030
|
startedAt: new Date(started).toISOString(),
|
|
8957
9031
|
upload: null
|
|
8958
9032
|
};
|
|
8959
|
-
const env = renderEnv(test.env, opts.vars,
|
|
8960
|
-
"run.id": opts.runId,
|
|
8961
|
-
"run.n": String(n),
|
|
8962
|
-
"test.name": test.name,
|
|
8963
|
-
FIREGOAT_OUT: opts.out,
|
|
8964
|
-
FIREGOAT_WORKDIR: workdir,
|
|
8965
|
-
FIREGOAT_HOME: home
|
|
8966
|
-
});
|
|
9033
|
+
const env = renderEnv(test.env, opts.vars, fill);
|
|
8967
9034
|
const stepEnv = {
|
|
8968
9035
|
...opts.stepEnv,
|
|
8969
9036
|
...env,
|
|
@@ -9008,7 +9075,10 @@ async function attempt(opts, r, env, stepEnv, homeEnvFile) {
|
|
|
9008
9075
|
cwd: workdir,
|
|
9009
9076
|
env: {
|
|
9010
9077
|
...claudeEnv(opts.configDir, env, homeEnvFile),
|
|
9011
|
-
PATH: opts.stepEnv.PATH
|
|
9078
|
+
PATH: opts.stepEnv.PATH,
|
|
9079
|
+
FIREGOAT_OUT: opts.out,
|
|
9080
|
+
FIREGOAT_WORKDIR: workdir,
|
|
9081
|
+
FIREGOAT_HOME: r.home
|
|
9012
9082
|
},
|
|
9013
9083
|
stderr,
|
|
9014
9084
|
timeoutMs: test.timeout * 1000,
|
|
@@ -9064,6 +9134,20 @@ async function attempt(opts, r, env, stepEnv, homeEnvFile) {
|
|
|
9064
9134
|
test.checks.length === 0 ? "no script checks" : `${r.checks.length}/${r.checks.length} checks passed`
|
|
9065
9135
|
];
|
|
9066
9136
|
}
|
|
9137
|
+
var MAX_LISTED_URLS = 5;
|
|
9138
|
+
function uploadedSummary(origin, results, runDir) {
|
|
9139
|
+
const urls = results.flatMap((r) => r.upload?.status === "uploaded" ? [r.upload.url] : []);
|
|
9140
|
+
if (urls.length === 0)
|
|
9141
|
+
return;
|
|
9142
|
+
const head = `Uploaded ${urls.length} session${urls.length === 1 ? "" : "s"} to ${new URL(origin).host} (private)`;
|
|
9143
|
+
if (urls.length > MAX_LISTED_URLS) {
|
|
9144
|
+
console.log(`${head}: each run's upload.url in ${join6(runDir, "summary.json")}`);
|
|
9145
|
+
return;
|
|
9146
|
+
}
|
|
9147
|
+
console.log(`${head}:`);
|
|
9148
|
+
for (const u of urls)
|
|
9149
|
+
console.log(` ${u}`);
|
|
9150
|
+
}
|
|
9067
9151
|
async function evalCommand(args, root) {
|
|
9068
9152
|
if (args.includes("--help") || args.includes("-h")) {
|
|
9069
9153
|
console.log(EVAL_HELP);
|
|
@@ -9116,14 +9200,15 @@ async function evalCommand(args, root) {
|
|
|
9116
9200
|
...new Set(tests.flatMap((t) => [
|
|
9117
9201
|
t.prompt,
|
|
9118
9202
|
...t.checks.map((c) => c.run),
|
|
9119
|
-
...Object.values(t.env)
|
|
9203
|
+
...Object.values(t.env),
|
|
9204
|
+
...t.claudeArgs
|
|
9120
9205
|
]).flatMap(varRefs).filter((k) => !(k in vars)))
|
|
9121
9206
|
];
|
|
9122
9207
|
if (missing.length > 0)
|
|
9123
9208
|
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`);
|
|
9124
9209
|
const claudePath = resolveBin(opts.claudeBin);
|
|
9125
9210
|
if (!claudePath)
|
|
9126
|
-
return fatal(EXIT.notGraded, `Not graded: claude binary ${JSON.stringify(opts.claudeBin)} not found. Install Claude Code (https://claude.com/claude-code) or pass --claude-bin PATH
|
|
9211
|
+
return fatal(EXIT.notGraded, `Not graded: claude binary ${JSON.stringify(opts.claudeBin)} not found. Install Claude Code (https://claude.com/claude-code) or pass --claude-bin PATH.`);
|
|
9127
9212
|
let repo;
|
|
9128
9213
|
try {
|
|
9129
9214
|
repo = gitRepo(root);
|
|
@@ -9145,7 +9230,7 @@ async function evalCommand(args, root) {
|
|
|
9145
9230
|
const runId = newRunId();
|
|
9146
9231
|
let upload;
|
|
9147
9232
|
try {
|
|
9148
|
-
const pre = await uploadPreflight(opts, ui, basename3(repo?.mainCheckout ?? repo?.toplevel ?? root)
|
|
9233
|
+
const pre = await uploadPreflight(opts, ui, basename3(repo?.mainCheckout ?? repo?.toplevel ?? root), runId, repo?.info ?? null);
|
|
9149
9234
|
if (pre && "exitCode" in pre)
|
|
9150
9235
|
return fatal(pre.exitCode, pre.error);
|
|
9151
9236
|
upload = pre;
|
|
@@ -9228,7 +9313,7 @@ async function evalCommand(args, root) {
|
|
|
9228
9313
|
results[i] = r;
|
|
9229
9314
|
Object.assign(row, {
|
|
9230
9315
|
state: r.status,
|
|
9231
|
-
|
|
9316
|
+
reportedTurns: r.num_turns,
|
|
9232
9317
|
reason: r.reason,
|
|
9233
9318
|
checks: r.checks,
|
|
9234
9319
|
cost: r.cost,
|
|
@@ -9276,7 +9361,7 @@ async function evalCommand(args, root) {
|
|
|
9276
9361
|
passed: totals.pass,
|
|
9277
9362
|
failed: totals.fail,
|
|
9278
9363
|
notGraded: totals.error,
|
|
9279
|
-
cost: results.reduce((sum, r) => sum +
|
|
9364
|
+
cost: results.every((r) => r.cost !== null) ? results.reduce((sum, r) => sum + r.cost, 0) : null,
|
|
9280
9365
|
uploaded: results.filter((r) => r.upload?.status === "uploaded").length,
|
|
9281
9366
|
uploadFailed: uploadsFailed
|
|
9282
9367
|
})}
|
|
@@ -9287,6 +9372,8 @@ async function evalCommand(args, root) {
|
|
|
9287
9372
|
rows,
|
|
9288
9373
|
seconds: Math.round((Date.now() - evalStarted) / 1000)
|
|
9289
9374
|
});
|
|
9375
|
+
if (upload && !opts.json)
|
|
9376
|
+
uploadedSummary(upload.origin, results, runDir);
|
|
9290
9377
|
for (const r of results)
|
|
9291
9378
|
for (const c of r.cleanup)
|
|
9292
9379
|
if (c.code !== 0)
|