firegoat 0.3.0 → 0.4.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 +18 -15
- package/dist/cli.js +151 -212
- package/package.json +1 -1
- package/skills/firegoat-onboarding/SKILL.md +6 -5
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Local-first AX evals: how well coding agents use your SDK, CLI, API and docs.
|
|
4
4
|
|
|
5
|
-
Status: early (v0.
|
|
5
|
+
Status: early (v0.4). See `plan.md`.
|
|
6
6
|
|
|
7
7
|
## Use
|
|
8
8
|
|
|
@@ -13,25 +13,28 @@ npx firegoat eval # runs each test with your local `claude`, sandboxe
|
|
|
13
13
|
npx firegoat open # opens the latest run's folder (--print to only print it)
|
|
14
14
|
```
|
|
15
15
|
|
|
16
|
-
### Local builds
|
|
16
|
+
### Local builds: setup
|
|
17
17
|
|
|
18
|
-
`
|
|
18
|
+
Plain shell, any language. `setup:` runs once per `eval` in the repo, on your machine; whatever it writes to `$FIREGOAT_OUT` is copied into each run's folder and put on the agent's PATH. `workdir_setup:` runs in each fresh workdir before the agent starts. The agent gets the build, never the source. Each takes one command or a list.
|
|
19
19
|
|
|
20
20
|
```yaml
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
21
|
+
# npm
|
|
22
|
+
setup: npm pack --pack-destination "$FIREGOAT_OUT"
|
|
23
|
+
workdir_setup: npm init -y >/dev/null && npm install "$FIREGOAT_OUT"/*.tgz
|
|
24
|
+
|
|
25
|
+
# python
|
|
26
|
+
setup: uv build --wheel --out-dir "$FIREGOAT_OUT"
|
|
27
|
+
workdir_setup: uv venv && uv pip install "$FIREGOAT_OUT"/*.whl
|
|
28
|
+
|
|
29
|
+
# a CLI binary: on PATH as is
|
|
30
|
+
setup: go build -o "$FIREGOAT_OUT/acme" ./cmd/acme
|
|
30
31
|
```
|
|
31
32
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
-
|
|
33
|
+
Then say so in the prompt: "The package is already installed from the build under test."
|
|
34
|
+
|
|
35
|
+
- `{{vars.KEY}}` in prompts and checks comes from `--var KEY=value` or `vars:` in `firegoat.yaml`. Vars are also env vars for `setup:`, `workdir_setup:` and checks, not for the agent. A missing var is a usage error; other `{{...}}` text is left alone.
|
|
36
|
+
- Checks also get `$FIREGOAT_OUT` (the run's copy).
|
|
37
|
+
- A failed `setup:` or `workdir_setup:` means not graded (exit 2); logs are `.firegoat/runs/<runId>/setup.txt` and `<test>/<n>/workdir_setup.txt`.
|
|
35
38
|
|
|
36
39
|
### Isolation
|
|
37
40
|
|
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.4.0",
|
|
6951
6951
|
repository: {
|
|
6952
6952
|
type: "git",
|
|
6953
6953
|
url: "git+https://github.com/team2027/firegoat-cli.git"
|
|
@@ -7081,7 +7081,10 @@ function parseConfig(text, warn = console.error) {
|
|
|
7081
7081
|
}
|
|
7082
7082
|
});
|
|
7083
7083
|
const runs = posInt(raw.runs, "runs", 1);
|
|
7084
|
-
|
|
7084
|
+
if (raw.build !== undefined)
|
|
7085
|
+
fail("build", "was replaced by setup: in firegoat 0.4.0: a shell command (or list) that writes what the agent gets to $FIREGOAT_OUT");
|
|
7086
|
+
const setup = commands(raw.setup, "setup");
|
|
7087
|
+
const workdirSetup = commands(raw.workdir_setup, "workdir_setup");
|
|
7085
7088
|
const vars = parseVars(raw.vars);
|
|
7086
7089
|
if (!Array.isArray(raw.tests) || raw.tests.length === 0)
|
|
7087
7090
|
fail("tests", "must be a non-empty list");
|
|
@@ -7118,40 +7121,24 @@ function parseConfig(text, warn = console.error) {
|
|
|
7118
7121
|
timeout: posInt(t.timeout, `${p}.timeout`, DEFAULT_TIMEOUT)
|
|
7119
7122
|
};
|
|
7120
7123
|
});
|
|
7121
|
-
|
|
7122
|
-
|
|
7123
|
-
|
|
7124
|
-
|
|
7125
|
-
|
|
7126
|
-
|
|
7127
|
-
|
|
7128
|
-
}
|
|
7129
|
-
return { agents, runs, build, vars, tests };
|
|
7124
|
+
return {
|
|
7125
|
+
agents,
|
|
7126
|
+
runs,
|
|
7127
|
+
setup,
|
|
7128
|
+
workdirSetup,
|
|
7129
|
+
vars,
|
|
7130
|
+
tests
|
|
7131
|
+
};
|
|
7130
7132
|
}
|
|
7131
7133
|
var NAME = /^[A-Za-z0-9._-]+$/;
|
|
7132
|
-
function
|
|
7134
|
+
function commands(raw, path) {
|
|
7133
7135
|
if (raw === undefined)
|
|
7134
7136
|
return [];
|
|
7135
|
-
if (
|
|
7136
|
-
|
|
7137
|
-
|
|
7138
|
-
|
|
7139
|
-
|
|
7140
|
-
fail(p, "name may only contain letters, digits, '.', '_' and '-'");
|
|
7141
|
-
if (typeof v === "string")
|
|
7142
|
-
return { name, path: str(v, p) };
|
|
7143
|
-
if (!isRecord(v))
|
|
7144
|
-
fail(p, "must be a path or a mapping with path and/or run");
|
|
7145
|
-
if (v.path === undefined && v.run === undefined)
|
|
7146
|
-
fail(p, "needs path, run, or both");
|
|
7147
|
-
const b = {
|
|
7148
|
-
name,
|
|
7149
|
-
path: v.path === undefined ? "." : str(v.path, `${p}.path`)
|
|
7150
|
-
};
|
|
7151
|
-
if (v.run !== undefined)
|
|
7152
|
-
b.run = str(v.run, `${p}.run`);
|
|
7153
|
-
return b;
|
|
7154
|
-
});
|
|
7137
|
+
if (typeof raw === "string")
|
|
7138
|
+
return [str(raw, path)];
|
|
7139
|
+
if (!Array.isArray(raw))
|
|
7140
|
+
fail(path, "must be a shell command or a list of them");
|
|
7141
|
+
return raw.map((c, i) => str(c, `${path}[${i}]`));
|
|
7155
7142
|
}
|
|
7156
7143
|
var VAR_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
7157
7144
|
function parseVars(raw) {
|
|
@@ -7169,18 +7156,15 @@ function parseVars(raw) {
|
|
|
7169
7156
|
}
|
|
7170
7157
|
return out;
|
|
7171
7158
|
}
|
|
7172
|
-
var PLACEHOLDER = /\{\{\s*
|
|
7173
|
-
function
|
|
7174
|
-
return [...text.matchAll(PLACEHOLDER)].map((m) =>
|
|
7175
|
-
kind: m[1],
|
|
7176
|
-
key: m[2]
|
|
7177
|
-
}));
|
|
7159
|
+
var PLACEHOLDER = /\{\{\s*vars\.([A-Za-z0-9_]+)\s*\}\}/g;
|
|
7160
|
+
function varRefs(text) {
|
|
7161
|
+
return [...text.matchAll(PLACEHOLDER)].map((m) => m[1]);
|
|
7178
7162
|
}
|
|
7179
|
-
function render(text,
|
|
7180
|
-
return text.replace(PLACEHOLDER, (_,
|
|
7181
|
-
const v =
|
|
7163
|
+
function render(text, vars) {
|
|
7164
|
+
return text.replace(PLACEHOLDER, (_, key) => {
|
|
7165
|
+
const v = vars[key];
|
|
7182
7166
|
if (v === undefined)
|
|
7183
|
-
throw new ConfigError(`no value for {{
|
|
7167
|
+
throw new ConfigError(`no value for {{vars.${key}}}`);
|
|
7184
7168
|
return v;
|
|
7185
7169
|
});
|
|
7186
7170
|
}
|
|
@@ -7206,27 +7190,15 @@ function findConfigDir(cwd) {
|
|
|
7206
7190
|
import { randomBytes } from "node:crypto";
|
|
7207
7191
|
import {
|
|
7208
7192
|
createWriteStream as createWriteStream3,
|
|
7209
|
-
existsSync as
|
|
7193
|
+
existsSync as existsSync3,
|
|
7210
7194
|
mkdirSync as mkdirSync3,
|
|
7211
7195
|
mkdtempSync,
|
|
7212
7196
|
readFileSync as readFileSync2,
|
|
7213
7197
|
realpathSync as realpathSync2,
|
|
7214
|
-
writeFileSync as writeFileSync2
|
|
7215
|
-
} from "node:fs";
|
|
7216
|
-
import { tmpdir } from "node:os";
|
|
7217
|
-
import { join as join5, resolve as resolve3 } from "node:path";
|
|
7218
|
-
|
|
7219
|
-
// src/build.ts
|
|
7220
|
-
import {
|
|
7221
|
-
cpSync,
|
|
7222
|
-
createWriteStream as createWriteStream2,
|
|
7223
|
-
existsSync as existsSync3,
|
|
7224
|
-
mkdirSync as mkdirSync2,
|
|
7225
|
-
readdirSync,
|
|
7226
|
-
statSync as statSync2,
|
|
7227
7198
|
writeFileSync
|
|
7228
7199
|
} from "node:fs";
|
|
7229
|
-
import {
|
|
7200
|
+
import { tmpdir } from "node:os";
|
|
7201
|
+
import { join as join5, resolve as resolve2 } from "node:path";
|
|
7230
7202
|
|
|
7231
7203
|
// src/claude.ts
|
|
7232
7204
|
import { spawn, spawnSync as spawnSync2 } from "node:child_process";
|
|
@@ -7640,9 +7612,55 @@ function checkLogin(opts) {
|
|
|
7640
7612
|
return `claude auth status gave no login state (exit ${r.status}): ${(r.stdout + r.stderr).trim().slice(0, 300)}`;
|
|
7641
7613
|
}
|
|
7642
7614
|
|
|
7615
|
+
// src/exit.ts
|
|
7616
|
+
var EXIT = { pass: 0, fail: 1, notGraded: 2, usage: 3 };
|
|
7617
|
+
|
|
7618
|
+
// src/setup.ts
|
|
7619
|
+
import { cpSync, createWriteStream as createWriteStream2, mkdirSync as mkdirSync2 } from "node:fs";
|
|
7620
|
+
import { delimiter, join as join3 } from "node:path";
|
|
7621
|
+
var SETUP_TIMEOUT_MS = 600000;
|
|
7622
|
+
async function runAll(commands, cwd, env, logPath, what) {
|
|
7623
|
+
const log = createWriteStream2(logPath);
|
|
7624
|
+
try {
|
|
7625
|
+
for (const run of commands) {
|
|
7626
|
+
const r = await runShell({
|
|
7627
|
+
run,
|
|
7628
|
+
cwd,
|
|
7629
|
+
env,
|
|
7630
|
+
timeoutMs: SETUP_TIMEOUT_MS,
|
|
7631
|
+
log
|
|
7632
|
+
});
|
|
7633
|
+
if (r.code !== 0)
|
|
7634
|
+
return `${what} failed (${r.error ?? `exit ${r.code}`}): ${run} (log: ${logPath})`;
|
|
7635
|
+
}
|
|
7636
|
+
return;
|
|
7637
|
+
} finally {
|
|
7638
|
+
await new Promise((r) => log.end(r));
|
|
7639
|
+
}
|
|
7640
|
+
}
|
|
7641
|
+
async function setupOnce(opts) {
|
|
7642
|
+
mkdirSync2(opts.out, { recursive: true });
|
|
7643
|
+
return runAll(opts.commands, opts.root, { ...opts.env, FIREGOAT_OUT: opts.out }, opts.logPath, "setup");
|
|
7644
|
+
}
|
|
7645
|
+
async function prepareRun(opts) {
|
|
7646
|
+
const out = join3(opts.runRoot, "out");
|
|
7647
|
+
if (opts.out === null)
|
|
7648
|
+
mkdirSync2(out);
|
|
7649
|
+
else
|
|
7650
|
+
cpSync(opts.out, out, { recursive: true });
|
|
7651
|
+
const path = [out, opts.env.PATH ?? ""].join(delimiter);
|
|
7652
|
+
const error = await runAll(opts.commands, opts.workdir, {
|
|
7653
|
+
...opts.env,
|
|
7654
|
+
PATH: path,
|
|
7655
|
+
FIREGOAT_OUT: out,
|
|
7656
|
+
FIREGOAT_WORKDIR: opts.workdir
|
|
7657
|
+
}, opts.logPath, "workdir_setup");
|
|
7658
|
+
return error ? { error } : { out, path };
|
|
7659
|
+
}
|
|
7660
|
+
|
|
7643
7661
|
// src/which.ts
|
|
7644
7662
|
import { accessSync, constants, statSync } from "node:fs";
|
|
7645
|
-
import { delimiter, join as
|
|
7663
|
+
import { delimiter as delimiter2, join as join4, resolve } from "node:path";
|
|
7646
7664
|
function resolveBin(bin) {
|
|
7647
7665
|
const executable = (p) => {
|
|
7648
7666
|
try {
|
|
@@ -7654,131 +7672,13 @@ function resolveBin(bin) {
|
|
|
7654
7672
|
};
|
|
7655
7673
|
if (bin.includes("/"))
|
|
7656
7674
|
return executable(bin) ? resolve(bin) : undefined;
|
|
7657
|
-
for (const dir of (process.env.PATH ?? "").split(
|
|
7658
|
-
if (dir && executable(
|
|
7659
|
-
return
|
|
7675
|
+
for (const dir of (process.env.PATH ?? "").split(delimiter2)) {
|
|
7676
|
+
if (dir && executable(join4(dir, bin)))
|
|
7677
|
+
return join4(dir, bin);
|
|
7660
7678
|
}
|
|
7661
7679
|
return;
|
|
7662
7680
|
}
|
|
7663
7681
|
|
|
7664
|
-
// src/build.ts
|
|
7665
|
-
var BUILD_TIMEOUT_MS = 600000;
|
|
7666
|
-
|
|
7667
|
-
class BuildError extends Error {
|
|
7668
|
-
}
|
|
7669
|
-
function kindOf(b, dir) {
|
|
7670
|
-
if (b.run)
|
|
7671
|
-
return "run";
|
|
7672
|
-
if (existsSync3(join4(dir, "package.json")))
|
|
7673
|
-
return "npm";
|
|
7674
|
-
if (existsSync3(join4(dir, "pyproject.toml")))
|
|
7675
|
-
return "python";
|
|
7676
|
-
throw new BuildError(`build.${b.name}: no package.json or pyproject.toml in ${dir}; add run: with a build command that writes to $FIREGOAT_OUT`);
|
|
7677
|
-
}
|
|
7678
|
-
function pythonBuildCommand() {
|
|
7679
|
-
if (resolveBin("uv"))
|
|
7680
|
-
return 'uv build --wheel --out-dir "$FIREGOAT_OUT"';
|
|
7681
|
-
return 'python3 -m build --wheel --outdir "$FIREGOAT_OUT"';
|
|
7682
|
-
}
|
|
7683
|
-
var BUILD_COMMAND = {
|
|
7684
|
-
npm: () => 'npm pack --ignore-scripts=false --pack-destination "$FIREGOAT_OUT"',
|
|
7685
|
-
python: pythonBuildCommand
|
|
7686
|
-
};
|
|
7687
|
-
var ARTIFACT = { npm: ".tgz", python: ".whl" };
|
|
7688
|
-
async function sh(run, cwd, env, log, what) {
|
|
7689
|
-
const r = await runShell({ run, cwd, env, timeoutMs: BUILD_TIMEOUT_MS, log });
|
|
7690
|
-
if (r.code !== 0)
|
|
7691
|
-
throw new BuildError(`${what} failed (${r.error ?? `exit ${r.code}`}): ${run}`);
|
|
7692
|
-
}
|
|
7693
|
-
async function buildOne(b, root, buildDir, env, log) {
|
|
7694
|
-
const dir = resolve2(root, b.path);
|
|
7695
|
-
if (!existsSync3(dir) || !statSync2(dir).isDirectory())
|
|
7696
|
-
throw new BuildError(`build.${b.name}: ${dir} is not a directory`);
|
|
7697
|
-
const kind = kindOf(b, dir);
|
|
7698
|
-
const out = join4(buildDir, b.name);
|
|
7699
|
-
mkdirSync2(out, { recursive: true });
|
|
7700
|
-
const run = kind === "run" ? b.run : BUILD_COMMAND[kind]();
|
|
7701
|
-
log.write(`## build.${b.name} (${kind}) in ${dir}
|
|
7702
|
-
`);
|
|
7703
|
-
await sh(run, dir, { ...env, FIREGOAT_OUT: out }, log, `build.${b.name}`);
|
|
7704
|
-
const files = readdirSync(out);
|
|
7705
|
-
if (kind !== "run") {
|
|
7706
|
-
const ext = ARTIFACT[kind];
|
|
7707
|
-
const hits = files.filter((f) => f.endsWith(ext));
|
|
7708
|
-
if (hits.length !== 1)
|
|
7709
|
-
throw new BuildError(`build.${b.name}: expected one ${ext} in ${out}, found ${hits.length ? hits.join(", ") : "none"}`);
|
|
7710
|
-
} else if (files.length === 0)
|
|
7711
|
-
throw new BuildError(`build.${b.name}: run: wrote nothing to $FIREGOAT_OUT (${out})`);
|
|
7712
|
-
return { name: b.name, kind, out };
|
|
7713
|
-
}
|
|
7714
|
-
async function buildAll(builds, root, buildDir, env) {
|
|
7715
|
-
mkdirSync2(buildDir, { recursive: true });
|
|
7716
|
-
const log = createWriteStream2(join4(buildDir, "build.txt"));
|
|
7717
|
-
try {
|
|
7718
|
-
const built = [];
|
|
7719
|
-
for (const b of builds)
|
|
7720
|
-
built.push(await buildOne(b, root, buildDir, env, log));
|
|
7721
|
-
return { built };
|
|
7722
|
-
} catch (e) {
|
|
7723
|
-
if (e instanceof BuildError)
|
|
7724
|
-
return { error: `${e.message} (log: ${join4(buildDir, "build.txt")})` };
|
|
7725
|
-
throw e;
|
|
7726
|
-
} finally {
|
|
7727
|
-
await new Promise((r) => log.end(r));
|
|
7728
|
-
}
|
|
7729
|
-
}
|
|
7730
|
-
async function install(built, runRoot, workdir, env, logPath) {
|
|
7731
|
-
const result = { paths: {}, env: {} };
|
|
7732
|
-
if (built.length === 0)
|
|
7733
|
-
return result;
|
|
7734
|
-
const log = createWriteStream2(logPath);
|
|
7735
|
-
const path = [];
|
|
7736
|
-
const artifacts = { npm: [], python: [] };
|
|
7737
|
-
try {
|
|
7738
|
-
for (const b of built) {
|
|
7739
|
-
const copy = join4(runRoot, "packages", b.name);
|
|
7740
|
-
cpSync(b.out, copy, { recursive: true });
|
|
7741
|
-
const files = readdirSync(copy);
|
|
7742
|
-
if (b.kind === "run") {
|
|
7743
|
-
result.paths[b.name] = files.length === 1 ? join4(copy, files[0]) : copy;
|
|
7744
|
-
path.push(copy);
|
|
7745
|
-
} else {
|
|
7746
|
-
const ext = ARTIFACT[b.kind];
|
|
7747
|
-
const file = join4(copy, files.find((f) => f.endsWith(ext)));
|
|
7748
|
-
result.paths[b.name] = file;
|
|
7749
|
-
artifacts[b.kind].push(file);
|
|
7750
|
-
}
|
|
7751
|
-
}
|
|
7752
|
-
if (artifacts.npm.length > 0) {
|
|
7753
|
-
writeFileSync(join4(workdir, "package.json"), `${JSON.stringify({ name: "app", version: "0.0.0", private: true }, null, 2)}
|
|
7754
|
-
`);
|
|
7755
|
-
await sh(`npm install --no-audit --no-fund ${artifacts.npm.map(q2).join(" ")}`, workdir, env, log, "npm install of the build");
|
|
7756
|
-
path.push(join4(workdir, "node_modules", ".bin"));
|
|
7757
|
-
}
|
|
7758
|
-
if (artifacts.python.length > 0) {
|
|
7759
|
-
const venv = join4(workdir, ".venv");
|
|
7760
|
-
const wheels = artifacts.python.map(q2).join(" ");
|
|
7761
|
-
await sh(resolveBin("uv") ? `uv venv .venv && uv pip install --python .venv/bin/python ${wheels}` : `python3 -m venv .venv && .venv/bin/pip install ${wheels}`, workdir, env, log, "pip install of the build");
|
|
7762
|
-
path.push(join4(venv, "bin"));
|
|
7763
|
-
result.env.VIRTUAL_ENV = venv;
|
|
7764
|
-
}
|
|
7765
|
-
result.env.PATH = [...path, env.PATH ?? ""].join(delimiter2);
|
|
7766
|
-
return result;
|
|
7767
|
-
} catch (e) {
|
|
7768
|
-
if (e instanceof BuildError)
|
|
7769
|
-
return { error: `${e.message} (log: ${logPath})` };
|
|
7770
|
-
throw e;
|
|
7771
|
-
} finally {
|
|
7772
|
-
await new Promise((r) => log.end(r));
|
|
7773
|
-
}
|
|
7774
|
-
}
|
|
7775
|
-
function q2(s) {
|
|
7776
|
-
return `'${s.replaceAll("'", `'\\''`)}'`;
|
|
7777
|
-
}
|
|
7778
|
-
|
|
7779
|
-
// src/exit.ts
|
|
7780
|
-
var EXIT = { pass: 0, fail: 1, notGraded: 2, usage: 3 };
|
|
7781
|
-
|
|
7782
7682
|
// src/eval.ts
|
|
7783
7683
|
var CHECK_TIMEOUT_MS = 120000;
|
|
7784
7684
|
|
|
@@ -7837,7 +7737,7 @@ function newRunId() {
|
|
|
7837
7737
|
}
|
|
7838
7738
|
function gitignoreHasFiregoat(cwd) {
|
|
7839
7739
|
const file = join5(cwd, ".gitignore");
|
|
7840
|
-
if (!
|
|
7740
|
+
if (!existsSync3(file))
|
|
7841
7741
|
return false;
|
|
7842
7742
|
return readFileSync2(file, "utf8").split(`
|
|
7843
7743
|
`).map((l) => l.trim()).some((l) => /^\/?\.firegoat\/?(\*\*?)?$/.test(l));
|
|
@@ -7889,15 +7789,28 @@ async function runOne(opts) {
|
|
|
7889
7789
|
const sandbox = isolate(opts.iso, opts.claudeBin, runRoot, workdir);
|
|
7890
7790
|
if ("error" in sandbox)
|
|
7891
7791
|
return done("error", `not graded: ${sandbox.error}`);
|
|
7892
|
-
const
|
|
7893
|
-
|
|
7894
|
-
|
|
7895
|
-
|
|
7792
|
+
const hostEnv = { ...process.env, ...opts.vars };
|
|
7793
|
+
let extra = {};
|
|
7794
|
+
if (opts.out !== null || opts.workdirSetup.length > 0) {
|
|
7795
|
+
const prepared = await prepareRun({
|
|
7796
|
+
out: opts.out,
|
|
7797
|
+
runRoot,
|
|
7798
|
+
workdir,
|
|
7799
|
+
commands: opts.workdirSetup,
|
|
7800
|
+
env: hostEnv,
|
|
7801
|
+
logPath: join5(dir, "workdir_setup.txt")
|
|
7802
|
+
});
|
|
7803
|
+
if ("error" in prepared)
|
|
7804
|
+
return done("error", `not graded: ${prepared.error}`);
|
|
7805
|
+
if (opts.out !== null)
|
|
7806
|
+
extra = { PATH: prepared.path, FIREGOAT_OUT: prepared.out };
|
|
7807
|
+
}
|
|
7808
|
+
const agentPath = extra.PATH ? { PATH: extra.PATH } : {};
|
|
7896
7809
|
const outcome = await runClaude({
|
|
7897
|
-
env: { ...agentEnv(runRoot), ...
|
|
7810
|
+
env: { ...agentEnv(runRoot), ...agentPath },
|
|
7898
7811
|
profile: sandbox.profile,
|
|
7899
7812
|
bin: opts.claudeBin,
|
|
7900
|
-
prompt: render(test.prompt,
|
|
7813
|
+
prompt: render(test.prompt, opts.vars),
|
|
7901
7814
|
cwd: workdir,
|
|
7902
7815
|
sessionPath: session,
|
|
7903
7816
|
stderrPath: join5(dir, "stderr.txt"),
|
|
@@ -7916,15 +7829,16 @@ async function runOne(opts) {
|
|
|
7916
7829
|
const log = createWriteStream3(join5(dir, "checks.txt"));
|
|
7917
7830
|
const env = {
|
|
7918
7831
|
...process.env,
|
|
7919
|
-
...
|
|
7920
|
-
|
|
7832
|
+
...hostEnv,
|
|
7833
|
+
...extra,
|
|
7834
|
+
FIREGOAT_SESSION: resolve2(session),
|
|
7921
7835
|
FIREGOAT_WORKDIR: workdir
|
|
7922
7836
|
};
|
|
7923
7837
|
const results = [];
|
|
7924
7838
|
try {
|
|
7925
7839
|
for (const c of test.checks) {
|
|
7926
7840
|
results.push(await runShell({
|
|
7927
|
-
run: render(c.run,
|
|
7841
|
+
run: render(c.run, opts.vars),
|
|
7928
7842
|
cwd: workdir,
|
|
7929
7843
|
env,
|
|
7930
7844
|
timeoutMs: CHECK_TIMEOUT_MS,
|
|
@@ -7971,7 +7885,7 @@ async function evalCommand(args, cwd) {
|
|
|
7971
7885
|
const runs = opts.runs ?? config.runs;
|
|
7972
7886
|
const vars = { ...config.vars, ...opts.vars };
|
|
7973
7887
|
const missing = [
|
|
7974
|
-
...new Set(tests.flatMap((t) => [t.prompt, ...t.checks.map((c) => c.run)]).flatMap(
|
|
7888
|
+
...new Set(tests.flatMap((t) => [t.prompt, ...t.checks.map((c) => c.run)]).flatMap(varRefs).filter((k) => !(k in vars)))
|
|
7975
7889
|
];
|
|
7976
7890
|
if (missing.length > 0) {
|
|
7977
7891
|
console.error(`firegoat eval: no value for ${missing.map((k) => `{{vars.${k}}}`).join(", ")}: pass --var ${missing[0]}=... or set it under vars: in firegoat.yaml`);
|
|
@@ -7984,7 +7898,7 @@ async function evalCommand(args, cwd) {
|
|
|
7984
7898
|
}
|
|
7985
7899
|
if (opts.host)
|
|
7986
7900
|
warn("warning: --host: the agent runs without isolation and can read and write your files");
|
|
7987
|
-
else if (process.platform !== "darwin" || !
|
|
7901
|
+
else if (process.platform !== "darwin" || !existsSync3(SANDBOX_EXEC)) {
|
|
7988
7902
|
console.error(`firegoat eval: isolation needs macOS (${SANDBOX_EXEC}); this is ${process.platform}. In CI or Linux, run inside a throwaway container and pass --host.`);
|
|
7989
7903
|
return EXIT.usage;
|
|
7990
7904
|
}
|
|
@@ -8013,18 +7927,21 @@ async function evalCommand(args, cwd) {
|
|
|
8013
7927
|
const runDir = join5(root, ".firegoat", "runs", runId);
|
|
8014
7928
|
mkdirSync3(runDir, { recursive: true });
|
|
8015
7929
|
const summaryPath = join5(runDir, "summary.json");
|
|
8016
|
-
let
|
|
8017
|
-
if (config.
|
|
8018
|
-
say(
|
|
8019
|
-
|
|
8020
|
-
|
|
8021
|
-
|
|
7930
|
+
let out = null;
|
|
7931
|
+
if (config.setup.length > 0) {
|
|
7932
|
+
say("setup...");
|
|
7933
|
+
out = join5(runDir, "out");
|
|
7934
|
+
const error = await setupOnce({
|
|
7935
|
+
commands: config.setup,
|
|
7936
|
+
root,
|
|
7937
|
+
out,
|
|
7938
|
+
env: { ...process.env, ...vars },
|
|
7939
|
+
logPath: join5(runDir, "setup.txt")
|
|
8022
7940
|
});
|
|
8023
|
-
if (
|
|
8024
|
-
console.error(`firegoat eval: not graded: ${
|
|
7941
|
+
if (error) {
|
|
7942
|
+
console.error(`firegoat eval: not graded: ${error}`);
|
|
8025
7943
|
return EXIT.notGraded;
|
|
8026
7944
|
}
|
|
8027
|
-
built = b.built;
|
|
8028
7945
|
}
|
|
8029
7946
|
const results = [];
|
|
8030
7947
|
const writeSummary = () => {
|
|
@@ -8035,11 +7952,10 @@ async function evalCommand(args, cwd) {
|
|
|
8035
7952
|
runId,
|
|
8036
7953
|
runsDir: runDir,
|
|
8037
7954
|
isolation: opts.host ? "host" : "seatbelt",
|
|
8038
|
-
builds: built.map((b) => ({ name: b.name, kind: b.kind })),
|
|
8039
7955
|
totals,
|
|
8040
7956
|
runs: results
|
|
8041
7957
|
};
|
|
8042
|
-
|
|
7958
|
+
writeFileSync(summaryPath, `${JSON.stringify(doc, null, 2)}
|
|
8043
7959
|
`);
|
|
8044
7960
|
return doc;
|
|
8045
7961
|
};
|
|
@@ -8053,7 +7969,8 @@ async function evalCommand(args, cwd) {
|
|
|
8053
7969
|
runDir,
|
|
8054
7970
|
claudeBin: claudePath,
|
|
8055
7971
|
iso,
|
|
8056
|
-
|
|
7972
|
+
out,
|
|
7973
|
+
workdirSetup: config.workdirSetup,
|
|
8057
7974
|
vars,
|
|
8058
7975
|
warn
|
|
8059
7976
|
});
|
|
@@ -8087,7 +8004,7 @@ ${pass} passed, ${fail} failed, ${error} not graded (${results.length} runs)`);
|
|
|
8087
8004
|
}
|
|
8088
8005
|
|
|
8089
8006
|
// src/init.ts
|
|
8090
|
-
import { cpSync as cpSync2, existsSync as
|
|
8007
|
+
import { cpSync as cpSync2, existsSync as existsSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
|
|
8091
8008
|
import { basename as basename2, join as join6 } from "node:path";
|
|
8092
8009
|
import { createInterface as createInterface2 } from "node:readline/promises";
|
|
8093
8010
|
import { fileURLToPath } from "node:url";
|
|
@@ -8127,7 +8044,7 @@ function tomlSections(text) {
|
|
|
8127
8044
|
return sections;
|
|
8128
8045
|
}
|
|
8129
8046
|
function detect(cwd) {
|
|
8130
|
-
const readme = ["README.md", "README", "readme.md", "Readme.md"].find((f) =>
|
|
8047
|
+
const readme = ["README.md", "README", "readme.md", "Readme.md"].find((f) => existsSync4(join6(cwd, f)));
|
|
8131
8048
|
const fallback = { name: basename2(cwd), library: true, readme };
|
|
8132
8049
|
const pkgText = readText(join6(cwd, "package.json"));
|
|
8133
8050
|
if (pkgText !== undefined) {
|
|
@@ -8252,6 +8169,28 @@ function renderYaml(p, tests) {
|
|
|
8252
8169
|
"# How many times to run each test.",
|
|
8253
8170
|
"runs: 1",
|
|
8254
8171
|
"",
|
|
8172
|
+
"# Optional: test your local build instead of the published package.",
|
|
8173
|
+
"# setup: runs once per eval, in this folder, on your machine. Whatever it writes to",
|
|
8174
|
+
"# $FIREGOAT_OUT is copied into each run's folder and put on the agent's PATH.",
|
|
8175
|
+
"# workdir_setup: runs in each fresh workdir before the agent starts. Then tell the",
|
|
8176
|
+
'# agent in the prompt: "The package is already installed from the build under test."',
|
|
8177
|
+
"#",
|
|
8178
|
+
"# npm:",
|
|
8179
|
+
'# setup: npm pack --pack-destination "$FIREGOAT_OUT"',
|
|
8180
|
+
'# workdir_setup: npm init -y >/dev/null && npm install "$FIREGOAT_OUT"/*.tgz',
|
|
8181
|
+
"#",
|
|
8182
|
+
"# python:",
|
|
8183
|
+
'# setup: uv build --wheel --out-dir "$FIREGOAT_OUT"',
|
|
8184
|
+
'# workdir_setup: uv venv && uv pip install "$FIREGOAT_OUT"/*.whl',
|
|
8185
|
+
"#",
|
|
8186
|
+
"# a CLI binary (lands on PATH as is):",
|
|
8187
|
+
'# setup: go build -o "$FIREGOAT_OUT/mycli" ./cmd/mycli',
|
|
8188
|
+
"#",
|
|
8189
|
+
"# Values for {{vars.KEY}} in prompts and checks, overridden by `--var KEY=value`;",
|
|
8190
|
+
"# setup commands and checks also get them as env vars.",
|
|
8191
|
+
"# vars:",
|
|
8192
|
+
"# SHA: main",
|
|
8193
|
+
"",
|
|
8255
8194
|
"tests:"
|
|
8256
8195
|
];
|
|
8257
8196
|
for (const t of tests) {
|
|
@@ -8265,7 +8204,7 @@ function ensureGitignore(cwd) {
|
|
|
8265
8204
|
const file = join6(cwd, ".gitignore");
|
|
8266
8205
|
const text = readText(file);
|
|
8267
8206
|
if (text === undefined) {
|
|
8268
|
-
|
|
8207
|
+
writeFileSync2(file, `${GITIGNORE_LINE}
|
|
8269
8208
|
`);
|
|
8270
8209
|
return `created .gitignore with ${GITIGNORE_LINE}`;
|
|
8271
8210
|
}
|
|
@@ -8275,7 +8214,7 @@ function ensureGitignore(cwd) {
|
|
|
8275
8214
|
const sep = text === "" || text.endsWith(`
|
|
8276
8215
|
`) ? "" : `
|
|
8277
8216
|
`;
|
|
8278
|
-
|
|
8217
|
+
writeFileSync2(file, `${text}${sep}${GITIGNORE_LINE}
|
|
8279
8218
|
`);
|
|
8280
8219
|
return `added ${GITIGNORE_LINE} to .gitignore`;
|
|
8281
8220
|
}
|
|
@@ -8330,7 +8269,7 @@ ${INIT_USAGE}`);
|
|
|
8330
8269
|
}
|
|
8331
8270
|
}
|
|
8332
8271
|
const configPath = join6(cwd, CONFIG_FILE);
|
|
8333
|
-
const keepConfig =
|
|
8272
|
+
const keepConfig = existsSync4(configPath) && !force;
|
|
8334
8273
|
if (keepConfig && !skills) {
|
|
8335
8274
|
console.error(`firegoat init: ${CONFIG_FILE} already exists; not overwriting. Use --force to replace it.`);
|
|
8336
8275
|
return EXIT.usage;
|
|
@@ -8341,7 +8280,7 @@ ${INIT_USAGE}`);
|
|
|
8341
8280
|
const project = detect(cwd);
|
|
8342
8281
|
const tests = templates(project);
|
|
8343
8282
|
console.log(`detected: ${describe(project)}`);
|
|
8344
|
-
|
|
8283
|
+
writeFileSync2(configPath, renderYaml(project, tests));
|
|
8345
8284
|
console.log(`wrote ${CONFIG_FILE} (${tests.length} test${tests.length === 1 ? "" : "s"}: ${tests.map((t) => t.name).join(", ")}); edit the prompts to fit your users`);
|
|
8346
8285
|
}
|
|
8347
8286
|
console.log(ensureGitignore(cwd));
|
|
@@ -8398,7 +8337,7 @@ function loginCommand(args) {
|
|
|
8398
8337
|
|
|
8399
8338
|
// src/open.ts
|
|
8400
8339
|
import { spawnSync as spawnSync4 } from "node:child_process";
|
|
8401
|
-
import { existsSync as
|
|
8340
|
+
import { existsSync as existsSync5, readdirSync } from "node:fs";
|
|
8402
8341
|
import { join as join7 } from "node:path";
|
|
8403
8342
|
var OPENER = {
|
|
8404
8343
|
darwin: "open",
|
|
@@ -8421,7 +8360,7 @@ function openCommand(args, cwd) {
|
|
|
8421
8360
|
return EXIT.usage;
|
|
8422
8361
|
}
|
|
8423
8362
|
const base = join7(findConfigDir(cwd), ".firegoat", "runs");
|
|
8424
|
-
const runs =
|
|
8363
|
+
const runs = existsSync5(base) ? readdirSync(base).sort() : [];
|
|
8425
8364
|
const id = ids[0] ?? runs.at(-1);
|
|
8426
8365
|
if (!id) {
|
|
8427
8366
|
console.error(`firegoat open: no runs in ${base}. Run: npx firegoat eval`);
|
|
@@ -8458,7 +8397,7 @@ Usage:
|
|
|
8458
8397
|
eval options:
|
|
8459
8398
|
test.. only run these tests (by name)
|
|
8460
8399
|
--runs N runs per test (overrides runs: in firegoat.yaml)
|
|
8461
|
-
--var KEY=value fills {{vars.KEY}} in prompts and checks
|
|
8400
|
+
--var KEY=value fills {{vars.KEY}} in prompts and checks; env for setup, workdir_setup and checks
|
|
8462
8401
|
--json JSON summary on stdout, progress on stderr
|
|
8463
8402
|
--host no sandbox (needed off macOS; use a throwaway container)
|
|
8464
8403
|
--claude-bin PATH claude binary to run (env: FIREGOAT_CLAUDE_BIN; default: claude on PATH)
|
package/package.json
CHANGED
|
@@ -73,10 +73,10 @@ Schema (v1):
|
|
|
73
73
|
```yaml
|
|
74
74
|
agents: [claude-code] # only claude-code in v1
|
|
75
75
|
runs: 1 # runs per test, positive integer
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
76
|
+
setup: npm pack --pack-destination "$FIREGOAT_OUT" # optional: once per eval, in the repo;
|
|
77
|
+
# $FIREGOAT_OUT is copied into each run and put on PATH
|
|
78
|
+
workdir_setup: npm init -y >/dev/null && npm install "$FIREGOAT_OUT"/*.tgz
|
|
79
|
+
# optional: in each fresh workdir before the agent starts
|
|
80
80
|
vars: # optional: defaults for {{vars.KEY}}; `--var KEY=value` overrides
|
|
81
81
|
SHA: main
|
|
82
82
|
tests:
|
|
@@ -89,7 +89,8 @@ tests:
|
|
|
89
89
|
timeout: 600 # optional, seconds
|
|
90
90
|
```
|
|
91
91
|
|
|
92
|
-
Add `
|
|
92
|
+
Add `setup:` / `workdir_setup:` when the tests should use the repo's local build (python:
|
|
93
|
+
`uv build --wheel --out-dir "$FIREGOAT_OUT"` and `uv venv && uv pip install "$FIREGOAT_OUT"/*.whl`);
|
|
93
94
|
then the prompt can say "The package is already installed from the build under test."
|
|
94
95
|
|
|
95
96
|
If `firegoat.yaml` exists, merge: keep every existing test and top-level key untouched, append
|