firegoat 0.27.0 → 0.28.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.
Files changed (3) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.js +704 -118
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -16,7 +16,7 @@ When you run `firegoat eval`, you get visibility into how agents use your produc
16
16
  npx firegoat@latest init
17
17
  ```
18
18
 
19
- Then paste the prompt it prints into your agent, and let it set up the evals that point agents at your product. Then run them and see the results:
19
+ In a terminal it asks where to install the firegoat-onboarding skill (global by default); run by an agent, it installs it into this repo's `.claude/skills/`. Then paste the prompt it prints into your agent, and let it set up the evals that point agents at your product. Then run them and see the results:
20
20
 
21
21
  ```bash
22
22
  npx firegoat@latest eval
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.27.0",
6950
+ version: "0.28.0",
6951
6951
  repository: {
6952
6952
  type: "git",
6953
6953
  url: "git+https://github.com/firegoat-ai/firegoat-cli.git"
@@ -7078,6 +7078,7 @@ function steps(events) {
7078
7078
  if (b.type === "tool_use")
7079
7079
  out.push({
7080
7080
  kind: "tool_call",
7081
+ id: typeof b.id === "string" ? b.id : null,
7081
7082
  name: typeof b.name === "string" ? b.name : "",
7082
7083
  input: typeof b.input === "object" && b.input !== null ? b.input : null
7083
7084
  });
@@ -7089,6 +7090,7 @@ function steps(events) {
7089
7090
  if (b.type === "tool_result")
7090
7091
  out.push({
7091
7092
  kind: "tool_result",
7093
+ callId: typeof b.tool_use_id === "string" ? b.tool_use_id : null,
7092
7094
  isError: b.is_error === true,
7093
7095
  text: resultText(b.content).trimEnd()
7094
7096
  });
@@ -7208,6 +7210,7 @@ var claude = {
7208
7210
  bin: "claude",
7209
7211
  installHint: "Install Claude Code (https://claude.com/claude-code) or pass --harness-bin PATH.",
7210
7212
  configDirName: ".claude",
7213
+ argsKey: "claude_args",
7211
7214
  reservedArgs: [
7212
7215
  [["-p", "--print", ...FLAGS.filter((f) => f.startsWith("-"))], SET],
7213
7216
  [["--model"], "choose the model with top-level model: (or eval --model)"],
@@ -7216,7 +7219,7 @@ var claude = {
7216
7219
  "every run is a fresh session"
7217
7220
  ]
7218
7221
  ],
7219
- uploads: true,
7222
+ claudeSessionFile: true,
7220
7223
  args: ({ prompt, model, extraArgs }) => [
7221
7224
  "-p",
7222
7225
  prompt,
@@ -7343,24 +7346,27 @@ function itemInput(item) {
7343
7346
  function steps2(events) {
7344
7347
  const out = [];
7345
7348
  const started = new Set;
7346
- for (const e of events) {
7349
+ for (const [i, e] of events.entries()) {
7347
7350
  const item = e?.item;
7348
7351
  if (!item)
7349
7352
  continue;
7353
+ const id = typeof item.id === "string" ? item.id : `event_${i}`;
7350
7354
  if (item.type === "command_execution") {
7351
7355
  const call = {
7352
7356
  kind: "tool_call",
7357
+ id,
7353
7358
  name: "shell",
7354
7359
  input: { command: unwrapShell(str(item.command)) }
7355
7360
  };
7356
7361
  if (e?.type === "item.started") {
7357
- started.add(item.id);
7362
+ started.add(id);
7358
7363
  out.push(call);
7359
7364
  } else if (e?.type === "item.completed") {
7360
- if (item.id === undefined || !started.has(item.id))
7365
+ if (!started.has(id))
7361
7366
  out.push(call);
7362
7367
  out.push({
7363
7368
  kind: "tool_result",
7369
+ callId: id,
7364
7370
  isError: item.exit_code !== 0,
7365
7371
  text: str(item.aggregated_output).trimEnd()
7366
7372
  });
@@ -7375,6 +7381,7 @@ function steps2(events) {
7375
7381
  } else if (item.type !== "reasoning" && item.type !== "error")
7376
7382
  out.push({
7377
7383
  kind: "tool_call",
7384
+ id,
7378
7385
  name: str(item.type),
7379
7386
  input: itemInput(item)
7380
7387
  });
@@ -7523,6 +7530,7 @@ var codex = {
7523
7530
  bin: "codex",
7524
7531
  installHint: "Install Codex CLI (https://github.com/openai/codex) or pass --harness-bin PATH.",
7525
7532
  configDirName: ".codex",
7533
+ argsKey: "codex_args",
7526
7534
  reservedArgs: [
7527
7535
  [[...FLAGS2, "-C", "--cd"], "firegoat sets it for every run"],
7528
7536
  [
@@ -7532,7 +7540,7 @@ var codex = {
7532
7540
  [["resume", "fork"], "every run is a fresh session"],
7533
7541
  [["--ephemeral"], "firegoat reads the session file codex saves"]
7534
7542
  ],
7535
- uploads: false,
7543
+ claudeSessionFile: false,
7536
7544
  args: ({ prompt, model, extraArgs, cwd }) => [
7537
7545
  "exec",
7538
7546
  ...FLAGS2,
@@ -7629,6 +7637,7 @@ function toolResult(b) {
7629
7637
  `) : "";
7630
7638
  return {
7631
7639
  kind: "tool_result",
7640
+ callId: typeof b.tool_use_id === "string" ? b.tool_use_id : null,
7632
7641
  isError: b.is_error === true || failed,
7633
7642
  text: text.trimEnd()
7634
7643
  };
@@ -7641,6 +7650,7 @@ function steps3(events) {
7641
7650
  if (b.type === "tool_use")
7642
7651
  out.push({
7643
7652
  kind: "tool_call",
7653
+ id: typeof b.id === "string" ? b.id : null,
7644
7654
  name: typeof b.name === "string" ? b.name : "",
7645
7655
  input: isObject(b.input) ? b.input : null
7646
7656
  });
@@ -7791,6 +7801,7 @@ var grok = {
7791
7801
  bin: BIN,
7792
7802
  installHint: "Install grok (curl -fsSL https://x.ai/cli/install.sh | bash) or pass --harness-bin PATH.",
7793
7803
  configDirName: ".grok",
7804
+ argsKey: "grok_args",
7794
7805
  reservedArgs: [
7795
7806
  [
7796
7807
  [
@@ -7826,7 +7837,7 @@ var grok = {
7826
7837
  "every run is a fresh session"
7827
7838
  ]
7828
7839
  ],
7829
- uploads: false,
7840
+ claudeSessionFile: false,
7830
7841
  args: ({ prompt, model, extraArgs }) => [
7831
7842
  "-p",
7832
7843
  prompt,
@@ -7912,7 +7923,7 @@ function parseConfig(text) {
7912
7923
  "cleanup",
7913
7924
  "vars",
7914
7925
  "env",
7915
- "harness_args",
7926
+ ...ARGS_KEYS,
7916
7927
  "tests"
7917
7928
  ]);
7918
7929
  const runs = posInt(raw.runs, "runs", 1);
@@ -7927,7 +7938,7 @@ function parseConfig(text) {
7927
7938
  const cleanup = commands(raw.cleanup, "cleanup", RUN_PLACEHOLDERS);
7928
7939
  const vars = parseVars(raw.vars);
7929
7940
  const env = parseEnv(raw.env, "env");
7930
- const harnessArgs = parseHarnessArgs(raw.harness_args, "harness_args");
7941
+ const harnessArgs = parseAllHarnessArgs(raw, "");
7931
7942
  if (!Array.isArray(raw.tests) || raw.tests.length === 0)
7932
7943
  fail("tests", "must be a non-empty list");
7933
7944
  const names = new Set;
@@ -7944,7 +7955,7 @@ function parseConfig(text) {
7944
7955
  "env",
7945
7956
  "install",
7946
7957
  "cleanup",
7947
- "harness_args"
7958
+ ...ARGS_KEYS
7948
7959
  ]);
7949
7960
  const name = str2(t.name, `${p}.name`);
7950
7961
  if (!NAME.test(name))
@@ -7974,10 +7985,7 @@ function parseConfig(text) {
7974
7985
  env: { ...env, ...parseEnv(t.env, `${p}.env`) },
7975
7986
  install: commands(t.install, `${p}.install`, RUN_PLACEHOLDERS),
7976
7987
  cleanup: commands(t.cleanup, `${p}.cleanup`, RUN_PLACEHOLDERS),
7977
- harnessArgs: [
7978
- ...harnessArgs,
7979
- ...parseHarnessArgs(t.harness_args, `${p}.harness_args`)
7980
- ]
7988
+ harnessArgs: mergeHarnessArgs(harnessArgs, parseAllHarnessArgs(t, `${p}.`))
7981
7989
  };
7982
7990
  });
7983
7991
  return {
@@ -8020,26 +8028,35 @@ function nameList(raw, key, what) {
8020
8028
  return name;
8021
8029
  });
8022
8030
  }
8023
- function parseHarnessArgs(raw, path) {
8031
+ var ARGS_KEYS = [...HARNESSES.values()].map((h) => h.argsKey);
8032
+ function parseAllHarnessArgs(raw, prefix) {
8033
+ const out = {};
8034
+ for (const h of HARNESSES.values())
8035
+ out[h.name] = parseHarnessArgs(raw[h.argsKey], `${prefix}${h.argsKey}`, h);
8036
+ return out;
8037
+ }
8038
+ function mergeHarnessArgs(top, test) {
8039
+ const out = {};
8040
+ for (const name of HARNESSES.keys())
8041
+ out[name] = [...top[name] ?? [], ...test[name] ?? []];
8042
+ return out;
8043
+ }
8044
+ function parseHarnessArgs(raw, path, harness) {
8024
8045
  if (raw === undefined)
8025
8046
  return [];
8026
8047
  if (!Array.isArray(raw))
8027
- fail(path, "must be a list of harness arguments, one per entry, e.g. [--chrome]");
8048
+ fail(path, `must be a list of ${harness.name} arguments, one per entry, e.g. [--flag, value]`);
8028
8049
  return raw.map((a, i) => {
8029
8050
  const p = `${path}[${i}]`;
8030
8051
  const arg = str2(a, p);
8031
8052
  checkPlaceholders(arg, p, RUN_PLACEHOLDERS);
8053
+ for (const [flags, why] of harness.reservedArgs)
8054
+ for (const f of flags)
8055
+ if (arg === f || arg.startsWith(`${f}=`))
8056
+ fail(p, `${f} is not allowed for ${harness.name}: ${why}`);
8032
8057
  return { arg, path: p };
8033
8058
  });
8034
8059
  }
8035
- function checkHarnessArgs(tests, harness) {
8036
- for (const t of tests)
8037
- for (const { arg, path } of t.harnessArgs)
8038
- for (const [flags, why] of harness.reservedArgs)
8039
- for (const f of flags)
8040
- if (arg === f || arg.startsWith(`${f}=`))
8041
- fail(path, `${f} is not allowed for ${harness.name}: ${why}`);
8042
- }
8043
8060
  var VAR_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
8044
8061
  function parseVars(raw) {
8045
8062
  if (raw === undefined)
@@ -8157,9 +8174,9 @@ import {
8157
8174
  appendFileSync,
8158
8175
  createWriteStream as createWriteStream2,
8159
8176
  mkdirSync as mkdirSync5,
8160
- writeFileSync as writeFileSync4
8177
+ writeFileSync as writeFileSync5
8161
8178
  } from "node:fs";
8162
- import { basename as basename3, delimiter as delimiter2, join as join10 } from "node:path";
8179
+ import { basename as basename2, delimiter as delimiter2, join as join10 } from "node:path";
8163
8180
 
8164
8181
  // src/agent-dirs.ts
8165
8182
  import { createHash, randomInt } from "node:crypto";
@@ -8796,19 +8813,22 @@ turns it off; FIREGOAT_TELEMETRY accepts only 0 or 1.
8796
8813
 
8797
8814
  ${EXIT_CODES}`;
8798
8815
  }
8799
- var INIT_HELP = `Usage: firegoat init [--force] [--local-skills | --no-skills] [--no-login]
8816
+ var INIT_HELP = `Usage: firegoat init [--force] [--local-skills | --global-skills | --no-skills]
8817
+ [--no-login]
8800
8818
 
8801
8819
  In the current folder: creates .firegoat/ and a starter firegoat.yaml, installs the
8802
- firegoat-onboarding skill globally, into ~/.claude/skills/ (npx skills add
8803
- firegoat-ai/skills -g; in a terminal it asks: global, this repo, or none), offers
8804
- firegoat login (in a terminal only; otherwise prints how), then prints a prompt to
8820
+ firegoat-onboarding skill (npx skills add firegoat-ai/skills; in a terminal it asks:
8821
+ global into ~/.claude/skills/, this repo, or none; otherwise, e.g. run by a coding
8822
+ agent, it installs into this repo's .claude/skills/), offers firegoat login (in a terminal only; otherwise prints how), then prints a prompt to
8805
8823
  paste into your coding agent. .firegoat/ holds local run output and ignores itself
8806
8824
  in git. Keep test fixtures (helper scripts, sample data) in fixtures/ next to
8807
8825
  firegoat.yaml, committed.
8808
8826
 
8809
8827
  --force Replace an existing firegoat.yaml
8810
- --local-skills Install the skill into this repo's .claude/skills/ instead
8811
- (commit it so teammates and CI get it), without asking
8828
+ --local-skills Install the skill into this repo's .claude/skills/ (commit it
8829
+ so teammates and CI get it), without asking
8830
+ --global-skills Install the skill into ~/.claude/skills/ (skills add -g),
8831
+ without asking
8812
8832
  --no-skills Don't install the skill
8813
8833
  --no-login Don't offer firegoat login
8814
8834
 
@@ -8856,27 +8876,33 @@ firegoat.yaml reference:
8856
8876
  tool that keeps state in ~ starts fresh in each
8857
8877
  run. Only a tool that ignores HOME needs its own
8858
8878
  var, e.g. MYTOOL_HOME: "$FIREGOAT_HOME/.mytool"
8859
- harness_args: [<arg>, ...] Extra harness arguments, after firegoat's own flags
8860
- and --model (for codex, before the prompt), to
8861
- every selected harness. Each entry is one
8862
- argument, passed as-is (no shell splitting):
8863
- claude: [--chrome] (Claude in Chrome, for tests
8864
- that need a browser), [--plugin-dir, ./plugins/x],
8865
- --mcp-config, --agents, --append-system-prompt;
8866
- codex: [-c, key=value], --enable, --add-dir.
8879
+ claude_args: [<arg>, ...] Extra claude-code arguments, after firegoat's own
8880
+ flags and --model. Each entry is one argument,
8881
+ passed as-is (no shell splitting): [--chrome]
8882
+ (Claude in Chrome, for tests that need a browser),
8883
+ [--plugin-dir, ./plugins/x], --mcp-config,
8884
+ --agents, --append-system-prompt
8885
+ codex_args: [<arg>, ...] Extra codex-cli arguments, before the prompt:
8886
+ [-c, key=value], --enable, --add-dir
8887
+ grok_args: [<arg>, ...] Extra grok-cli arguments, after firegoat's flags.
8888
+ Each key goes to its harness only. A key for a
8889
+ harness this eval doesn't run is checked but not
8890
+ used, so one firegoat.yaml can carry args for
8891
+ every --harness.
8867
8892
  Entries expand like env: values
8868
8893
  ($FIREGOAT_WORKDIR, $FIREGOAT_HOME, $FIREGOAT_OUT,
8869
8894
  {{vars.KEY}}, {{run.*}}); run.json harnessArgs has
8870
- the result. Flags firegoat sets are errors, also
8871
- as --flag=value. claude-code: -p, --print,
8872
- --output-format, --verbose, --setting-sources,
8873
- --strict-mcp-config, --disable-slash-commands,
8895
+ the run's harness's result. Flags firegoat sets
8896
+ are errors, also as --flag=value. claude_args:
8897
+ -p, --print, --output-format, --verbose,
8898
+ --setting-sources, --strict-mcp-config,
8899
+ --disable-slash-commands,
8874
8900
  --dangerously-skip-permissions, --model (use
8875
8901
  model:), --session-id, --resume, -r, --continue,
8876
- -c. codex-cli: --json, --skip-git-repo-check,
8902
+ -c. codex_args: --json, --skip-git-repo-check,
8877
8903
  --dangerously-bypass-approvals-and-sandbox, -C,
8878
8904
  --cd, -m, --model (use model:), resume, fork,
8879
- --ephemeral. grok-cli: -p, --single,
8905
+ --ephemeral. grok_args: -p, --single,
8880
8906
  --prompt-file, --prompt-json, --output-format,
8881
8907
  --json-schema, --always-approve,
8882
8908
  --permission-mode, --no-auto-update, --cwd, -w,
@@ -8894,7 +8920,8 @@ firegoat.yaml reference:
8894
8920
  env: {NAME: value} Optional, on top of the top-level env:
8895
8921
  install: <sh> | [...] Optional, runs after the top-level install
8896
8922
  cleanup: <sh> | [...] Optional, runs before the top-level cleanup
8897
- harness_args: [...] Optional, after the top-level harness_args
8923
+ claude_args: [...] Optional, after the top-level claude_args (same
8924
+ for codex_args, grok_args)
8898
8925
 
8899
8926
  Step order: build once per eval; then per run: install (top-level, then the
8900
8927
  test's), the agent, checks, cleanup (the test's, then top-level).
@@ -8930,7 +8957,7 @@ firegoat.yaml reference:
8930
8957
  so the agent's commands see them too.
8931
8958
 
8932
8959
  Testing an MCP server: commit its config as fixtures/mcp.json, copy it in build:
8933
- and pass it with harness_args (claude-code). Only the configs you pass load: --strict-mcp-config
8960
+ and pass it with claude_args. Only the configs you pass load: --strict-mcp-config
8934
8961
  is always on. An MCP server inherits claude's env, which keeps your real HOME
8935
8962
  (claude's login is there), so set the server's HOME to the run's home, or it
8936
8963
  writes to yours. claude gets $FIREGOAT_HOME, $FIREGOAT_WORKDIR and $FIREGOAT_OUT
@@ -8941,7 +8968,7 @@ relative paths in it are from the run's folder, claude's cwd:
8941
8968
  "env": {"HOME": "\${FIREGOAT_HOME}"}}}}
8942
8969
  firegoat.yaml:
8943
8970
  build: cp -R fixtures/. "$FIREGOAT_OUT"
8944
- harness_args: [--mcp-config, $FIREGOAT_OUT/mcp.json]
8971
+ claude_args: [--mcp-config, $FIREGOAT_OUT/mcp.json]
8945
8972
 
8946
8973
  Example:
8947
8974
  build: npm pack --pack-destination "$FIREGOAT_OUT"
@@ -8964,7 +8991,7 @@ Example:
8964
8991
  Get an acme API key from the acme dashboard (I'm logged in in Chrome) and put
8965
8992
  it in .env as ACME_API_KEY.
8966
8993
  end_goal: .env holds a key copied from the dashboard, not a made-up one.
8967
- harness_args: [--chrome]
8994
+ claude_args: [--chrome]
8968
8995
 
8969
8996
  init also says that firegoat sends anonymous usage stats; FIREGOAT_TELEMETRY=0
8970
8997
  (or DO_NOT_TRACK=1) turns them off. Fields: firegoat --help, Telemetry.
@@ -8973,7 +9000,7 @@ Exit codes: 0 done, 1 the firegoat login failed (the files stay), 2 the skill
8973
9000
  install failed, 3 usage error.`;
8974
9001
  var LOGIN_HELP = `Usage: firegoat login
8975
9002
 
8976
- Logs in to firegoat.ai so eval uploads each run's claude session there as a private
9003
+ Logs in to firegoat.ai so eval uploads each run's session there as a private
8977
9004
  trace (you share it from its page). OAuth device flow: prints a URL and a code (and
8978
9005
  opens the browser in a terminal); approve it there, and the trace key is saved to
8979
9006
  ~/.firegoat/credentials.json (mode 0600). The key is never passed to the agent,
@@ -8983,7 +9010,9 @@ read ~/.firegoat, and a FIREGOAT_API_KEY in firegoat's own process env (ps eww,
8983
9010
  your own traces; revoke it on the firegoat.ai traces page.
8984
9011
 
8985
9012
  What eval uploads per run: session.jsonl (prompts, tool calls and outputs, file
8986
- contents: possibly secrets from your env or repo), checks.txt and stderr.txt (check
9013
+ contents: possibly secrets from your env or repo; claude's own session file, or for
9014
+ codex and grok one in claude's format made from the run's stream, the run dir's
9015
+ session.jsonl), checks.txt and stderr.txt (check
8987
9016
  output, which sees vars:). firegoat.ai masks secrets when showing a trace but stores
8988
9017
  the text as sent. eval --no-upload keeps a run local.
8989
9018
 
@@ -9040,7 +9069,7 @@ the run's folder.
9040
9069
 
9041
9070
  claude runs on its own login (or ANTHROPIC_API_KEY if set, as claude always does)
9042
9071
  with a fresh config dir, ~/.firegoat/runs/<hash>/.claude: none of your settings,
9043
- CLAUDE.md, plugins, skills or MCP servers (harness_args: in firegoat.yaml adds
9072
+ CLAUDE.md, plugins, skills or MCP servers (claude_args: in firegoat.yaml adds
9044
9073
  claude flags, e.g. --chrome or --plugin-dir). claude saves its session there. There
9045
9074
  is no sandbox: the agent can still reach the rest of your machine, including the
9046
9075
  repo, if it goes looking.
@@ -9050,9 +9079,10 @@ fresh CODEX_HOME, ~/.firegoat/runs/<hash>/.codex, holding a link to your codex l
9050
9079
  (~/.codex/auth.json, or $CODEX_HOME/auth.json; run codex login first), and with
9051
9080
  -c project_doc_max_bytes=0: no AGENTS.md, config.toml, skills or plugins of
9052
9081
  yours. codex's own HOME is the run's home (below), also for its Read/Write. It
9053
- saves its session (rollout) under that CODEX_HOME's sessions/. firegoat.ai doesn't
9054
- read codex sessions yet: those runs are not uploaded. codex reports no turns or
9055
- cost; its model comes from the session file.
9082
+ saves its session (rollout) under that CODEX_HOME's sessions/. firegoat.ai reads
9083
+ claude's session format: the upload is the run dir's session.jsonl, made from the
9084
+ --json stream. codex reports no turns or cost; its model comes from the session
9085
+ file.
9056
9086
 
9057
9087
  grok-cli runs ~/.grok/bin/grok (where grok's installer puts it; never a grok on
9058
9088
  PATH, which may be a wrapper such as cmux's: a #! script is refused) as grok -p
@@ -9064,8 +9094,8 @@ your real one it loads Claude Code's skills, plugins, MCP servers and hooks. eva
9064
9094
  refuses a login that expires within 5 minutes (run grok -p hi once to refresh it):
9065
9095
  a refresh inside a run would replace the link with a file, which the next eval
9066
9096
  asks you to move back over ~/.grok/auth.json. The session is a directory,
9067
- $GROK_HOME/sessions/<cwd>/<id>/ ($FIREGOAT_SESSION). firegoat.ai doesn't read
9068
- grok sessions yet: those runs are not uploaded.
9097
+ $GROK_HOME/sessions/<cwd>/<id>/ ($FIREGOAT_SESSION). Like codex's, its upload is
9098
+ the run dir's session.jsonl, made from the stream in claude's session format.
9069
9099
 
9070
9100
  Every run gets its own HOME, ~/.firegoat/runs/<hash>/<random>/home next to work
9071
9101
  (with XDG_CONFIG_HOME, XDG_CACHE_HOME, XDG_DATA_HOME, XDG_STATE_HOME inside it), as
@@ -9112,7 +9142,8 @@ firegoat.yaml, fixtures and build: stay in the current folder.
9112
9142
  --dry-run Check firegoat.yaml as eval would (test names, --var,
9113
9143
  --model, --harness, missing vars), print each planned
9114
9144
  run ("<test>#<n> [harness model]" with its install, checks
9115
- and cleanup counts and rendered harness_args, paths under
9145
+ and cleanup counts and its harness's rendered args
9146
+ (claude_args, ...), paths under
9116
9147
  ~/.firegoat/runs/<hash>/<random>/) and exit 0. Builds
9117
9148
  nothing, starts no agent, uploads nothing, writes no
9118
9149
  files. Config errors
@@ -9150,12 +9181,16 @@ Output: .firegoat/runs/<runId>/
9150
9181
  <test>/<n>/run.json One run: status and reason (from the checks), harness,
9151
9182
  model (asked for, or null) and reportedModel (what
9152
9183
  the harness reported running, or null), harnessArgs
9153
- (harness_args passed), prompt, end_goal,
9184
+ (its harness's claude_args/codex_args/grok_args
9185
+ passed, expanded), prompt, end_goal,
9154
9186
  checks [{run, code}], cleanup [{run, code}],
9155
9187
  leftover [{pid, command}] (processes stopped after
9156
9188
  cleanup), and paths: dir (this folder), workdir, home
9157
9189
  (the run's HOME; both real paths, under ~/.firegoat),
9158
- transcript, session (the harness's session file), stderr,
9190
+ transcript, session (the harness's session file),
9191
+ claudeSession (the session.jsonl uploaded: claude's
9192
+ own file, else <test>/<n>/session.jsonl made from the
9193
+ stream) and sessionId (its id, the trace's), stderr,
9159
9194
  checksLog, cleanupLog; startedAt; upload ({status: "uploaded",
9160
9195
  url} | {status: "failed", error} | {status:
9161
9196
  "skipped", reason}, null when not uploading)
@@ -9334,6 +9369,544 @@ async function stopLeftovers(opts) {
9334
9369
  return leftover;
9335
9370
  }
9336
9371
 
9372
+ // src/session.ts
9373
+ import { randomUUID } from "node:crypto";
9374
+ import { writeFileSync as writeFileSync3 } from "node:fs";
9375
+
9376
+ // src/atif.ts
9377
+ var MAX_BLOCK_CHARS = 50000;
9378
+ function flattenContent(value) {
9379
+ if (typeof value === "string")
9380
+ return value;
9381
+ if (Array.isArray(value)) {
9382
+ return value.map((item) => {
9383
+ if (typeof item === "string")
9384
+ return item;
9385
+ if (item && typeof item === "object") {
9386
+ const obj = item;
9387
+ if (typeof obj.text === "string")
9388
+ return obj.text;
9389
+ if (typeof obj.content === "string")
9390
+ return obj.content;
9391
+ }
9392
+ return JSON.stringify(item);
9393
+ }).join(`
9394
+ `);
9395
+ }
9396
+ if (value == null)
9397
+ return "";
9398
+ return JSON.stringify(value);
9399
+ }
9400
+ function cap(text) {
9401
+ return text.length > MAX_BLOCK_CHARS ? text.slice(0, MAX_BLOCK_CHARS) : text;
9402
+ }
9403
+ function shellQuote2(path) {
9404
+ return `'${path.replace(/'/g, `'\\''`)}'`;
9405
+ }
9406
+ var CAPPED_STRING_FIELDS = [
9407
+ "content",
9408
+ "old_string",
9409
+ "new_string",
9410
+ "command",
9411
+ "input"
9412
+ ];
9413
+ function capToolInput(args) {
9414
+ const input = args ?? {};
9415
+ const serialized = JSON.stringify(input);
9416
+ if (serialized.length <= MAX_BLOCK_CHARS)
9417
+ return input;
9418
+ if (input && typeof input === "object") {
9419
+ const obj = input;
9420
+ const capped = {};
9421
+ for (const [key, value] of Object.entries(obj)) {
9422
+ if (CAPPED_STRING_FIELDS.includes(key) && typeof value === "string") {
9423
+ capped[key] = cap(value);
9424
+ } else if (key === "edits" && Array.isArray(value)) {
9425
+ capped[key] = value.map((edit) => edit && typeof edit === "object" ? {
9426
+ ...edit,
9427
+ old_string: typeof edit.old_string === "string" ? cap(edit.old_string) : edit.old_string,
9428
+ new_string: typeof edit.new_string === "string" ? cap(edit.new_string) : edit.new_string
9429
+ } : edit);
9430
+ } else {
9431
+ capped[key] = value;
9432
+ }
9433
+ }
9434
+ if (JSON.stringify(capped).length <= MAX_BLOCK_CHARS)
9435
+ return capped;
9436
+ }
9437
+ return { _truncated: serialized.slice(0, MAX_BLOCK_CHARS) };
9438
+ }
9439
+ var PATCH_FILE_MARKERS = [
9440
+ { re: /^\*\*\* Update File: (.+)$/, op: "modify" },
9441
+ { re: /^\*\*\* Add File: (.+)$/, op: "add" },
9442
+ { re: /^\*\*\* Delete File: (.+)$/, op: "delete" }
9443
+ ];
9444
+ function parseCodexPatch(patch) {
9445
+ if (typeof patch !== "string" || patch.length === 0)
9446
+ return null;
9447
+ const lines = patch.split(`
9448
+ `);
9449
+ const sections = [];
9450
+ let current = null;
9451
+ for (const line of lines) {
9452
+ if (line === "*** Begin Patch")
9453
+ continue;
9454
+ if (line === "*** End Patch")
9455
+ break;
9456
+ const marker = PATCH_FILE_MARKERS.map((m) => ({
9457
+ m,
9458
+ hit: line.match(m.re)
9459
+ })).find((x) => x.hit);
9460
+ if (marker) {
9461
+ current = {
9462
+ op: marker.m.op,
9463
+ path: marker.hit[1],
9464
+ body: []
9465
+ };
9466
+ sections.push(current);
9467
+ continue;
9468
+ }
9469
+ const move = line.match(/^\*\*\* Move to: (.+)$/);
9470
+ if (move && current) {
9471
+ current.renameTo = move[1];
9472
+ continue;
9473
+ }
9474
+ if (line.startsWith("*** "))
9475
+ continue;
9476
+ if (current)
9477
+ current.body.push(line);
9478
+ }
9479
+ if (sections.length === 0)
9480
+ return null;
9481
+ return sections;
9482
+ }
9483
+ function reconstructHunk(hunkLines) {
9484
+ const oldLines = [];
9485
+ const newLines = [];
9486
+ for (const line of hunkLines) {
9487
+ const marker = line[0];
9488
+ if (marker === "-") {
9489
+ oldLines.push(line.slice(1));
9490
+ } else if (marker === "+") {
9491
+ newLines.push(line.slice(1));
9492
+ } else if (marker === " ") {
9493
+ oldLines.push(line.slice(1));
9494
+ newLines.push(line.slice(1));
9495
+ } else if (line === "") {
9496
+ oldLines.push("");
9497
+ newLines.push("");
9498
+ }
9499
+ }
9500
+ return { old_string: oldLines.join(`
9501
+ `), new_string: newLines.join(`
9502
+ `) };
9503
+ }
9504
+ function splitHunks(body) {
9505
+ const hunks = [];
9506
+ let current = null;
9507
+ for (const line of body) {
9508
+ if (line.startsWith("@@")) {
9509
+ current = [];
9510
+ hunks.push(current);
9511
+ continue;
9512
+ }
9513
+ if (current)
9514
+ current.push(line);
9515
+ }
9516
+ return hunks.filter((h) => h.length > 0);
9517
+ }
9518
+ function sectionToBlock(section) {
9519
+ if (section.op === "delete") {
9520
+ return [
9521
+ {
9522
+ name: "Bash",
9523
+ input: { command: `rm -f -- ${shellQuote2(section.path)}` }
9524
+ }
9525
+ ];
9526
+ }
9527
+ if (section.op === "add") {
9528
+ const content = section.body.filter((l) => l.startsWith("+")).map((l) => l.slice(1)).join(`
9529
+ `);
9530
+ return [{ name: "Write", input: { file_path: section.path, content } }];
9531
+ }
9532
+ const targetPath = section.renameTo ?? section.path;
9533
+ const rename = section.renameTo ? [
9534
+ {
9535
+ name: "Bash",
9536
+ input: {
9537
+ command: `mv -- ${shellQuote2(section.path)} ${shellQuote2(section.renameTo)}`
9538
+ }
9539
+ }
9540
+ ] : [];
9541
+ const edits = splitHunks(section.body).map(reconstructHunk);
9542
+ if (edits.length === 0)
9543
+ return rename;
9544
+ const [only] = edits;
9545
+ if (edits.length === 1 && only) {
9546
+ return [
9547
+ ...rename,
9548
+ {
9549
+ name: "Edit",
9550
+ input: {
9551
+ file_path: targetPath,
9552
+ old_string: only.old_string,
9553
+ new_string: only.new_string
9554
+ }
9555
+ }
9556
+ ];
9557
+ }
9558
+ return [
9559
+ ...rename,
9560
+ { name: "MultiEdit", input: { file_path: targetPath, edits } }
9561
+ ];
9562
+ }
9563
+ var str3 = (v) => typeof v === "string";
9564
+ function withReadRange(input, a) {
9565
+ if (typeof a.offset === "number")
9566
+ input.offset = a.offset;
9567
+ if (typeof a.limit === "number")
9568
+ input.limit = a.limit;
9569
+ return input;
9570
+ }
9571
+ var TOOL_TRANSFORMS = {
9572
+ shellToolCall: (a) => str3(a.command) ? { name: "Bash", input: { command: a.command } } : null,
9573
+ readToolCall: (a) => str3(a.path) ? { name: "Read", input: { file_path: a.path } } : null,
9574
+ editToolCall: (a) => str3(a.path) ? {
9575
+ name: "Write",
9576
+ input: { file_path: a.path, content: a.streamContent }
9577
+ } : null,
9578
+ globToolCall: (a) => str3(a.globPattern) ? {
9579
+ name: "Glob",
9580
+ input: { pattern: a.globPattern, path: a.targetDirectory }
9581
+ } : null,
9582
+ grepToolCall: (a) => str3(a.pattern) ? { name: "Grep", input: { pattern: a.pattern, path: a.path } } : null,
9583
+ webFetchToolCall: (a) => str3(a.url) ? { name: "WebFetch", input: { url: a.url } } : null,
9584
+ webSearchToolCall: (a) => str3(a.searchTerm) ? { name: "WebSearch", input: { query: a.searchTerm } } : null,
9585
+ bash: (a) => str3(a.command) ? { name: "Bash", input: { command: a.command } } : null,
9586
+ glob: (a) => str3(a.pattern) ? { name: "Glob", input: { pattern: a.pattern, path: a.path } } : null,
9587
+ grep: (a) => str3(a.pattern) ? { name: "Grep", input: { pattern: a.pattern, path: a.path } } : null,
9588
+ webfetch: (a) => str3(a.url) ? { name: "WebFetch", input: { url: a.url } } : null,
9589
+ todowrite: (a) => Array.isArray(a.todos) ? {
9590
+ name: "TodoWrite",
9591
+ input: {
9592
+ todos: a.todos.map((t) => ({
9593
+ content: t.content,
9594
+ status: t.status
9595
+ }))
9596
+ }
9597
+ } : null,
9598
+ read: (a) => {
9599
+ const filePath = str3(a.filePath) ? a.filePath : str3(a.path) ? a.path : null;
9600
+ return filePath ? { name: "Read", input: withReadRange({ file_path: filePath }, a) } : null;
9601
+ },
9602
+ exec: (a) => str3(a.command) ? { name: "Bash", input: { command: a.command } } : null,
9603
+ write: (a) => str3(a.path) ? { name: "Write", input: { file_path: a.path, content: a.content } } : null,
9604
+ edit: (a) => {
9605
+ if (!str3(a.path) || !Array.isArray(a.edits) || a.edits.length === 0)
9606
+ return null;
9607
+ if (a.edits.length === 1) {
9608
+ const e = a.edits[0];
9609
+ return {
9610
+ name: "Edit",
9611
+ input: {
9612
+ file_path: a.path,
9613
+ old_string: e.oldText,
9614
+ new_string: e.newText
9615
+ }
9616
+ };
9617
+ }
9618
+ return {
9619
+ name: "MultiEdit",
9620
+ input: {
9621
+ file_path: a.path,
9622
+ edits: a.edits.map((e) => ({
9623
+ old_string: e.oldText,
9624
+ new_string: e.newText
9625
+ }))
9626
+ }
9627
+ };
9628
+ },
9629
+ web_fetch: (a) => str3(a.url) ? { name: "WebFetch", input: { url: a.url } } : null,
9630
+ update_plan: (a) => Array.isArray(a.plan) ? {
9631
+ name: "TodoWrite",
9632
+ input: {
9633
+ todos: a.plan.map((p) => ({
9634
+ content: p.step,
9635
+ status: p.status
9636
+ }))
9637
+ }
9638
+ } : null,
9639
+ shell: (a) => str3(a.command) && (a.action === undefined || a.action === "run") ? { name: "Bash", input: { command: a.command } } : null,
9640
+ read_file: (a) => {
9641
+ if (!str3(a.path))
9642
+ return null;
9643
+ const input = { file_path: a.path };
9644
+ if (typeof a.start_line === "number")
9645
+ input.offset = a.start_line;
9646
+ if (typeof a.line_count === "number")
9647
+ input.limit = a.line_count;
9648
+ return { name: "Read", input };
9649
+ },
9650
+ write_file: (a) => str3(a.path) ? { name: "Write", input: { file_path: a.path, content: a.content } } : null,
9651
+ edit_file: (a) => str3(a.path) ? {
9652
+ name: "Edit",
9653
+ input: {
9654
+ file_path: a.path,
9655
+ old_string: a.old_string,
9656
+ new_string: a.new_string
9657
+ }
9658
+ } : null,
9659
+ glob_files: (a) => str3(a.pattern) ? { name: "Glob", input: { pattern: a.pattern, path: a.path } } : null,
9660
+ grep_files: (a) => str3(a.pattern) ? { name: "Grep", input: { pattern: a.pattern, path: a.path } } : null,
9661
+ exa_search: (a) => str3(a.query) ? { name: "WebSearch", input: { query: a.query } } : null
9662
+ };
9663
+ function normalizeToolCall(functionName, args) {
9664
+ const obj = args && typeof args === "object" ? args : null;
9665
+ if (functionName === "exec_command" && obj && "cmd" in obj) {
9666
+ const { cmd, ...rest } = obj;
9667
+ return [{ name: "Bash", input: { ...rest, command: cmd } }];
9668
+ }
9669
+ if (functionName === "apply_patch" && obj) {
9670
+ const rawPatch = obj.input ?? obj.patchText;
9671
+ const sections = parseCodexPatch(rawPatch);
9672
+ if (sections) {
9673
+ const blocks = sections.flatMap(sectionToBlock);
9674
+ if (blocks.length > 0)
9675
+ return blocks;
9676
+ }
9677
+ return [{ name: functionName, input: args }];
9678
+ }
9679
+ if (functionName === "web_search_call" && obj) {
9680
+ const actionType = obj.action_type;
9681
+ if (actionType === "open_page")
9682
+ return [{ name: "WebFetch", input: obj }];
9683
+ if (actionType === "search") {
9684
+ if ("query" in obj && typeof obj.query === "string")
9685
+ return [{ name: "WebSearch", input: obj }];
9686
+ const altKey = ["queries", "q", "search_query"].find((k) => (k in obj));
9687
+ if (altKey) {
9688
+ const { [altKey]: raw, ...rest } = obj;
9689
+ const query = Array.isArray(raw) ? raw.find((q) => typeof q === "string") : raw;
9690
+ if (typeof query !== "string")
9691
+ return [{ name: functionName, input: args }];
9692
+ const preserved = Array.isArray(raw) ? { queries: raw } : {};
9693
+ return [{ name: "WebSearch", input: { ...rest, ...preserved, query } }];
9694
+ }
9695
+ return [{ name: functionName, input: args }];
9696
+ }
9697
+ return [{ name: functionName, input: args }];
9698
+ }
9699
+ if (functionName && functionName in TOOL_TRANSFORMS && obj) {
9700
+ const block = TOOL_TRANSFORMS[functionName](obj);
9701
+ if (block)
9702
+ return [block];
9703
+ }
9704
+ return [{ name: functionName, input: args }];
9705
+ }
9706
+ function toolCallId(tc, step, index) {
9707
+ if (tc.tool_call_id)
9708
+ return tc.tool_call_id;
9709
+ return `call_${step.step_id}_${tc.function_name ?? "tool"}_${index}`;
9710
+ }
9711
+ var HARBOR_PLACEHOLDER_MESSAGES = new Set([
9712
+ "(tool use)",
9713
+ "(no assistant text)"
9714
+ ]);
9715
+ function isExecutedRestatement(message, toolCalls) {
9716
+ const trimmed = message.trimStart();
9717
+ if (!trimmed.startsWith("Executed "))
9718
+ return false;
9719
+ const rest = trimmed.slice("Executed ".length);
9720
+ return toolCalls.some((tc) => tc.function_name && rest.startsWith(tc.function_name));
9721
+ }
9722
+ var ENVELOPE_META = [
9723
+ /^Chunk ID:/,
9724
+ /^Wall time:/,
9725
+ /^Original token count:/,
9726
+ /^Process exited with code /,
9727
+ /^Exit code:/
9728
+ ];
9729
+ function stripCodexResultEnvelope(content) {
9730
+ const lines = content.split(`
9731
+ `);
9732
+ const outputIdx = lines.findIndex((l) => l === "Output:");
9733
+ if (outputIdx === -1)
9734
+ return content;
9735
+ const preamble = lines.slice(0, outputIdx);
9736
+ if (!preamble.some((l) => ENVELOPE_META.some((re) => re.test(l))))
9737
+ return content;
9738
+ let exitCode = 0;
9739
+ for (const line of preamble) {
9740
+ const m = line.match(/^(?:Process exited with code|Exit code:) (\d+)$/);
9741
+ if (m)
9742
+ exitCode = Number(m[1]);
9743
+ }
9744
+ const output = lines.slice(outputIdx + 1).join(`
9745
+ `);
9746
+ return exitCode === 0 ? output : `[exit code ${exitCode}]
9747
+ ${output}`;
9748
+ }
9749
+ function convertAtifToSession(trajectory) {
9750
+ if (!Array.isArray(trajectory?.steps)) {
9751
+ throw new Error("atif: the trajectory has no steps array");
9752
+ }
9753
+ const events = [];
9754
+ for (const step of trajectory.steps) {
9755
+ if (step.source === "system")
9756
+ continue;
9757
+ if (step.source === "user") {
9758
+ const userText = flattenContent(step.message);
9759
+ if (userText.includes("<environment_context>"))
9760
+ continue;
9761
+ events.push({
9762
+ uuid: `atif-${step.step_id}-user`,
9763
+ type: "user",
9764
+ timestamp: step.timestamp,
9765
+ message: { role: "user", content: cap(userText) }
9766
+ });
9767
+ continue;
9768
+ }
9769
+ if (step.source !== "agent")
9770
+ continue;
9771
+ const toolCalls = step.tool_calls ?? [];
9772
+ const blocks = [];
9773
+ const reasoning = flattenContent(step.reasoning_content);
9774
+ if (reasoning)
9775
+ blocks.push({ type: "thinking", thinking: cap(reasoning) });
9776
+ const messageText = flattenContent(step.message);
9777
+ if (messageText && !isExecutedRestatement(messageText, toolCalls) && !HARBOR_PLACEHOLDER_MESSAGES.has(messageText.trim())) {
9778
+ blocks.push({ type: "text", text: cap(messageText) });
9779
+ }
9780
+ const toolUseIds = [];
9781
+ for (let i = 0;i < toolCalls.length; i++) {
9782
+ const tc = toolCalls[i];
9783
+ const id = toolCallId(tc, step, i);
9784
+ toolUseIds.push(id);
9785
+ const split = normalizeToolCall(tc.function_name, tc.arguments);
9786
+ split.forEach(({ name, input }, k) => {
9787
+ const isLast = k === split.length - 1;
9788
+ blocks.push({
9789
+ type: "tool_use",
9790
+ id: isLast ? id : `${id}__patch${k}`,
9791
+ name,
9792
+ input: capToolInput(input)
9793
+ });
9794
+ });
9795
+ }
9796
+ if (blocks.length > 0) {
9797
+ events.push({
9798
+ uuid: `atif-${step.step_id}-assistant`,
9799
+ type: "assistant",
9800
+ timestamp: step.timestamp,
9801
+ message: {
9802
+ id: `atif-step-${step.step_id}`,
9803
+ role: "assistant",
9804
+ model: step.model_name,
9805
+ content: blocks
9806
+ }
9807
+ });
9808
+ }
9809
+ const results = step.observation?.results;
9810
+ if (results && results.length > 0) {
9811
+ const resultBlocks = results.map((result, i) => {
9812
+ const content = result.subagent_trajectory_ref ? `[subagent trajectory: ${result.subagent_trajectory_ref}]` : stripCodexResultEnvelope(flattenContent(result.content));
9813
+ return {
9814
+ type: "tool_result",
9815
+ tool_use_id: result.source_call_id || toolUseIds[i] || `call_${step.step_id}_result${i}`,
9816
+ content: cap(content)
9817
+ };
9818
+ });
9819
+ events.push({
9820
+ uuid: `atif-${step.step_id}-results`,
9821
+ type: "user",
9822
+ timestamp: step.timestamp,
9823
+ message: { role: "user", content: resultBlocks }
9824
+ });
9825
+ }
9826
+ }
9827
+ return events;
9828
+ }
9829
+
9830
+ // src/session.ts
9831
+ var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
9832
+ function convertedSessionId(harnessId) {
9833
+ return UUID.test(harnessId) ? harnessId.toLowerCase() : randomUUID();
9834
+ }
9835
+ function toTrajectory(t) {
9836
+ const steps = [{ step_id: 1, source: "user", message: t.prompt }];
9837
+ const agent = (fields) => {
9838
+ const step = {
9839
+ step_id: steps.length + 1,
9840
+ source: "agent",
9841
+ message: "",
9842
+ ...t.model === null ? {} : { model_name: t.model },
9843
+ ...fields
9844
+ };
9845
+ steps.push(step);
9846
+ return step;
9847
+ };
9848
+ const calls = new Map;
9849
+ const failed = new Set;
9850
+ let lastText = null;
9851
+ for (const s of t.steps) {
9852
+ if (s.kind === "text") {
9853
+ agent({ message: s.text });
9854
+ lastText = s.text;
9855
+ } else if (s.kind === "tool_call") {
9856
+ const id = s.id ?? `firegoat_call_${steps.length + 1}`;
9857
+ const step = agent({
9858
+ tool_calls: [
9859
+ { tool_call_id: id, function_name: s.name, arguments: s.input }
9860
+ ]
9861
+ });
9862
+ calls.set(id, step);
9863
+ } else {
9864
+ const call = s.callId === null ? undefined : calls.get(s.callId);
9865
+ const id = s.callId ?? `firegoat_result_${steps.length + 1}`;
9866
+ const result = { source_call_id: id, content: s.text };
9867
+ if (!call)
9868
+ agent({ observation: { results: [result] } });
9869
+ else if (call.observation)
9870
+ call.observation.results.push(result);
9871
+ else
9872
+ call.observation = { results: [result] };
9873
+ if (s.isError)
9874
+ failed.add(id);
9875
+ }
9876
+ }
9877
+ if (t.final !== "" && t.final !== lastText)
9878
+ agent({ message: t.final });
9879
+ return {
9880
+ trajectory: {
9881
+ schema_version: "ATIF-v1.6",
9882
+ session_id: t.sessionId,
9883
+ agent: {
9884
+ name: t.harness,
9885
+ ...t.version === null ? {} : { version: t.version },
9886
+ ...t.model === null ? {} : { model_name: t.model }
9887
+ },
9888
+ steps
9889
+ },
9890
+ failed
9891
+ };
9892
+ }
9893
+ function toClaudeSession(t) {
9894
+ const { trajectory, failed } = toTrajectory(t);
9895
+ return convertAtifToSession(trajectory).map((e) => {
9896
+ const content = e.message?.content;
9897
+ if (Array.isArray(content)) {
9898
+ for (const b of content)
9899
+ if (b.type === "tool_result" && failed.has(b.tool_use_id))
9900
+ b.is_error = true;
9901
+ }
9902
+ return { ...e, sessionId: t.sessionId };
9903
+ });
9904
+ }
9905
+ function writeClaudeSession(path, t) {
9906
+ writeFileSync3(path, toClaudeSession(t).map((e) => `${JSON.stringify(e)}
9907
+ `).join(""));
9908
+ }
9909
+
9337
9910
  // src/steps.ts
9338
9911
  import { createWriteStream } from "node:fs";
9339
9912
  var STEP_TIMEOUT_MS = 600000;
@@ -9468,7 +10041,7 @@ async function sendTelemetry(event, exitCode, startedAt) {
9468
10041
  }
9469
10042
 
9470
10043
  // src/transcript.ts
9471
- import { readdirSync as readdirSync5, statSync as statSync2, writeFileSync as writeFileSync3 } from "node:fs";
10044
+ import { readdirSync as readdirSync5, statSync as statSync2, writeFileSync as writeFileSync4 } from "node:fs";
9472
10045
  import { join as join8, relative } from "node:path";
9473
10046
  var OUTPUT_LINES = 15;
9474
10047
  var OUTPUT_CHARS = 1500;
@@ -9552,7 +10125,7 @@ ${indent(truncate(s.text, OUTPUT_LINES, OUTPUT_CHARS))}` : ` → ${status}`);
9552
10125
  out.pop();
9553
10126
  out.push("", "## final answer", final ? prose(final) : "(none)");
9554
10127
  out.push("", "## files in the workdir", ...workdirFiles(opts.workdir));
9555
- writeFileSync3(opts.path, `${out.join(`
10128
+ writeFileSync4(opts.path, `${out.join(`
9556
10129
  `)}
9557
10130
  `);
9558
10131
  }
@@ -9857,11 +10430,9 @@ function ttyUI(out, opts) {
9857
10430
 
9858
10431
  // src/upload.ts
9859
10432
  import { existsSync as existsSync5, readFileSync as readFileSync5 } from "node:fs";
9860
- import { basename as basename2 } from "node:path";
9861
10433
  var MAX_FILE = 20 * 1024 * 1024;
9862
10434
  var MAX_CHECKS = 200;
9863
10435
  var MAX_TEXT = 8192;
9864
- var UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
9865
10436
  var FILES = ["session.jsonl", "checks.txt", "stderr.txt"];
9866
10437
  function clip(s, max, marker) {
9867
10438
  return s.length > max ? `${s.slice(0, max - marker.length)}${marker}` : s;
@@ -9918,9 +10489,11 @@ async function put(name, entry, bytes) {
9918
10489
  throw new ApiError(`PUT ${name} ${r.status}`, r.status);
9919
10490
  }
9920
10491
  async function uploadRun(t, r) {
9921
- if (!r.session)
10492
+ if (!r.claudeSession)
9922
10493
  return { status: "skipped", reason: "No session file" };
9923
- const sessionId = basename2(r.session, ".jsonl");
10494
+ const { sessionId } = r;
10495
+ if (sessionId === null)
10496
+ throw new Error(`run ${r.test}#${r.n} has a session file but no session id`);
9924
10497
  if (!UUID.test(sessionId))
9925
10498
  return {
9926
10499
  status: "failed",
@@ -9932,7 +10505,7 @@ async function uploadRun(t, r) {
9932
10505
  error: `${r.checks.length} checks, the upload takes at most ${MAX_CHECKS}`
9933
10506
  };
9934
10507
  const paths = {
9935
- "session.jsonl": r.session,
10508
+ "session.jsonl": r.claudeSession,
9936
10509
  "checks.txt": r.checksLog,
9937
10510
  "stderr.txt": r.stderr
9938
10511
  };
@@ -9973,6 +10546,7 @@ async function uploadRun(t, r) {
9973
10546
  num_turns: r.num_turns,
9974
10547
  cost: r.cost,
9975
10548
  duration_ms: r.duration_ms,
10549
+ harness: r.harness,
9976
10550
  model,
9977
10551
  ...git && { git }
9978
10552
  },
@@ -10122,7 +10696,7 @@ function newRunId() {
10122
10696
  return `${ts}-${randomBytes2(2).toString("hex")}`;
10123
10697
  }
10124
10698
  function writeRunJson(r) {
10125
- writeFileSync4(join10(r.dir, "run.json"), `${JSON.stringify(r, null, 2)}
10699
+ writeFileSync5(join10(r.dir, "run.json"), `${JSON.stringify(r, null, 2)}
10126
10700
  `);
10127
10701
  }
10128
10702
  async function uploadPreflight(opts, ui, project, runGroup, git) {
@@ -10173,8 +10747,8 @@ function runFill(runId, n, testName, out, agentDir) {
10173
10747
  FIREGOAT_HOME: join10(agentDir, "home")
10174
10748
  };
10175
10749
  }
10176
- function renderHarnessArgs(test, vars, fill) {
10177
- return test.harnessArgs.map((a) => renderValue(a.arg, vars, fill, a.path));
10750
+ function renderHarnessArgs(test, harness, vars, fill) {
10751
+ return (test.harnessArgs[harness] ?? []).map((a) => renderValue(a.arg, vars, fill, a.path));
10178
10752
  }
10179
10753
  function renderSteps(what, commands, vars, fill) {
10180
10754
  return commands.map((c, i) => renderStep(c, vars, fill, `${what}[${i}]`));
@@ -10211,7 +10785,7 @@ function dryRun(opts) {
10211
10785
  renderEnv(test.env, opts.vars, fill);
10212
10786
  return {
10213
10787
  ...p,
10214
- harnessArgs: renderHarnessArgs(test, opts.vars, fill),
10788
+ harnessArgs: renderHarnessArgs(test, p.harness, opts.vars, fill),
10215
10789
  ...renderRunSteps(test, opts.config, opts.vars, fill)
10216
10790
  };
10217
10791
  });
@@ -10223,7 +10797,7 @@ function dryRun(opts) {
10223
10797
  const count = (n, what) => `${n} ${what}${n === 1 ? "" : "s"}`;
10224
10798
  opts.say(`Dry run: ${count(runs.length, "run")}, build ${count(opts.config.build.length, "step")}. Nothing was started.`);
10225
10799
  for (const r of runs)
10226
- opts.say(`${r.test}#${r.n}${runTag(opts.showHarness ? r.harness : null, r.model)} install ${r.install.length}, checks ${r.checks.length}, cleanup ${r.cleanup.length}${r.harnessArgs.length > 0 ? `, harness_args ${JSON.stringify(r.harnessArgs)}` : ""}`);
10800
+ opts.say(`${r.test}#${r.n}${runTag(opts.showHarness ? r.harness : null, r.model)} install ${r.install.length}, checks ${r.checks.length}, cleanup ${r.cleanup.length}${r.harnessArgs.length > 0 ? `, ${HARNESSES.get(r.harness).argsKey} ${JSON.stringify(r.harnessArgs)}` : ""}`);
10227
10801
  }
10228
10802
  async function runOne(opts) {
10229
10803
  const { test, n } = opts;
@@ -10243,7 +10817,7 @@ async function runOne(opts) {
10243
10817
  harness: opts.harness.name,
10244
10818
  model: opts.model,
10245
10819
  reportedModel: null,
10246
- harnessArgs: renderHarnessArgs(test, opts.vars, fill),
10820
+ harnessArgs: renderHarnessArgs(test, opts.harness.name, opts.vars, fill),
10247
10821
  status: "error",
10248
10822
  reason: "",
10249
10823
  prompt: render(test.prompt, opts.vars),
@@ -10253,6 +10827,8 @@ async function runOne(opts) {
10253
10827
  workdir,
10254
10828
  home,
10255
10829
  session: null,
10830
+ claudeSession: null,
10831
+ sessionId: null,
10256
10832
  transcript: join10(dir, "transcript.txt"),
10257
10833
  stderr: join10(dir, "stderr.txt"),
10258
10834
  checksLog: join10(dir, "checks.txt"),
@@ -10341,6 +10917,24 @@ async function attempt(opts, r, steps, env, stepEnv, homeEnvFile, agent) {
10341
10917
  if (outcome.sessionId)
10342
10918
  r.session = harness.findSession(opts.configDir, outcome.sessionId);
10343
10919
  r.reportedModel = outcome.reportedModel ?? (r.session && harness.sessionModel ? harness.sessionModel(r.session) : null);
10920
+ if (r.session && outcome.sessionId) {
10921
+ if (harness.claudeSessionFile) {
10922
+ r.claudeSession = r.session;
10923
+ r.sessionId = outcome.sessionId;
10924
+ } else {
10925
+ r.sessionId = convertedSessionId(outcome.sessionId);
10926
+ r.claudeSession = join10(dir, "session.jsonl");
10927
+ writeClaudeSession(r.claudeSession, {
10928
+ sessionId: r.sessionId,
10929
+ harness: harness.name,
10930
+ version: outcome.version,
10931
+ model: r.reportedModel,
10932
+ prompt: r.prompt,
10933
+ steps: outcome.steps,
10934
+ final: outcome.final
10935
+ });
10936
+ }
10937
+ }
10344
10938
  const about = [
10345
10939
  opts.shownHarness,
10346
10940
  opts.model === null ? null : `model ${opts.model}`
@@ -10390,9 +10984,6 @@ async function attempt(opts, r, steps, env, stepEnv, homeEnvFile, agent) {
10390
10984
  test.checks.length === 0 ? "no script checks" : `${r.checks.length}/${r.checks.length} checks passed`
10391
10985
  ];
10392
10986
  }
10393
- function notUploaded(h) {
10394
- return `firegoat.ai doesn't read ${h.name} sessions yet: its runs stay local`;
10395
- }
10396
10987
  var MAX_LISTED_URLS = 5;
10397
10988
  function uploadedSummary(origin, results, runDir) {
10398
10989
  const urls = results.flatMap((r) => r.upload?.status === "uploaded" ? [r.upload.url] : []);
@@ -10431,7 +11022,7 @@ async function evalCommand(args, root) {
10431
11022
  const say = opts.json ? console.error : console.log;
10432
11023
  const stream = opts.json ? process.stderr : process.stdout;
10433
11024
  const ui = stream.isTTY ? ttyUI(stream, {
10434
- project: basename3(root),
11025
+ project: basename2(root),
10435
11026
  version: package_default.version,
10436
11027
  color: !process.env.NO_COLOR
10437
11028
  }) : plainUI(say);
@@ -10460,14 +11051,6 @@ async function evalCommand(args, root) {
10460
11051
  return fatal(EXIT.usage, `--harness-bin needs exactly one harness, this eval runs ${harnessNames.length} (${harnessNames.join(", ")}): pick one with --harness`, "usage");
10461
11052
  const harnesses = harnessNames.map((h) => HARNESSES.get(h));
10462
11053
  const showHarness = showsHarness(harnessNames);
10463
- try {
10464
- for (const h of harnesses)
10465
- checkHarnessArgs(tests, h);
10466
- } catch (e) {
10467
- if (e instanceof ConfigError)
10468
- return fatal(EXIT.usage, e.message, "config");
10469
- throw e;
10470
- }
10471
11054
  evalStats({
10472
11055
  testCount: tests.length,
10473
11056
  runCount: tests.length * runs * Math.max(models.length, 1) * harnesses.length,
@@ -10480,7 +11063,7 @@ async function evalCommand(args, root) {
10480
11063
  t.prompt,
10481
11064
  ...t.checks.map((c) => c.run),
10482
11065
  ...Object.values(t.env),
10483
- ...t.harnessArgs.map((a) => a.arg),
11066
+ ...harnessNames.flatMap((h) => (t.harnessArgs[h] ?? []).map((a) => a.arg)),
10484
11067
  ...t.install,
10485
11068
  ...t.cleanup
10486
11069
  ]).concat(config.build, config.install, config.cleanup).flatMap(varRefs).filter((k) => !(k in vars)))
@@ -10552,15 +11135,10 @@ async function evalCommand(args, root) {
10552
11135
  const runId = newRunId();
10553
11136
  let upload;
10554
11137
  try {
10555
- const pre = await uploadPreflight(opts, ui, basename3(repo?.mainCheckout ?? repo?.toplevel ?? root), runId, repo?.info ?? null);
11138
+ const pre = await uploadPreflight(opts, ui, basename2(repo?.mainCheckout ?? repo?.toplevel ?? root), runId, repo?.info ?? null);
10556
11139
  if (pre && "exitCode" in pre)
10557
11140
  return fatal(pre.exitCode, pre.error, pre.code);
10558
11141
  upload = pre;
10559
- if (upload) {
10560
- for (const h of harnesses)
10561
- if (!h.uploads)
10562
- ui.note(notUploaded(h));
10563
- }
10564
11142
  } catch (e) {
10565
11143
  if (e instanceof ConfigError)
10566
11144
  return fatal(EXIT.usage, e.message, "config");
@@ -10645,10 +11223,7 @@ async function evalCommand(args, root) {
10645
11223
  command: undefined
10646
11224
  });
10647
11225
  ui.update();
10648
- if (upload && !harness.uploads) {
10649
- r.upload = { status: "skipped", reason: notUploaded(harness) };
10650
- writeRunJson(r);
10651
- } else if (upload) {
11226
+ if (upload) {
10652
11227
  row.upload = "uploading";
10653
11228
  ui.update();
10654
11229
  r.upload = await uploadRun(upload, r);
@@ -10694,7 +11269,7 @@ async function evalCommand(args, root) {
10694
11269
  status,
10695
11270
  exitCode
10696
11271
  };
10697
- writeFileSync4(join10(runDir, "summary.json"), `${JSON.stringify(doc, null, 2)}
11272
+ writeFileSync5(join10(runDir, "summary.json"), `${JSON.stringify(doc, null, 2)}
10698
11273
  `);
10699
11274
  appendFileSync(join10(runsRoot, "index.jsonl"), `${JSON.stringify({
10700
11275
  runId,
@@ -11011,8 +11586,8 @@ firegoat ${package_default.version}` : `firegoat ${package_default.version}`,
11011
11586
 
11012
11587
  // src/init.ts
11013
11588
  import { spawn as spawn4, spawnSync as spawnSync4 } from "node:child_process";
11014
- import { existsSync as existsSync6, writeFileSync as writeFileSync5 } from "node:fs";
11015
- import { basename as basename4, join as join11 } from "node:path";
11589
+ import { existsSync as existsSync6, writeFileSync as writeFileSync6 } from "node:fs";
11590
+ import { basename as basename3, join as join11 } from "node:path";
11016
11591
 
11017
11592
  // src/login.ts
11018
11593
  import { spawn as spawn3 } from "node:child_process";
@@ -11023,7 +11598,7 @@ var FORM = { "Content-Type": "application/x-www-form-urlencoded" };
11023
11598
  var UPLOADS_ON = "Eval uploads each session to firegoat.ai as a private trace; --no-upload keeps runs local.";
11024
11599
  var UPLOAD_NOTICE = `${UPLOADS_ON} A session holds prompts, tool outputs and file contents: possibly secrets from your env or repo. The agent runs as you and can read ~/.firegoat (and a FIREGOAT_API_KEY in firegoat's own process env).`;
11025
11600
  var TERMS = "By logging in, you agree to the firegoat.ai Terms of Service (https://firegoat.ai/terms) and Privacy Policy (https://firegoat.ai/privacy).";
11026
- function str3(o, k, step) {
11601
+ function str4(o, k, step) {
11027
11602
  const v = o?.[k];
11028
11603
  if (typeof v !== "string" || v === "")
11029
11604
  throw new ApiError(`${step}: response has no ${k}`);
@@ -11054,9 +11629,9 @@ async function loginFlow(show, interactive) {
11054
11629
  });
11055
11630
  if (cfg.status !== 200)
11056
11631
  throw replyError(cfgStep, cfg);
11057
- const clientId = str3(cfg.json, "client_id", cfgStep);
11058
- const deviceUrl = assertSafeUrl(str3(cfg.json, "device_authorization_endpoint", cfgStep), "device_authorization_endpoint");
11059
- const tokenUrl = assertSafeUrl(str3(cfg.json, "token_endpoint", cfgStep), "token_endpoint");
11632
+ const clientId = str4(cfg.json, "client_id", cfgStep);
11633
+ const deviceUrl = assertSafeUrl(str4(cfg.json, "device_authorization_endpoint", cfgStep), "device_authorization_endpoint");
11634
+ const tokenUrl = assertSafeUrl(str4(cfg.json, "token_endpoint", cfgStep), "token_endpoint");
11060
11635
  if (tokenUrl.origin !== origin)
11061
11636
  throw new ApiError(`token_endpoint must be on ${origin}, got ${printable(tokenUrl.origin, 100)}`);
11062
11637
  const devStep = "Device authorization";
@@ -11065,16 +11640,19 @@ async function loginFlow(show, interactive) {
11065
11640
  url: deviceUrl,
11066
11641
  method: "POST",
11067
11642
  headers: FORM,
11068
- body: new URLSearchParams({ client_id: clientId }).toString(),
11643
+ body: new URLSearchParams({
11644
+ client_id: clientId,
11645
+ scope: "openid profile email"
11646
+ }).toString(),
11069
11647
  timeoutMs: TIMEOUT_MS3
11070
11648
  });
11071
11649
  if (dev.status !== 200)
11072
11650
  throw replyError(devStep, dev);
11073
- const deviceCode = str3(dev.json, "device_code", devStep);
11074
- const userCode = str3(dev.json, "user_code", devStep);
11651
+ const deviceCode = str4(dev.json, "device_code", devStep);
11652
+ const userCode = str4(dev.json, "user_code", devStep);
11075
11653
  if (!/^[A-Za-z0-9-]{1,64}$/.test(userCode))
11076
11654
  throw new ApiError(`${devStep}: user_code has unexpected characters`);
11077
- const pageRaw = typeof dev.json?.verification_uri_complete === "string" ? dev.json.verification_uri_complete : str3(dev.json, "verification_uri", devStep);
11655
+ const pageRaw = typeof dev.json?.verification_uri_complete === "string" ? dev.json.verification_uri_complete : str4(dev.json, "verification_uri", devStep);
11078
11656
  const page = assertSafeUrl(pageRaw, "verification_uri");
11079
11657
  if (page.protocol !== "https:" || pageRaw.startsWith("-"))
11080
11658
  throw new ApiError("verification_uri must be https");
@@ -11376,6 +11954,11 @@ runs: 1
11376
11954
  # MYTOOL_HOME: "$FIREGOAT_HOME/.mytool"
11377
11955
  # UV_FIND_LINKS: "$FIREGOAT_OUT"
11378
11956
 
11957
+ # Optional: extra arguments for one harness, after firegoat's own flags, one per
11958
+ # entry: claude_args, codex_args, grok_args. A test can add its own. E.g. Claude in
11959
+ # Chrome, for tests that need a browser:
11960
+ # claude_args: [--chrome]
11961
+
11379
11962
  # Optional: values for {{vars.KEY}} in prompts, checks and build/install/cleanup
11380
11963
  # steps (pasted in as-is, unquoted); eval --var KEY=value overrides them. Steps
11381
11964
  # and checks also get each one as an env var ("$KEY": safe for any value).
@@ -11532,8 +12115,7 @@ ${UPLOADS_ON}` : LOGIN_HINT);
11532
12115
  }
11533
12116
  async function initCommand(args, cwd) {
11534
12117
  let force = false;
11535
- let noSkills = false;
11536
- let localSkills = false;
12118
+ const skillFlags = [];
11537
12119
  let login = true;
11538
12120
  for (const a of args) {
11539
12121
  if (a === "--help" || a === "-h") {
@@ -11543,9 +12125,11 @@ async function initCommand(args, cwd) {
11543
12125
  if (a === "--force" || a === "-f")
11544
12126
  force = true;
11545
12127
  else if (a === "--no-skills")
11546
- noSkills = true;
12128
+ skillFlags.push({ flag: a, scope: "none" });
11547
12129
  else if (a === "--local-skills")
11548
- localSkills = true;
12130
+ skillFlags.push({ flag: a, scope: "local" });
12131
+ else if (a === "--global-skills")
12132
+ skillFlags.push({ flag: a, scope: "global" });
11549
12133
  else if (a === "--no-login")
11550
12134
  login = false;
11551
12135
  else {
@@ -11555,15 +12139,17 @@ ${INIT_HELP}`);
11555
12139
  return EXIT.usage;
11556
12140
  }
11557
12141
  }
11558
- if (noSkills && localSkills) {
11559
- console.error(`firegoat init: --no-skills and --local-skills contradict each other: pass one
12142
+ const flags = [...new Set(skillFlags.map((f) => f.flag))];
12143
+ if (flags.length > 1) {
12144
+ console.error(`firegoat init: ${flags.join(" and ")} contradict each other: pass one
11560
12145
 
11561
12146
  ${INIT_HELP}`);
11562
12147
  return EXIT.usage;
11563
12148
  }
11564
- let scope = noSkills ? "none" : localSkills ? "local" : "global";
11565
- initSkills(scope);
11566
12149
  const interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY);
12150
+ const asked = skillFlags[0] === undefined && interactive;
12151
+ let scope = skillFlags[0]?.scope ?? (interactive ? "global" : "local");
12152
+ initSkills(scope);
11567
12153
  const color = interactive && !process.env.NO_COLOR;
11568
12154
  const paint = (code, text) => color ? `\x1B[${code}m${text}\x1B[0m` : text;
11569
12155
  const width = Math.min(process.stdout.columns || 80, 80);
@@ -11578,7 +12164,7 @@ ${INIT_HELP}`);
11578
12164
  const configPath = join11(cwd, CONFIG_FILE);
11579
12165
  const keep = existsSync6(configPath) && !force;
11580
12166
  if (!keep)
11581
- writeFileSync5(configPath, template(basename4(cwd)));
12167
+ writeFileSync6(configPath, template(basename3(cwd)));
11582
12168
  if (ui) {
11583
12169
  console.log(ui.done(1, CONFIG_FILE, keep ? "Kept (--force replaces it)" : "Created"));
11584
12170
  ui.gap();
@@ -11588,7 +12174,7 @@ ${INIT_HELP}`);
11588
12174
  const skill = "firegoat-onboarding skill";
11589
12175
  const block = liveBlock();
11590
12176
  ui?.gap();
11591
- if (ui && !noSkills && !localSkills) {
12177
+ if (ui && asked) {
11592
12178
  const picked = await ui.ask(block, 3, "Install the firegoat-onboarding skill", [
11593
12179
  ["Global", skillsDir("global")],
11594
12180
  ["This repo", `${skillsDir("local")} (commit it for your team)`],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "firegoat",
3
- "version": "0.27.0",
3
+ "version": "0.28.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/firegoat-ai/firegoat-cli.git"