firegoat 0.18.0 → 0.20.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 +86 -12
- package/dist/cli.js +309 -113
- 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,56 @@ 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). Entries expand like `env:` values: `$FIREGOAT_WORKDIR`, `$FIREGOAT_HOME`, `$FIREGOAT_OUT` (also `${...}`), `{{vars.KEY}}`, `{{run.id}}`, `{{run.n}}`, `{{test.name}}`; no other `$` expansion. Each run's `run.json` records the expanded `claudeArgs`.
|
|
160
|
+
|
|
161
|
+
### Testing an MCP server
|
|
162
|
+
|
|
163
|
+
```yaml
|
|
164
|
+
install: |
|
|
165
|
+
cat > .mcp.json <<EOF
|
|
166
|
+
{"mcpServers": {"acme": {"command": "npx", "args": ["-y", "acme-mcp"],
|
|
167
|
+
"env": {"HOME": "$FIREGOAT_HOME"}}}}
|
|
168
|
+
EOF
|
|
169
|
+
claude_args: [--mcp-config, .mcp.json]
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Write the MCP config per run in `install:` (or copy a committed one to `$FIREGOAT_OUT` in `build:` and pass `"$FIREGOAT_OUT/mcp.json"`, but then it can't hold the run's home), and pass it with `claude_args: [--mcp-config, .mcp.json]`: relative paths are from the run's folder, claude's cwd. `--strict-mcp-config` is always on, so only the configs you pass load. MCP servers that claude starts inherit claude's own env, which keeps your real `HOME` (claude needs it for its login): set `"env": {"HOME": "<the run's $FIREGOAT_HOME>"}` on the server, as above, or it writes to your real home.
|
|
173
|
+
|
|
174
|
+
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.
|
|
175
|
+
|
|
176
|
+
### Env vars for the agent: a feature flag, a tool that ignores HOME
|
|
117
177
|
|
|
118
178
|
```yaml
|
|
119
179
|
env:
|
|
120
|
-
ACME_HOME: "
|
|
180
|
+
ACME_HOME: "$FIREGOAT_HOME/.acme" # Only for a tool that ignores HOME
|
|
121
181
|
ACME_TELEMETRY: "off"
|
|
122
182
|
UV_FIND_LINKS: "$FIREGOAT_OUT" # Resolve the wheel build: made
|
|
123
183
|
tests:
|
|
@@ -130,21 +190,35 @@ tests:
|
|
|
130
190
|
- run: ls "$ACME_HOME"/sessions | grep -q .
|
|
131
191
|
```
|
|
132
192
|
|
|
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:`.
|
|
193
|
+
`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.
|
|
194
|
+
|
|
195
|
+
### Each run gets its own HOME
|
|
196
|
+
|
|
197
|
+
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`.
|
|
198
|
+
|
|
199
|
+
- 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).
|
|
200
|
+
- `install`, checks and `cleanup` get `HOME` and the XDG vars set to the run's home, plus `$FIREGOAT_HOME`. `build` keeps your real home.
|
|
201
|
+
- `run.json` records it as `home`.
|
|
202
|
+
|
|
203
|
+
Limits:
|
|
134
204
|
|
|
135
|
-
|
|
205
|
+
- claude's own Read/Write/Edit tools expand `~` to your real home; only Bash commands get the run's home.
|
|
206
|
+
- Lookups by user id (`os.userInfo().homedir`, python's `pwd`, `getpwuid`) still return your real home.
|
|
207
|
+
- Keychain-backed logins (`gh`, `docker`, git credential helpers) look logged out inside a run. Log tools in from `install:`, or pass tokens with `env:`.
|
|
208
|
+
- The fresh home has no `.gitconfig`: if the agent commits, set `git config --global user.name ...` / `user.email` in `install:`.
|
|
209
|
+
- claude's shell snapshot still sources your real `~/.zshrc` (or `~/.bashrc`).
|
|
136
210
|
|
|
137
211
|
## What eval does
|
|
138
212
|
|
|
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.
|
|
213
|
+
- 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
214
|
- 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
215
|
- 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.
|
|
216
|
+
- 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`.
|
|
217
|
+
- 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
218
|
- 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.
|
|
219
|
+
- 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
220
|
|
|
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`.
|
|
221
|
+
`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
222
|
|
|
149
223
|
### Stop what the agent left running, seed one test
|
|
150
224
|
|
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.20.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,42 @@ 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
|
+
checkPlaceholders(arg, p);
|
|
7215
|
+
return arg;
|
|
7216
|
+
});
|
|
7217
|
+
}
|
|
7175
7218
|
var VAR_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
7176
7219
|
function parseVars(raw) {
|
|
7177
7220
|
if (raw === undefined)
|
|
@@ -7193,10 +7236,18 @@ var RESERVED_ENV = new Set([
|
|
|
7193
7236
|
"CLAUDE_CONFIG_DIR",
|
|
7194
7237
|
"CLAUDE_SECURESTORAGE_CONFIG_DIR"
|
|
7195
7238
|
]);
|
|
7239
|
+
var HOME_ENV = new Set([
|
|
7240
|
+
"HOME",
|
|
7241
|
+
"XDG_CONFIG_HOME",
|
|
7242
|
+
"XDG_CACHE_HOME",
|
|
7243
|
+
"XDG_DATA_HOME",
|
|
7244
|
+
"XDG_STATE_HOME",
|
|
7245
|
+
"CLAUDE_ENV_FILE"
|
|
7246
|
+
]);
|
|
7196
7247
|
var RUN_PLACEHOLDERS = ["run.id", "run.n", "test.name"];
|
|
7197
7248
|
var EVAL_PLACEHOLDERS = ["run.id"];
|
|
7198
7249
|
var ANY_PLACEHOLDER = /\{\{\s*([^{}]*?)\s*\}\}/g;
|
|
7199
|
-
var DIR_REF = /\$(?:\{(FIREGOAT_OUT|FIREGOAT_WORKDIR)\}|(FIREGOAT_OUT|FIREGOAT_WORKDIR)(?![A-Za-z0-9_]))/g;
|
|
7250
|
+
var DIR_REF = /\$(?:\{(FIREGOAT_OUT|FIREGOAT_WORKDIR|FIREGOAT_HOME)\}|(FIREGOAT_OUT|FIREGOAT_WORKDIR|FIREGOAT_HOME)(?![A-Za-z0-9_]))/g;
|
|
7200
7251
|
var ENV_REF = new RegExp(`${ANY_PLACEHOLDER.source}|${DIR_REF.source}`, "g");
|
|
7201
7252
|
function parseEnv(raw, path) {
|
|
7202
7253
|
if (raw === undefined)
|
|
@@ -7210,19 +7261,24 @@ function parseEnv(raw, path) {
|
|
|
7210
7261
|
fail(p, "name must be letters, digits and _, not starting with a digit");
|
|
7211
7262
|
if (k === "ANTHROPIC_MODEL")
|
|
7212
7263
|
fail(p, "is reserved: choose the model with top-level model: (or eval --model)");
|
|
7264
|
+
if (HOME_ENV.has(k))
|
|
7265
|
+
fail(p, "Reserved: every run gets its own home ($FIREGOAT_HOME)");
|
|
7213
7266
|
if (RESERVED_ENV.has(k) || k.startsWith("FIREGOAT_"))
|
|
7214
7267
|
fail(p, "is set by firegoat and cannot be overridden (reserved: PATH, CLAUDE_CONFIG_DIR, CLAUDE_SECURESTORAGE_CONFIG_DIR, FIREGOAT_*)");
|
|
7215
7268
|
if (typeof v !== "string")
|
|
7216
7269
|
fail(p, "must be a string (quote numbers and booleans)");
|
|
7217
|
-
|
|
7218
|
-
const name = m[1];
|
|
7219
|
-
if (!/^vars\.[A-Za-z0-9_]+$/.test(name) && !RUN_PLACEHOLDERS.includes(name))
|
|
7220
|
-
fail(p, `unknown placeholder ${m[0]} (allowed: {{vars.KEY}}, ${RUN_PLACEHOLDERS.map((n) => `{{${n}}}`).join(", ")})`);
|
|
7221
|
-
}
|
|
7270
|
+
checkPlaceholders(v, p);
|
|
7222
7271
|
out[k] = v;
|
|
7223
7272
|
}
|
|
7224
7273
|
return out;
|
|
7225
7274
|
}
|
|
7275
|
+
function checkPlaceholders(v, p) {
|
|
7276
|
+
for (const m of v.matchAll(ANY_PLACEHOLDER)) {
|
|
7277
|
+
const name = m[1];
|
|
7278
|
+
if (!/^vars\.[A-Za-z0-9_]+$/.test(name) && !RUN_PLACEHOLDERS.includes(name))
|
|
7279
|
+
fail(p, `unknown placeholder ${m[0]} (allowed: {{vars.KEY}}, ${RUN_PLACEHOLDERS.map((n) => `{{${n}}}`).join(", ")})`);
|
|
7280
|
+
}
|
|
7281
|
+
}
|
|
7226
7282
|
function isEvalLevel(value) {
|
|
7227
7283
|
const dirsOk = [...value.matchAll(DIR_REF)].every((m) => (m[1] ?? m[2]) === "FIREGOAT_OUT");
|
|
7228
7284
|
return dirsOk && [...value.matchAll(ANY_PLACEHOLDER)].every((m) => {
|
|
@@ -7230,16 +7286,19 @@ function isEvalLevel(value) {
|
|
|
7230
7286
|
return name.startsWith("vars.") || EVAL_PLACEHOLDERS.includes(name);
|
|
7231
7287
|
});
|
|
7232
7288
|
}
|
|
7289
|
+
function renderValue(value, vars, run, where) {
|
|
7290
|
+
return value.replace(ENV_REF, (whole, name, braced, bare) => {
|
|
7291
|
+
const key = name ?? braced ?? bare;
|
|
7292
|
+
const filled = key.startsWith("vars.") ? vars[key.slice("vars.".length)] : run[key];
|
|
7293
|
+
if (filled === undefined)
|
|
7294
|
+
throw new ConfigError(`${where}: no value for ${whole}`);
|
|
7295
|
+
return filled;
|
|
7296
|
+
});
|
|
7297
|
+
}
|
|
7233
7298
|
function renderEnv(env, vars, run) {
|
|
7234
7299
|
const out = {};
|
|
7235
7300
|
for (const [k, v] of Object.entries(env))
|
|
7236
|
-
out[k] = v
|
|
7237
|
-
const key = name ?? braced ?? bare;
|
|
7238
|
-
const filled = key.startsWith("vars.") ? vars[key.slice("vars.".length)] : run[key];
|
|
7239
|
-
if (filled === undefined)
|
|
7240
|
-
throw new ConfigError(`env.${k}: no value for ${whole}`);
|
|
7241
|
-
return filled;
|
|
7242
|
-
});
|
|
7301
|
+
out[k] = renderValue(v, vars, run, `env.${k}`);
|
|
7243
7302
|
return out;
|
|
7244
7303
|
}
|
|
7245
7304
|
var PLACEHOLDER = /\{\{\s*vars\.([A-Za-z0-9_]+)\s*\}\}/g;
|
|
@@ -7267,11 +7326,11 @@ import {
|
|
|
7267
7326
|
appendFileSync,
|
|
7268
7327
|
createWriteStream as createWriteStream2,
|
|
7269
7328
|
existsSync as existsSync3,
|
|
7270
|
-
mkdirSync as
|
|
7329
|
+
mkdirSync as mkdirSync4,
|
|
7271
7330
|
readdirSync as readdirSync2,
|
|
7272
|
-
writeFileSync as
|
|
7331
|
+
writeFileSync as writeFileSync4
|
|
7273
7332
|
} from "node:fs";
|
|
7274
|
-
import { basename as basename3, delimiter as delimiter2, join as
|
|
7333
|
+
import { basename as basename3, delimiter as delimiter2, join as join6 } from "node:path";
|
|
7275
7334
|
|
|
7276
7335
|
// src/claude.ts
|
|
7277
7336
|
import { spawn, spawnSync } from "node:child_process";
|
|
@@ -7286,15 +7345,18 @@ var CLAUDE_FLAGS = [
|
|
|
7286
7345
|
"--disable-slash-commands"
|
|
7287
7346
|
];
|
|
7288
7347
|
var NPM_INJECTED = /^(npm_config_|npm_lifecycle_|npm_package_)|^(npm_execpath|npm_node_execpath|npm_command|INIT_CWD)$/;
|
|
7348
|
+
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)$/;
|
|
7349
|
+
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
7350
|
function hostEnv() {
|
|
7290
|
-
return Object.fromEntries(Object.entries(process.env).filter(([k]) => !NPM_INJECTED.test(k) && !k.startsWith("FIREGOAT_")));
|
|
7351
|
+
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
7352
|
}
|
|
7292
|
-
function claudeEnv(configDir, env = {}) {
|
|
7353
|
+
function claudeEnv(configDir, env = {}, envFile) {
|
|
7293
7354
|
return {
|
|
7294
7355
|
...hostEnv(),
|
|
7295
7356
|
...env,
|
|
7296
7357
|
CLAUDE_CONFIG_DIR: configDir,
|
|
7297
|
-
CLAUDE_SECURESTORAGE_CONFIG_DIR: ""
|
|
7358
|
+
CLAUDE_SECURESTORAGE_CONFIG_DIR: "",
|
|
7359
|
+
...envFile === undefined ? {} : { CLAUDE_ENV_FILE: envFile }
|
|
7298
7360
|
};
|
|
7299
7361
|
}
|
|
7300
7362
|
function run(opts) {
|
|
@@ -7337,7 +7399,8 @@ async function runClaude(opts) {
|
|
|
7337
7399
|
"-p",
|
|
7338
7400
|
opts.prompt,
|
|
7339
7401
|
...CLAUDE_FLAGS,
|
|
7340
|
-
...opts.model === undefined ? [] : ["--model", opts.model]
|
|
7402
|
+
...opts.model === undefined ? [] : ["--model", opts.model],
|
|
7403
|
+
...opts.extraArgs
|
|
7341
7404
|
],
|
|
7342
7405
|
cwd: opts.cwd,
|
|
7343
7406
|
env: opts.env,
|
|
@@ -7828,15 +7891,19 @@ function topHelp(version) {
|
|
|
7828
7891
|
folder, with none of your claude config; firegoat saves the session and runs your
|
|
7829
7892
|
script checks.
|
|
7830
7893
|
|
|
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>"
|
|
7894
|
+
npx firegoat@latest init Create firegoat.yaml and .firegoat/, install the skill
|
|
7895
|
+
npx firegoat@latest eval Run the tests (claude must be logged in: run \`claude\`)
|
|
7896
|
+
npx firegoat@latest login Log in to firegoat.ai: eval then uploads each session
|
|
7897
|
+
npx firegoat@latest logout Forget that login
|
|
7898
|
+
npx firegoat@latest feedback "<what got in the way>"
|
|
7899
|
+
Tell the firegoat team
|
|
7900
|
+
|
|
7901
|
+
Use npx firegoat@latest: plain npx firegoat can keep running an old cached version.
|
|
7902
|
+
Each eval prints its version first.
|
|
7836
7903
|
|
|
7837
7904
|
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
|
|
7905
|
+
1. Read npx firegoat@latest init --help, then edit firegoat.yaml
|
|
7906
|
+
2. npx firegoat@latest eval --json
|
|
7840
7907
|
3. Judge each run's end_goal from its run.json, session and workdir: firegoat only
|
|
7841
7908
|
runs the script checks
|
|
7842
7909
|
|
|
@@ -7880,20 +7947,32 @@ firegoat.yaml reference:
|
|
|
7880
7947
|
env: {NAME: value} Env vars for the agent, and for install, checks
|
|
7881
7948
|
and cleanup of the same run; values are strings and can
|
|
7882
7949
|
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.
|
|
7950
|
+
{{test.name}}, $FIREGOAT_OUT, $FIREGOAT_WORKDIR,
|
|
7951
|
+
$FIREGOAT_HOME (no other $ expansion: $HOME stays
|
|
7952
|
+
literal). The build step gets only the entries that
|
|
7953
|
+
use only {{vars.*}}, {{run.id}} and $FIREGOAT_OUT.
|
|
7887
7954
|
PATH, CLAUDE_CONFIG_DIR, FIREGOAT_* and
|
|
7888
|
-
ANTHROPIC_MODEL (use model:) are reserved
|
|
7889
|
-
|
|
7890
|
-
|
|
7891
|
-
|
|
7892
|
-
|
|
7893
|
-
|
|
7894
|
-
|
|
7895
|
-
|
|
7896
|
-
|
|
7955
|
+
ANTHROPIC_MODEL (use model:) are reserved, and so
|
|
7956
|
+
are HOME, XDG_CONFIG_HOME, XDG_CACHE_HOME,
|
|
7957
|
+
XDG_DATA_HOME, XDG_STATE_HOME and CLAUDE_ENV_FILE:
|
|
7958
|
+
every run gets its own home ($FIREGOAT_HOME), so a
|
|
7959
|
+
tool that keeps state in ~ starts fresh in each
|
|
7960
|
+
run. Only a tool that ignores HOME needs its own
|
|
7961
|
+
var, e.g. MYTOOL_HOME: "$FIREGOAT_HOME/.mytool"
|
|
7962
|
+
claude_args: [<arg>, ...] Extra claude arguments, after firegoat's own flags
|
|
7963
|
+
and --model. Each entry is one argument, passed
|
|
7964
|
+
as-is (no shell splitting): [--chrome] (Claude in
|
|
7965
|
+
Chrome, for tests that need a browser),
|
|
7966
|
+
[--plugin-dir, ./plugins/x], --mcp-config, --agents,
|
|
7967
|
+
--append-system-prompt. Entries expand like env:
|
|
7968
|
+
values ($FIREGOAT_WORKDIR, $FIREGOAT_HOME,
|
|
7969
|
+
$FIREGOAT_OUT, {{vars.KEY}}, {{run.*}}); run.json
|
|
7970
|
+
claudeArgs has the result. Flags firegoat sets are
|
|
7971
|
+
errors, also as --flag=value: -p, --print,
|
|
7972
|
+
--output-format, --verbose, --setting-sources,
|
|
7973
|
+
--strict-mcp-config, --disable-slash-commands,
|
|
7974
|
+
--dangerously-skip-permissions, --model (use
|
|
7975
|
+
model:), --session-id, --resume, -r, --continue, -c
|
|
7897
7976
|
tests:
|
|
7898
7977
|
- name: quickstart Letters, digits, '.', '_', '-'; unique
|
|
7899
7978
|
prompt: ... What a user would ask the agent; one concrete outcome
|
|
@@ -7905,14 +7984,20 @@ firegoat.yaml reference:
|
|
|
7905
7984
|
env: {NAME: value} Optional, on top of the top-level env:
|
|
7906
7985
|
install: <sh> | [...] Optional, runs after the top-level install
|
|
7907
7986
|
cleanup: <sh> | [...] Optional, runs before the top-level cleanup
|
|
7987
|
+
claude_args: [...] Optional, after the top-level claude_args
|
|
7908
7988
|
|
|
7909
7989
|
Step order: build once per eval; then per run: install (top-level, then the
|
|
7910
7990
|
test's), the agent, checks, cleanup (the test's, then top-level).
|
|
7911
7991
|
|
|
7912
7992
|
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
|
-
|
|
7993
|
+
e.g. fixtures/ (committed) and copy them in build or install. To start each run
|
|
7994
|
+
from a template project, commit it as e.g. fixtures/starter-app/, then:
|
|
7995
|
+
build: cp -R fixtures/starter-app "$FIREGOAT_OUT/starter-app"
|
|
7996
|
+
install: cp -R "$FIREGOAT_OUT/starter-app/." .
|
|
7997
|
+
The build, install and cleanup commands and the checks get the vars as env
|
|
7998
|
+
vars, plus $FIREGOAT_OUT and $FIREGOAT_WORKDIR; install, checks and cleanup also
|
|
7999
|
+
get $FIREGOAT_HOME, the run's own home, as HOME (build keeps your real HOME).
|
|
8000
|
+
Checks also get $FIREGOAT_TRANSCRIPT, a short plain-text
|
|
7916
8001
|
transcript, greppable line by line: "[1] $ <command>" on every line of a shell
|
|
7917
8002
|
call (one line per line of the command, same n), "[2] Write <path>" for other
|
|
7918
8003
|
tools, each output indented, and "agent: " on every line of the agent's text and
|
|
@@ -7925,6 +8010,18 @@ firegoat.yaml reference:
|
|
|
7925
8010
|
every env firegoat starts, unless env: sets them. So is every FIREGOAT_* var
|
|
7926
8011
|
(FIREGOAT_API_KEY above all) firegoat itself was started with.
|
|
7927
8012
|
|
|
8013
|
+
Testing an MCP server: write its config per run in install: and pass it with
|
|
8014
|
+
claude_args (relative paths are from the run's folder, claude's cwd). Only the
|
|
8015
|
+
configs you pass load: --strict-mcp-config is always on. An MCP server inherits
|
|
8016
|
+
claude's env, which keeps your real HOME (claude's login is there), so set the
|
|
8017
|
+
server's HOME to the run's home, or it writes to yours:
|
|
8018
|
+
install: |
|
|
8019
|
+
cat > .mcp.json <<EOF
|
|
8020
|
+
{"mcpServers": {"acme": {"command": "npx", "args": ["-y", "acme-mcp"],
|
|
8021
|
+
"env": {"HOME": "$FIREGOAT_HOME"}}}}
|
|
8022
|
+
EOF
|
|
8023
|
+
claude_args: [--mcp-config, .mcp.json]
|
|
8024
|
+
|
|
7928
8025
|
Example:
|
|
7929
8026
|
build: npm pack --pack-destination "$FIREGOAT_OUT"
|
|
7930
8027
|
install: npm init -y >/dev/null && npm install "$FIREGOAT_OUT"/*.tgz
|
|
@@ -7942,6 +8039,12 @@ Example:
|
|
|
7942
8039
|
- run: |
|
|
7943
8040
|
test -s "$FIREGOAT_TRANSCRIPT" &&
|
|
7944
8041
|
! grep -Eq '^\\[[0-9]+\\] \\$ .*npm (install|i|add) .*acme' "$FIREGOAT_TRANSCRIPT"
|
|
8042
|
+
- name: dashboard-key
|
|
8043
|
+
prompt: >-
|
|
8044
|
+
Get an acme API key from the acme dashboard (I'm logged in in Chrome) and put
|
|
8045
|
+
it in .env as ACME_API_KEY.
|
|
8046
|
+
end_goal: .env holds a key copied from the dashboard, not a made-up one.
|
|
8047
|
+
claude_args: [--chrome]
|
|
7945
8048
|
|
|
7946
8049
|
Exit codes: 0 done, 1 the firegoat login failed (the files stay), 2 the skill
|
|
7947
8050
|
install failed, 3 usage error.`;
|
|
@@ -7993,9 +8096,30 @@ changes the run's status or the exit code.
|
|
|
7993
8096
|
|
|
7994
8097
|
claude runs on its own login (or ANTHROPIC_API_KEY if set, as claude always does)
|
|
7995
8098
|
with a fresh config dir, .firegoat/.claude: none of your settings, CLAUDE.md,
|
|
7996
|
-
plugins, skills or MCP servers
|
|
8099
|
+
plugins, skills or MCP servers (claude_args: in firegoat.yaml adds claude flags,
|
|
8100
|
+
e.g. --chrome or --plugin-dir). claude saves its session there. There is no
|
|
7997
8101
|
sandbox: the agent can reach the rest of your machine, including the repo.
|
|
7998
8102
|
|
|
8103
|
+
Every run gets its own HOME, .firegoat/runs/<runId>/<test>/<n>/home (with
|
|
8104
|
+
XDG_CONFIG_HOME, XDG_CACHE_HOME, XDG_DATA_HOME, XDG_STATE_HOME inside it), as
|
|
8105
|
+
$FIREGOAT_HOME: tools that keep state in ~ start fresh and parallel runs don't
|
|
8106
|
+
collide. claude itself keeps your real HOME (its login is there) and sources
|
|
8107
|
+
<n>/home.env (CLAUDE_ENV_FILE) before each Bash command the agent runs; install,
|
|
8108
|
+
checks and cleanup get the run's HOME directly; build keeps yours. Limits: claude's
|
|
8109
|
+
own Read/Write tools expand ~ to your real home; getpwuid lookups (os.userInfo,
|
|
8110
|
+
python pwd) still see it; keychain logins (gh, docker, git credential helpers) look
|
|
8111
|
+
logged out inside a run, so log tools in from install: or with env: tokens; the
|
|
8112
|
+
fresh home has no .gitconfig, so set user.name/user.email in install: if the agent
|
|
8113
|
+
commits; claude's shell snapshot still sources your real ~/.zshrc.
|
|
8114
|
+
|
|
8115
|
+
Vars a parent Claude Code session sets (CLAUDECODE, CLAUDE_CODE_*, CLAUDE_PID,
|
|
8116
|
+
CLAUDE_ENV_FILE, CLAUDE_SESSION_ID, CLAUDE_PROJECT_DIR, CLAUDE_EFFORT,
|
|
8117
|
+
CLAUDE_AGENT_SDK_*, CLAUDE_PLUGIN_ROOT, CLAUDE_PLUGIN_DATA, CLAUDE_SKILL_DIR) are
|
|
8118
|
+
removed from every env firegoat starts, so the claude under test doesn't act as a
|
|
8119
|
+
nested session. Login and provider vars stay: ANTHROPIC_*, CLAUDE_CODE_OAUTH_TOKEN,
|
|
8120
|
+
CLAUDE_CODE_OAUTH_REFRESH_TOKEN, CLAUDE_CODE_USE_*, CLAUDE_CODE_SKIP_*_AUTH,
|
|
8121
|
+
CLAUDE_CODE_CLIENT_CERT, CLAUDE_CODE_CLIENT_KEY, CLAUDE_CODE_CLIENT_KEY_PASSPHRASE.
|
|
8122
|
+
|
|
7999
8123
|
In a linked git worktree (git worktree add), .firegoat/runs/ and .firegoat/.claude
|
|
8000
8124
|
live in the main checkout instead, so runs outlive the worktree; firegoat.yaml,
|
|
8001
8125
|
fixtures and build: stay in the current folder.
|
|
@@ -8005,8 +8129,8 @@ fixtures and build: stay in the current folder.
|
|
|
8005
8129
|
--model NAME claude --model, replacing model: in firegoat.yaml; repeat it
|
|
8006
8130
|
(--model a --model b) to run each test runs times per model
|
|
8007
8131
|
--parallel N Runs at once (default 4; 1 runs them one after another).
|
|
8008
|
-
|
|
8009
|
-
|
|
8132
|
+
Each run has its own HOME; state outside it (a fixed port,
|
|
8133
|
+
/tmp paths) can still collide: use 1 then
|
|
8010
8134
|
--var KEY=value Fills {{vars.KEY}}; env for build, install, checks and
|
|
8011
8135
|
cleanup
|
|
8012
8136
|
--json The summary as JSON on stdout (or {error, exitCode} if eval
|
|
@@ -8034,9 +8158,10 @@ Output: .firegoat/runs/<runId>/
|
|
|
8034
8158
|
uploadFailed
|
|
8035
8159
|
<test>/<n>/run.json One run: status and reason (from the checks), model
|
|
8036
8160
|
(asked for, or null) and claudeModel (what claude's
|
|
8037
|
-
init line reported),
|
|
8161
|
+
init line reported), claudeArgs (claude_args
|
|
8162
|
+
passed), prompt, end_goal,
|
|
8038
8163
|
checks [{run, code}], cleanup [{run, code}],
|
|
8039
|
-
and paths: workdir,
|
|
8164
|
+
and paths: workdir, home (the run's HOME),
|
|
8040
8165
|
transcript, session (claude's session file), stderr,
|
|
8041
8166
|
checksLog, cleanupLog; startedAt; upload ({status: "uploaded",
|
|
8042
8167
|
url} | {status: "failed", error} | {status:
|
|
@@ -8045,6 +8170,8 @@ Output: .firegoat/runs/<runId>/
|
|
|
8045
8170
|
("[n] $ " on every line of a shell call), short output
|
|
8046
8171
|
(indented), its text and final answer ("agent: " on
|
|
8047
8172
|
every line), files it made
|
|
8173
|
+
<test>/<n>/home/, home.env The run's HOME, and the file claude sources
|
|
8174
|
+
before the agent's Bash commands
|
|
8048
8175
|
build.txt, <test>/<n>/install.txt, checks.txt, cleanup.txt Logs
|
|
8049
8176
|
|
|
8050
8177
|
Grading: firegoat runs only the script checks; they set the exit code. end_goal is
|
|
@@ -8083,6 +8210,30 @@ Example:
|
|
|
8083
8210
|
|
|
8084
8211
|
Exit codes: 0 sent, 2 the service failed (or rate limit), 3 usage error.`;
|
|
8085
8212
|
|
|
8213
|
+
// src/home.ts
|
|
8214
|
+
import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "node:fs";
|
|
8215
|
+
import { join as join3 } from "node:path";
|
|
8216
|
+
function homeEnv(home) {
|
|
8217
|
+
return {
|
|
8218
|
+
HOME: home,
|
|
8219
|
+
XDG_CONFIG_HOME: join3(home, ".config"),
|
|
8220
|
+
XDG_CACHE_HOME: join3(home, ".cache"),
|
|
8221
|
+
XDG_DATA_HOME: join3(home, ".local", "share"),
|
|
8222
|
+
XDG_STATE_HOME: join3(home, ".local", "state")
|
|
8223
|
+
};
|
|
8224
|
+
}
|
|
8225
|
+
var shellQuote = (s) => `'${s.replaceAll("'", `'\\''`)}'`;
|
|
8226
|
+
function makeRunHome(dir) {
|
|
8227
|
+
const home = join3(dir, "home");
|
|
8228
|
+
const env = homeEnv(home);
|
|
8229
|
+
for (const d of Object.values(env))
|
|
8230
|
+
mkdirSync3(d, { recursive: true });
|
|
8231
|
+
const envFile = join3(dir, "home.env");
|
|
8232
|
+
writeFileSync2(envFile, Object.entries(env).map(([k, v]) => `export ${k}=${shellQuote(v)}
|
|
8233
|
+
`).join(""));
|
|
8234
|
+
return { home, envFile };
|
|
8235
|
+
}
|
|
8236
|
+
|
|
8086
8237
|
// src/steps.ts
|
|
8087
8238
|
import { createWriteStream } from "node:fs";
|
|
8088
8239
|
var STEP_TIMEOUT_MS = 600000;
|
|
@@ -8124,8 +8275,8 @@ async function runAllSteps(opts) {
|
|
|
8124
8275
|
}
|
|
8125
8276
|
|
|
8126
8277
|
// src/transcript.ts
|
|
8127
|
-
import { readdirSync, statSync, writeFileSync as
|
|
8128
|
-
import { join as
|
|
8278
|
+
import { readdirSync, statSync, writeFileSync as writeFileSync3 } from "node:fs";
|
|
8279
|
+
import { join as join4, relative } from "node:path";
|
|
8129
8280
|
var OUTPUT_LINES = 15;
|
|
8130
8281
|
var OUTPUT_CHARS = 1500;
|
|
8131
8282
|
var TEXT_CHARS = 1500;
|
|
@@ -8169,7 +8320,7 @@ function workdirFiles(dir) {
|
|
|
8169
8320
|
for (const name of readdirSync(d)) {
|
|
8170
8321
|
if (SKIP_DIRS.has(name))
|
|
8171
8322
|
continue;
|
|
8172
|
-
const p =
|
|
8323
|
+
const p = join4(d, name);
|
|
8173
8324
|
if (statSync(p).isDirectory())
|
|
8174
8325
|
walk(p);
|
|
8175
8326
|
else
|
|
@@ -8212,7 +8363,7 @@ ${indent(truncate(text, OUTPUT_LINES, OUTPUT_CHARS))}` : ` → ${status}`);
|
|
|
8212
8363
|
out.pop();
|
|
8213
8364
|
out.push("", "## final answer", final ? prose(final) : "(none)");
|
|
8214
8365
|
out.push("", "## files in the workdir", ...workdirFiles(opts.workdir));
|
|
8215
|
-
|
|
8366
|
+
writeFileSync3(opts.path, `${out.join(`
|
|
8216
8367
|
`)}
|
|
8217
8368
|
`);
|
|
8218
8369
|
}
|
|
@@ -8392,7 +8543,7 @@ function ttyUI(out, opts) {
|
|
|
8392
8543
|
const doneCount = c.pass + c.fail + c.error;
|
|
8393
8544
|
const badge = finished ? `DONE · RUN ${finished.runId.slice(-4)}` : `RUNNING · ${doneCount}/${rows.length}`;
|
|
8394
8545
|
const out = [
|
|
8395
|
-
...logoHeader(p, `firegoat eval · ${opts.project}`, p(finished ? C.green : C.ember, badge), w),
|
|
8546
|
+
...logoHeader(p, `firegoat ${opts.version} eval · ${opts.project}`, p(finished ? C.green : C.ember, badge), w),
|
|
8396
8547
|
rule
|
|
8397
8548
|
];
|
|
8398
8549
|
if (authText)
|
|
@@ -8653,7 +8804,7 @@ async function uploadRun(t, r) {
|
|
|
8653
8804
|
|
|
8654
8805
|
// src/which.ts
|
|
8655
8806
|
import { accessSync, constants, statSync as statSync2 } from "node:fs";
|
|
8656
|
-
import { delimiter, join as
|
|
8807
|
+
import { delimiter, join as join5, resolve } from "node:path";
|
|
8657
8808
|
function resolveBin(bin) {
|
|
8658
8809
|
const executable = (p) => {
|
|
8659
8810
|
try {
|
|
@@ -8666,8 +8817,8 @@ function resolveBin(bin) {
|
|
|
8666
8817
|
if (bin.includes("/"))
|
|
8667
8818
|
return executable(bin) ? resolve(bin) : undefined;
|
|
8668
8819
|
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
8669
|
-
if (dir && executable(
|
|
8670
|
-
return
|
|
8820
|
+
if (dir && executable(join5(dir, bin)))
|
|
8821
|
+
return join5(dir, bin);
|
|
8671
8822
|
}
|
|
8672
8823
|
return;
|
|
8673
8824
|
}
|
|
@@ -8740,11 +8891,11 @@ function parseArgs(args) {
|
|
|
8740
8891
|
return opts;
|
|
8741
8892
|
}
|
|
8742
8893
|
function findSession(configDir, sessionId) {
|
|
8743
|
-
const projects =
|
|
8894
|
+
const projects = join6(configDir, "projects");
|
|
8744
8895
|
if (!existsSync3(projects))
|
|
8745
8896
|
return null;
|
|
8746
8897
|
for (const d of readdirSync2(projects)) {
|
|
8747
|
-
const f =
|
|
8898
|
+
const f = join6(projects, d, `${sessionId}.jsonl`);
|
|
8748
8899
|
if (existsSync3(f))
|
|
8749
8900
|
return f;
|
|
8750
8901
|
}
|
|
@@ -8755,7 +8906,7 @@ function newRunId() {
|
|
|
8755
8906
|
return `${ts}-${randomBytes2(2).toString("hex")}`;
|
|
8756
8907
|
}
|
|
8757
8908
|
function writeRunJson(r) {
|
|
8758
|
-
|
|
8909
|
+
writeFileSync4(join6(r.dir, "run.json"), `${JSON.stringify(r, null, 2)}
|
|
8759
8910
|
`);
|
|
8760
8911
|
}
|
|
8761
8912
|
async function uploadPreflight(opts, ui, project, runGroup, git) {
|
|
@@ -8797,14 +8948,24 @@ async function uploadPreflight(opts, ui, project, runGroup, git) {
|
|
|
8797
8948
|
async function runOne(opts) {
|
|
8798
8949
|
const { test, n } = opts;
|
|
8799
8950
|
const started = Date.now();
|
|
8800
|
-
const dir =
|
|
8801
|
-
const workdir =
|
|
8802
|
-
|
|
8951
|
+
const dir = join6(opts.runDir, test.name, String(n));
|
|
8952
|
+
const workdir = join6(dir, "work");
|
|
8953
|
+
mkdirSync4(workdir, { recursive: true });
|
|
8954
|
+
const { home, envFile } = makeRunHome(dir);
|
|
8955
|
+
const fill = {
|
|
8956
|
+
"run.id": opts.runId,
|
|
8957
|
+
"run.n": String(n),
|
|
8958
|
+
"test.name": test.name,
|
|
8959
|
+
FIREGOAT_OUT: opts.out,
|
|
8960
|
+
FIREGOAT_WORKDIR: workdir,
|
|
8961
|
+
FIREGOAT_HOME: home
|
|
8962
|
+
};
|
|
8803
8963
|
const r = {
|
|
8804
8964
|
test: test.name,
|
|
8805
8965
|
n,
|
|
8806
8966
|
model: opts.model,
|
|
8807
8967
|
claudeModel: null,
|
|
8968
|
+
claudeArgs: test.claudeArgs.map((a, i) => renderValue(a, opts.vars, fill, `claude_args[${i}]`)),
|
|
8808
8969
|
status: "error",
|
|
8809
8970
|
reason: "",
|
|
8810
8971
|
prompt: render(test.prompt, opts.vars),
|
|
@@ -8812,27 +8973,28 @@ async function runOne(opts) {
|
|
|
8812
8973
|
checks: [],
|
|
8813
8974
|
dir,
|
|
8814
8975
|
workdir,
|
|
8976
|
+
home,
|
|
8815
8977
|
session: null,
|
|
8816
|
-
transcript:
|
|
8817
|
-
stderr:
|
|
8818
|
-
checksLog:
|
|
8978
|
+
transcript: join6(dir, "transcript.txt"),
|
|
8979
|
+
stderr: join6(dir, "stderr.txt"),
|
|
8980
|
+
checksLog: join6(dir, "checks.txt"),
|
|
8819
8981
|
cleanup: [],
|
|
8820
|
-
cleanupLog:
|
|
8982
|
+
cleanupLog: join6(dir, "cleanup.txt"),
|
|
8821
8983
|
num_turns: null,
|
|
8822
8984
|
cost: null,
|
|
8823
8985
|
duration_ms: 0,
|
|
8824
8986
|
startedAt: new Date(started).toISOString(),
|
|
8825
8987
|
upload: null
|
|
8826
8988
|
};
|
|
8827
|
-
const env = renderEnv(test.env, opts.vars,
|
|
8828
|
-
|
|
8829
|
-
|
|
8830
|
-
|
|
8831
|
-
|
|
8832
|
-
FIREGOAT_WORKDIR: workdir
|
|
8833
|
-
|
|
8834
|
-
|
|
8835
|
-
const [status, reason] = await attempt(opts, r, env, stepEnv);
|
|
8989
|
+
const env = renderEnv(test.env, opts.vars, fill);
|
|
8990
|
+
const stepEnv = {
|
|
8991
|
+
...opts.stepEnv,
|
|
8992
|
+
...env,
|
|
8993
|
+
...homeEnv(home),
|
|
8994
|
+
FIREGOAT_WORKDIR: workdir,
|
|
8995
|
+
FIREGOAT_HOME: home
|
|
8996
|
+
};
|
|
8997
|
+
const [status, reason] = await attempt(opts, r, env, stepEnv, envFile);
|
|
8836
8998
|
const cleanup = [...test.cleanup, ...opts.cleanup];
|
|
8837
8999
|
if (cleanup.length > 0)
|
|
8838
9000
|
r.cleanup = await runAllSteps({
|
|
@@ -8847,7 +9009,7 @@ async function runOne(opts) {
|
|
|
8847
9009
|
writeRunJson(r);
|
|
8848
9010
|
return r;
|
|
8849
9011
|
}
|
|
8850
|
-
async function attempt(opts, r, env, stepEnv) {
|
|
9012
|
+
async function attempt(opts, r, env, stepEnv, homeEnvFile) {
|
|
8851
9013
|
const { test, n } = opts;
|
|
8852
9014
|
const { dir, workdir } = r;
|
|
8853
9015
|
const install = [...opts.install, ...test.install];
|
|
@@ -8857,7 +9019,7 @@ async function attempt(opts, r, env, stepEnv) {
|
|
|
8857
9019
|
commands: install,
|
|
8858
9020
|
cwd: workdir,
|
|
8859
9021
|
env: stepEnv,
|
|
8860
|
-
logPath:
|
|
9022
|
+
logPath: join6(dir, "install.txt")
|
|
8861
9023
|
});
|
|
8862
9024
|
if (error)
|
|
8863
9025
|
return ["error", `not graded: ${error}`];
|
|
@@ -8867,10 +9029,14 @@ async function attempt(opts, r, env, stepEnv) {
|
|
|
8867
9029
|
bin: opts.claudeBin,
|
|
8868
9030
|
prompt: r.prompt,
|
|
8869
9031
|
cwd: workdir,
|
|
8870
|
-
env: {
|
|
9032
|
+
env: {
|
|
9033
|
+
...claudeEnv(opts.configDir, env, homeEnvFile),
|
|
9034
|
+
PATH: opts.stepEnv.PATH
|
|
9035
|
+
},
|
|
8871
9036
|
stderr,
|
|
8872
9037
|
timeoutMs: test.timeout * 1000,
|
|
8873
9038
|
model: opts.model ?? undefined,
|
|
9039
|
+
extraArgs: r.claudeArgs,
|
|
8874
9040
|
onEvent: opts.onEvent
|
|
8875
9041
|
});
|
|
8876
9042
|
r.claudeModel = outcome.claudeModel;
|
|
@@ -8921,6 +9087,20 @@ async function attempt(opts, r, env, stepEnv) {
|
|
|
8921
9087
|
test.checks.length === 0 ? "no script checks" : `${r.checks.length}/${r.checks.length} checks passed`
|
|
8922
9088
|
];
|
|
8923
9089
|
}
|
|
9090
|
+
var MAX_LISTED_URLS = 5;
|
|
9091
|
+
function uploadedSummary(origin, results, runDir) {
|
|
9092
|
+
const urls = results.flatMap((r) => r.upload?.status === "uploaded" ? [r.upload.url] : []);
|
|
9093
|
+
if (urls.length === 0)
|
|
9094
|
+
return;
|
|
9095
|
+
const head = `Uploaded ${urls.length} session${urls.length === 1 ? "" : "s"} to ${new URL(origin).host} (private)`;
|
|
9096
|
+
if (urls.length > MAX_LISTED_URLS) {
|
|
9097
|
+
console.log(`${head}: each run's upload.url in ${join6(runDir, "summary.json")}`);
|
|
9098
|
+
return;
|
|
9099
|
+
}
|
|
9100
|
+
console.log(`${head}:`);
|
|
9101
|
+
for (const u of urls)
|
|
9102
|
+
console.log(` ${u}`);
|
|
9103
|
+
}
|
|
8924
9104
|
async function evalCommand(args, root) {
|
|
8925
9105
|
if (args.includes("--help") || args.includes("-h")) {
|
|
8926
9106
|
console.log(EVAL_HELP);
|
|
@@ -8943,7 +9123,13 @@ async function evalCommand(args, root) {
|
|
|
8943
9123
|
}
|
|
8944
9124
|
const say = opts.json ? console.error : console.log;
|
|
8945
9125
|
const stream = opts.json ? process.stderr : process.stdout;
|
|
8946
|
-
const ui = stream.isTTY ? ttyUI(stream, {
|
|
9126
|
+
const ui = stream.isTTY ? ttyUI(stream, {
|
|
9127
|
+
project: basename3(root),
|
|
9128
|
+
version: package_default.version,
|
|
9129
|
+
color: !process.env.NO_COLOR
|
|
9130
|
+
}) : plainUI(say);
|
|
9131
|
+
if (!stream.isTTY)
|
|
9132
|
+
say(`firegoat ${package_default.version}`);
|
|
8947
9133
|
let config;
|
|
8948
9134
|
try {
|
|
8949
9135
|
config = loadConfig(root);
|
|
@@ -8967,7 +9153,8 @@ async function evalCommand(args, root) {
|
|
|
8967
9153
|
...new Set(tests.flatMap((t) => [
|
|
8968
9154
|
t.prompt,
|
|
8969
9155
|
...t.checks.map((c) => c.run),
|
|
8970
|
-
...Object.values(t.env)
|
|
9156
|
+
...Object.values(t.env),
|
|
9157
|
+
...t.claudeArgs
|
|
8971
9158
|
]).flatMap(varRefs).filter((k) => !(k in vars)))
|
|
8972
9159
|
];
|
|
8973
9160
|
if (missing.length > 0)
|
|
@@ -8982,8 +9169,8 @@ async function evalCommand(args, root) {
|
|
|
8982
9169
|
return fatal(EXIT.usage, e.message);
|
|
8983
9170
|
}
|
|
8984
9171
|
const firegoatDir = ensureFiregoatDir(repo?.mainCheckout ?? root);
|
|
8985
|
-
const configDir =
|
|
8986
|
-
|
|
9172
|
+
const configDir = join6(firegoatDir, ".claude");
|
|
9173
|
+
mkdirSync4(configDir, { recursive: true });
|
|
8987
9174
|
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
9175
|
const loginProblem = checkLogin({
|
|
8989
9176
|
bin: claudePath,
|
|
@@ -9005,10 +9192,10 @@ async function evalCommand(args, root) {
|
|
|
9005
9192
|
return fatal(EXIT.usage, e.message);
|
|
9006
9193
|
throw e;
|
|
9007
9194
|
}
|
|
9008
|
-
const runsRoot =
|
|
9009
|
-
const runDir =
|
|
9010
|
-
const out =
|
|
9011
|
-
|
|
9195
|
+
const runsRoot = join6(firegoatDir, "runs");
|
|
9196
|
+
const runDir = join6(runsRoot, runId);
|
|
9197
|
+
const out = join6(runDir, "out");
|
|
9198
|
+
mkdirSync4(out, { recursive: true });
|
|
9012
9199
|
const stepEnv = {
|
|
9013
9200
|
...hostEnv(),
|
|
9014
9201
|
...vars,
|
|
@@ -9034,7 +9221,7 @@ async function evalCommand(args, root) {
|
|
|
9034
9221
|
...stepEnv,
|
|
9035
9222
|
...renderEnv(Object.fromEntries(Object.entries(config.env).filter(([, v]) => isEvalLevel(v))), vars, { "run.id": runId, FIREGOAT_OUT: out })
|
|
9036
9223
|
},
|
|
9037
|
-
logPath:
|
|
9224
|
+
logPath: join6(runDir, "build.txt")
|
|
9038
9225
|
});
|
|
9039
9226
|
const secs = Math.round((Date.now() - buildStarted) / 1000);
|
|
9040
9227
|
ui.build(error ? "fail" : "ok", secs);
|
|
@@ -9107,6 +9294,7 @@ async function evalCommand(args, root) {
|
|
|
9107
9294
|
totals[r.status]++;
|
|
9108
9295
|
const uploadsFailed = results.filter((r) => r.upload?.status === "failed").length;
|
|
9109
9296
|
const doc = {
|
|
9297
|
+
firegoatVersion: package_default.version,
|
|
9110
9298
|
runId,
|
|
9111
9299
|
runsDir: runDir,
|
|
9112
9300
|
dir: root,
|
|
@@ -9115,9 +9303,9 @@ async function evalCommand(args, root) {
|
|
|
9115
9303
|
totals,
|
|
9116
9304
|
runs: results
|
|
9117
9305
|
};
|
|
9118
|
-
|
|
9306
|
+
writeFileSync4(join6(runDir, "summary.json"), `${JSON.stringify(doc, null, 2)}
|
|
9119
9307
|
`);
|
|
9120
|
-
appendFileSync(
|
|
9308
|
+
appendFileSync(join6(runsRoot, "index.jsonl"), `${JSON.stringify({
|
|
9121
9309
|
runId,
|
|
9122
9310
|
startedAt: new Date(evalStarted).toISOString(),
|
|
9123
9311
|
dir: root,
|
|
@@ -9137,6 +9325,8 @@ async function evalCommand(args, root) {
|
|
|
9137
9325
|
rows,
|
|
9138
9326
|
seconds: Math.round((Date.now() - evalStarted) / 1000)
|
|
9139
9327
|
});
|
|
9328
|
+
if (upload && !opts.json)
|
|
9329
|
+
uploadedSummary(upload.origin, results, runDir);
|
|
9140
9330
|
for (const r of results)
|
|
9141
9331
|
for (const c of r.cleanup)
|
|
9142
9332
|
if (c.code !== 0)
|
|
@@ -9417,8 +9607,8 @@ firegoat ${package_default.version}` : `firegoat ${package_default.version}`,
|
|
|
9417
9607
|
|
|
9418
9608
|
// src/init.ts
|
|
9419
9609
|
import { spawnSync as spawnSync3 } from "node:child_process";
|
|
9420
|
-
import { existsSync as existsSync4, writeFileSync as
|
|
9421
|
-
import { basename as basename4, join as
|
|
9610
|
+
import { existsSync as existsSync4, writeFileSync as writeFileSync5 } from "node:fs";
|
|
9611
|
+
import { basename as basename4, join as join7 } from "node:path";
|
|
9422
9612
|
import { createInterface } from "node:readline/promises";
|
|
9423
9613
|
|
|
9424
9614
|
// src/login.ts
|
|
@@ -9613,7 +9803,7 @@ function skillsDir(scope) {
|
|
|
9613
9803
|
if (scope === "local")
|
|
9614
9804
|
return ".claude/skills/";
|
|
9615
9805
|
const config = process.env.CLAUDE_CONFIG_DIR?.trim();
|
|
9616
|
-
return config ? `${
|
|
9806
|
+
return config ? `${join7(config, "skills")}/` : "~/.claude/skills/";
|
|
9617
9807
|
}
|
|
9618
9808
|
function template(product) {
|
|
9619
9809
|
return `# firegoat.yaml: evals for how well coding agents use ${product}.
|
|
@@ -9647,6 +9837,10 @@ runs: 1
|
|
|
9647
9837
|
# Helper files (scripts, sample data) go in e.g. fixtures/ (committed), not inline here.
|
|
9648
9838
|
# The build step runs in this folder, so: build: cp -R fixtures/. "$FIREGOAT_OUT"
|
|
9649
9839
|
# Then e.g. install: cp "$FIREGOAT_OUT/wizard.mjs" .
|
|
9840
|
+
#
|
|
9841
|
+
# To start each run from a template project, commit it as e.g. fixtures/starter-app/:
|
|
9842
|
+
# build: cp -R fixtures/starter-app "$FIREGOAT_OUT/starter-app"
|
|
9843
|
+
# install: cp -R "$FIREGOAT_OUT/starter-app/." .
|
|
9650
9844
|
|
|
9651
9845
|
# Optional: cleanup: runs in each run's folder once the run is over (after the
|
|
9652
9846
|
# checks, also after an error or timeout), e.g. to stop a server the agent left
|
|
@@ -9658,28 +9852,30 @@ runs: 1
|
|
|
9658
9852
|
|
|
9659
9853
|
# Optional: env vars for the agent (and for install, checks and cleanup of the
|
|
9660
9854
|
# 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
|
-
#
|
|
9855
|
+
# {{run.id}}, {{run.n}}, {{test.name}}, and $FIREGOAT_OUT / $FIREGOAT_WORKDIR /
|
|
9856
|
+
# $FIREGOAT_HOME (no other $ expansion: $HOME stays literal). A test can add its
|
|
9857
|
+
# own env:. Every run gets its own HOME ($FIREGOAT_HOME), so tools that keep state
|
|
9858
|
+
# in ~ are isolated per run already; set a tool's own *_HOME var only if the tool
|
|
9859
|
+
# ignores HOME, as below.
|
|
9665
9860
|
# env:
|
|
9666
|
-
# MYTOOL_HOME: "
|
|
9861
|
+
# MYTOOL_HOME: "$FIREGOAT_HOME/.mytool"
|
|
9667
9862
|
# UV_FIND_LINKS: "$FIREGOAT_OUT"
|
|
9668
9863
|
|
|
9669
9864
|
tests:
|
|
9670
|
-
|
|
9671
|
-
|
|
9672
|
-
|
|
9673
|
-
|
|
9674
|
-
|
|
9865
|
+
# A smoke test that firegoat and claude work. Replace it with tests for your
|
|
9866
|
+
# product: the firegoat-onboarding skill writes them.
|
|
9867
|
+
- name: goat
|
|
9868
|
+
# What a user would ask their agent.
|
|
9869
|
+
prompt: Who's the GOAT? Answer in one short sentence.
|
|
9675
9870
|
# True when the user's problem is solved. Not graded by firegoat: it goes to
|
|
9676
9871
|
# 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.
|
|
9872
|
+
end_goal: Claude answers that you (the user) are the goat, e.g. YOU'RE THE GOAT.
|
|
9680
9873
|
# Shell commands run in the agent's folder afterwards; exit 0 = pass.
|
|
9874
|
+
# $FIREGOAT_TRANSCRIPT has "agent: " on every line of the agent's text.
|
|
9681
9875
|
checks:
|
|
9682
|
-
- run:
|
|
9876
|
+
- run: >-
|
|
9877
|
+
test -s "$FIREGOAT_TRANSCRIPT" &&
|
|
9878
|
+
grep -Eiq '^agent: .*goat' "$FIREGOAT_TRANSCRIPT"
|
|
9683
9879
|
# Seconds before the agent run is stopped.
|
|
9684
9880
|
timeout: 600
|
|
9685
9881
|
`;
|
|
@@ -9798,16 +9994,16 @@ ${INIT_HELP}`);
|
|
|
9798
9994
|
console.log(rule);
|
|
9799
9995
|
}
|
|
9800
9996
|
ensureFiregoatDir(cwd);
|
|
9801
|
-
const configPath =
|
|
9997
|
+
const configPath = join7(cwd, CONFIG_FILE);
|
|
9802
9998
|
const keep = existsSync4(configPath) && !force;
|
|
9803
9999
|
if (!keep)
|
|
9804
|
-
|
|
10000
|
+
writeFileSync5(configPath, template(basename4(cwd)));
|
|
9805
10001
|
if (interactive) {
|
|
9806
10002
|
step(1, CONFIG_FILE, keep ? "Kept (--force replaces it)" : "Created");
|
|
9807
10003
|
console.log(paint("90", " │"));
|
|
9808
10004
|
step(2, ".firegoat/", "Ready");
|
|
9809
10005
|
} else
|
|
9810
|
-
console.log(keep ? `Kept ${CONFIG_FILE} (--force replaces it)` : `Wrote ${CONFIG_FILE}:
|
|
10006
|
+
console.log(keep ? `Kept ${CONFIG_FILE} (--force replaces it)` : `Wrote ${CONFIG_FILE}: replace the goat smoke test with tests for your product`);
|
|
9811
10007
|
if (interactive && !noSkills)
|
|
9812
10008
|
console.log(paint("90", " │"));
|
|
9813
10009
|
if (interactive && !noSkills && !localSkills) {
|
|
@@ -9861,7 +10057,7 @@ Prompt for your coding agent: ${AGENT_PROMPT}`);
|
|
|
9861
10057
|
console.log(AGENT_PROMPT);
|
|
9862
10058
|
console.log("");
|
|
9863
10059
|
const copied = copyToClipboard(AGENT_PROMPT);
|
|
9864
|
-
console.log(` ${copied ? `${paint("32", "✓ Prompt copied!")} ` : ""}Then: ${paint("36", "npx firegoat eval")}`);
|
|
10060
|
+
console.log(` ${copied ? `${paint("32", "✓ Prompt copied!")} ` : ""}Then: ${paint("36", "npx firegoat@latest eval")}`);
|
|
9865
10061
|
return EXIT.pass;
|
|
9866
10062
|
}
|
|
9867
10063
|
|