firegoat 0.18.0 → 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 +73 -12
- package/dist/cli.js +250 -94
- 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,35 @@ tests:
|
|
|
130
177
|
- run: ls "$ACME_HOME"/sessions | grep -q .
|
|
131
178
|
```
|
|
132
179
|
|
|
133
|
-
`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` (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:`.
|
|
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`.
|
|
189
|
+
|
|
190
|
+
Limits:
|
|
134
191
|
|
|
135
|
-
|
|
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.
|
|
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.
|
|
140
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.
|
|
146
207
|
|
|
147
|
-
`firegoat.yaml` keys: `runs`, `model`, `build`, `install`, `cleanup`, `vars`, `env`, and `tests` (`name`, `prompt`, `end_goal`, `checks`, `timeout`, `env`, `install`, `cleanup`). Unknown keys are errors. Full reference: `npx firegoat init --help`.
|
|
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`.
|
|
148
209
|
|
|
149
210
|
### Stop what the agent left running, seed one test
|
|
150
211
|
|
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"
|
|
@@ -7081,6 +7081,7 @@ function parseConfig(text) {
|
|
|
7081
7081
|
"cleanup",
|
|
7082
7082
|
"vars",
|
|
7083
7083
|
"env",
|
|
7084
|
+
"claude_args",
|
|
7084
7085
|
"tests"
|
|
7085
7086
|
]);
|
|
7086
7087
|
const runs = posInt(raw.runs, "runs", 1);
|
|
@@ -7090,6 +7091,7 @@ function parseConfig(text) {
|
|
|
7090
7091
|
const cleanup = commands(raw.cleanup, "cleanup");
|
|
7091
7092
|
const vars = parseVars(raw.vars);
|
|
7092
7093
|
const env = parseEnv(raw.env, "env");
|
|
7094
|
+
const claudeArgs = parseClaudeArgs(raw.claude_args, "claude_args");
|
|
7093
7095
|
if (!Array.isArray(raw.tests) || raw.tests.length === 0)
|
|
7094
7096
|
fail("tests", "must be a non-empty list");
|
|
7095
7097
|
const names = new Set;
|
|
@@ -7105,7 +7107,8 @@ function parseConfig(text) {
|
|
|
7105
7107
|
"timeout",
|
|
7106
7108
|
"env",
|
|
7107
7109
|
"install",
|
|
7108
|
-
"cleanup"
|
|
7110
|
+
"cleanup",
|
|
7111
|
+
"claude_args"
|
|
7109
7112
|
]);
|
|
7110
7113
|
const name = str(t.name, `${p}.name`);
|
|
7111
7114
|
if (!NAME.test(name))
|
|
@@ -7132,7 +7135,11 @@ function parseConfig(text) {
|
|
|
7132
7135
|
timeout: posInt(t.timeout, `${p}.timeout`, DEFAULT_TIMEOUT),
|
|
7133
7136
|
env: { ...env, ...parseEnv(t.env, `${p}.env`) },
|
|
7134
7137
|
install: commands(t.install, `${p}.install`),
|
|
7135
|
-
cleanup: commands(t.cleanup, `${p}.cleanup`)
|
|
7138
|
+
cleanup: commands(t.cleanup, `${p}.cleanup`),
|
|
7139
|
+
claudeArgs: [
|
|
7140
|
+
...claudeArgs,
|
|
7141
|
+
...parseClaudeArgs(t.claude_args, `${p}.claude_args`)
|
|
7142
|
+
]
|
|
7136
7143
|
};
|
|
7137
7144
|
});
|
|
7138
7145
|
return {
|
|
@@ -7172,6 +7179,41 @@ function parseModels(raw) {
|
|
|
7172
7179
|
return name;
|
|
7173
7180
|
});
|
|
7174
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
|
+
}
|
|
7175
7217
|
var VAR_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
7176
7218
|
function parseVars(raw) {
|
|
7177
7219
|
if (raw === undefined)
|
|
@@ -7193,10 +7235,18 @@ var RESERVED_ENV = new Set([
|
|
|
7193
7235
|
"CLAUDE_CONFIG_DIR",
|
|
7194
7236
|
"CLAUDE_SECURESTORAGE_CONFIG_DIR"
|
|
7195
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
|
+
]);
|
|
7196
7246
|
var RUN_PLACEHOLDERS = ["run.id", "run.n", "test.name"];
|
|
7197
7247
|
var EVAL_PLACEHOLDERS = ["run.id"];
|
|
7198
7248
|
var ANY_PLACEHOLDER = /\{\{\s*([^{}]*?)\s*\}\}/g;
|
|
7199
|
-
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;
|
|
7200
7250
|
var ENV_REF = new RegExp(`${ANY_PLACEHOLDER.source}|${DIR_REF.source}`, "g");
|
|
7201
7251
|
function parseEnv(raw, path) {
|
|
7202
7252
|
if (raw === undefined)
|
|
@@ -7210,6 +7260,8 @@ function parseEnv(raw, path) {
|
|
|
7210
7260
|
fail(p, "name must be letters, digits and _, not starting with a digit");
|
|
7211
7261
|
if (k === "ANTHROPIC_MODEL")
|
|
7212
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)");
|
|
7213
7265
|
if (RESERVED_ENV.has(k) || k.startsWith("FIREGOAT_"))
|
|
7214
7266
|
fail(p, "is set by firegoat and cannot be overridden (reserved: PATH, CLAUDE_CONFIG_DIR, CLAUDE_SECURESTORAGE_CONFIG_DIR, FIREGOAT_*)");
|
|
7215
7267
|
if (typeof v !== "string")
|
|
@@ -7267,11 +7319,11 @@ import {
|
|
|
7267
7319
|
appendFileSync,
|
|
7268
7320
|
createWriteStream as createWriteStream2,
|
|
7269
7321
|
existsSync as existsSync3,
|
|
7270
|
-
mkdirSync as
|
|
7322
|
+
mkdirSync as mkdirSync4,
|
|
7271
7323
|
readdirSync as readdirSync2,
|
|
7272
|
-
writeFileSync as
|
|
7324
|
+
writeFileSync as writeFileSync4
|
|
7273
7325
|
} from "node:fs";
|
|
7274
|
-
import { basename as basename3, delimiter as delimiter2, join as
|
|
7326
|
+
import { basename as basename3, delimiter as delimiter2, join as join6 } from "node:path";
|
|
7275
7327
|
|
|
7276
7328
|
// src/claude.ts
|
|
7277
7329
|
import { spawn, spawnSync } from "node:child_process";
|
|
@@ -7286,15 +7338,18 @@ var CLAUDE_FLAGS = [
|
|
|
7286
7338
|
"--disable-slash-commands"
|
|
7287
7339
|
];
|
|
7288
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)$/;
|
|
7289
7343
|
function hostEnv() {
|
|
7290
|
-
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))));
|
|
7291
7345
|
}
|
|
7292
|
-
function claudeEnv(configDir, env = {}) {
|
|
7346
|
+
function claudeEnv(configDir, env = {}, envFile) {
|
|
7293
7347
|
return {
|
|
7294
7348
|
...hostEnv(),
|
|
7295
7349
|
...env,
|
|
7296
7350
|
CLAUDE_CONFIG_DIR: configDir,
|
|
7297
|
-
CLAUDE_SECURESTORAGE_CONFIG_DIR: ""
|
|
7351
|
+
CLAUDE_SECURESTORAGE_CONFIG_DIR: "",
|
|
7352
|
+
...envFile === undefined ? {} : { CLAUDE_ENV_FILE: envFile }
|
|
7298
7353
|
};
|
|
7299
7354
|
}
|
|
7300
7355
|
function run(opts) {
|
|
@@ -7337,7 +7392,8 @@ async function runClaude(opts) {
|
|
|
7337
7392
|
"-p",
|
|
7338
7393
|
opts.prompt,
|
|
7339
7394
|
...CLAUDE_FLAGS,
|
|
7340
|
-
...opts.model === undefined ? [] : ["--model", opts.model]
|
|
7395
|
+
...opts.model === undefined ? [] : ["--model", opts.model],
|
|
7396
|
+
...opts.extraArgs
|
|
7341
7397
|
],
|
|
7342
7398
|
cwd: opts.cwd,
|
|
7343
7399
|
env: opts.env,
|
|
@@ -7828,15 +7884,19 @@ function topHelp(version) {
|
|
|
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
|
|
|
@@ -7880,20 +7940,29 @@ firegoat.yaml reference:
|
|
|
7880
7940
|
env: {NAME: value} Env vars for the agent, and for install, checks
|
|
7881
7941
|
and cleanup of the same run; values are strings and can
|
|
7882
7942
|
use {{vars.KEY}}, {{run.id}}, {{run.n}},
|
|
7883
|
-
{{test.name}}, $FIREGOAT_OUT, $FIREGOAT_WORKDIR
|
|
7884
|
-
(no other $ expansion: $HOME stays
|
|
7885
|
-
The build step gets only the entries that
|
|
7886
|
-
{{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.
|
|
7887
7947
|
PATH, CLAUDE_CONFIG_DIR, FIREGOAT_* and
|
|
7888
|
-
ANTHROPIC_MODEL (use model:) are reserved
|
|
7889
|
-
|
|
7890
|
-
|
|
7891
|
-
|
|
7892
|
-
|
|
7893
|
-
|
|
7894
|
-
|
|
7895
|
-
|
|
7896
|
-
|
|
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
|
|
7897
7966
|
tests:
|
|
7898
7967
|
- name: quickstart Letters, digits, '.', '_', '-'; unique
|
|
7899
7968
|
prompt: ... What a user would ask the agent; one concrete outcome
|
|
@@ -7905,14 +7974,20 @@ firegoat.yaml reference:
|
|
|
7905
7974
|
env: {NAME: value} Optional, on top of the top-level env:
|
|
7906
7975
|
install: <sh> | [...] Optional, runs after the top-level install
|
|
7907
7976
|
cleanup: <sh> | [...] Optional, runs before the top-level cleanup
|
|
7977
|
+
claude_args: [...] Optional, after the top-level claude_args
|
|
7908
7978
|
|
|
7909
7979
|
Step order: build once per eval; then per run: install (top-level, then the
|
|
7910
7980
|
test's), the agent, checks, cleanup (the test's, then top-level).
|
|
7911
7981
|
|
|
7912
7982
|
Unknown keys are errors. Keep helper files out of firegoat.yaml: put them in
|
|
7913
|
-
e.g. fixtures/ (committed) and copy them in build or install.
|
|
7914
|
-
|
|
7915
|
-
|
|
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
|
|
7916
7991
|
transcript, greppable line by line: "[1] $ <command>" on every line of a shell
|
|
7917
7992
|
call (one line per line of the command, same n), "[2] Write <path>" for other
|
|
7918
7993
|
tools, each output indented, and "agent: " on every line of the agent's text and
|
|
@@ -7942,6 +8017,12 @@ Example:
|
|
|
7942
8017
|
- run: |
|
|
7943
8018
|
test -s "$FIREGOAT_TRANSCRIPT" &&
|
|
7944
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]
|
|
7945
8026
|
|
|
7946
8027
|
Exit codes: 0 done, 1 the firegoat login failed (the files stay), 2 the skill
|
|
7947
8028
|
install failed, 3 usage error.`;
|
|
@@ -7993,9 +8074,30 @@ changes the run's status or the exit code.
|
|
|
7993
8074
|
|
|
7994
8075
|
claude runs on its own login (or ANTHROPIC_API_KEY if set, as claude always does)
|
|
7995
8076
|
with a fresh config dir, .firegoat/.claude: none of your settings, CLAUDE.md,
|
|
7996
|
-
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
|
|
7997
8079
|
sandbox: the agent can reach the rest of your machine, including the repo.
|
|
7998
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
|
+
|
|
7999
8101
|
In a linked git worktree (git worktree add), .firegoat/runs/ and .firegoat/.claude
|
|
8000
8102
|
live in the main checkout instead, so runs outlive the worktree; firegoat.yaml,
|
|
8001
8103
|
fixtures and build: stay in the current folder.
|
|
@@ -8005,8 +8107,8 @@ fixtures and build: stay in the current folder.
|
|
|
8005
8107
|
--model NAME claude --model, replacing model: in firegoat.yaml; repeat it
|
|
8006
8108
|
(--model a --model b) to run each test runs times per model
|
|
8007
8109
|
--parallel N Runs at once (default 4; 1 runs them one after another).
|
|
8008
|
-
|
|
8009
|
-
|
|
8110
|
+
Each run has its own HOME; state outside it (a fixed port,
|
|
8111
|
+
/tmp paths) can still collide: use 1 then
|
|
8010
8112
|
--var KEY=value Fills {{vars.KEY}}; env for build, install, checks and
|
|
8011
8113
|
cleanup
|
|
8012
8114
|
--json The summary as JSON on stdout (or {error, exitCode} if eval
|
|
@@ -8034,9 +8136,10 @@ Output: .firegoat/runs/<runId>/
|
|
|
8034
8136
|
uploadFailed
|
|
8035
8137
|
<test>/<n>/run.json One run: status and reason (from the checks), model
|
|
8036
8138
|
(asked for, or null) and claudeModel (what claude's
|
|
8037
|
-
init line reported),
|
|
8139
|
+
init line reported), claudeArgs (claude_args
|
|
8140
|
+
passed), prompt, end_goal,
|
|
8038
8141
|
checks [{run, code}], cleanup [{run, code}],
|
|
8039
|
-
and paths: workdir,
|
|
8142
|
+
and paths: workdir, home (the run's HOME),
|
|
8040
8143
|
transcript, session (claude's session file), stderr,
|
|
8041
8144
|
checksLog, cleanupLog; startedAt; upload ({status: "uploaded",
|
|
8042
8145
|
url} | {status: "failed", error} | {status:
|
|
@@ -8045,6 +8148,8 @@ Output: .firegoat/runs/<runId>/
|
|
|
8045
8148
|
("[n] $ " on every line of a shell call), short output
|
|
8046
8149
|
(indented), its text and final answer ("agent: " on
|
|
8047
8150
|
every line), files it made
|
|
8151
|
+
<test>/<n>/home/, home.env The run's HOME, and the file claude sources
|
|
8152
|
+
before the agent's Bash commands
|
|
8048
8153
|
build.txt, <test>/<n>/install.txt, checks.txt, cleanup.txt Logs
|
|
8049
8154
|
|
|
8050
8155
|
Grading: firegoat runs only the script checks; they set the exit code. end_goal is
|
|
@@ -8083,6 +8188,30 @@ Example:
|
|
|
8083
8188
|
|
|
8084
8189
|
Exit codes: 0 sent, 2 the service failed (or rate limit), 3 usage error.`;
|
|
8085
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
|
+
|
|
8086
8215
|
// src/steps.ts
|
|
8087
8216
|
import { createWriteStream } from "node:fs";
|
|
8088
8217
|
var STEP_TIMEOUT_MS = 600000;
|
|
@@ -8124,8 +8253,8 @@ async function runAllSteps(opts) {
|
|
|
8124
8253
|
}
|
|
8125
8254
|
|
|
8126
8255
|
// src/transcript.ts
|
|
8127
|
-
import { readdirSync, statSync, writeFileSync as
|
|
8128
|
-
import { join as
|
|
8256
|
+
import { readdirSync, statSync, writeFileSync as writeFileSync3 } from "node:fs";
|
|
8257
|
+
import { join as join4, relative } from "node:path";
|
|
8129
8258
|
var OUTPUT_LINES = 15;
|
|
8130
8259
|
var OUTPUT_CHARS = 1500;
|
|
8131
8260
|
var TEXT_CHARS = 1500;
|
|
@@ -8169,7 +8298,7 @@ function workdirFiles(dir) {
|
|
|
8169
8298
|
for (const name of readdirSync(d)) {
|
|
8170
8299
|
if (SKIP_DIRS.has(name))
|
|
8171
8300
|
continue;
|
|
8172
|
-
const p =
|
|
8301
|
+
const p = join4(d, name);
|
|
8173
8302
|
if (statSync(p).isDirectory())
|
|
8174
8303
|
walk(p);
|
|
8175
8304
|
else
|
|
@@ -8212,7 +8341,7 @@ ${indent(truncate(text, OUTPUT_LINES, OUTPUT_CHARS))}` : ` → ${status}`);
|
|
|
8212
8341
|
out.pop();
|
|
8213
8342
|
out.push("", "## final answer", final ? prose(final) : "(none)");
|
|
8214
8343
|
out.push("", "## files in the workdir", ...workdirFiles(opts.workdir));
|
|
8215
|
-
|
|
8344
|
+
writeFileSync3(opts.path, `${out.join(`
|
|
8216
8345
|
`)}
|
|
8217
8346
|
`);
|
|
8218
8347
|
}
|
|
@@ -8392,7 +8521,7 @@ function ttyUI(out, opts) {
|
|
|
8392
8521
|
const doneCount = c.pass + c.fail + c.error;
|
|
8393
8522
|
const badge = finished ? `DONE · RUN ${finished.runId.slice(-4)}` : `RUNNING · ${doneCount}/${rows.length}`;
|
|
8394
8523
|
const out = [
|
|
8395
|
-
...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),
|
|
8396
8525
|
rule
|
|
8397
8526
|
];
|
|
8398
8527
|
if (authText)
|
|
@@ -8653,7 +8782,7 @@ async function uploadRun(t, r) {
|
|
|
8653
8782
|
|
|
8654
8783
|
// src/which.ts
|
|
8655
8784
|
import { accessSync, constants, statSync as statSync2 } from "node:fs";
|
|
8656
|
-
import { delimiter, join as
|
|
8785
|
+
import { delimiter, join as join5, resolve } from "node:path";
|
|
8657
8786
|
function resolveBin(bin) {
|
|
8658
8787
|
const executable = (p) => {
|
|
8659
8788
|
try {
|
|
@@ -8666,8 +8795,8 @@ function resolveBin(bin) {
|
|
|
8666
8795
|
if (bin.includes("/"))
|
|
8667
8796
|
return executable(bin) ? resolve(bin) : undefined;
|
|
8668
8797
|
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
8669
|
-
if (dir && executable(
|
|
8670
|
-
return
|
|
8798
|
+
if (dir && executable(join5(dir, bin)))
|
|
8799
|
+
return join5(dir, bin);
|
|
8671
8800
|
}
|
|
8672
8801
|
return;
|
|
8673
8802
|
}
|
|
@@ -8740,11 +8869,11 @@ function parseArgs(args) {
|
|
|
8740
8869
|
return opts;
|
|
8741
8870
|
}
|
|
8742
8871
|
function findSession(configDir, sessionId) {
|
|
8743
|
-
const projects =
|
|
8872
|
+
const projects = join6(configDir, "projects");
|
|
8744
8873
|
if (!existsSync3(projects))
|
|
8745
8874
|
return null;
|
|
8746
8875
|
for (const d of readdirSync2(projects)) {
|
|
8747
|
-
const f =
|
|
8876
|
+
const f = join6(projects, d, `${sessionId}.jsonl`);
|
|
8748
8877
|
if (existsSync3(f))
|
|
8749
8878
|
return f;
|
|
8750
8879
|
}
|
|
@@ -8755,7 +8884,7 @@ function newRunId() {
|
|
|
8755
8884
|
return `${ts}-${randomBytes2(2).toString("hex")}`;
|
|
8756
8885
|
}
|
|
8757
8886
|
function writeRunJson(r) {
|
|
8758
|
-
|
|
8887
|
+
writeFileSync4(join6(r.dir, "run.json"), `${JSON.stringify(r, null, 2)}
|
|
8759
8888
|
`);
|
|
8760
8889
|
}
|
|
8761
8890
|
async function uploadPreflight(opts, ui, project, runGroup, git) {
|
|
@@ -8797,14 +8926,16 @@ async function uploadPreflight(opts, ui, project, runGroup, git) {
|
|
|
8797
8926
|
async function runOne(opts) {
|
|
8798
8927
|
const { test, n } = opts;
|
|
8799
8928
|
const started = Date.now();
|
|
8800
|
-
const dir =
|
|
8801
|
-
const workdir =
|
|
8802
|
-
|
|
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);
|
|
8803
8933
|
const r = {
|
|
8804
8934
|
test: test.name,
|
|
8805
8935
|
n,
|
|
8806
8936
|
model: opts.model,
|
|
8807
8937
|
claudeModel: null,
|
|
8938
|
+
claudeArgs: test.claudeArgs,
|
|
8808
8939
|
status: "error",
|
|
8809
8940
|
reason: "",
|
|
8810
8941
|
prompt: render(test.prompt, opts.vars),
|
|
@@ -8812,12 +8943,13 @@ async function runOne(opts) {
|
|
|
8812
8943
|
checks: [],
|
|
8813
8944
|
dir,
|
|
8814
8945
|
workdir,
|
|
8946
|
+
home,
|
|
8815
8947
|
session: null,
|
|
8816
|
-
transcript:
|
|
8817
|
-
stderr:
|
|
8818
|
-
checksLog:
|
|
8948
|
+
transcript: join6(dir, "transcript.txt"),
|
|
8949
|
+
stderr: join6(dir, "stderr.txt"),
|
|
8950
|
+
checksLog: join6(dir, "checks.txt"),
|
|
8819
8951
|
cleanup: [],
|
|
8820
|
-
cleanupLog:
|
|
8952
|
+
cleanupLog: join6(dir, "cleanup.txt"),
|
|
8821
8953
|
num_turns: null,
|
|
8822
8954
|
cost: null,
|
|
8823
8955
|
duration_ms: 0,
|
|
@@ -8829,10 +8961,17 @@ async function runOne(opts) {
|
|
|
8829
8961
|
"run.n": String(n),
|
|
8830
8962
|
"test.name": test.name,
|
|
8831
8963
|
FIREGOAT_OUT: opts.out,
|
|
8832
|
-
FIREGOAT_WORKDIR: workdir
|
|
8964
|
+
FIREGOAT_WORKDIR: workdir,
|
|
8965
|
+
FIREGOAT_HOME: home
|
|
8833
8966
|
});
|
|
8834
|
-
const stepEnv = {
|
|
8835
|
-
|
|
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);
|
|
8836
8975
|
const cleanup = [...test.cleanup, ...opts.cleanup];
|
|
8837
8976
|
if (cleanup.length > 0)
|
|
8838
8977
|
r.cleanup = await runAllSteps({
|
|
@@ -8847,7 +8986,7 @@ async function runOne(opts) {
|
|
|
8847
8986
|
writeRunJson(r);
|
|
8848
8987
|
return r;
|
|
8849
8988
|
}
|
|
8850
|
-
async function attempt(opts, r, env, stepEnv) {
|
|
8989
|
+
async function attempt(opts, r, env, stepEnv, homeEnvFile) {
|
|
8851
8990
|
const { test, n } = opts;
|
|
8852
8991
|
const { dir, workdir } = r;
|
|
8853
8992
|
const install = [...opts.install, ...test.install];
|
|
@@ -8857,7 +8996,7 @@ async function attempt(opts, r, env, stepEnv) {
|
|
|
8857
8996
|
commands: install,
|
|
8858
8997
|
cwd: workdir,
|
|
8859
8998
|
env: stepEnv,
|
|
8860
|
-
logPath:
|
|
8999
|
+
logPath: join6(dir, "install.txt")
|
|
8861
9000
|
});
|
|
8862
9001
|
if (error)
|
|
8863
9002
|
return ["error", `not graded: ${error}`];
|
|
@@ -8867,10 +9006,14 @@ async function attempt(opts, r, env, stepEnv) {
|
|
|
8867
9006
|
bin: opts.claudeBin,
|
|
8868
9007
|
prompt: r.prompt,
|
|
8869
9008
|
cwd: workdir,
|
|
8870
|
-
env: {
|
|
9009
|
+
env: {
|
|
9010
|
+
...claudeEnv(opts.configDir, env, homeEnvFile),
|
|
9011
|
+
PATH: opts.stepEnv.PATH
|
|
9012
|
+
},
|
|
8871
9013
|
stderr,
|
|
8872
9014
|
timeoutMs: test.timeout * 1000,
|
|
8873
9015
|
model: opts.model ?? undefined,
|
|
9016
|
+
extraArgs: r.claudeArgs,
|
|
8874
9017
|
onEvent: opts.onEvent
|
|
8875
9018
|
});
|
|
8876
9019
|
r.claudeModel = outcome.claudeModel;
|
|
@@ -8943,7 +9086,13 @@ async function evalCommand(args, root) {
|
|
|
8943
9086
|
}
|
|
8944
9087
|
const say = opts.json ? console.error : console.log;
|
|
8945
9088
|
const stream = opts.json ? process.stderr : process.stdout;
|
|
8946
|
-
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}`);
|
|
8947
9096
|
let config;
|
|
8948
9097
|
try {
|
|
8949
9098
|
config = loadConfig(root);
|
|
@@ -8982,8 +9131,8 @@ async function evalCommand(args, root) {
|
|
|
8982
9131
|
return fatal(EXIT.usage, e.message);
|
|
8983
9132
|
}
|
|
8984
9133
|
const firegoatDir = ensureFiregoatDir(repo?.mainCheckout ?? root);
|
|
8985
|
-
const configDir =
|
|
8986
|
-
|
|
9134
|
+
const configDir = join6(firegoatDir, ".claude");
|
|
9135
|
+
mkdirSync4(configDir, { recursive: true });
|
|
8987
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");
|
|
8988
9137
|
const loginProblem = checkLogin({
|
|
8989
9138
|
bin: claudePath,
|
|
@@ -9005,10 +9154,10 @@ async function evalCommand(args, root) {
|
|
|
9005
9154
|
return fatal(EXIT.usage, e.message);
|
|
9006
9155
|
throw e;
|
|
9007
9156
|
}
|
|
9008
|
-
const runsRoot =
|
|
9009
|
-
const runDir =
|
|
9010
|
-
const out =
|
|
9011
|
-
|
|
9157
|
+
const runsRoot = join6(firegoatDir, "runs");
|
|
9158
|
+
const runDir = join6(runsRoot, runId);
|
|
9159
|
+
const out = join6(runDir, "out");
|
|
9160
|
+
mkdirSync4(out, { recursive: true });
|
|
9012
9161
|
const stepEnv = {
|
|
9013
9162
|
...hostEnv(),
|
|
9014
9163
|
...vars,
|
|
@@ -9034,7 +9183,7 @@ async function evalCommand(args, root) {
|
|
|
9034
9183
|
...stepEnv,
|
|
9035
9184
|
...renderEnv(Object.fromEntries(Object.entries(config.env).filter(([, v]) => isEvalLevel(v))), vars, { "run.id": runId, FIREGOAT_OUT: out })
|
|
9036
9185
|
},
|
|
9037
|
-
logPath:
|
|
9186
|
+
logPath: join6(runDir, "build.txt")
|
|
9038
9187
|
});
|
|
9039
9188
|
const secs = Math.round((Date.now() - buildStarted) / 1000);
|
|
9040
9189
|
ui.build(error ? "fail" : "ok", secs);
|
|
@@ -9107,6 +9256,7 @@ async function evalCommand(args, root) {
|
|
|
9107
9256
|
totals[r.status]++;
|
|
9108
9257
|
const uploadsFailed = results.filter((r) => r.upload?.status === "failed").length;
|
|
9109
9258
|
const doc = {
|
|
9259
|
+
firegoatVersion: package_default.version,
|
|
9110
9260
|
runId,
|
|
9111
9261
|
runsDir: runDir,
|
|
9112
9262
|
dir: root,
|
|
@@ -9115,9 +9265,9 @@ async function evalCommand(args, root) {
|
|
|
9115
9265
|
totals,
|
|
9116
9266
|
runs: results
|
|
9117
9267
|
};
|
|
9118
|
-
|
|
9268
|
+
writeFileSync4(join6(runDir, "summary.json"), `${JSON.stringify(doc, null, 2)}
|
|
9119
9269
|
`);
|
|
9120
|
-
appendFileSync(
|
|
9270
|
+
appendFileSync(join6(runsRoot, "index.jsonl"), `${JSON.stringify({
|
|
9121
9271
|
runId,
|
|
9122
9272
|
startedAt: new Date(evalStarted).toISOString(),
|
|
9123
9273
|
dir: root,
|
|
@@ -9417,8 +9567,8 @@ firegoat ${package_default.version}` : `firegoat ${package_default.version}`,
|
|
|
9417
9567
|
|
|
9418
9568
|
// src/init.ts
|
|
9419
9569
|
import { spawnSync as spawnSync3 } from "node:child_process";
|
|
9420
|
-
import { existsSync as existsSync4, writeFileSync as
|
|
9421
|
-
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";
|
|
9422
9572
|
import { createInterface } from "node:readline/promises";
|
|
9423
9573
|
|
|
9424
9574
|
// src/login.ts
|
|
@@ -9613,7 +9763,7 @@ function skillsDir(scope) {
|
|
|
9613
9763
|
if (scope === "local")
|
|
9614
9764
|
return ".claude/skills/";
|
|
9615
9765
|
const config = process.env.CLAUDE_CONFIG_DIR?.trim();
|
|
9616
|
-
return config ? `${
|
|
9766
|
+
return config ? `${join7(config, "skills")}/` : "~/.claude/skills/";
|
|
9617
9767
|
}
|
|
9618
9768
|
function template(product) {
|
|
9619
9769
|
return `# firegoat.yaml: evals for how well coding agents use ${product}.
|
|
@@ -9647,6 +9797,10 @@ runs: 1
|
|
|
9647
9797
|
# Helper files (scripts, sample data) go in e.g. fixtures/ (committed), not inline here.
|
|
9648
9798
|
# The build step runs in this folder, so: build: cp -R fixtures/. "$FIREGOAT_OUT"
|
|
9649
9799
|
# Then e.g. install: cp "$FIREGOAT_OUT/wizard.mjs" .
|
|
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/." .
|
|
9650
9804
|
|
|
9651
9805
|
# Optional: cleanup: runs in each run's folder once the run is over (after the
|
|
9652
9806
|
# checks, also after an error or timeout), e.g. to stop a server the agent left
|
|
@@ -9658,28 +9812,30 @@ runs: 1
|
|
|
9658
9812
|
|
|
9659
9813
|
# Optional: env vars for the agent (and for install, checks and cleanup of the
|
|
9660
9814
|
# same run). The agent gets none of vars:, only these. Placeholders: {{vars.KEY}},
|
|
9661
|
-
# {{run.id}}, {{run.n}}, {{test.name}}, and $FIREGOAT_OUT / $FIREGOAT_WORKDIR
|
|
9662
|
-
# other $ expansion: $HOME stays literal). A test can add its
|
|
9663
|
-
#
|
|
9664
|
-
#
|
|
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.
|
|
9665
9820
|
# env:
|
|
9666
|
-
# MYTOOL_HOME: "
|
|
9821
|
+
# MYTOOL_HOME: "$FIREGOAT_HOME/.mytool"
|
|
9667
9822
|
# UV_FIND_LINKS: "$FIREGOAT_OUT"
|
|
9668
9823
|
|
|
9669
9824
|
tests:
|
|
9670
|
-
|
|
9671
|
-
|
|
9672
|
-
|
|
9673
|
-
|
|
9674
|
-
|
|
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.
|
|
9675
9830
|
# True when the user's problem is solved. Not graded by firegoat: it goes to
|
|
9676
9831
|
# your agent (eval --json, run.json) to judge.
|
|
9677
|
-
end_goal:
|
|
9678
|
-
A minimal project uses ${product}, runs without errors, and output.txt holds
|
|
9679
|
-
its output.
|
|
9832
|
+
end_goal: Claude answers that you (the user) are the goat, e.g. YOU'RE THE GOAT.
|
|
9680
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.
|
|
9681
9835
|
checks:
|
|
9682
|
-
- run:
|
|
9836
|
+
- run: >-
|
|
9837
|
+
test -s "$FIREGOAT_TRANSCRIPT" &&
|
|
9838
|
+
grep -Eiq '^agent: .*goat' "$FIREGOAT_TRANSCRIPT"
|
|
9683
9839
|
# Seconds before the agent run is stopped.
|
|
9684
9840
|
timeout: 600
|
|
9685
9841
|
`;
|
|
@@ -9798,16 +9954,16 @@ ${INIT_HELP}`);
|
|
|
9798
9954
|
console.log(rule);
|
|
9799
9955
|
}
|
|
9800
9956
|
ensureFiregoatDir(cwd);
|
|
9801
|
-
const configPath =
|
|
9957
|
+
const configPath = join7(cwd, CONFIG_FILE);
|
|
9802
9958
|
const keep = existsSync4(configPath) && !force;
|
|
9803
9959
|
if (!keep)
|
|
9804
|
-
|
|
9960
|
+
writeFileSync5(configPath, template(basename4(cwd)));
|
|
9805
9961
|
if (interactive) {
|
|
9806
9962
|
step(1, CONFIG_FILE, keep ? "Kept (--force replaces it)" : "Created");
|
|
9807
9963
|
console.log(paint("90", " │"));
|
|
9808
9964
|
step(2, ".firegoat/", "Ready");
|
|
9809
9965
|
} else
|
|
9810
|
-
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`);
|
|
9811
9967
|
if (interactive && !noSkills)
|
|
9812
9968
|
console.log(paint("90", " │"));
|
|
9813
9969
|
if (interactive && !noSkills && !localSkills) {
|
|
@@ -9861,7 +10017,7 @@ Prompt for your coding agent: ${AGENT_PROMPT}`);
|
|
|
9861
10017
|
console.log(AGENT_PROMPT);
|
|
9862
10018
|
console.log("");
|
|
9863
10019
|
const copied = copyToClipboard(AGENT_PROMPT);
|
|
9864
|
-
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")}`);
|
|
9865
10021
|
return EXIT.pass;
|
|
9866
10022
|
}
|
|
9867
10023
|
|