firegoat 0.17.1 → 0.19.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 +89 -13
- package/dist/cli.js +372 -143
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -7,12 +7,12 @@ Status: early. See `plan.md`.
|
|
|
7
7
|
## Quickstart
|
|
8
8
|
|
|
9
9
|
```bash
|
|
10
|
-
npx firegoat init # Creates firegoat.yaml and .firegoat/, installs the firegoat-onboarding skill, copies a prompt for your agent
|
|
10
|
+
npx firegoat@latest init # Creates firegoat.yaml and .firegoat/, installs the firegoat-onboarding skill, copies a prompt for your agent
|
|
11
11
|
# Edit firegoat.yaml (or paste the prompt into your coding agent and let it)
|
|
12
|
-
npx firegoat eval # Runs your claude on each test, in .firegoat/runs/<runId>/<test>/<n>/work
|
|
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. `claude` must be installed and logged in (run `claude` once).
|
|
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 0.18.0`), 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
|
|
|
@@ -98,6 +98,21 @@ tests:
|
|
|
98
98
|
npx firegoat eval --var SHA=$(git rev-parse HEAD)
|
|
99
99
|
```
|
|
100
100
|
|
|
101
|
+
### Start from a template project
|
|
102
|
+
|
|
103
|
+
```yaml
|
|
104
|
+
build: cp -R fixtures/starter-app "$FIREGOAT_OUT/starter-app"
|
|
105
|
+
install: cp -R "$FIREGOAT_OUT/starter-app/." .
|
|
106
|
+
tests:
|
|
107
|
+
- name: add-auth
|
|
108
|
+
prompt: Add acme login to this app. Keep the existing pages working.
|
|
109
|
+
end_goal: The app signs users in with acme, and the existing pages still render.
|
|
110
|
+
checks:
|
|
111
|
+
- run: npm test
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
No template feature needed: keep the project committed in `fixtures/starter-app/`, `build` copies it once per eval, and `install` copies it into each fresh folder, so every run begins in its own copy of that project.
|
|
115
|
+
|
|
101
116
|
### Same tests on several models
|
|
102
117
|
|
|
103
118
|
```yaml
|
|
@@ -113,11 +128,43 @@ tests:
|
|
|
113
128
|
|
|
114
129
|
`model:` is passed to claude as `--model`. A list is a matrix: each test runs `runs` times per model, numbered `1..runs×models` (here `quickstart/1`, `2` on haiku, `3`, `4` on sonnet). `npx firegoat eval --model claude-opus-5-5` replaces it for one eval; repeat the flag for a matrix. Unset, firegoat passes no `--model` and claude uses its default. Each run's `run.json` records `model` (what firegoat asked for, or null) and `claudeModel` (what claude reported starting with), and the output labels each run with its model.
|
|
115
130
|
|
|
116
|
-
###
|
|
131
|
+
### Compare two setups
|
|
132
|
+
|
|
133
|
+
```yaml
|
|
134
|
+
build: npm pack --pack-destination "$FIREGOAT_OUT"
|
|
135
|
+
tests:
|
|
136
|
+
- name: deploy-local-build
|
|
137
|
+
prompt: &deploy Deploy this app to an acme preview environment and print the URL.
|
|
138
|
+
end_goal: &deployed The app runs on an acme preview URL, and the URL is printed.
|
|
139
|
+
install: npm install "$FIREGOAT_OUT"/acme-*.tgz
|
|
140
|
+
- name: deploy-npm-release
|
|
141
|
+
prompt: *deploy
|
|
142
|
+
end_goal: *deployed
|
|
143
|
+
install: npm install acme@latest
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
There is no variants feature: to compare setups (your local build vs the npm release, with vs without `--chrome`, docs A vs docs B), write one test per setup with the same prompt and a different per-test `install:`, `env:` or `claude_args:`. A YAML anchor (`&deploy`) and alias (`*deploy`) keep the prompt in one place. Skills installed into the run's folder are not loaded as skills: eval runs claude with `--setting-sources ""` and `--disable-slash-commands`, so a with/without-skill comparison isn't possible yet.
|
|
147
|
+
|
|
148
|
+
### Extra claude flags
|
|
149
|
+
|
|
150
|
+
```yaml
|
|
151
|
+
claude_args: [--plugin-dir, ./plugins/acme] # Every test
|
|
152
|
+
tests:
|
|
153
|
+
- name: dashboard-key
|
|
154
|
+
prompt: Get an acme API key from the acme dashboard (I'm logged in in Chrome) and put it in .env as ACME_API_KEY.
|
|
155
|
+
end_goal: .env holds a key copied from the dashboard, not a made-up one.
|
|
156
|
+
claude_args: [--chrome] # Claude in Chrome, this test only
|
|
157
|
+
```
|
|
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`.
|
|
160
|
+
|
|
161
|
+
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
|
+
|
|
163
|
+
### Env vars for the agent: a feature flag, a tool that ignores HOME
|
|
117
164
|
|
|
118
165
|
```yaml
|
|
119
166
|
env:
|
|
120
|
-
ACME_HOME: "
|
|
167
|
+
ACME_HOME: "$FIREGOAT_HOME/.acme" # Only for a tool that ignores HOME
|
|
121
168
|
ACME_TELEMETRY: "off"
|
|
122
169
|
UV_FIND_LINKS: "$FIREGOAT_OUT" # Resolve the wheel build: made
|
|
123
170
|
tests:
|
|
@@ -130,21 +177,50 @@ tests:
|
|
|
130
177
|
- run: ls "$ACME_HOME"/sessions | grep -q .
|
|
131
178
|
```
|
|
132
179
|
|
|
133
|
-
`env:` goes to the agent, and to `install
|
|
180
|
+
`env:` goes to the agent, and to `install`, checks and `cleanup` of the same run, so checks see the same values. Placeholders: `{{vars.KEY}}`, `{{run.id}}` (the eval's run id), `{{run.n}}` (1..runs, or 1..runs×models), `{{test.name}}`, and `$FIREGOAT_OUT` / `$FIREGOAT_WORKDIR` / `$FIREGOAT_HOME` (also `${...}`), the same dirs `build`, `install` and checks see. There is no other `$` expansion: `$HOME` stays the literal text `$HOME`. `build` runs once, before any run: it gets only the top-level entries that use nothing but `{{vars.*}}`, `{{run.id}}` and `$FIREGOAT_OUT`. Values are strings (quote numbers). `PATH`, `CLAUDE_CONFIG_DIR` and `FIREGOAT_*` are firegoat's and can't be set; `ANTHROPIC_MODEL` neither: choose the model with `model:`. `HOME`, `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`, `XDG_DATA_HOME`, `XDG_STATE_HOME` and `CLAUDE_ENV_FILE` are reserved too: every run gets its own home.
|
|
181
|
+
|
|
182
|
+
### Each run gets its own HOME
|
|
183
|
+
|
|
184
|
+
Every run has its own home, `.firegoat/runs/<runId>/<test>/<n>/home/`, with `.config`, `.cache`, `.local/share` and `.local/state` for the XDG dirs. A tool that keeps logins, config or caches in `~` starts fresh in every run, and parallel runs don't collide. Point a tool's own `*_HOME` var at `$FIREGOAT_HOME` only if the tool ignores `HOME`.
|
|
185
|
+
|
|
186
|
+
- The agent's shell commands see it: firegoat writes `<n>/home.env` (exporting `HOME` and `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`, `XDG_DATA_HOME`, `XDG_STATE_HOME`) and passes it to claude as `CLAUDE_ENV_FILE`, which claude sources before every Bash command. claude itself keeps your real `HOME`, where its login is (the macOS keychain lookup, `~/.claude/.credentials.json` on Linux).
|
|
187
|
+
- `install`, checks and `cleanup` get `HOME` and the XDG vars set to the run's home, plus `$FIREGOAT_HOME`. `build` keeps your real home.
|
|
188
|
+
- `run.json` records it as `home`.
|
|
134
189
|
|
|
135
|
-
|
|
190
|
+
Limits:
|
|
191
|
+
|
|
192
|
+
- claude's own Read/Write/Edit tools expand `~` to your real home; only Bash commands get the run's home.
|
|
193
|
+
- Lookups by user id (`os.userInfo().homedir`, python's `pwd`, `getpwuid`) still return your real home.
|
|
194
|
+
- Keychain-backed logins (`gh`, `docker`, git credential helpers) look logged out inside a run. Log tools in from `install:`, or pass tokens with `env:`.
|
|
195
|
+
- The fresh home has no `.gitconfig`: if the agent commits, set `git config --global user.name ...` / `user.email` in `install:`.
|
|
196
|
+
- claude's shell snapshot still sources your real `~/.zshrc` (or `~/.bashrc`).
|
|
136
197
|
|
|
137
198
|
## What eval does
|
|
138
199
|
|
|
139
|
-
- claude runs on its own login (or `ANTHROPIC_API_KEY`, if set) with `CLAUDE_CONFIG_DIR=.firegoat/.claude` and `--setting-sources "" --strict-mcp-config --disable-slash-commands`: none of your settings, CLAUDE.md, plugins, skills or MCP servers. claude saves its session there, in its own format.
|
|
140
|
-
- No sandbox: the agent's folder is inside `.firegoat/runs/`, and it can reach the rest of your machine.
|
|
200
|
+
- 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) outside it, e.g. in `fixtures/` next to `firegoat.yaml`, and commit them.
|
|
141
202
|
- In a linked git worktree (`git worktree add`), `.firegoat/runs/` and `.firegoat/.claude/` are in the main checkout, so runs (and their session files) survive `git worktree remove`; `firegoat.yaml`, fixtures and `build:` stay in the worktree. Each `summary.json` records `dir` (where eval ran) and `git: {branch, commit, dirty}`, and `.firegoat/runs/index.jsonl` gets one line per eval (`runId, startedAt, dir, branch, commit, passed, failed, notGraded, cost, uploaded, uploadFailed`) to tell runs from different branches apart.
|
|
142
|
-
- The agent gets your environment plus `env:`, never `vars:`. Vars that npm/npx set for firegoat itself (`npm_config_*`, `npm_lifecycle_*`, `npm_package_*`, `npm_execpath`, `npm_node_execpath`, `npm_command`, `INIT_CWD`) are removed from every env firegoat starts (agent, build, install, checks): `npx -y firegoat` sets `npm_config_yes=true`, which would make the agent's `npm init` / `npx create-*` skip their prompts. Set one under `env:` to keep it. Every `FIREGOAT_*` var firegoat was started with (`FIREGOAT_API_KEY` above all) is removed too
|
|
143
|
-
- Runs (tests × models × `runs`) go 4 at a time, each in its own folder with its own session; `--parallel N` changes that, `--parallel 1` runs them one after another. `summary.json` lists them in `firegoat.yaml` order.
|
|
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`.
|
|
204
|
+
- 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.
|
|
144
205
|
- Script checks run in the agent's folder afterwards and set the exit code. `end_goal` is not graded by firegoat: it's for you, or your coding agent, to judge from `run.json`, the session and the folder.
|
|
145
|
-
- Output per run, in `.firegoat/runs/<runId>/<test>/<n>/`: `transcript.txt` (each tool call with its command and short output, the final answer, files made; checks get it as `$FIREGOAT_TRANSCRIPT`; greppable line by line: every line of a shell call starts with `[n] $ `, every line of the agent's text and final answer with `agent: `, output is indented, so `grep -E '^\[[0-9]+\] \$ .*npm install'` matches only commands, also inside heredocs and multi-line scripts), `run.json` (status, prompt, end_goal, check results, paths), `work/`, logs. Read the transcript to judge `end_goal`; claude's full session file is ~50x larger. `summary.json` covers the whole eval; `--json` prints it.
|
|
206
|
+
- 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.
|
|
207
|
+
|
|
208
|
+
`firegoat.yaml` keys: `runs`, `model`, `build`, `install`, `cleanup`, `vars`, `env`, `claude_args`, and `tests` (`name`, `prompt`, `end_goal`, `checks`, `timeout`, `env`, `install`, `cleanup`, `claude_args`). Unknown keys are errors. Full reference: `npx firegoat init --help`.
|
|
209
|
+
|
|
210
|
+
### Stop what the agent left running, seed one test
|
|
211
|
+
|
|
212
|
+
```yaml
|
|
213
|
+
install: npm init -y >/dev/null
|
|
214
|
+
cleanup: pkill -f "$FIREGOAT_WORKDIR" || true # A dev server the agent started
|
|
215
|
+
tests:
|
|
216
|
+
- name: migrate
|
|
217
|
+
prompt: Migrate the app in this folder to acme v2 and start it.
|
|
218
|
+
end_goal: The app runs on acme v2.
|
|
219
|
+
install: cp -R "$FIREGOAT_OUT"/v1-app/. . # This test only, after the top-level install
|
|
220
|
+
cleanup: acme down || true # This test only, before the top-level cleanup
|
|
221
|
+
```
|
|
146
222
|
|
|
147
|
-
`
|
|
223
|
+
Step order: `build` once per eval, in your folder; then per run, in its folder: `install` (top-level, then the test's), the agent, checks, `cleanup` (the test's, then top-level). `cleanup` runs whenever the run's folder exists: after the checks, and also after a failed install, a claude error or a timeout. Every cleanup step runs; a failing one prints a warning and is recorded in `run.json` (`cleanup: [{run, code}]`, output in `cleanup.txt`), but never changes the run's status or eval's exit code.
|
|
148
224
|
|
|
149
225
|
Want a plain `npm i acme-sdk` to get your build, with no hint in the prompt? See [`examples/local-npm-registry`](examples/local-npm-registry/README.md).
|
|
150
226
|
|
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.19.0",
|
|
6951
6951
|
repository: {
|
|
6952
6952
|
type: "git",
|
|
6953
6953
|
url: "git+https://github.com/firegoat-ai/firegoat-cli.git"
|
|
@@ -7078,16 +7078,20 @@ function parseConfig(text) {
|
|
|
7078
7078
|
"model",
|
|
7079
7079
|
"build",
|
|
7080
7080
|
"install",
|
|
7081
|
+
"cleanup",
|
|
7081
7082
|
"vars",
|
|
7082
7083
|
"env",
|
|
7084
|
+
"claude_args",
|
|
7083
7085
|
"tests"
|
|
7084
7086
|
]);
|
|
7085
7087
|
const runs = posInt(raw.runs, "runs", 1);
|
|
7086
7088
|
const models = parseModels(raw.model);
|
|
7087
7089
|
const build = commands(raw.build, "build");
|
|
7088
7090
|
const install = commands(raw.install, "install");
|
|
7091
|
+
const cleanup = commands(raw.cleanup, "cleanup");
|
|
7089
7092
|
const vars = parseVars(raw.vars);
|
|
7090
7093
|
const env = parseEnv(raw.env, "env");
|
|
7094
|
+
const claudeArgs = parseClaudeArgs(raw.claude_args, "claude_args");
|
|
7091
7095
|
if (!Array.isArray(raw.tests) || raw.tests.length === 0)
|
|
7092
7096
|
fail("tests", "must be a non-empty list");
|
|
7093
7097
|
const names = new Set;
|
|
@@ -7095,7 +7099,17 @@ function parseConfig(text) {
|
|
|
7095
7099
|
const p = `tests[${i}]`;
|
|
7096
7100
|
if (!isRecord(t))
|
|
7097
7101
|
fail(p, "must be a mapping");
|
|
7098
|
-
onlyKeys(t, p, [
|
|
7102
|
+
onlyKeys(t, p, [
|
|
7103
|
+
"name",
|
|
7104
|
+
"prompt",
|
|
7105
|
+
"end_goal",
|
|
7106
|
+
"checks",
|
|
7107
|
+
"timeout",
|
|
7108
|
+
"env",
|
|
7109
|
+
"install",
|
|
7110
|
+
"cleanup",
|
|
7111
|
+
"claude_args"
|
|
7112
|
+
]);
|
|
7099
7113
|
const name = str(t.name, `${p}.name`);
|
|
7100
7114
|
if (!NAME.test(name))
|
|
7101
7115
|
fail(`${p}.name`, "may only contain letters, digits, '.', '_' and '-'");
|
|
@@ -7119,7 +7133,13 @@ function parseConfig(text) {
|
|
|
7119
7133
|
end_goal: str(t.end_goal, `${p}.end_goal`),
|
|
7120
7134
|
checks,
|
|
7121
7135
|
timeout: posInt(t.timeout, `${p}.timeout`, DEFAULT_TIMEOUT),
|
|
7122
|
-
env: { ...env, ...parseEnv(t.env, `${p}.env`) }
|
|
7136
|
+
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`)
|
|
7142
|
+
]
|
|
7123
7143
|
};
|
|
7124
7144
|
});
|
|
7125
7145
|
return {
|
|
@@ -7127,6 +7147,7 @@ function parseConfig(text) {
|
|
|
7127
7147
|
models,
|
|
7128
7148
|
build,
|
|
7129
7149
|
install,
|
|
7150
|
+
cleanup,
|
|
7130
7151
|
vars,
|
|
7131
7152
|
env,
|
|
7132
7153
|
tests
|
|
@@ -7158,6 +7179,41 @@ function parseModels(raw) {
|
|
|
7158
7179
|
return name;
|
|
7159
7180
|
});
|
|
7160
7181
|
}
|
|
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) {
|
|
7203
|
+
if (raw === undefined)
|
|
7204
|
+
return [];
|
|
7205
|
+
if (!Array.isArray(raw))
|
|
7206
|
+
fail(path, "must be a list of claude arguments, one per entry, e.g. [--chrome]");
|
|
7207
|
+
return raw.map((a, i) => {
|
|
7208
|
+
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
|
+
return arg;
|
|
7215
|
+
});
|
|
7216
|
+
}
|
|
7161
7217
|
var VAR_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
7162
7218
|
function parseVars(raw) {
|
|
7163
7219
|
if (raw === undefined)
|
|
@@ -7179,10 +7235,18 @@ var RESERVED_ENV = new Set([
|
|
|
7179
7235
|
"CLAUDE_CONFIG_DIR",
|
|
7180
7236
|
"CLAUDE_SECURESTORAGE_CONFIG_DIR"
|
|
7181
7237
|
]);
|
|
7238
|
+
var HOME_ENV = new Set([
|
|
7239
|
+
"HOME",
|
|
7240
|
+
"XDG_CONFIG_HOME",
|
|
7241
|
+
"XDG_CACHE_HOME",
|
|
7242
|
+
"XDG_DATA_HOME",
|
|
7243
|
+
"XDG_STATE_HOME",
|
|
7244
|
+
"CLAUDE_ENV_FILE"
|
|
7245
|
+
]);
|
|
7182
7246
|
var RUN_PLACEHOLDERS = ["run.id", "run.n", "test.name"];
|
|
7183
7247
|
var EVAL_PLACEHOLDERS = ["run.id"];
|
|
7184
7248
|
var ANY_PLACEHOLDER = /\{\{\s*([^{}]*?)\s*\}\}/g;
|
|
7185
|
-
var DIR_REF = /\$(?:\{(FIREGOAT_OUT|FIREGOAT_WORKDIR)\}|(FIREGOAT_OUT|FIREGOAT_WORKDIR)(?![A-Za-z0-9_]))/g;
|
|
7249
|
+
var DIR_REF = /\$(?:\{(FIREGOAT_OUT|FIREGOAT_WORKDIR|FIREGOAT_HOME)\}|(FIREGOAT_OUT|FIREGOAT_WORKDIR|FIREGOAT_HOME)(?![A-Za-z0-9_]))/g;
|
|
7186
7250
|
var ENV_REF = new RegExp(`${ANY_PLACEHOLDER.source}|${DIR_REF.source}`, "g");
|
|
7187
7251
|
function parseEnv(raw, path) {
|
|
7188
7252
|
if (raw === undefined)
|
|
@@ -7196,6 +7260,8 @@ function parseEnv(raw, path) {
|
|
|
7196
7260
|
fail(p, "name must be letters, digits and _, not starting with a digit");
|
|
7197
7261
|
if (k === "ANTHROPIC_MODEL")
|
|
7198
7262
|
fail(p, "is reserved: choose the model with top-level model: (or eval --model)");
|
|
7263
|
+
if (HOME_ENV.has(k))
|
|
7264
|
+
fail(p, "Reserved: every run gets its own home ($FIREGOAT_HOME)");
|
|
7199
7265
|
if (RESERVED_ENV.has(k) || k.startsWith("FIREGOAT_"))
|
|
7200
7266
|
fail(p, "is set by firegoat and cannot be overridden (reserved: PATH, CLAUDE_CONFIG_DIR, CLAUDE_SECURESTORAGE_CONFIG_DIR, FIREGOAT_*)");
|
|
7201
7267
|
if (typeof v !== "string")
|
|
@@ -7252,12 +7318,12 @@ import { randomBytes as randomBytes2 } from "node:crypto";
|
|
|
7252
7318
|
import {
|
|
7253
7319
|
appendFileSync,
|
|
7254
7320
|
createWriteStream as createWriteStream2,
|
|
7255
|
-
existsSync as
|
|
7256
|
-
mkdirSync as
|
|
7321
|
+
existsSync as existsSync3,
|
|
7322
|
+
mkdirSync as mkdirSync4,
|
|
7257
7323
|
readdirSync as readdirSync2,
|
|
7258
|
-
writeFileSync as
|
|
7324
|
+
writeFileSync as writeFileSync4
|
|
7259
7325
|
} from "node:fs";
|
|
7260
|
-
import { basename as basename3, delimiter as delimiter2, join as
|
|
7326
|
+
import { basename as basename3, delimiter as delimiter2, join as join6 } from "node:path";
|
|
7261
7327
|
|
|
7262
7328
|
// src/claude.ts
|
|
7263
7329
|
import { spawn, spawnSync } from "node:child_process";
|
|
@@ -7272,15 +7338,18 @@ var CLAUDE_FLAGS = [
|
|
|
7272
7338
|
"--disable-slash-commands"
|
|
7273
7339
|
];
|
|
7274
7340
|
var NPM_INJECTED = /^(npm_config_|npm_lifecycle_|npm_package_)|^(npm_execpath|npm_node_execpath|npm_command|INIT_CWD)$/;
|
|
7341
|
+
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)$/;
|
|
7342
|
+
var CLAUDE_AUTH = /^CLAUDE_CODE_(OAUTH_TOKEN|OAUTH_REFRESH_TOKEN|USE_[A-Z_]+|SKIP_[A-Z]+_AUTH|CLIENT_CERT|CLIENT_KEY|CLIENT_KEY_PASSPHRASE)$/;
|
|
7275
7343
|
function hostEnv() {
|
|
7276
|
-
return Object.fromEntries(Object.entries(process.env).filter(([k]) => !NPM_INJECTED.test(k) && !k.startsWith("FIREGOAT_")));
|
|
7344
|
+
return Object.fromEntries(Object.entries(process.env).filter(([k]) => !NPM_INJECTED.test(k) && !k.startsWith("FIREGOAT_") && (!CLAUDE_SESSION.test(k) || CLAUDE_AUTH.test(k))));
|
|
7277
7345
|
}
|
|
7278
|
-
function claudeEnv(configDir, env = {}) {
|
|
7346
|
+
function claudeEnv(configDir, env = {}, envFile) {
|
|
7279
7347
|
return {
|
|
7280
7348
|
...hostEnv(),
|
|
7281
7349
|
...env,
|
|
7282
7350
|
CLAUDE_CONFIG_DIR: configDir,
|
|
7283
|
-
CLAUDE_SECURESTORAGE_CONFIG_DIR: ""
|
|
7351
|
+
CLAUDE_SECURESTORAGE_CONFIG_DIR: "",
|
|
7352
|
+
...envFile === undefined ? {} : { CLAUDE_ENV_FILE: envFile }
|
|
7284
7353
|
};
|
|
7285
7354
|
}
|
|
7286
7355
|
function run(opts) {
|
|
@@ -7323,7 +7392,8 @@ async function runClaude(opts) {
|
|
|
7323
7392
|
"-p",
|
|
7324
7393
|
opts.prompt,
|
|
7325
7394
|
...CLAUDE_FLAGS,
|
|
7326
|
-
...opts.model === undefined ? [] : ["--model", opts.model]
|
|
7395
|
+
...opts.model === undefined ? [] : ["--model", opts.model],
|
|
7396
|
+
...opts.extraArgs
|
|
7327
7397
|
],
|
|
7328
7398
|
cwd: opts.cwd,
|
|
7329
7399
|
env: opts.env,
|
|
@@ -7732,28 +7802,14 @@ function resolveKey(base) {
|
|
|
7732
7802
|
var EXIT = { pass: 0, fail: 1, notGraded: 2, usage: 3 };
|
|
7733
7803
|
|
|
7734
7804
|
// src/firegoat-dir.ts
|
|
7735
|
-
import {
|
|
7805
|
+
import { mkdirSync as mkdirSync2, writeFileSync } from "node:fs";
|
|
7736
7806
|
import { join as join2 } from "node:path";
|
|
7737
|
-
var GITIGNORE =
|
|
7738
|
-
.claude/
|
|
7739
|
-
`;
|
|
7740
|
-
var README = `# .firegoat
|
|
7741
|
-
|
|
7742
|
-
Files for the evals in firegoat.yaml (run them with \`npx firegoat eval\`).
|
|
7743
|
-
|
|
7744
|
-
- \`fixtures/\`: test fixtures (helper scripts, sample data) for build:, install: and checks. Commit them.
|
|
7745
|
-
- \`runs/\`: one folder per eval (transcripts, sessions, check output). Ignored by git.
|
|
7746
|
-
- \`.claude/\`: the empty claude config eval runs the agent with. Ignored by git.
|
|
7747
|
-
|
|
7748
|
-
Reference: \`npx firegoat init --help\`.
|
|
7807
|
+
var GITIGNORE = `*
|
|
7749
7808
|
`;
|
|
7750
7809
|
function ensureFiregoatDir(root) {
|
|
7751
7810
|
const dir = join2(root, ".firegoat");
|
|
7752
|
-
mkdirSync2(
|
|
7811
|
+
mkdirSync2(dir, { recursive: true });
|
|
7753
7812
|
writeFileSync(join2(dir, ".gitignore"), GITIGNORE);
|
|
7754
|
-
const readme = join2(dir, "README.md");
|
|
7755
|
-
if (!existsSync2(readme))
|
|
7756
|
-
writeFileSync(readme, README);
|
|
7757
7813
|
return dir;
|
|
7758
7814
|
}
|
|
7759
7815
|
|
|
@@ -7822,21 +7878,25 @@ var EXIT_CODES = `Exit codes:
|
|
|
7822
7878
|
3 Usage or config error (bad flag, unknown test, invalid or missing firegoat.yaml,
|
|
7823
7879
|
firegoat.ai answered 401/403 for the upload key); any other failure of
|
|
7824
7880
|
that key check exits 2
|
|
7825
|
-
Upload failures don't change the exit code.`;
|
|
7881
|
+
Upload and cleanup failures don't change the exit code.`;
|
|
7826
7882
|
function topHelp(version) {
|
|
7827
7883
|
return `firegoat ${version}: local AX evals. Your own claude gets one prompt in a fresh
|
|
7828
7884
|
folder, with none of your claude config; firegoat saves the session and runs your
|
|
7829
7885
|
script checks.
|
|
7830
7886
|
|
|
7831
|
-
npx firegoat init Create firegoat.yaml and .firegoat/, install the skill
|
|
7832
|
-
npx firegoat eval Run the tests (claude must be logged in: run \`claude\`
|
|
7833
|
-
npx firegoat login Log in to firegoat.ai: eval then uploads each session
|
|
7834
|
-
npx firegoat logout Forget that login
|
|
7835
|
-
npx firegoat feedback "<what got in the way>"
|
|
7887
|
+
npx firegoat@latest init Create firegoat.yaml and .firegoat/, install the skill
|
|
7888
|
+
npx firegoat@latest eval Run the tests (claude must be logged in: run \`claude\`)
|
|
7889
|
+
npx firegoat@latest login Log in to firegoat.ai: eval then uploads each session
|
|
7890
|
+
npx firegoat@latest logout Forget that login
|
|
7891
|
+
npx firegoat@latest feedback "<what got in the way>"
|
|
7892
|
+
Tell the firegoat team
|
|
7893
|
+
|
|
7894
|
+
Use npx firegoat@latest: plain npx firegoat can keep running an old cached version.
|
|
7895
|
+
Each eval prints its version first.
|
|
7836
7896
|
|
|
7837
7897
|
Typical flow for a coding agent driving firegoat:
|
|
7838
|
-
1. Read npx firegoat init --help, then edit firegoat.yaml
|
|
7839
|
-
2. npx firegoat eval --json
|
|
7898
|
+
1. Read npx firegoat@latest init --help, then edit firegoat.yaml
|
|
7899
|
+
2. npx firegoat@latest eval --json
|
|
7840
7900
|
3. Judge each run's end_goal from its run.json, session and workdir: firegoat only
|
|
7841
7901
|
runs the script checks
|
|
7842
7902
|
|
|
@@ -7850,9 +7910,9 @@ In the current folder: creates .firegoat/ and a starter firegoat.yaml, installs
|
|
|
7850
7910
|
firegoat-onboarding skill globally, into ~/.claude/skills/ (npx skills add
|
|
7851
7911
|
firegoat-ai/skills -g; in a terminal it asks: global, this repo, or none), offers
|
|
7852
7912
|
firegoat login (in a terminal only; otherwise prints how), then prints a prompt to
|
|
7853
|
-
paste into your coding agent.
|
|
7854
|
-
|
|
7855
|
-
|
|
7913
|
+
paste into your coding agent. .firegoat/ holds local run output and ignores itself
|
|
7914
|
+
in git. Keep test fixtures (helper scripts, sample data) outside it, e.g. in
|
|
7915
|
+
fixtures/ next to firegoat.yaml, and commit them.
|
|
7856
7916
|
|
|
7857
7917
|
--force Replace an existing firegoat.yaml
|
|
7858
7918
|
--local-skills Install the skill into this repo's .claude/skills/ instead
|
|
@@ -7868,26 +7928,41 @@ firegoat.yaml reference:
|
|
|
7868
7928
|
--model, claude's default. eval --model overrides
|
|
7869
7929
|
build: <sh> | [<sh>, ...] Once per eval, in this folder; what it writes to
|
|
7870
7930
|
$FIREGOAT_OUT is first on the agent's PATH
|
|
7871
|
-
install: <sh> | [<sh>, ...] In each fresh folder, before the agent starts
|
|
7931
|
+
install: <sh> | [<sh>, ...] In each fresh folder, before the agent starts; a
|
|
7932
|
+
failure: that run is not graded
|
|
7933
|
+
cleanup: <sh> | [<sh>, ...] In each run's folder once the run is over: after the
|
|
7934
|
+
checks, and also after an error or timeout. Stops
|
|
7935
|
+
what the agent left running (servers, daemons).
|
|
7936
|
+
Every step runs; a failure is a warning (run.json
|
|
7937
|
+
cleanup, <test>/<n>/cleanup.txt), never the verdict
|
|
7872
7938
|
vars: {KEY: value} Defaults for {{vars.KEY}} in prompts and checks;
|
|
7873
7939
|
--var KEY=value overrides
|
|
7874
|
-
env: {NAME: value} Env vars for the agent, and for install
|
|
7875
|
-
|
|
7940
|
+
env: {NAME: value} Env vars for the agent, and for install, checks
|
|
7941
|
+
and cleanup of the same run; values are strings and can
|
|
7876
7942
|
use {{vars.KEY}}, {{run.id}}, {{run.n}},
|
|
7877
|
-
{{test.name}}, $FIREGOAT_OUT, $FIREGOAT_WORKDIR
|
|
7878
|
-
(no other $ expansion: $HOME stays
|
|
7879
|
-
The build step gets only the entries that
|
|
7880
|
-
{{vars.*}}, {{run.id}} and $FIREGOAT_OUT.
|
|
7943
|
+
{{test.name}}, $FIREGOAT_OUT, $FIREGOAT_WORKDIR,
|
|
7944
|
+
$FIREGOAT_HOME (no other $ expansion: $HOME stays
|
|
7945
|
+
literal). The build step gets only the entries that
|
|
7946
|
+
use only {{vars.*}}, {{run.id}} and $FIREGOAT_OUT.
|
|
7881
7947
|
PATH, CLAUDE_CONFIG_DIR, FIREGOAT_* and
|
|
7882
|
-
ANTHROPIC_MODEL (use model:) are reserved
|
|
7883
|
-
|
|
7884
|
-
|
|
7885
|
-
|
|
7886
|
-
|
|
7887
|
-
|
|
7888
|
-
|
|
7889
|
-
|
|
7890
|
-
|
|
7948
|
+
ANTHROPIC_MODEL (use model:) are reserved, and so
|
|
7949
|
+
are HOME, XDG_CONFIG_HOME, XDG_CACHE_HOME,
|
|
7950
|
+
XDG_DATA_HOME, XDG_STATE_HOME and CLAUDE_ENV_FILE:
|
|
7951
|
+
every run gets its own home ($FIREGOAT_HOME), so a
|
|
7952
|
+
tool that keeps state in ~ starts fresh in each
|
|
7953
|
+
run. Only a tool that ignores HOME needs its own
|
|
7954
|
+
var, e.g. MYTOOL_HOME: "$FIREGOAT_HOME/.mytool"
|
|
7955
|
+
claude_args: [<arg>, ...] Extra claude arguments, after firegoat's own flags
|
|
7956
|
+
and --model. Each entry is one argument, passed
|
|
7957
|
+
as-is (no shell splitting): [--chrome] (Claude in
|
|
7958
|
+
Chrome, for tests that need a browser),
|
|
7959
|
+
[--plugin-dir, ./plugins/x], --mcp-config, --agents,
|
|
7960
|
+
--append-system-prompt. Flags firegoat sets are
|
|
7961
|
+
errors, also as --flag=value: -p, --print,
|
|
7962
|
+
--output-format, --verbose, --setting-sources,
|
|
7963
|
+
--strict-mcp-config, --disable-slash-commands,
|
|
7964
|
+
--dangerously-skip-permissions, --model (use
|
|
7965
|
+
model:), --session-id, --resume, -r, --continue, -c
|
|
7891
7966
|
tests:
|
|
7892
7967
|
- name: quickstart Letters, digits, '.', '_', '-'; unique
|
|
7893
7968
|
prompt: ... What a user would ask the agent; one concrete outcome
|
|
@@ -7897,11 +7972,22 @@ firegoat.yaml reference:
|
|
|
7897
7972
|
- run: ... Afterwards; exit 0 = pass; they set eval's exit code
|
|
7898
7973
|
timeout: 600 Optional, seconds
|
|
7899
7974
|
env: {NAME: value} Optional, on top of the top-level env:
|
|
7975
|
+
install: <sh> | [...] Optional, runs after the top-level install
|
|
7976
|
+
cleanup: <sh> | [...] Optional, runs before the top-level cleanup
|
|
7977
|
+
claude_args: [...] Optional, after the top-level claude_args
|
|
7978
|
+
|
|
7979
|
+
Step order: build once per eval; then per run: install (top-level, then the
|
|
7980
|
+
test's), the agent, checks, cleanup (the test's, then top-level).
|
|
7900
7981
|
|
|
7901
7982
|
Unknown keys are errors. Keep helper files out of firegoat.yaml: put them in
|
|
7902
|
-
.
|
|
7903
|
-
|
|
7904
|
-
|
|
7983
|
+
e.g. fixtures/ (committed) and copy them in build or install. To start each run
|
|
7984
|
+
from a template project, commit it as e.g. fixtures/starter-app/, then:
|
|
7985
|
+
build: cp -R fixtures/starter-app "$FIREGOAT_OUT/starter-app"
|
|
7986
|
+
install: cp -R "$FIREGOAT_OUT/starter-app/." .
|
|
7987
|
+
The build, install and cleanup commands and the checks get the vars as env
|
|
7988
|
+
vars, plus $FIREGOAT_OUT and $FIREGOAT_WORKDIR; install, checks and cleanup also
|
|
7989
|
+
get $FIREGOAT_HOME, the run's own home, as HOME (build keeps your real HOME).
|
|
7990
|
+
Checks also get $FIREGOAT_TRANSCRIPT, a short plain-text
|
|
7905
7991
|
transcript, greppable line by line: "[1] $ <command>" on every line of a shell
|
|
7906
7992
|
call (one line per line of the command, same n), "[2] Write <path>" for other
|
|
7907
7993
|
tools, each output indented, and "agent: " on every line of the agent's text and
|
|
@@ -7917,6 +8003,7 @@ firegoat.yaml reference:
|
|
|
7917
8003
|
Example:
|
|
7918
8004
|
build: npm pack --pack-destination "$FIREGOAT_OUT"
|
|
7919
8005
|
install: npm init -y >/dev/null && npm install "$FIREGOAT_OUT"/*.tgz
|
|
8006
|
+
cleanup: pkill -f "$FIREGOAT_WORKDIR" || true
|
|
7920
8007
|
tests:
|
|
7921
8008
|
- name: first-call
|
|
7922
8009
|
prompt: >-
|
|
@@ -7930,6 +8017,12 @@ Example:
|
|
|
7930
8017
|
- run: |
|
|
7931
8018
|
test -s "$FIREGOAT_TRANSCRIPT" &&
|
|
7932
8019
|
! grep -Eq '^\\[[0-9]+\\] \\$ .*npm (install|i|add) .*acme' "$FIREGOAT_TRANSCRIPT"
|
|
8020
|
+
- name: dashboard-key
|
|
8021
|
+
prompt: >-
|
|
8022
|
+
Get an acme API key from the acme dashboard (I'm logged in in Chrome) and put
|
|
8023
|
+
it in .env as ACME_API_KEY.
|
|
8024
|
+
end_goal: .env holds a key copied from the dashboard, not a made-up one.
|
|
8025
|
+
claude_args: [--chrome]
|
|
7933
8026
|
|
|
7934
8027
|
Exit codes: 0 done, 1 the firegoat login failed (the files stay), 2 the skill
|
|
7935
8028
|
install failed, 3 usage error.`;
|
|
@@ -7973,11 +8066,38 @@ Runs each test in firegoat.yaml (in the current folder): your claude gets the pr
|
|
|
7973
8066
|
in a fresh, empty folder, .firegoat/runs/<runId>/<test>/<n>/work, with build:'s
|
|
7974
8067
|
outputs on PATH. Then the test's checks run in that folder.
|
|
7975
8068
|
|
|
8069
|
+
Step order: build: once, in this folder; then per run, in its folder: install:
|
|
8070
|
+
(top-level, then the test's), the agent, checks, cleanup: (the test's, then
|
|
8071
|
+
top-level). cleanup: runs whenever the folder exists, also after a failed install,
|
|
8072
|
+
a claude error or a timeout; a failing cleanup step prints a warning and never
|
|
8073
|
+
changes the run's status or the exit code.
|
|
8074
|
+
|
|
7976
8075
|
claude runs on its own login (or ANTHROPIC_API_KEY if set, as claude always does)
|
|
7977
8076
|
with a fresh config dir, .firegoat/.claude: none of your settings, CLAUDE.md,
|
|
7978
|
-
plugins, skills or MCP servers
|
|
8077
|
+
plugins, skills or MCP servers (claude_args: in firegoat.yaml adds claude flags,
|
|
8078
|
+
e.g. --chrome or --plugin-dir). claude saves its session there. There is no
|
|
7979
8079
|
sandbox: the agent can reach the rest of your machine, including the repo.
|
|
7980
8080
|
|
|
8081
|
+
Every run gets its own HOME, .firegoat/runs/<runId>/<test>/<n>/home (with
|
|
8082
|
+
XDG_CONFIG_HOME, XDG_CACHE_HOME, XDG_DATA_HOME, XDG_STATE_HOME inside it), as
|
|
8083
|
+
$FIREGOAT_HOME: tools that keep state in ~ start fresh and parallel runs don't
|
|
8084
|
+
collide. claude itself keeps your real HOME (its login is there) and sources
|
|
8085
|
+
<n>/home.env (CLAUDE_ENV_FILE) before each Bash command the agent runs; install,
|
|
8086
|
+
checks and cleanup get the run's HOME directly; build keeps yours. Limits: claude's
|
|
8087
|
+
own Read/Write tools expand ~ to your real home; getpwuid lookups (os.userInfo,
|
|
8088
|
+
python pwd) still see it; keychain logins (gh, docker, git credential helpers) look
|
|
8089
|
+
logged out inside a run, so log tools in from install: or with env: tokens; the
|
|
8090
|
+
fresh home has no .gitconfig, so set user.name/user.email in install: if the agent
|
|
8091
|
+
commits; claude's shell snapshot still sources your real ~/.zshrc.
|
|
8092
|
+
|
|
8093
|
+
Vars a parent Claude Code session sets (CLAUDECODE, CLAUDE_CODE_*, CLAUDE_PID,
|
|
8094
|
+
CLAUDE_ENV_FILE, CLAUDE_SESSION_ID, CLAUDE_PROJECT_DIR, CLAUDE_EFFORT,
|
|
8095
|
+
CLAUDE_AGENT_SDK_*, CLAUDE_PLUGIN_ROOT, CLAUDE_PLUGIN_DATA, CLAUDE_SKILL_DIR) are
|
|
8096
|
+
removed from every env firegoat starts, so the claude under test doesn't act as a
|
|
8097
|
+
nested session. Login and provider vars stay: ANTHROPIC_*, CLAUDE_CODE_OAUTH_TOKEN,
|
|
8098
|
+
CLAUDE_CODE_OAUTH_REFRESH_TOKEN, CLAUDE_CODE_USE_*, CLAUDE_CODE_SKIP_*_AUTH,
|
|
8099
|
+
CLAUDE_CODE_CLIENT_CERT, CLAUDE_CODE_CLIENT_KEY, CLAUDE_CODE_CLIENT_KEY_PASSPHRASE.
|
|
8100
|
+
|
|
7981
8101
|
In a linked git worktree (git worktree add), .firegoat/runs/ and .firegoat/.claude
|
|
7982
8102
|
live in the main checkout instead, so runs outlive the worktree; firegoat.yaml,
|
|
7983
8103
|
fixtures and build: stay in the current folder.
|
|
@@ -7987,9 +8107,10 @@ fixtures and build: stay in the current folder.
|
|
|
7987
8107
|
--model NAME claude --model, replacing model: in firegoat.yaml; repeat it
|
|
7988
8108
|
(--model a --model b) to run each test runs times per model
|
|
7989
8109
|
--parallel N Runs at once (default 4; 1 runs them one after another).
|
|
7990
|
-
|
|
7991
|
-
|
|
7992
|
-
--var KEY=value Fills {{vars.KEY}}; env for build, install and
|
|
8110
|
+
Each run has its own HOME; state outside it (a fixed port,
|
|
8111
|
+
/tmp paths) can still collide: use 1 then
|
|
8112
|
+
--var KEY=value Fills {{vars.KEY}}; env for build, install, checks and
|
|
8113
|
+
cleanup
|
|
7993
8114
|
--json The summary as JSON on stdout (or {error, exitCode} if eval
|
|
7994
8115
|
stops early); progress goes to stderr
|
|
7995
8116
|
--no-upload Keep runs local even when logged in to firegoat.ai
|
|
@@ -8015,17 +8136,21 @@ Output: .firegoat/runs/<runId>/
|
|
|
8015
8136
|
uploadFailed
|
|
8016
8137
|
<test>/<n>/run.json One run: status and reason (from the checks), model
|
|
8017
8138
|
(asked for, or null) and claudeModel (what claude's
|
|
8018
|
-
init line reported),
|
|
8019
|
-
|
|
8139
|
+
init line reported), claudeArgs (claude_args
|
|
8140
|
+
passed), prompt, end_goal,
|
|
8141
|
+
checks [{run, code}], cleanup [{run, code}],
|
|
8142
|
+
and paths: workdir, home (the run's HOME),
|
|
8020
8143
|
transcript, session (claude's session file), stderr,
|
|
8021
|
-
checksLog; startedAt; upload ({status: "uploaded",
|
|
8144
|
+
checksLog, cleanupLog; startedAt; upload ({status: "uploaded",
|
|
8022
8145
|
url} | {status: "failed", error} | {status:
|
|
8023
8146
|
"skipped", reason}, null when not uploading)
|
|
8024
8147
|
<test>/<n>/transcript.txt What the agent did: each tool call with its command
|
|
8025
8148
|
("[n] $ " on every line of a shell call), short output
|
|
8026
8149
|
(indented), its text and final answer ("agent: " on
|
|
8027
8150
|
every line), files it made
|
|
8028
|
-
|
|
8151
|
+
<test>/<n>/home/, home.env The run's HOME, and the file claude sources
|
|
8152
|
+
before the agent's Bash commands
|
|
8153
|
+
build.txt, <test>/<n>/install.txt, checks.txt, cleanup.txt Logs
|
|
8029
8154
|
|
|
8030
8155
|
Grading: firegoat runs only the script checks; they set the exit code. end_goal is
|
|
8031
8156
|
yours to judge: read transcript.txt first (a few KB); the session file has everything
|
|
@@ -8063,6 +8188,30 @@ Example:
|
|
|
8063
8188
|
|
|
8064
8189
|
Exit codes: 0 sent, 2 the service failed (or rate limit), 3 usage error.`;
|
|
8065
8190
|
|
|
8191
|
+
// src/home.ts
|
|
8192
|
+
import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "node:fs";
|
|
8193
|
+
import { join as join3 } from "node:path";
|
|
8194
|
+
function homeEnv(home) {
|
|
8195
|
+
return {
|
|
8196
|
+
HOME: home,
|
|
8197
|
+
XDG_CONFIG_HOME: join3(home, ".config"),
|
|
8198
|
+
XDG_CACHE_HOME: join3(home, ".cache"),
|
|
8199
|
+
XDG_DATA_HOME: join3(home, ".local", "share"),
|
|
8200
|
+
XDG_STATE_HOME: join3(home, ".local", "state")
|
|
8201
|
+
};
|
|
8202
|
+
}
|
|
8203
|
+
var shellQuote = (s) => `'${s.replaceAll("'", `'\\''`)}'`;
|
|
8204
|
+
function makeRunHome(dir) {
|
|
8205
|
+
const home = join3(dir, "home");
|
|
8206
|
+
const env = homeEnv(home);
|
|
8207
|
+
for (const d of Object.values(env))
|
|
8208
|
+
mkdirSync3(d, { recursive: true });
|
|
8209
|
+
const envFile = join3(dir, "home.env");
|
|
8210
|
+
writeFileSync2(envFile, Object.entries(env).map(([k, v]) => `export ${k}=${shellQuote(v)}
|
|
8211
|
+
`).join(""));
|
|
8212
|
+
return { home, envFile };
|
|
8213
|
+
}
|
|
8214
|
+
|
|
8066
8215
|
// src/steps.ts
|
|
8067
8216
|
import { createWriteStream } from "node:fs";
|
|
8068
8217
|
var STEP_TIMEOUT_MS = 600000;
|
|
@@ -8085,10 +8234,27 @@ async function runSteps(opts) {
|
|
|
8085
8234
|
await new Promise((r) => log.end(r));
|
|
8086
8235
|
}
|
|
8087
8236
|
}
|
|
8237
|
+
async function runAllSteps(opts) {
|
|
8238
|
+
const log = createWriteStream(opts.logPath);
|
|
8239
|
+
const outcomes = [];
|
|
8240
|
+
try {
|
|
8241
|
+
for (const run of opts.commands)
|
|
8242
|
+
outcomes.push(await runShell({
|
|
8243
|
+
run,
|
|
8244
|
+
cwd: opts.cwd,
|
|
8245
|
+
env: opts.env,
|
|
8246
|
+
timeoutMs: STEP_TIMEOUT_MS,
|
|
8247
|
+
log
|
|
8248
|
+
}));
|
|
8249
|
+
return outcomes;
|
|
8250
|
+
} finally {
|
|
8251
|
+
await new Promise((r) => log.end(r));
|
|
8252
|
+
}
|
|
8253
|
+
}
|
|
8088
8254
|
|
|
8089
8255
|
// src/transcript.ts
|
|
8090
|
-
import { readdirSync, statSync, writeFileSync as
|
|
8091
|
-
import { join as
|
|
8256
|
+
import { readdirSync, statSync, writeFileSync as writeFileSync3 } from "node:fs";
|
|
8257
|
+
import { join as join4, relative } from "node:path";
|
|
8092
8258
|
var OUTPUT_LINES = 15;
|
|
8093
8259
|
var OUTPUT_CHARS = 1500;
|
|
8094
8260
|
var TEXT_CHARS = 1500;
|
|
@@ -8132,7 +8298,7 @@ function workdirFiles(dir) {
|
|
|
8132
8298
|
for (const name of readdirSync(d)) {
|
|
8133
8299
|
if (SKIP_DIRS.has(name))
|
|
8134
8300
|
continue;
|
|
8135
|
-
const p =
|
|
8301
|
+
const p = join4(d, name);
|
|
8136
8302
|
if (statSync(p).isDirectory())
|
|
8137
8303
|
walk(p);
|
|
8138
8304
|
else
|
|
@@ -8175,7 +8341,7 @@ ${indent(truncate(text, OUTPUT_LINES, OUTPUT_CHARS))}` : ` → ${status}`);
|
|
|
8175
8341
|
out.pop();
|
|
8176
8342
|
out.push("", "## final answer", final ? prose(final) : "(none)");
|
|
8177
8343
|
out.push("", "## files in the workdir", ...workdirFiles(opts.workdir));
|
|
8178
|
-
|
|
8344
|
+
writeFileSync3(opts.path, `${out.join(`
|
|
8179
8345
|
`)}
|
|
8180
8346
|
`);
|
|
8181
8347
|
}
|
|
@@ -8355,7 +8521,7 @@ function ttyUI(out, opts) {
|
|
|
8355
8521
|
const doneCount = c.pass + c.fail + c.error;
|
|
8356
8522
|
const badge = finished ? `DONE · RUN ${finished.runId.slice(-4)}` : `RUNNING · ${doneCount}/${rows.length}`;
|
|
8357
8523
|
const out = [
|
|
8358
|
-
...logoHeader(p, `firegoat eval · ${opts.project}`, p(finished ? C.green : C.ember, badge), w),
|
|
8524
|
+
...logoHeader(p, `firegoat ${opts.version} eval · ${opts.project}`, p(finished ? C.green : C.ember, badge), w),
|
|
8359
8525
|
rule
|
|
8360
8526
|
];
|
|
8361
8527
|
if (authText)
|
|
@@ -8459,7 +8625,7 @@ function ttyUI(out, opts) {
|
|
|
8459
8625
|
}
|
|
8460
8626
|
|
|
8461
8627
|
// src/upload.ts
|
|
8462
|
-
import { existsSync as
|
|
8628
|
+
import { existsSync as existsSync2, readFileSync as readFileSync3 } from "node:fs";
|
|
8463
8629
|
import { basename as basename2 } from "node:path";
|
|
8464
8630
|
var MAX_FILE = 20 * 1024 * 1024;
|
|
8465
8631
|
var MAX_CHECKS = 200;
|
|
@@ -8535,7 +8701,7 @@ async function uploadRun(t, r) {
|
|
|
8535
8701
|
};
|
|
8536
8702
|
const bytes = {};
|
|
8537
8703
|
for (const name of FILES) {
|
|
8538
|
-
const b =
|
|
8704
|
+
const b = existsSync2(paths[name]) ? readFileSync3(paths[name]) : Buffer.alloc(0);
|
|
8539
8705
|
if (b.byteLength > MAX_FILE)
|
|
8540
8706
|
return {
|
|
8541
8707
|
status: "failed",
|
|
@@ -8616,7 +8782,7 @@ async function uploadRun(t, r) {
|
|
|
8616
8782
|
|
|
8617
8783
|
// src/which.ts
|
|
8618
8784
|
import { accessSync, constants, statSync as statSync2 } from "node:fs";
|
|
8619
|
-
import { delimiter, join as
|
|
8785
|
+
import { delimiter, join as join5, resolve } from "node:path";
|
|
8620
8786
|
function resolveBin(bin) {
|
|
8621
8787
|
const executable = (p) => {
|
|
8622
8788
|
try {
|
|
@@ -8629,8 +8795,8 @@ function resolveBin(bin) {
|
|
|
8629
8795
|
if (bin.includes("/"))
|
|
8630
8796
|
return executable(bin) ? resolve(bin) : undefined;
|
|
8631
8797
|
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
8632
|
-
if (dir && executable(
|
|
8633
|
-
return
|
|
8798
|
+
if (dir && executable(join5(dir, bin)))
|
|
8799
|
+
return join5(dir, bin);
|
|
8634
8800
|
}
|
|
8635
8801
|
return;
|
|
8636
8802
|
}
|
|
@@ -8703,12 +8869,12 @@ function parseArgs(args) {
|
|
|
8703
8869
|
return opts;
|
|
8704
8870
|
}
|
|
8705
8871
|
function findSession(configDir, sessionId) {
|
|
8706
|
-
const projects =
|
|
8707
|
-
if (!
|
|
8872
|
+
const projects = join6(configDir, "projects");
|
|
8873
|
+
if (!existsSync3(projects))
|
|
8708
8874
|
return null;
|
|
8709
8875
|
for (const d of readdirSync2(projects)) {
|
|
8710
|
-
const f =
|
|
8711
|
-
if (
|
|
8876
|
+
const f = join6(projects, d, `${sessionId}.jsonl`);
|
|
8877
|
+
if (existsSync3(f))
|
|
8712
8878
|
return f;
|
|
8713
8879
|
}
|
|
8714
8880
|
return null;
|
|
@@ -8718,7 +8884,7 @@ function newRunId() {
|
|
|
8718
8884
|
return `${ts}-${randomBytes2(2).toString("hex")}`;
|
|
8719
8885
|
}
|
|
8720
8886
|
function writeRunJson(r) {
|
|
8721
|
-
|
|
8887
|
+
writeFileSync4(join6(r.dir, "run.json"), `${JSON.stringify(r, null, 2)}
|
|
8722
8888
|
`);
|
|
8723
8889
|
}
|
|
8724
8890
|
async function uploadPreflight(opts, ui, project, runGroup, git) {
|
|
@@ -8760,14 +8926,16 @@ async function uploadPreflight(opts, ui, project, runGroup, git) {
|
|
|
8760
8926
|
async function runOne(opts) {
|
|
8761
8927
|
const { test, n } = opts;
|
|
8762
8928
|
const started = Date.now();
|
|
8763
|
-
const dir =
|
|
8764
|
-
const workdir =
|
|
8765
|
-
|
|
8929
|
+
const dir = join6(opts.runDir, test.name, String(n));
|
|
8930
|
+
const workdir = join6(dir, "work");
|
|
8931
|
+
mkdirSync4(workdir, { recursive: true });
|
|
8932
|
+
const { home, envFile } = makeRunHome(dir);
|
|
8766
8933
|
const r = {
|
|
8767
8934
|
test: test.name,
|
|
8768
8935
|
n,
|
|
8769
8936
|
model: opts.model,
|
|
8770
8937
|
claudeModel: null,
|
|
8938
|
+
claudeArgs: test.claudeArgs,
|
|
8771
8939
|
status: "error",
|
|
8772
8940
|
reason: "",
|
|
8773
8941
|
prompt: render(test.prompt, opts.vars),
|
|
@@ -8775,51 +8943,77 @@ async function runOne(opts) {
|
|
|
8775
8943
|
checks: [],
|
|
8776
8944
|
dir,
|
|
8777
8945
|
workdir,
|
|
8946
|
+
home,
|
|
8778
8947
|
session: null,
|
|
8779
|
-
transcript:
|
|
8780
|
-
stderr:
|
|
8781
|
-
checksLog:
|
|
8948
|
+
transcript: join6(dir, "transcript.txt"),
|
|
8949
|
+
stderr: join6(dir, "stderr.txt"),
|
|
8950
|
+
checksLog: join6(dir, "checks.txt"),
|
|
8951
|
+
cleanup: [],
|
|
8952
|
+
cleanupLog: join6(dir, "cleanup.txt"),
|
|
8782
8953
|
num_turns: null,
|
|
8783
8954
|
cost: null,
|
|
8784
8955
|
duration_ms: 0,
|
|
8785
8956
|
startedAt: new Date(started).toISOString(),
|
|
8786
8957
|
upload: null
|
|
8787
8958
|
};
|
|
8788
|
-
const done = (status, reason) => {
|
|
8789
|
-
r.status = status;
|
|
8790
|
-
r.reason = reason;
|
|
8791
|
-
r.duration_ms = Date.now() - started;
|
|
8792
|
-
writeRunJson(r);
|
|
8793
|
-
return r;
|
|
8794
|
-
};
|
|
8795
8959
|
const env = renderEnv(test.env, opts.vars, {
|
|
8796
8960
|
"run.id": opts.runId,
|
|
8797
8961
|
"run.n": String(n),
|
|
8798
8962
|
"test.name": test.name,
|
|
8799
8963
|
FIREGOAT_OUT: opts.out,
|
|
8800
|
-
FIREGOAT_WORKDIR: workdir
|
|
8964
|
+
FIREGOAT_WORKDIR: workdir,
|
|
8965
|
+
FIREGOAT_HOME: home
|
|
8801
8966
|
});
|
|
8802
|
-
const stepEnv = {
|
|
8803
|
-
|
|
8967
|
+
const stepEnv = {
|
|
8968
|
+
...opts.stepEnv,
|
|
8969
|
+
...env,
|
|
8970
|
+
...homeEnv(home),
|
|
8971
|
+
FIREGOAT_WORKDIR: workdir,
|
|
8972
|
+
FIREGOAT_HOME: home
|
|
8973
|
+
};
|
|
8974
|
+
const [status, reason] = await attempt(opts, r, env, stepEnv, envFile);
|
|
8975
|
+
const cleanup = [...test.cleanup, ...opts.cleanup];
|
|
8976
|
+
if (cleanup.length > 0)
|
|
8977
|
+
r.cleanup = await runAllSteps({
|
|
8978
|
+
commands: cleanup,
|
|
8979
|
+
cwd: workdir,
|
|
8980
|
+
env: stepEnv,
|
|
8981
|
+
logPath: r.cleanupLog
|
|
8982
|
+
});
|
|
8983
|
+
r.status = status;
|
|
8984
|
+
r.reason = reason;
|
|
8985
|
+
r.duration_ms = Date.now() - started;
|
|
8986
|
+
writeRunJson(r);
|
|
8987
|
+
return r;
|
|
8988
|
+
}
|
|
8989
|
+
async function attempt(opts, r, env, stepEnv, homeEnvFile) {
|
|
8990
|
+
const { test, n } = opts;
|
|
8991
|
+
const { dir, workdir } = r;
|
|
8992
|
+
const install = [...opts.install, ...test.install];
|
|
8993
|
+
if (install.length > 0) {
|
|
8804
8994
|
const error = await runSteps({
|
|
8805
8995
|
what: "install",
|
|
8806
|
-
commands:
|
|
8996
|
+
commands: install,
|
|
8807
8997
|
cwd: workdir,
|
|
8808
8998
|
env: stepEnv,
|
|
8809
|
-
logPath:
|
|
8999
|
+
logPath: join6(dir, "install.txt")
|
|
8810
9000
|
});
|
|
8811
9001
|
if (error)
|
|
8812
|
-
return
|
|
9002
|
+
return ["error", `not graded: ${error}`];
|
|
8813
9003
|
}
|
|
8814
9004
|
const stderr = createWriteStream2(r.stderr);
|
|
8815
9005
|
const outcome = await runClaude({
|
|
8816
9006
|
bin: opts.claudeBin,
|
|
8817
9007
|
prompt: r.prompt,
|
|
8818
9008
|
cwd: workdir,
|
|
8819
|
-
env: {
|
|
9009
|
+
env: {
|
|
9010
|
+
...claudeEnv(opts.configDir, env, homeEnvFile),
|
|
9011
|
+
PATH: opts.stepEnv.PATH
|
|
9012
|
+
},
|
|
8820
9013
|
stderr,
|
|
8821
9014
|
timeoutMs: test.timeout * 1000,
|
|
8822
9015
|
model: opts.model ?? undefined,
|
|
9016
|
+
extraArgs: r.claudeArgs,
|
|
8823
9017
|
onEvent: opts.onEvent
|
|
8824
9018
|
});
|
|
8825
9019
|
r.claudeModel = outcome.claudeModel;
|
|
@@ -8836,9 +9030,12 @@ async function runOne(opts) {
|
|
|
8836
9030
|
r.num_turns = outcome.result?.num_turns ?? null;
|
|
8837
9031
|
r.cost = outcome.result?.total_cost_usd ?? null;
|
|
8838
9032
|
if (!outcome.ok)
|
|
8839
|
-
return
|
|
9033
|
+
return ["error", `not graded: ${outcome.reason}`];
|
|
8840
9034
|
if (!r.session)
|
|
8841
|
-
return
|
|
9035
|
+
return [
|
|
9036
|
+
"error",
|
|
9037
|
+
`not graded: claude saved no session file for ${outcome.sessionId} under ${opts.configDir}/projects`
|
|
9038
|
+
];
|
|
8842
9039
|
const log = createWriteStream2(r.checksLog);
|
|
8843
9040
|
try {
|
|
8844
9041
|
for (const c of test.checks)
|
|
@@ -8858,8 +9055,14 @@ async function runOne(opts) {
|
|
|
8858
9055
|
}
|
|
8859
9056
|
const failed = r.checks.filter((c) => c.code !== 0);
|
|
8860
9057
|
if (failed.length > 0)
|
|
8861
|
-
return
|
|
8862
|
-
|
|
9058
|
+
return [
|
|
9059
|
+
"fail",
|
|
9060
|
+
failed.map((c) => `check failed (${c.error ?? `exit ${c.code}`}): ${c.run}`).join("; ")
|
|
9061
|
+
];
|
|
9062
|
+
return [
|
|
9063
|
+
"pass",
|
|
9064
|
+
test.checks.length === 0 ? "no script checks" : `${r.checks.length}/${r.checks.length} checks passed`
|
|
9065
|
+
];
|
|
8863
9066
|
}
|
|
8864
9067
|
async function evalCommand(args, root) {
|
|
8865
9068
|
if (args.includes("--help") || args.includes("-h")) {
|
|
@@ -8883,7 +9086,13 @@ async function evalCommand(args, root) {
|
|
|
8883
9086
|
}
|
|
8884
9087
|
const say = opts.json ? console.error : console.log;
|
|
8885
9088
|
const stream = opts.json ? process.stderr : process.stdout;
|
|
8886
|
-
const ui = stream.isTTY ? ttyUI(stream, {
|
|
9089
|
+
const ui = stream.isTTY ? ttyUI(stream, {
|
|
9090
|
+
project: basename3(root),
|
|
9091
|
+
version: package_default.version,
|
|
9092
|
+
color: !process.env.NO_COLOR
|
|
9093
|
+
}) : plainUI(say);
|
|
9094
|
+
if (!stream.isTTY)
|
|
9095
|
+
say(`firegoat ${package_default.version}`);
|
|
8887
9096
|
let config;
|
|
8888
9097
|
try {
|
|
8889
9098
|
config = loadConfig(root);
|
|
@@ -8922,8 +9131,8 @@ async function evalCommand(args, root) {
|
|
|
8922
9131
|
return fatal(EXIT.usage, e.message);
|
|
8923
9132
|
}
|
|
8924
9133
|
const firegoatDir = ensureFiregoatDir(repo?.mainCheckout ?? root);
|
|
8925
|
-
const configDir =
|
|
8926
|
-
|
|
9134
|
+
const configDir = join6(firegoatDir, ".claude");
|
|
9135
|
+
mkdirSync4(configDir, { recursive: true });
|
|
8927
9136
|
ui.auth(process.env.ANTHROPIC_API_KEY ? "ANTHROPIC_API_KEY (claude bills the API key; unset it to use your claude login)" : "your claude login");
|
|
8928
9137
|
const loginProblem = checkLogin({
|
|
8929
9138
|
bin: claudePath,
|
|
@@ -8945,10 +9154,10 @@ async function evalCommand(args, root) {
|
|
|
8945
9154
|
return fatal(EXIT.usage, e.message);
|
|
8946
9155
|
throw e;
|
|
8947
9156
|
}
|
|
8948
|
-
const runsRoot =
|
|
8949
|
-
const runDir =
|
|
8950
|
-
const out =
|
|
8951
|
-
|
|
9157
|
+
const runsRoot = join6(firegoatDir, "runs");
|
|
9158
|
+
const runDir = join6(runsRoot, runId);
|
|
9159
|
+
const out = join6(runDir, "out");
|
|
9160
|
+
mkdirSync4(out, { recursive: true });
|
|
8952
9161
|
const stepEnv = {
|
|
8953
9162
|
...hostEnv(),
|
|
8954
9163
|
...vars,
|
|
@@ -8974,7 +9183,7 @@ async function evalCommand(args, root) {
|
|
|
8974
9183
|
...stepEnv,
|
|
8975
9184
|
...renderEnv(Object.fromEntries(Object.entries(config.env).filter(([, v]) => isEvalLevel(v))), vars, { "run.id": runId, FIREGOAT_OUT: out })
|
|
8976
9185
|
},
|
|
8977
|
-
logPath:
|
|
9186
|
+
logPath: join6(runDir, "build.txt")
|
|
8978
9187
|
});
|
|
8979
9188
|
const secs = Math.round((Date.now() - buildStarted) / 1000);
|
|
8980
9189
|
ui.build(error ? "fail" : "ok", secs);
|
|
@@ -9011,6 +9220,7 @@ async function evalCommand(args, root) {
|
|
|
9011
9220
|
claudeBin: claudePath,
|
|
9012
9221
|
configDir,
|
|
9013
9222
|
install: config.install,
|
|
9223
|
+
cleanup: config.cleanup,
|
|
9014
9224
|
out,
|
|
9015
9225
|
stepEnv,
|
|
9016
9226
|
vars
|
|
@@ -9046,6 +9256,7 @@ async function evalCommand(args, root) {
|
|
|
9046
9256
|
totals[r.status]++;
|
|
9047
9257
|
const uploadsFailed = results.filter((r) => r.upload?.status === "failed").length;
|
|
9048
9258
|
const doc = {
|
|
9259
|
+
firegoatVersion: package_default.version,
|
|
9049
9260
|
runId,
|
|
9050
9261
|
runsDir: runDir,
|
|
9051
9262
|
dir: root,
|
|
@@ -9054,9 +9265,9 @@ async function evalCommand(args, root) {
|
|
|
9054
9265
|
totals,
|
|
9055
9266
|
runs: results
|
|
9056
9267
|
};
|
|
9057
|
-
|
|
9268
|
+
writeFileSync4(join6(runDir, "summary.json"), `${JSON.stringify(doc, null, 2)}
|
|
9058
9269
|
`);
|
|
9059
|
-
appendFileSync(
|
|
9270
|
+
appendFileSync(join6(runsRoot, "index.jsonl"), `${JSON.stringify({
|
|
9060
9271
|
runId,
|
|
9061
9272
|
startedAt: new Date(evalStarted).toISOString(),
|
|
9062
9273
|
dir: root,
|
|
@@ -9076,6 +9287,10 @@ async function evalCommand(args, root) {
|
|
|
9076
9287
|
rows,
|
|
9077
9288
|
seconds: Math.round((Date.now() - evalStarted) / 1000)
|
|
9078
9289
|
});
|
|
9290
|
+
for (const r of results)
|
|
9291
|
+
for (const c of r.cleanup)
|
|
9292
|
+
if (c.code !== 0)
|
|
9293
|
+
console.error(`firegoat eval: Warning: ${r.test}#${r.n} cleanup failed (${c.error ?? `exit ${c.code}`}): ${c.run} (log: ${r.cleanupLog})`);
|
|
9079
9294
|
if (uploadsFailed > 0)
|
|
9080
9295
|
console.error(`firegoat eval: ${uploadsFailed} upload${uploadsFailed === 1 ? "" : "s"} failed (each run's upload.error in summary.json)`);
|
|
9081
9296
|
if (opts.json)
|
|
@@ -9352,8 +9567,8 @@ firegoat ${package_default.version}` : `firegoat ${package_default.version}`,
|
|
|
9352
9567
|
|
|
9353
9568
|
// src/init.ts
|
|
9354
9569
|
import { spawnSync as spawnSync3 } from "node:child_process";
|
|
9355
|
-
import { existsSync as
|
|
9356
|
-
import { basename as basename4, join as
|
|
9570
|
+
import { existsSync as existsSync4, writeFileSync as writeFileSync5 } from "node:fs";
|
|
9571
|
+
import { basename as basename4, join as join7 } from "node:path";
|
|
9357
9572
|
import { createInterface } from "node:readline/promises";
|
|
9358
9573
|
|
|
9359
9574
|
// src/login.ts
|
|
@@ -9548,7 +9763,7 @@ function skillsDir(scope) {
|
|
|
9548
9763
|
if (scope === "local")
|
|
9549
9764
|
return ".claude/skills/";
|
|
9550
9765
|
const config = process.env.CLAUDE_CONFIG_DIR?.trim();
|
|
9551
|
-
return config ? `${
|
|
9766
|
+
return config ? `${join7(config, "skills")}/` : "~/.claude/skills/";
|
|
9552
9767
|
}
|
|
9553
9768
|
function template(product) {
|
|
9554
9769
|
return `# firegoat.yaml: evals for how well coding agents use ${product}.
|
|
@@ -9579,34 +9794,48 @@ runs: 1
|
|
|
9579
9794
|
# (An npm/pypi CLI that users run via npx/uvx: pack + install it as above instead;
|
|
9580
9795
|
# a binary on PATH does not stop "npx mycli" from fetching the registry release)
|
|
9581
9796
|
#
|
|
9582
|
-
# Helper files (scripts, sample data) go in .
|
|
9583
|
-
# The build step runs in this folder, so: build: cp -R
|
|
9797
|
+
# Helper files (scripts, sample data) go in e.g. fixtures/ (committed), not inline here.
|
|
9798
|
+
# The build step runs in this folder, so: build: cp -R fixtures/. "$FIREGOAT_OUT"
|
|
9584
9799
|
# Then e.g. install: cp "$FIREGOAT_OUT/wizard.mjs" .
|
|
9585
|
-
|
|
9586
|
-
#
|
|
9587
|
-
#
|
|
9588
|
-
#
|
|
9589
|
-
|
|
9590
|
-
#
|
|
9591
|
-
#
|
|
9800
|
+
#
|
|
9801
|
+
# To start each run from a template project, commit it as e.g. fixtures/starter-app/:
|
|
9802
|
+
# build: cp -R fixtures/starter-app "$FIREGOAT_OUT/starter-app"
|
|
9803
|
+
# install: cp -R "$FIREGOAT_OUT/starter-app/." .
|
|
9804
|
+
|
|
9805
|
+
# Optional: cleanup: runs in each run's folder once the run is over (after the
|
|
9806
|
+
# checks, also after an error or timeout), e.g. to stop a server the agent left
|
|
9807
|
+
# running. A failing cleanup step is a warning, never the verdict.
|
|
9808
|
+
# cleanup:
|
|
9809
|
+
# - pkill -f "$FIREGOAT_WORKDIR" || true
|
|
9810
|
+
#
|
|
9811
|
+
# A test can add its own install: (after this one) and cleanup: (before this one).
|
|
9812
|
+
|
|
9813
|
+
# Optional: env vars for the agent (and for install, checks and cleanup of the
|
|
9814
|
+
# same run). The agent gets none of vars:, only these. Placeholders: {{vars.KEY}},
|
|
9815
|
+
# {{run.id}}, {{run.n}}, {{test.name}}, and $FIREGOAT_OUT / $FIREGOAT_WORKDIR /
|
|
9816
|
+
# $FIREGOAT_HOME (no other $ expansion: $HOME stays literal). A test can add its
|
|
9817
|
+
# own env:. Every run gets its own HOME ($FIREGOAT_HOME), so tools that keep state
|
|
9818
|
+
# in ~ are isolated per run already; set a tool's own *_HOME var only if the tool
|
|
9819
|
+
# ignores HOME, as below.
|
|
9592
9820
|
# env:
|
|
9593
|
-
# MYTOOL_HOME: "
|
|
9821
|
+
# MYTOOL_HOME: "$FIREGOAT_HOME/.mytool"
|
|
9594
9822
|
# UV_FIND_LINKS: "$FIREGOAT_OUT"
|
|
9595
9823
|
|
|
9596
9824
|
tests:
|
|
9597
|
-
|
|
9598
|
-
|
|
9599
|
-
|
|
9600
|
-
|
|
9601
|
-
|
|
9825
|
+
# A smoke test that firegoat and claude work. Replace it with tests for your
|
|
9826
|
+
# product: the firegoat-onboarding skill writes them.
|
|
9827
|
+
- name: goat
|
|
9828
|
+
# What a user would ask their agent.
|
|
9829
|
+
prompt: Who's the GOAT? Answer in one short sentence.
|
|
9602
9830
|
# True when the user's problem is solved. Not graded by firegoat: it goes to
|
|
9603
9831
|
# your agent (eval --json, run.json) to judge.
|
|
9604
|
-
end_goal:
|
|
9605
|
-
A minimal project uses ${product}, runs without errors, and output.txt holds
|
|
9606
|
-
its output.
|
|
9832
|
+
end_goal: Claude answers that you (the user) are the goat, e.g. YOU'RE THE GOAT.
|
|
9607
9833
|
# Shell commands run in the agent's folder afterwards; exit 0 = pass.
|
|
9834
|
+
# $FIREGOAT_TRANSCRIPT has "agent: " on every line of the agent's text.
|
|
9608
9835
|
checks:
|
|
9609
|
-
- run:
|
|
9836
|
+
- run: >-
|
|
9837
|
+
test -s "$FIREGOAT_TRANSCRIPT" &&
|
|
9838
|
+
grep -Eiq '^agent: .*goat' "$FIREGOAT_TRANSCRIPT"
|
|
9610
9839
|
# Seconds before the agent run is stopped.
|
|
9611
9840
|
timeout: 600
|
|
9612
9841
|
`;
|
|
@@ -9725,16 +9954,16 @@ ${INIT_HELP}`);
|
|
|
9725
9954
|
console.log(rule);
|
|
9726
9955
|
}
|
|
9727
9956
|
ensureFiregoatDir(cwd);
|
|
9728
|
-
const configPath =
|
|
9729
|
-
const keep =
|
|
9957
|
+
const configPath = join7(cwd, CONFIG_FILE);
|
|
9958
|
+
const keep = existsSync4(configPath) && !force;
|
|
9730
9959
|
if (!keep)
|
|
9731
|
-
|
|
9960
|
+
writeFileSync5(configPath, template(basename4(cwd)));
|
|
9732
9961
|
if (interactive) {
|
|
9733
9962
|
step(1, CONFIG_FILE, keep ? "Kept (--force replaces it)" : "Created");
|
|
9734
9963
|
console.log(paint("90", " │"));
|
|
9735
9964
|
step(2, ".firegoat/", "Ready");
|
|
9736
9965
|
} else
|
|
9737
|
-
console.log(keep ? `Kept ${CONFIG_FILE} (--force replaces it)` : `Wrote ${CONFIG_FILE}:
|
|
9966
|
+
console.log(keep ? `Kept ${CONFIG_FILE} (--force replaces it)` : `Wrote ${CONFIG_FILE}: replace the goat smoke test with tests for your product`);
|
|
9738
9967
|
if (interactive && !noSkills)
|
|
9739
9968
|
console.log(paint("90", " │"));
|
|
9740
9969
|
if (interactive && !noSkills && !localSkills) {
|
|
@@ -9788,7 +10017,7 @@ Prompt for your coding agent: ${AGENT_PROMPT}`);
|
|
|
9788
10017
|
console.log(AGENT_PROMPT);
|
|
9789
10018
|
console.log("");
|
|
9790
10019
|
const copied = copyToClipboard(AGENT_PROMPT);
|
|
9791
|
-
console.log(` ${copied ? `${paint("32", "✓ Prompt copied!")} ` : ""}Then: ${paint("36", "npx firegoat eval")}`);
|
|
10020
|
+
console.log(` ${copied ? `${paint("32", "✓ Prompt copied!")} ` : ""}Then: ${paint("36", "npx firegoat@latest eval")}`);
|
|
9792
10021
|
return EXIT.pass;
|
|
9793
10022
|
}
|
|
9794
10023
|
|