lots of change - all to start my 3rd redo

This commit is contained in:
2026-09-26 14:31:52 -04:00
parent 7f4d388e19
commit bceb52e8ee
1046 changed files with 4476 additions and 0 deletions

View File

@@ -0,0 +1,286 @@
version = 1
[[harness]]
id = "claude-code"
label = "Claude Code"
agent_import_path = "snapshot_agent:SnapshotClaudeCode"
# `[metadata] browser = true` swaps in these: same reduced toolset plus `Read`, so an agent
# given a browser can look at the screenshot it just took. Distinct classes with distinct
# names, because a different toolset is a different agent.
agent_import_path_browser = "snapshot_agent:BrowserSnapshotClaudeCode"
agent_import_path_single_turn_browser = "snapshot_agent:BrowserPreinstalledClaudeCode"
agent_import_path_single_turn = "snapshot_agent:PreinstalledClaudeCode"
import_path_aliases = [
"snapshot_agent:FullToolsetSnapshotClaudeCode",
"snapshot_agent:FullToolsetPreinstalledClaudeCode",
"harbor.agents.installed.claude_code:ClaudeCode",
]
legacy_bare_model_rows = true
default_model = "claude-opus-5[1m]"
model_id_shape = "bare"
effort_kwarg = "reasoning_effort"
effort_default = "max"
fast_kwarg = "fast_mode"
key_env = "ANTHROPIC_API_KEY"
base_url_env = "ANTHROPIC_BASE_URL"
proxy_path = "anthropic"
writes_atif = true
capture = true
seed_native = true
seed_atif = true
authoring = true
cli = "claude"
install = "for i in 1 2 3; do curl -fsSL https://claude.ai/install.sh | bash && break; echo \"claude install attempt $i failed; retrying in 10s\" >&2; sleep 10; done"
# No agent_config: claude reduces its toolset with `--tools`, not `-c key=value`, so the
# reduction is a launch flag here and `--tools Bash` in snapshot_agent.py for the trial.
# Two expressions of one intent, which the $RACCOON_AGENT_FLAGS guard cannot police —
# unlike model and effort, which are interpolated from this row.
explore_launch = """exec claude --model '$RACCOON_MODEL' --effort $RACCOON_EFFORT --tools "$RACCOON_TOOLS" --append-system-prompt "$RACCOON_TOOLSET_NOTE" --plugin-dir /workspace/plugins/create-snapshot --dangerously-skip-permissions "$@""""
[[harness]]
id = "codex"
label = "OpenAI Codex CLI"
agent_import_path = "codex_agent:NativeSnapshotCodex"
agent_import_path_single_turn = "codex_agent:SystemNodeCodex"
import_path_aliases = [
"codex_agent:InlineSnapshotCodex",
"harbor.agents.installed.codex:Codex",
]
legacy_bare_model_rows = true
default_model = "gpt-5.6-sol"
model_id_shape = "bare"
effort_kwarg = "reasoning_effort"
effort_default = "max"
key_env = "OPENAI_API_KEY"
base_url_env = "OPENAI_BASE_URL"
proxy_path = "openai/v1"
writes_atif = true
capture = true
seed_native = true
seed_atif = true
authoring = true
cli = "codex"
install = "for i in 1 2 3; do curl -fsSL https://chatgpt.com/codex/install.sh | CODEX_NON_INTERACTIVE=1 sh && break; echo \"codex install attempt $i failed; retrying in 10s\" >&2; sleep 10; done"
skills_dir = "$HOME/.agents/skills"
config_path = "${CODEX_HOME:-$HOME/.codex}/config.toml"
auth_path = "${CODEX_HOME:-$HOME/.codex}/auth.json"
auth_key_env = "OPENAI_API_KEY"
agent_config = """
web_search = "disabled"
[agents]
enabled = false
[tools]
update_plan = { enabled = false }
experimental_request_user_input = { enabled = false }
[features]
goals = false
multi_agent = false
multi_agent_v2 = false
memories = false
external_agent_memory_import = false
"""
# A provider of our own, not the built-in `openai`: codex reserves built-in provider ids,
# and env_http_headers — the only place codex can be told to send the call-origin header —
# is a per-provider setting. base_url has to live in the table with it (a provider without
# one silently falls back to api.openai.com), so harness_refresh_config_keys refreshes
# [model_providers.*] keys as well as root ones.
container_config = """
model_provider = "llm-proxy"
[model_providers.llm-proxy]
name = "LLM proxy"
base_url = "${OPENAI_BASE_URL}"
# A custom provider reads its key from this env var and never from auth.json, so it
# names the one key .env actually carries. That makes the key live per launch rather
# than baked at container create — better than the auth-file path it replaces.
env_key = "ANTHROPIC_API_KEY"
wire_api = "responses"
env_http_headers = { "X-Surge-Client-Metadata" = "LLM_CALL_METADATA" }
"""
explore_config = """
[hooks]
SessionStart = [ { hooks = [ { type = "command", command = "/workspace/plugins/create-snapshot/bin/save-session-info.mjs" } ] } ]
UserPromptSubmit = [ { hooks = [ { type = "command", command = "/workspace/plugins/create-snapshot/bin/checkpoint-workspace.mjs" } ] } ]
"""
explore_launch = """exec codex $RACCOON_AGENT_FLAGS --model $RACCOON_MODEL -c model_reasoning_effort=$RACCOON_EFFORT ${RACCOON_BROWSER_FLAGS[@]+"${RACCOON_BROWSER_FLAGS[@]}"} --dangerously-bypass-approvals-and-sandbox --dangerously-bypass-hook-trust "$@""""
[[harness]]
id = "gemini-cli"
label = "Gemini CLI"
agent_import_path = "gemini_agent:NativeSnapshotGeminiCli"
agent_import_path_single_turn = "gemini_agent:SystemNodeGeminiCli"
import_path_aliases = ["harbor.agents.installed.gemini_cli:GeminiCli"]
legacy_bare_model_rows = true
default_model = "gemini-3.5-flash"
model_id_shape = "provider/model"
effort_kwarg = "reasoning_effort"
effort_default = "high"
key_env = "GEMINI_API_KEY"
base_url_env = "GEMINI_API_BASE"
proxy_path = "gemini"
writes_atif = true
capture = false
seed_native = true
seed_atif = false
[[harness]]
id = "antigravity-cli"
label = "Antigravity CLI"
agent_import_path = "harness_agents:BenchAntigravity"
import_path_aliases = ["harbor.agents.installed.antigravity_cli:AntigravityCli"]
legacy_bare_model_rows = false
# The prefix is load-bearing: harbor's adapter raises without a "/" in the id.
# agy carries its own model catalogue and DROPS entries between point releases
# (1.1.25 removed gemini-3.5-flash, breaking every run). If trials start failing
# with "not recognized as a known model", run `agy --model bogus --prompt=x` to
# print the current catalogue and update this.
default_model = "google/gemini-3.8-flash"
model_id_shape = "provider/model"
# Not optional: agy refuses a Gemini 3 model with no --effort ("requires --effort
# (available: low, medium, high)"). low/high are safe on pro and flash alike.
effort_kwarg = "reasoning_effort"
effort_default = "high"
key_env = "GEMINI_API_KEY"
base_url_env = "GOOGLE_GEMINI_BASE_URL"
proxy_path = "gemini"
writes_atif = true
capture = false
# agy cannot be handed externally-produced history, so multi-turn tasks must
# hard-fail rather than silently run cold. See work-logs/antigravity-harness.md.
seed_native = false
seed_atif = false
[[harness]]
id = "opencode"
label = "OpenCode"
agent_import_path = "harness_agents:BenchOpenCode"
legacy_bare_model_rows = false
model_id_shape = "provider/model"
effort_kwarg = ""
key_env = "ANTHROPIC_API_KEY"
base_url_env = "ANTHROPIC_BASE_URL"
proxy_path = "anthropic"
writes_atif = true
capture = false
seed_native = false
seed_atif = false
flaky_hangs = true
[[harness]]
id = "goose"
label = "Goose"
agent_import_path = "harness_agents:BenchGoose"
legacy_bare_model_rows = false
model_id_shape = "provider/model"
effort_kwarg = ""
key_env = "ANTHROPIC_API_KEY"
base_url_env = "ANTHROPIC_BASE_URL"
proxy_path = "anthropic"
writes_atif = true
capture = false
seed_native = false
seed_atif = false
[[harness]]
id = "mini-swe-agent"
label = "mini-swe-agent"
agent_import_path = "harness_agents:BenchMiniSweAgent"
legacy_bare_model_rows = false
model_id_shape = "provider/model"
effort_kwarg = ""
key_env = "ANTHROPIC_API_KEY"
base_url_env = "ANTHROPIC_BASE_URL"
proxy_path = "anthropic"
writes_atif = true
capture = false
seed_native = false
seed_atif = false
[[harness]]
id = "cline-cli"
label = "Cline CLI"
agent_import_path = "harness_agents:BenchCline"
legacy_bare_model_rows = false
model_id_shape = "provider:model"
effort_kwarg = ""
key_env = "ANTHROPIC_API_KEY"
base_url_env = "ANTHROPIC_BASE_URL"
proxy_path = "anthropic"
writes_atif = true
capture = false
seed_native = false
seed_atif = false
[[harness]]
id = "crush"
label = "Crush"
agent_import_path = "harness_agents:Crush"
legacy_bare_model_rows = false
model_id_shape = "provider/model"
effort_kwarg = ""
key_env = "ANTHROPIC_API_KEY"
base_url_env = "ANTHROPIC_BASE_URL"
proxy_path = "anthropic"
writes_atif = true
capture = false
seed_native = false
seed_atif = false
flaky_hangs = true
[[harness]]
id = "amp"
label = "Amp"
agent_import_path = "harness_agents:Amp"
legacy_bare_model_rows = false
model_id_shape = "bare"
effort_kwarg = ""
key_env = "AMP_API_KEY"
writes_atif = true
capture = false
seed_native = false
seed_atif = false
enabled = false
[[harness]]
id = "cursor-cli"
label = "Cursor CLI"
agent_import_path = "harness_agents:BenchCursorCli"
legacy_bare_model_rows = false
model_id_shape = "bare"
effort_kwarg = ""
key_env = "CURSOR_API_KEY"
writes_atif = true
capture = false
seed_native = false
seed_atif = false
enabled = false
[[harness]]
id = "copilot-cli"
label = "GitHub Copilot CLI"
agent_import_path = "harness_agents:BenchCopilotCli"
legacy_bare_model_rows = false
model_id_shape = "bare"
effort_kwarg = ""
key_env = "GITHUB_TOKEN"
writes_atif = true
capture = false
seed_native = false
seed_atif = false
enabled = false
[[harness]]
id = "aider"
label = "Aider"
agent_import_path = "harness_agents:BenchAider"
legacy_bare_model_rows = false
model_id_shape = "provider/model"
effort_kwarg = ""
writes_atif = false
capture = false
seed_native = false
seed_atif = false
enabled = false

View File

@@ -0,0 +1,328 @@
#!/bin/bash
# Read the harness registry and derive per-harness credentials from it.
#
# Source it — the whole point is exporting into the caller's environment, which a subshell
# would lose:
#
# HARNESS_SCRIPTS_DIR=/workspace/scripts . /workspace/scripts/lib/harness-credentials.sh
# harness_setup_credentials
#
# Three callers: `harbor-run`, which needs only this; `refresh-harness-auth`, which
# re-derives and rewrites the auth files before an interactive launch; and
# `setup-harnesses.sh`, which sources it and adds installs, config writing and launchers
# on top.
#
# No -e here — this file is SOURCED, and shell options belong to the caller's shell (both
# post-creates run with -e). An unguarded failure below therefore aborts container
# creation, which is why every failure site is individually guarded rather than relying on
# this line.
set -uo pipefail
_HARNESS_REGISTRY_DIR="${HARNESS_SCRIPTS_DIR:-/workspace/scripts}"
# The registry is read with tomllib (stdlib from 3.11), and `python3` is not always new
# enough — macOS ships 3.9, and a container may symlink an older managed interpreter. Pick
# the first one that can actually import it rather than assuming.
_raccoon_python() {
local p
for p in "${RACCOON_PYTHON:-}" python3 python3.13 python3.12 python3.11; do
[ -n "$p" ] || continue
command -v "$p" >/dev/null 2>&1 || continue
if "$p" -c "import tomllib" >/dev/null 2>&1; then
printf '%s' "$p"
return 0
fi
done
return 1
}
_harness_query() {
local py
py=$(_raccoon_python) || return 1
"$py" "$_HARNESS_REGISTRY_DIR/resolve_harness.py" "$@"
}
# Drop every whitespace character from a value read out of .env. A Windows-saved .env leaves a
# \r on each value, which reaches the proxy as a 401; no key or base URL legitimately contains
# whitespace anywhere, so deleting rather than trimming needs no cases.
_harness_trim() {
local out
# Fall back to the raw value: a trim that cannot run must never turn a working key into an
# empty one, which is what an unavailable `tr` would otherwise do to every caller.
out="$(printf '%s' "$1" | tr -d '[:space:]' 2>/dev/null)" || out="$1"
printf '%s' "${out:-$1}"
}
# The proxy root: the worker's ANTHROPIC_BASE_URL minus its provider path.
_harness_proxy_root() {
local base_url
base_url="$(_harness_trim "${ANTHROPIC_BASE_URL:-}")"
[ -n "$base_url" ] || return 1
base_url="${base_url%"${base_url##*[!/]}"}"
# ".../llm_proxy/projects/<id>/anthropic" -> ".../llm_proxy/projects/<id>", so each
# harness's proxy_path composes onto the project route. Requires a path to strip: a base
# URL that is a bare host with no path — a provider's own API root rather than the
# proxy — would yield "https:/", handed to codex as a base URL and failing obscurely.
case "${base_url#*://}" in
*/*) printf '%s' "${base_url%/*}" ;;
*) return 2 ;;
esac
}
harness_setup_credentials() {
# `|| rc=$?` and not a bare assignment: this is sourced into a `set -e` shell (see the
# note at the top), and a bare failing assignment would exit the caller's post-create
# outright — silently, since the failure paths below are what do the explaining.
local root rc=0
root="$(_harness_proxy_root)" || rc=$?
if [ "$rc" -ne 0 ]; then
if [ "$rc" -eq 2 ]; then
echo "harness-setup: ANTHROPIC_BASE_URL (${ANTHROPIC_BASE_URL:-}) has no provider" >&2
echo "harness-setup: path, so it is not the proxy URL other harnesses derive their" >&2
echo "harness-setup: credentials from. claude will work; codex will not be" >&2
echo "harness-setup: authenticated. Use the base URL you were given." >&2
else
echo "harness-setup: ANTHROPIC_BASE_URL unset — skipping credential derivation" >&2
fi
return 0
fi
ANTHROPIC_BASE_URL="$(_harness_trim "${ANTHROPIC_BASE_URL:-}")"
export ANTHROPIC_BASE_URL
local key
key="$(_harness_trim "${ANTHROPIC_API_KEY:-}")"
if [ -z "$key" ]; then
echo "harness-setup: ANTHROPIC_API_KEY unset — skipping credential derivation" >&2
return 0
fi
# harbor-run sources .env itself and passes ANTHROPIC_* through to the trial sandbox, so
# cleaning only the derived per-harness copies would leave a claude trial carrying the CR.
export ANTHROPIC_API_KEY="$key"
local id key_env base_url_env proxy_path
while IFS=$'\t' read -r id key_env base_url_env proxy_path; do
[ -n "$key_env" ] || continue
# ${!name} is an indirect expansion. Only set when empty: an explicit key wins.
if [ -z "${!key_env:-}" ]; then
export "$key_env=$key"
fi
if [ -n "$base_url_env" ] && [ -n "$proxy_path" ] && [ -z "${!base_url_env:-}" ]; then
export "$base_url_env=$root/$proxy_path"
fi
echo "harness-setup: $id credentials ready ($key_env, ${base_url_env:-no base url})" >&2
done < <(_harness_query --authoring-credentials 2>/dev/null || true)
}
# Write the auth file for harnesses that read credentials from disk rather than $ENV.
harness_write_auth() {
local id auth_path key_env target key py
py=$(_raccoon_python) || {
echo "harness-setup: no python3.11+ with tomllib — skipping auth files" >&2
return 0
}
while IFS=$'\t' read -r id auth_path key_env; do
[ -n "$auth_path" ] && [ -n "$key_env" ] || continue
# Last mile: an explicit OPENAI_API_KEY bypasses the derivation above, so trim here
# too — this is the value that reaches the file the harness authenticates with.
key="$(_harness_trim "${!key_env:-}")"
if [ -z "$key" ]; then
echo "harness-setup: $key_env unset — skipping $id auth file" >&2
continue
fi
target=$(eval "printf '%s' \"$auth_path\"") || {
echo "harness-setup: WARNING $id auth_path could not be expanded — skipping" >&2
continue
}
mkdir -p "$(dirname "$target")" || {
echo "harness-setup: WARNING $id auth dir not creatable — skipping $target" >&2
continue
}
# json.dumps, not printf: a key containing a quote or backslash would otherwise
# produce a file the CLI cannot parse, and the failure would surface as an auth
# error rather than a malformed file.
# 0600 tmp + rename, never a redirect onto the target: a redirect truncates the live
# file first, so a write dying mid-flight leaves codex an EMPTY auth.json.
if ! RACCOON_AUTH_K="$key_env" RACCOON_AUTH_V="$key" RACCOON_AUTH_TARGET="$target" \
"$py" -c 'import json, os
target = os.environ["RACCOON_AUTH_TARGET"]
tmp = target + ".raccoon-tmp." + str(os.getpid())
try:
with os.fdopen(os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600), "w") as fh:
json.dump({os.environ["RACCOON_AUTH_K"]: os.environ["RACCOON_AUTH_V"]}, fh)
fh.write("\n")
os.replace(tmp, target)
except OSError:
try:
os.unlink(tmp)
except OSError:
pass
raise SystemExit(1)
'; then
echo "harness-setup: WARNING $id auth file NOT written — $target unwritable." >&2
echo "harness-setup: the key already on disk (if any) is left untouched." >&2
continue
fi
echo "harness-setup: $id auth -> $target" >&2
done < <(_harness_query --auth-files 2>/dev/null || true)
}
# Re-set just the root keys of a harness's config file (codex's `openai_base_url`),
# leaving every other line — the explore surface's [hooks] table included — untouched.
harness_refresh_config_keys() {
local id config_path blob target py
py=$(_raccoon_python) || return 0
# The surface only decides what a CREATE writes. An update takes the root keys off the
# front of the same blob, so a surface's tables survive byte-for-byte either way.
while IFS=$'\t' read -r id config_path blob; do
[ -n "$config_path" ] && [ -n "$blob" ] || continue
target=$(eval "printf '%s' \"$config_path\"") || continue
mkdir -p "$(dirname "$target")" || continue
if printf '%s' "$blob" | base64 -d |
RACCOON_CONFIG_TARGET="$target" "$py" -c '
import os, re, sys, tomllib
HEADER = "# Generated from harness-registry.toml — edits here are overwritten."
target = os.environ["RACCOON_CONFIG_TARGET"]
text = sys.stdin.read()
# Empty counts as unresolved: writing an empty base URL would break a container whose
# config is currently right, which is the one thing this must never do.
if [m for m in re.finditer(r"\$\{(\w+)\}", text) if not os.environ.get(m.group(1))]:
raise SystemExit(1)
text = os.path.expandvars(text)
# Root keys, plus keys inside a [model_providers.*] table: codex reserves its built-in
# provider ids, so the proxy URL it must follow lives in a provider table, not at the
# root. Every other table, [hooks] on the explore surface included, is left alone.
REFRESHABLE_TABLE = re.compile(r"\[model_providers\.[^]]+\]$")
wanted = []
section = None
for line in text.splitlines():
stripped = line.strip()
if stripped.startswith("["):
section = stripped if REFRESHABLE_TABLE.match(stripped) else False
continue
if section is False:
continue
m = re.match(r"\s*\"?([A-Za-z0-9_.-]+)\"?\s*=", line)
if m:
wanted.append((section, m.group(1), line.rstrip()))
if not wanted:
raise SystemExit(0)
def section_path(header):
"""[model_providers.llm-proxy] -> ("model_providers", "llm-proxy")."""
return tuple(header.strip("[]").split("."))
def lookup(doc, header, key):
"""The value a parsed config holds for a wanted key, or KeyError."""
node = doc
if header:
for part in section_path(header):
node = node[part]
return node[key]
mode = None
if os.path.exists(target):
try:
with open(target, encoding="utf-8") as fh:
lines = fh.read().splitlines()
mode = os.stat(target).st_mode & 0o777
except OSError:
raise SystemExit(1)
def span(header):
"""The line range a section owns, or None when the file has no such section.
Root is everything above the first table header: a key appended below one
would be reparented into it, so searches and inserts stay inside the span.
"""
heads = [i for i, l in enumerate(lines) if l.lstrip().startswith("[")]
if header is None:
return 0, (heads[0] if heads else len(lines))
at = next((i for i in heads if lines[i].strip() == header), None)
if at is None:
return None
after = next((i for i in heads if i > at), len(lines))
return at + 1, after
# Grouped, root first, so a section this file lacks can be written whole.
grouped = {}
for header, key, line in wanted:
grouped.setdefault(header, []).append((key, line))
ordered = sorted(grouped, key=lambda h: (h is not None, h or ""))
changed = False
for header in ordered:
if span(header) is None:
# A config written before this section existed. Write the whole table
# rather than leave a root key naming a provider that is not there.
if lines and lines[-1].strip():
lines.append("")
lines.append(header)
lines.extend(line for _, line in grouped[header])
changed = True
continue
for key, line in grouped[header]:
# Re-read the span: an insert for an earlier key moved it.
start, end = span(header)
# The quoted spelling is the same key: replace rather than duplicate.
pat = re.compile(r"\s*\"?" + re.escape(key) + r"\"?\s*=")
at = next((i for i in range(start, end) if pat.match(lines[i])), None)
if at is None:
if end < len(lines) and lines[end].strip():
lines.insert(end, "")
lines.insert(end, line)
changed = True
elif lines[at] != line:
lines[at] = line
changed = True
if not changed:
raise SystemExit(0)
out = "\n".join(lines).rstrip("\n") + "\n"
else:
# No file means container-create could not write one, so write what it would have:
# on the explore surface that is the capture hooks too, not just the root keys.
out = HEADER + "\n" + text
try:
doc = tomllib.loads(out)
except tomllib.TOMLDecodeError:
raise SystemExit(1)
# Parsing is not enough: a line edit can land inside a multi-line value, which still
# parses while leaving the key unset. Require every key to have landed on the value the
# blob asks for, in its own section — skipping sections this file does not carry.
blob_doc = tomllib.loads(text)
for header, key, _ in wanted:
try:
expected = lookup(blob_doc, header, key)
except (KeyError, TypeError):
raise SystemExit(1)
try:
got = lookup(doc, header, key)
except (KeyError, TypeError):
if header is None:
raise SystemExit(1)
continue
if got != expected:
raise SystemExit(1)
# Pid-suffixed: two launches at once must not write the same scratch path.
tmp = target + ".raccoon-tmp." + str(os.getpid())
try:
with open(tmp, "w", encoding="utf-8") as fh:
fh.write(out)
if mode is not None:
os.chmod(tmp, mode)
os.replace(tmp, target)
except OSError:
try:
os.unlink(tmp)
except OSError:
pass
raise SystemExit(1)
'; then
echo "harness-setup: $id config keys refreshed -> $target" >&2
fi
done < <(_harness_query --container-configs --surface "${RACCOON_SURFACE:-authoring}" 2>/dev/null || true)
}

View File

@@ -0,0 +1,418 @@
"""harness_registry.py — Python loader for ``scripts/harness-registry.toml``.
The ONE loader for the registry: TS callers shell into ``resolve_harness.py`` rather than
parse the TOML themselves, which is why the toolkit ships no TOML parser for TS (its
package.json has no zod/smol-toml).
This module supersedes ``benchmark_models_lib``'s ``HARNESS_BY_IMPORT_PATH`` and
``LEGACY_BARE_MODEL_AGENTS``; those should read from here rather than keep private
copies.
Harbor-free and dependency-free (stdlib ``tomllib``) so it can be imported from a
sandbox agent, a plain unit test, or the devcontainer python alike.
"""
from __future__ import annotations
import tomllib
from dataclasses import dataclass, field
from pathlib import Path
REGISTRY_PATH = Path(__file__).resolve().parent.parent / "harness-registry.toml"
MODEL_ID_SHAPES = frozenset({"bare", "provider/model", "provider:model"})
@dataclass(frozen=True)
class Harness:
"""One harness, as declared in harness-registry.toml."""
id: str
label: str
agent_import_path: str
model_id_shape: str
writes_atif: bool
capture: bool
seed_native: bool
seed_atif: bool
agent_import_path_single_turn: str | None = None
# Browser-opt-in variants (`[metadata] browser = true`). A harness that has no variant
# keeps its normal class: codex, for instance, gains the browser and its disclosure but
# has no `Read` equivalent to switch toolsets for.
agent_import_path_browser: str | None = None
agent_import_path_single_turn_browser: str | None = None
import_path_aliases: tuple[str, ...] = ()
legacy_bare_model_rows: bool = False
default_model: str | None = None
effort_kwarg: str = ""
effort_default: str | None = None
# Agent kwarg that opts a trial into the harness's fast/priority serving mode
# (claude-code: fast mode). Empty means the harness has none and --fast refuses.
fast_kwarg: str = ""
key_env: str | None = None
base_url_env: str | None = None
proxy_path: str | None = None
flaky_hangs: bool = False
enabled: bool = True
# Worker-container fields; see the registry header.
authoring: bool = False
cli: str | None = None
install: str | None = None
skills_dir: str | None = None
auth_path: str | None = None
auth_key_env: str | None = None
explore_launch: str | None = None
config_path: str | None = None
# Config the harness needs wherever it runs, trial sandbox included.
agent_config: str | None = None
# Config for both worker containers (explore and authoring).
container_config: str | None = None
# Config for the EXPLORE container only — the capture hooks, whose commands ship in
# explore/plugins/. Writing them in authoring would register hooks against files that
# are not there, firing on every prompt.
explore_config: str | None = None
# Fields added for a later phase, kept verbatim so this loader doesn't have to
# be edited in lockstep with the schema.
extra: dict = field(default_factory=dict, compare=False)
def agent_import_path_for(self, *, multi_turn: bool, browser: bool = False) -> str:
"""Agent class to launch. Multi-turn tasks need the resuming class; a
single-turn task given it would try to resume a session that isn't there.
``browser`` selects the opt-in variant, which for claude also carries the ``Read``
built-in — a different toolset is a different agent, so it is a different class with
its own name rather than a flag on the canonical one. Harnesses without a variant fall
through to their normal class."""
if browser:
variant = (
self.agent_import_path_browser
if multi_turn
else (self.agent_import_path_single_turn_browser or self.agent_import_path_browser)
)
if variant:
return variant
if multi_turn:
return self.agent_import_path
return self.agent_import_path_single_turn or self.agent_import_path
def row_label(self, model: str) -> str:
"""Row identity for one trial: bare model for legacy harnesses (so
published manifests keep their labels), else ``<harness>:<model>``."""
return model if self.legacy_bare_model_rows else f"{self.id}:{model}"
def agent_config_overrides(self) -> dict[str, str]:
"""``agent_config`` as flat ``dotted.key -> value`` pairs in CLI-override form.
Values are rendered bare — ``disabled``, not ``"disabled"``. Every consumer
interpolates these into a shell command, which would strip the quotes anyway;
emitting them would only make the result depend on how many shell layers the
string crosses. Bare is what the CLIs document (``-c model="o3"`` reaches the
binary as ``model=o3``).
These settings ride the command line as ``-c dotted.key=value`` everywhere the
harness runs, never a config file. A trial sandbox rules the file out: the
harness's own runner appends root keys to it, and TOML has no way back to the
root scope once a table has opened, so a table we appended would swallow them.
Overrides compose in any order and beat the file, so the same rendering serves
the explore launcher too — one declaration, one mechanism.
"""
if not self.agent_config:
return {}
try:
parsed = tomllib.loads(self.agent_config)
except tomllib.TOMLDecodeError as exc:
raise HarnessRegistryError(
f"{self.id}: agent_config is not valid TOML ({exc})"
) from exc
flat: dict[str, str] = {}
def walk(node: dict, prefix: str) -> None:
for key, value in node.items():
path = f"{prefix}{key}"
if isinstance(value, dict):
walk(value, f"{path}.")
elif isinstance(value, bool):
flat[path] = "true" if value else "false"
elif isinstance(value, (int, float)):
flat[path] = str(value)
elif isinstance(value, str):
if value != value.strip() or any(c in value for c in " \"'\\"):
raise HarnessRegistryError(
f"{self.id}: agent_config key {path!r} has a value needing "
"shell quoting, which the -c override form cannot carry"
)
flat[path] = value
else:
raise HarnessRegistryError(
f"{self.id}: agent_config key {path!r} has type "
f"{type(value).__name__}, which has no -c override form"
)
walk(parsed, "")
return flat
def container_config_text(self, *, surface: str) -> str | None:
"""Config file body for a worker container. `surface` is "explore" or
"authoring"; explore additionally gets `explore_config`. Root keys come from
`container_config` first, so appending a table section stays valid TOML."""
parts = [self.container_config]
if surface == "explore":
parts.append(self.explore_config)
kept = [part.strip("\n") for part in parts if part and part.strip()]
return "\n\n".join(kept) + "\n" if kept else None
def agent_config_flags(self) -> str:
"""``agent_config`` as a ``-c key=value`` command-line string."""
return " ".join(
f"-c {key}={value}"
for key, value in sorted(self.agent_config_overrides().items())
)
def explore_launch_command(self) -> str | None:
"""``explore_launch`` with the registry's own values substituted in.
The worker's Explore session and the trial must run the same agent, so the
model, effort and reductions are declared once here and rendered into both.
A literal in the launch string would be a second declaration, and the two
would drift the first time one of them was updated alone.
Only these three placeholders are substituted; ``$@`` and
``$RACCOON_TOOLSET_NOTE`` stay for the launcher's own shell to expand.
"""
if not self.explore_launch:
return None
return (
self.explore_launch.replace("$RACCOON_AGENT_FLAGS", self.agent_config_flags())
.replace("$RACCOON_MODEL", self.default_model or "")
.replace("$RACCOON_EFFORT", self.effort_default or "")
)
def known_import_paths(self) -> tuple[str, ...]:
paths = [self.agent_import_path, *self.import_path_aliases]
if self.agent_import_path_single_turn:
paths.append(self.agent_import_path_single_turn)
return tuple(paths)
_KNOWN_FIELDS = frozenset(
{
"id",
"label",
"agent_import_path",
"agent_import_path_single_turn",
"agent_import_path_browser",
"agent_import_path_single_turn_browser",
"import_path_aliases",
"legacy_bare_model_rows",
"default_model",
"model_id_shape",
"effort_kwarg",
"effort_default",
"fast_kwarg",
"key_env",
"base_url_env",
"proxy_path",
"writes_atif",
"capture",
"seed_native",
"seed_atif",
"flaky_hangs",
"enabled",
"authoring",
"cli",
"install",
"skills_dir",
"auth_path",
"auth_key_env",
"explore_launch",
"config_path",
"agent_config",
"container_config",
"explore_config",
}
)
_REQUIRED_FIELDS = (
"id",
"label",
"agent_import_path",
"model_id_shape",
"writes_atif",
"capture",
"seed_native",
"seed_atif",
)
class HarnessRegistryError(ValueError):
"""Malformed registry. Raised rather than tolerated: a broken registry is a
broken deployment, and silently defaulting would pick the wrong agent."""
def _references_agent_flags(launch: str) -> bool:
return "$RACCOON_AGENT_FLAGS" in launch or "${RACCOON_AGENT_FLAGS}" in launch
@dataclass(frozen=True)
class HarnessRegistry:
version: int
harnesses: tuple[Harness, ...]
def all(self) -> tuple[Harness, ...]:
return self.harnesses
def enabled(self) -> tuple[Harness, ...]:
return tuple(h for h in self.harnesses if h.enabled)
def authoring(self) -> tuple[Harness, ...]:
"""Harnesses a worker can author with — what the worker containers install.
Narrower than enabled(): a harness can be runnable in a trial without having
an authoring story (no CLI to converse with, or no capture)."""
return tuple(h for h in self.harnesses if h.enabled and h.authoring)
def find(self, harness_id: str) -> Harness | None:
return next((h for h in self.harnesses if h.id == harness_id), None)
def require(self, harness_id: str) -> Harness:
harness = self.find(harness_id)
if harness is not None:
return harness
available = ", ".join(sorted(h.id for h in self.enabled()))
raise HarnessRegistryError(
f'Unknown harness "{harness_id}". Available: {available}'
)
def by_import_path(self, agent: str) -> Harness | None:
"""Resolve an agent identity — a ``name()`` or import path from
``result.json`` ``config.agent``, or a manifest row — to its harness."""
needle = (agent or "").strip()
if not needle:
return None
for harness in self.harnesses:
if needle == harness.id or needle in harness.known_import_paths():
return harness
return None
def _build(entry: dict, index: int) -> Harness:
for name in _REQUIRED_FIELDS:
if name not in entry:
raise HarnessRegistryError(
f"harness[{index}]: missing required field '{name}'"
)
shape = entry["model_id_shape"]
if shape not in MODEL_ID_SHAPES:
raise HarnessRegistryError(
f"harness[{index}] ({entry['id']}): model_id_shape {shape!r} not one of "
f"{sorted(MODEL_ID_SHAPES)}"
)
# These three reach `eval` in setup-harnesses.sh, which is how they support the
# `${CODEX_HOME:-$HOME/.codex}` default-value syntax that python's expandvars cannot
# express. Under eval a backtick or $( would EXECUTE, so refuse them here — the registry
# is ours, but "ours" is not an argument that survives a careless future edit.
for shell_field in ("config_path", "auth_path", "skills_dir"):
value = entry.get(shell_field)
if not isinstance(value, str):
continue
# A backtick or $( executes outright. A double quote closes the string these are
# interpolated into, and a semicolon then starts a new command inside it — same
# outcome, one step removed.
bad = [t for t in ("`", "$(", '"', ";") if t in value]
if bad:
raise HarnessRegistryError(
f"harness[{index}] ({entry['id']}): {shell_field} contains "
f"{', '.join(repr(t) for t in bad)} ({value!r}). This value is shell-"
f"expanded, so that would execute; use plain $VAR or ${{VAR:-default}} only."
)
launch = entry.get("explore_launch")
if entry.get("agent_config") and launch and not _references_agent_flags(launch):
raise HarnessRegistryError(
f"harness[{index}] ({entry['id']}): declares agent_config but its "
"explore_launch does not pass $RACCOON_AGENT_FLAGS. The worker's session "
"would then run with a different toolset than the trial it is authoring "
"for, which is the drift agent_config exists to prevent."
)
return Harness(
id=entry["id"],
label=entry["label"],
agent_import_path=entry["agent_import_path"],
agent_import_path_single_turn=entry.get("agent_import_path_single_turn"),
agent_import_path_browser=entry.get("agent_import_path_browser"),
agent_import_path_single_turn_browser=entry.get("agent_import_path_single_turn_browser"),
import_path_aliases=tuple(entry.get("import_path_aliases", ())),
legacy_bare_model_rows=bool(entry.get("legacy_bare_model_rows", False)),
default_model=entry.get("default_model"),
model_id_shape=shape,
effort_kwarg=entry.get("effort_kwarg", ""),
effort_default=entry.get("effort_default"),
fast_kwarg=entry.get("fast_kwarg", ""),
key_env=entry.get("key_env"),
base_url_env=entry.get("base_url_env"),
proxy_path=entry.get("proxy_path"),
writes_atif=bool(entry["writes_atif"]),
capture=bool(entry["capture"]),
seed_native=bool(entry["seed_native"]),
seed_atif=bool(entry["seed_atif"]),
flaky_hangs=bool(entry.get("flaky_hangs", False)),
enabled=bool(entry.get("enabled", True)),
authoring=bool(entry.get("authoring", False)),
cli=entry.get("cli"),
install=entry.get("install"),
skills_dir=entry.get("skills_dir"),
auth_path=entry.get("auth_path"),
auth_key_env=entry.get("auth_key_env"),
explore_launch=entry.get("explore_launch"),
config_path=entry.get("config_path"),
agent_config=entry.get("agent_config"),
container_config=entry.get("container_config"),
explore_config=entry.get("explore_config"),
extra={k: v for k, v in entry.items() if k not in _KNOWN_FIELDS},
)
_cache: dict[Path, HarnessRegistry] = {}
def load_harness_registry(path: Path | str = REGISTRY_PATH) -> HarnessRegistry:
"""Parse and validate the registry. Raises HarnessRegistryError on a malformed
file, a duplicate id, or an import path claimed by two harnesses (which would
make ``by_import_path`` depend on declaration order)."""
resolved = Path(path).resolve()
if resolved in _cache:
return _cache[resolved]
with open(resolved, "rb") as handle:
doc = tomllib.load(handle)
if "version" not in doc:
raise HarnessRegistryError("harness-registry: missing 'version'")
entries = doc.get("harness") or []
if not entries:
raise HarnessRegistryError("harness-registry: no [[harness]] entries")
harnesses = tuple(_build(entry, i) for i, entry in enumerate(entries))
seen_ids: set[str] = set()
for harness in harnesses:
if harness.id in seen_ids:
raise HarnessRegistryError(
f"harness-registry: duplicate harness id: {harness.id}"
)
seen_ids.add(harness.id)
owners: dict[str, str] = {}
for harness in harnesses:
for import_path in harness.known_import_paths():
owner = owners.get(import_path)
if owner is not None and owner != harness.id:
raise HarnessRegistryError(
f'harness-registry: import path "{import_path}" claimed by both '
f'"{owner}" and "{harness.id}"'
)
owners[import_path] = harness.id
registry = HarnessRegistry(version=int(doc["version"]), harnesses=harnesses)
_cache[resolved] = registry
return registry

View File

@@ -0,0 +1,54 @@
#!/bin/bash
# Rewrite the auth FILES harnesses read their key from — and the base URL beside them —
# off the live .env, then exec "$@".
#
# codex reads its key from ${CODEX_HOME:-$HOME/.codex}/auth.json, which container-create
# wrote once from the .env of that moment — so a key rotated afterwards never reached it
# and needed a rebuild. claude needs none of this: it has an apiKeyHelper that re-reads
# .env per request. Interactive launches route through here so each one re-derives first.
#
# The base URL never rotates, so the case that matters is the one where container-create
# could not derive it at all (no .env yet) and wrote no config: the key then refreshes
# fine while codex still has no proxy URL and talks to the provider directly.
#
# Trials are unaffected either way: harbor-run re-derives OPENAI_API_KEY per invocation
# and harbor's codex agent authenticates the sandbox from that env var, not from this file.
set -uo pipefail
_scripts_dir="${HARNESS_SCRIPTS_DIR:-/workspace/scripts}"
# Subshell, and every failure swallowed: a refresh that cannot run must never stop the
# agent from starting. The auth file already on disk is the PREVIOUS key, not nothing, so
# failing open leaves the worker exactly where they were before this wrapper existed.
(
set -a
# shellcheck disable=SC1090
. "${RACCOON_ENV_FILE:-/workspace/.env}" 2>/dev/null || true
set +a
# shellcheck disable=SC1091
HARNESS_SCRIPTS_DIR="$_scripts_dir" . "$_scripts_dir/lib/harness-credentials.sh" || exit 0
harness_setup_credentials
harness_write_auth
harness_refresh_config_keys
) >/dev/null 2>&1 || true
# No args is a valid call: refresh only, for a lifecycle hook.
[ "$#" -gt 0 ] || exit 0
# Outside the subshell, because these have to reach the exec'd command: codex now reads
# its key from $ANTHROPIC_API_KEY per request, and a non-login shell sourced neither
# .bashrc (the key, the call origin) nor the profile that puts the CLI on PATH.
# Failures stay swallowed — an unreadable .env must not stop the agent starting.
export PATH="$HOME/.local/bin:$PATH"
if [ -f "${RACCOON_ENV_FILE:-/workspace/.env}" ]; then
set -a
# shellcheck disable=SC1090
. "${RACCOON_ENV_FILE:-/workspace/.env}" 2>/dev/null || true
set +a
fi
if [ -f "$HOME/.raccoon-call-origin" ]; then
# shellcheck disable=SC1091
. "$HOME/.raccoon-call-origin" 2>/dev/null || true
fi
exec "$@"

View File

@@ -0,0 +1,459 @@
#!/usr/bin/env python3
"""resolve_harness.py — turn a harness id + task dir into the flags a trial needs.
``scripts/harbor-run`` is bash and cannot parse the TOML registry, so it shells in
here and evals the result::
RESOLVED="$(python3 scripts/resolve_harness.py --task-dir "$TASK_DIR")" || exit 1
eval "$RESOLVED"
Python rather than TS on purpose: this ships in the worker toolkit, whose
package.json has no ``zod``/``smol-toml``, and ``tomllib`` is stdlib — so the
toolkit gains a harness-aware harbor-run with zero new dependencies. There is no TS
loader: TS callers (submit-task) shell in here, so both the schema and the selection
policy exist exactly once and there is nothing to drift.
Output is POSIX ``KEY='value'`` assignments (single-quoted, embedded quotes
escaped) on stdout; everything human-facing goes to stderr, so the eval only ever
sees assignments. A non-zero exit means "do not launch" — the point is to fail in a
second rather than burn agent minutes on a trial that cannot produce a usable grade.
Refuses to resolve when:
- the harness id is unknown or disabled
- the harness writes no ATIF trajectory (the grader would have no transcript)
- the task ships a session to resume but the harness cannot resume one. This is
the important one: it is the only failure here that would otherwise look like
SUCCESS, with the agent answering a prompt whose conversation it never saw.
- the harness's credential env var is unset
``--check-model`` additionally asks the proxy whether the model is granted. Opt-in
on purpose: it is a network call, and one in every run's critical path trades a fast
local failure for a new way to hang. The credential check, which is free, always runs.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import shlex
import sys
import tomllib
import urllib.error
import urllib.request
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent / "lib"))
from harness_registry import ( # noqa: E402
Harness,
HarnessRegistryError,
load_harness_registry,
)
# Harness used when nothing selects one. Keeps every existing caller on today's
# behaviour, so adding harness selection changes no current run.
DEFAULT_HARNESS = "claude-code"
MODELS_TIMEOUT_SEC = 20
def warn(message: str) -> None:
print(f"resolve-harness: {message}", file=sys.stderr)
def fail(message: str) -> "None":
print(f"resolve-harness: ERROR: {message}", file=sys.stderr)
raise SystemExit(1)
def is_multi_turn(task_dir: str | None) -> bool:
"""A task is multi-turn when it ships a NON-EMPTY session to resume. Empty is
the documented one-shot-snapshot fallback and must run cold, so size is the
test, not existence."""
if not task_dir:
return False
session = Path(task_dir) / "environment" / "session.jsonl"
return session.is_file() and session.stat().st_size > 0
def wants_browser(task_dir: str | None) -> bool:
"""True when task.toml opts into a browser (`[metadata] browser = true`).
Read straight from the file rather than via tomllib: this must agree with
build-workspace.sh, which decides whether the IMAGE gets Playwright using the same
text match. If the two ever disagree the agent is told about a browser the image
lacks, which is the one failure the disclosure is designed to make impossible.
Accepts the quoted form for the same reason build-workspace.sh does."""
if not task_dir:
return False
toml_path = Path(task_dir) / "task.toml"
if not toml_path.is_file():
return False
try:
text = toml_path.read_text(encoding="utf-8")
except OSError:
return False
return re.search(r'^[ \t]*browser[ \t]*=[ \t]*"?true"?[ \t]*$', text, re.M) is not None
def harness_from_task_toml(task_dir: str | None) -> str | None:
"""The task's own `[agent] harness` — the authoritative record of which harness
this task was authored against.
This is where the worker's choice lands: the snapshot flow stamps it from the CLI
that produced the snapshot, and a manual author writes it themselves. Either way
it is set at task-creation time, BEFORE any trial, so nothing here depends on a
trial's output.
Parsed with tomllib rather than a grep: a regex would happily match a commented
line or the wrong table, and picking the wrong harness is a silent
wrong-agent-runs bug.
Returns None when the field is simply absent — the normal case for every task
finalized before harness selection existed — so the caller falls through to the
toolkit default.
But an UNPARSEABLE task.toml refuses outright rather than falling back. Those are
different situations and treating them alike is how the wrong harness runs
quietly: the most likely way to break this file is adding a second `[agent]`
table instead of a `harness` line inside the existing one (tasks already carry
`[agent] timeout_sec`), and TOML rejects a duplicate table. Falling back there
would run claude against a task its author wrote for codex and grade it as if
nothing were wrong.
"""
if not task_dir:
return None
path = Path(task_dir) / "task.toml"
if not path.is_file():
return None
try:
with open(path, "rb") as handle:
doc = tomllib.load(handle)
except (OSError, tomllib.TOMLDecodeError) as exc:
fail(
f"{path} could not be parsed ({exc}). Refusing to guess a harness — fix "
f'the file. If you were adding a harness, put `harness = "..."` inside '
f"the EXISTING [agent] table rather than starting a second one."
)
harness = (doc.get("agent") or {}).get("harness")
return harness if isinstance(harness, str) and harness else None
def normalize_model(harness: Harness, model: str) -> str:
"""Model id on the wire, per the harness's declared shape."""
if harness.model_id_shape == "provider:model":
return model.replace("/", ":")
return model
def granted_models(harness: Harness) -> list[str] | None:
"""Model ids the key is granted, or None when the check couldn't run."""
base_url = os.environ.get(harness.base_url_env or "")
key = os.environ.get(harness.key_env or "")
if not base_url or not key:
warn("--check-model skipped: base URL or key env is unset")
return None
request = urllib.request.Request(
f"{base_url.rstrip('/')}/models", headers={"Authorization": f"Bearer {key}"}
)
try:
with urllib.request.urlopen(request, timeout=MODELS_TIMEOUT_SEC) as response:
body = json.loads(response.read().decode("utf-8"))
except (urllib.error.URLError, TimeoutError, ValueError, OSError) as exc:
warn(f"--check-model skipped: /models unreachable ({exc})")
return None
return [m["id"] for m in body.get("data", []) if isinstance(m.get("id"), str)]
def assert_model_granted(harness: Harness, model: str) -> None:
granted = granted_models(harness)
if granted is None:
return
# The proxy LISTS ids provider-prefixed ("openai/gpt-5.6-sol") but 400s on that
# form — requests take the bare id. Accept either spelling.
bare = {g.split("/")[-1] for g in granted}
if model not in granted and model not in bare:
shown = ", ".join(granted[:12]) + (", …" if len(granted) > 12 else "")
fail(
f'Model "{model}" is not granted for this key. Granted ({len(granted)}): {shown}'
)
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument(
"--harness",
help=f"harness id (default: the task's [agent] harness, else {DEFAULT_HARNESS})",
)
parser.add_argument(
"--task-dir",
help="task directory; decides multi-turn from environment/session.jsonl",
)
parser.add_argument("--model", help="override the harness's default model")
parser.add_argument(
"--fast",
action="store_true",
help="run the trial agent in the harness's fast serving mode (higher token "
"rate, faster output). Refuses on a harness that has none.",
)
parser.add_argument(
"--check-model",
action="store_true",
help="also ask the proxy whether the model is granted (network call)",
)
parser.add_argument(
"--authoring-installs",
action="store_true",
help="print '<id>\\t<cli>\\t<install>' for each harness a worker can author "
"with, and exit. Consumed by scripts/setup-harnesses.sh so the worker "
"containers install from the registry rather than from hardcoded lists that "
"drift.",
)
parser.add_argument(
"--container-configs",
action="store_true",
help="print '<id>\\t<config_path>\\t<base64 container_config>' for each "
"authoring harness that declares one, and exit. Base64 because the config is "
"multi-line TOML and these query modes are line-oriented.",
)
parser.add_argument(
"--surface",
choices=("authoring", "explore"),
default="authoring",
help="which worker container --container-configs is for; explore additionally "
"gets the capture hooks, whose commands only ship there.",
)
parser.add_argument(
"--defaults",
action="store_true",
help="print '<id>\\t<default_model>\\t<effort_default>' for every harness, and "
"exit. For recording what a task was authored against; nothing reads it back.",
)
parser.add_argument(
"--explore-launchers",
action="store_true",
help="print '<id>\\t<cli>\\t<launch command>' for each authoring harness, and "
"exit. The launch command has the registry's model, effort and agent_config "
"already substituted, so Explore and a trial cannot disagree about them. "
"Consumed by setup-harnesses.sh to write one launcher per harness.",
)
parser.add_argument(
"--skills-dirs",
action="store_true",
help="print '<id>\t<skills_dir>' for each authoring harness that discovers "
"skills from a directory, and exit. Lets setup-harnesses.sh install the "
"snapshot skill for harnesses that have no plugin system.",
)
parser.add_argument(
"--auth-files",
action="store_true",
help="print '<id>\t<auth_path>\t<auth_key_env>' for each authoring harness that "
"authenticates from a file rather than the environment, and exit.",
)
parser.add_argument(
"--authoring-credentials",
action="store_true",
help="print '<id>\\t<key_env>\\t<base_url_env>\\t<proxy_path>' for each "
"authoring harness, and exit. Lets the containers point every harness at the "
"same proxy key on its own provider path.",
)
parser.add_argument(
"--declared-harness",
action="store_true",
help="print ONLY the harness --task-dir's task.toml declares (empty if it "
"declares none) and exit. Unlike the default mode this applies no fallback, so "
"a caller can tell 'declared' from 'defaulted'. Exists so consumers without a "
"TOML parser never hand-roll one: a regex would match a commented line or the "
"wrong table, and the duplicate-[agent] shape is exactly the likely mistake.",
)
parser.add_argument(
"--resolve-identity",
action="append",
default=None,
metavar="AGENT",
help="resolve agent identities (a result.json config.agent import_path or name) "
"to harness ids and exit; repeatable. Prints one TAB-separated "
"'<identity>\\t<harness-id>' line each, with an empty id when nothing claims it. "
"Lets callers that cannot import the registry (the worker toolkit has no "
"zod/smol-toml) still resolve through the one source of truth.",
)
parser.add_argument(
"--list",
action="store_true",
help="print the selectable harnesses and exit (what task.toml's [agent] harness accepts)",
)
parser.add_argument("--registry", default=None, help="registry path (tests)")
args = parser.parse_args(argv)
try:
registry = (
load_harness_registry(args.registry)
if args.registry
else load_harness_registry()
)
except HarnessRegistryError as exc:
fail(str(exc))
# --- read-only query modes: answer and exit, never emit assignments -------
if args.authoring_installs:
for harness in registry.authoring():
print(f"{harness.id}\t{harness.cli or ''}\t{harness.install or ''}")
return 0
if args.container_configs:
import base64
for harness in registry.authoring():
config = harness.container_config_text(surface=args.surface)
if not (harness.config_path and config):
continue
blob = base64.b64encode(config.encode()).decode()
print(f"{harness.id}\t{harness.config_path}\t{blob}")
return 0
if args.defaults:
for harness in registry.all():
print(
f"{harness.id}\t{harness.default_model or ''}\t"
f"{harness.effort_default or ''}"
)
return 0
if args.explore_launchers:
for harness in registry.authoring():
print(
f"{harness.id}\t{harness.cli or ''}\t"
f"{harness.explore_launch_command() or ''}"
)
return 0
if args.skills_dirs:
for harness in registry.authoring():
if harness.skills_dir:
print(f"{harness.id}\t{harness.skills_dir}")
return 0
if args.auth_files:
for harness in registry.authoring():
if harness.auth_path and harness.auth_key_env:
print(f"{harness.id}\t{harness.auth_path}\t{harness.auth_key_env}")
return 0
if args.authoring_credentials:
for harness in registry.authoring():
print(
f"{harness.id}\t{harness.key_env or ''}\t"
f"{harness.base_url_env or ''}\t{harness.proxy_path or ''}"
)
return 0
if args.declared_harness:
print(harness_from_task_toml(args.task_dir) or "")
return 0
if args.resolve_identity:
for identity in args.resolve_identity:
harness = registry.by_import_path(identity)
print(f"{identity}\t{harness.id if harness else ''}")
return 0
if args.list:
# Printed on stdout because it is the requested output here, not the
# eval-able assignments — this mode is for a human, and never shelled into.
for harness in registry.enabled():
turns = (
"multi-turn + single-turn"
if harness.seed_native
else "single-turn only"
)
model = harness.default_model or "(pass --model)"
print(f"{harness.id:<16} {harness.label:<20} {turns:<24} {model}")
return 0
# --- selection ------------------------------------------------------------
# Precedence: an explicit --harness (a benchmark, or a deliberate override) beats the
# task's own record, which is what its author chose. Everything else — every task
# finalized before harness selection existed — is the default.
requested = args.harness or harness_from_task_toml(args.task_dir) or DEFAULT_HARNESS
try:
harness = registry.require(requested)
except HarnessRegistryError as exc:
fail(str(exc))
if not harness.enabled:
fail(
f'Harness "{harness.id}" is disabled in the registry (never verified here). '
f"Enable it in scripts/harness-registry.toml once a trial has been run with it."
)
if not harness.writes_atif:
fail(
f'Harness "{harness.id}" writes no ATIF trajectory, so the grader would have '
f"no transcript and its rewards would be meaningless."
)
multi_turn = is_multi_turn(args.task_dir)
if multi_turn and not harness.seed_native:
fail(
f'Task ships a session to resume, but harness "{harness.id}" cannot resume '
f"one. Running anyway would look like a success while the agent answered a "
f"prompt whose conversation it never saw."
)
if harness.key_env and not os.environ.get(harness.key_env):
fail(f'{harness.key_env} is unset — required by harness "{harness.id}".')
if args.fast and not harness.fast_kwarg:
fail(
f'Harness "{harness.id}" has no fast serving mode (no fast_kwarg in the '
f"registry). Drop --fast or pick a harness that declares one."
)
model = args.model or harness.default_model
if not model:
fail(
f'Harness "{harness.id}" has no default_model in the registry; pass --model '
f"explicitly."
)
if args.check_model:
assert_model_granted(harness, model)
# Every assignment here becomes a harbor flag. Nothing else: the caller is bash, and
# anything it would only echo back at the worker is said below instead.
browser = wants_browser(args.task_dir)
assignments = {
"AGENT_IMPORT_PATH": harness.agent_import_path_for(multi_turn=multi_turn, browser=browser),
"MODEL": normalize_model(harness, model),
"EFFORT_KWARG": harness.effort_kwarg,
"EFFORT_VALUE": (harness.effort_default or "") if harness.effort_kwarg else "",
"FAST_KWARG": harness.fast_kwarg if args.fast else "",
}
warn(
f"{harness.label} · model={assignments['MODEL']} · "
f"{'multi-turn' if multi_turn else 'single-turn'} · "
f"{'browser · ' if browser else ''}"
f"{'fast · ' if args.fast else ''}"
f"agent={assignments['AGENT_IMPORT_PATH']}"
)
if browser and not harness.agent_import_path_browser:
# Not a failure: the image still gets Playwright and the agent is still told about
# it. Only the Read-enabled toolset swap is claude-specific, and saying so beats
# letting someone infer from a log line that the opt-in was ignored entirely.
warn(
f'"{harness.id}" has no browser-specific agent, so it runs its usual toolset. '
f"The browser and its disclosure are unaffected."
)
if harness.flaky_hangs:
warn(
f"{harness.label} is known to hang with no client-side timeout on a small "
f"fraction of trials. A silent, output-less trial is that, not a task defect."
)
for key, value in assignments.items():
print(f"{key}={shlex.quote(value)}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,353 @@
#!/bin/bash
# Install the harnesses a worker can author with, from scripts/harness-registry.toml.
#
# Source it, then call unpiped — it exports credentials, which a subshell would lose:
#
# . /workspace/scripts/setup-harnesses.sh
# harness_setup_all
#
# Registry reading and credential derivation live in lib/harness-credentials.sh, sourced
# below, because `harbor-run` needs those and nothing else here.
#
# No -e here — but this file is SOURCED, and shell options belong to the caller's shell:
# both post-creates run with -e, so that is what is in force. An unguarded failure below
# therefore aborts container creation, which is why every failure site is individually
# guarded (`|| true`, `if !`) rather than relying on this line.
set -uo pipefail
_HARNESS_REGISTRY_DIR="${HARNESS_SCRIPTS_DIR:-/workspace/scripts}"
if [ ! -f "$_HARNESS_REGISTRY_DIR/lib/harness-credentials.sh" ]; then
echo "harness-setup: FATAL — $_HARNESS_REGISTRY_DIR/lib/harness-credentials.sh is" >&2
echo "harness-setup: missing, so nothing here can read the registry. Every step below" >&2
echo "harness-setup: would report a missing interpreter instead of this." >&2
return 1 2>/dev/null || exit 1
fi
# shellcheck disable=SC1091
. "$_HARNESS_REGISTRY_DIR/lib/harness-credentials.sh"
# Every setup step reads the registry through _harness_query, and each call suppresses
# stderr so one bad row can't abort the container. That means a BROKEN interpreter turns
# the whole of setup into a silent no-op: no credentials, no CLIs, no config, no
# launchers, and no error anywhere. Check it once, loudly, before any of that.
harness_preflight() {
local err py found=yes
py=$(_raccoon_python) || { py=python3; found=no; }
if ! err=$("$py" "$_HARNESS_REGISTRY_DIR/resolve_harness.py" --list 2>&1 >/dev/null); then
echo "harness-setup: FATAL — cannot read the harness registry, so no agent CLI" >&2
echo "harness-setup: would be installed. Nothing below will run." >&2
echo "harness-setup: interpreter: $(command -v "$py" || echo MISSING) ($("$py" -V 2>&1))" >&2
if [ "$found" = no ]; then
echo "harness-setup: no python3.11+ with tomllib found; set RACCOON_PYTHON to override" >&2
fi
echo "harness-setup: registry: $_HARNESS_REGISTRY_DIR/harness-registry.toml" >&2
printf 'harness-setup: %s\n' "$err" >&2
return 1
fi
}
# claude installs into $HOME/.local/bin, which is not on PATH during post-create.
case ":$PATH:" in
*":$HOME/.local/bin:"*) ;;
*) export PATH="$HOME/.local/bin:$PATH" ;;
esac
# --- installs ----------------------------------------------------------------
harness_install_clis() {
local id cli install
while IFS=$'\t' read -r id cli install; do
[ -n "$install" ] || continue
if command -v "$cli" >/dev/null 2>&1; then
echo "harness-setup: $cli already installed — skipping" >&2
continue
fi
echo "harness-setup: installing $id ($cli)" >&2
# Reported as unavailable below rather than fatal.
if ! bash -c "$install" >&2; then
echo "harness-setup: WARNING $id failed to install — $cli will be unavailable" >&2
fi
done < <(_harness_query --authoring-installs 2>/dev/null || true)
}
# Report which CLIs are usable. Non-zero when NONE are: one harness missing is survivable
# (a worker uses the other), but zero means the container cannot author anything at all,
# and that must stop setup rather than read as a couple of warnings.
harness_report() {
local id cli install ready=0 missing=0
while IFS=$'\t' read -r id cli install; do
[ -n "$cli" ] || continue
if command -v "$cli" >/dev/null 2>&1; then
echo " $cli — ready" >&2
ready=$((ready + 1))
else
echo " $cli — NOT AVAILABLE (install failed; see above)" >&2
missing=$((missing + 1))
fi
done < <(_harness_query --authoring-installs 2>/dev/null || true)
# A CLI on PATH with no key is worse than a missing one: it starts, then fails at the
# first request with the harness's own auth error, which says nothing about setup.
local id key_env base_url_env proxy_path
while IFS=$'\t' read -r id key_env base_url_env proxy_path; do
[ -n "$key_env" ] || continue
if [ -z "${!key_env:-}" ]; then
echo " $id — installed but NO CREDENTIALS: $key_env is unset." >&2
echo " Derived from ANTHROPIC_BASE_URL + ANTHROPIC_API_KEY; set both in .env." >&2
fi
done < <(_harness_query --authoring-credentials 2>/dev/null || true)
if [ "$ready" -eq 0 ]; then
echo "harness-setup: FATAL — no agent CLI installed ($missing attempted)." >&2
echo "harness-setup: This container cannot author a task. Check the install" >&2
echo "harness-setup: output above: the CLIs download over the network, so a" >&2
echo "harness-setup: proxy, DNS or upstream change breaks every one at once." >&2
return 1
fi
[ "$missing" -gt 0 ] && echo "harness-setup: $missing harness(es) unavailable; $ready usable" >&2
return 0
}
# --- Explore launchers -------------------------------------------------------
# One `raccoon-explore-<cli>` per harness, aliased to its `cli`.
harness_install_launchers() {
local bin="$HOME/.local/bin"
mkdir -p "$bin"
# Read at launcher run time so the note stays a file, not a baked-in copy.
local note_src="${HARNESS_TOOLSET_NOTE:-/workspace/scripts/toolset_note.md}"
local browser_note_src="${note_src%.md}_browser.md"
local read_note_src="${note_src%.md}_read.md"
local agent_cli_dir="${AGENT_CLI_DIR:-/opt/agent-cli}"
# Which harnesses keep their key in a file rather than reading $ENV per request. Those
# launchers refresh it first: the file dates from container create, so a key rotated in
# .env since then would otherwise reach the harness only after a rebuild.
local file_auth_ids="" aid apath akey
while IFS=$'\t' read -r aid apath akey; do
[ -n "$apath" ] || continue
file_auth_ids="${file_auth_ids:+$file_auth_ids }$aid"
done < <(_harness_query --auth-files 2>/dev/null || true)
local id cli launch switchable refresh_line
while IFS=$'\t' read -r id cli launch; do
[ -n "$cli" ] && [ -n "$launch" ] || continue
# `|| true` twice over (here and inside the script): the launcher runs under
# `set -e`, and a failed refresh must not cost the worker their agent.
if [[ " $file_auth_ids " == *" $id "* ]]; then
refresh_line="\"$_HARNESS_REGISTRY_DIR/refresh-harness-auth\" || true"
else
refresh_line=""
fi
# Whether RACCOON_BROWSER_TASK can change THIS harness's toolset, read off the
# registry rather than hardcoded: a launch line that interpolates $RACCOON_TOOLS
# can, and one that doesn't cannot. codex is the second case — it ships view_image,
# so a browser task needs nothing added and the flag has nothing to switch.
# Match the whole variable name: a substring test also hits RACCOON_TOOLSET_NOTE,
# which every launch line references, and every harness would look switchable.
if [[ "$launch" =~ \$\{?RACCOON_TOOLS\}?([^A-Za-z0-9_]|$) ]]; then
switchable=1
else
switchable=0
fi
cat > "$bin/raccoon-explore-$cli" <<LAUNCHER
#!/bin/bash
# GENERATED by scripts/setup-harnesses.sh from harness-registry.toml — do not edit.
set -euo pipefail
# A harness that reads its key from \$ENV per request needs .env in its environment,
# and only an interactive shell sources .bashrc — which is also where PATH picks up
# ~/.local/bin, where the CLI itself lives. Both are set here so a launch works the
# same either way, with the key .env holds right now.
export PATH="\$HOME/.local/bin:\$PATH"
if [ -f "\${RACCOON_ENV_FILE:-/workspace/.env}" ]; then
set -a
. "\${RACCOON_ENV_FILE:-/workspace/.env}"
set +a
fi
if [ -f "\$HOME/.raccoon-call-origin" ]; then
. "\$HOME/.raccoon-call-origin"
fi
if [ -f "$note_src" ]; then
RACCOON_TOOLSET_NOTE="\$(sed "s#/opt/agent-cli#$agent_cli_dir#g" "$note_src")"
else
RACCOON_TOOLSET_NOTE=""
fi
# RACCOON_BROWSER_TASK=1 explores with the toolset a \`browser = true\` task runs under.
# Named for the flag it mirrors: one word, \`browser\`, whether it's set in task.toml or
# here. Per invocation, not per container — authoring a browser task shouldn't need a
# rebuild, and neither should changing your mind. Default off, so ordinary exploring
# still mirrors an ordinary trial.
#
# The correction must be appended AFTER the base note, which says there is no Read tool.
RACCOON_TOOLS="Bash"
if [ "\${RACCOON_BROWSER_TASK:-0}" = "1" ] && [ "$switchable" = "1" ] && [ -f "$read_note_src" ]; then
RACCOON_TOOLS="Bash,Read"
RACCOON_TOOLSET_NOTE="\${RACCOON_TOOLSET_NOTE}
\$(cat "$read_note_src")"
fi
export RACCOON_TOOLS
# Only mention the browser on an image that actually has one — most don't. Probed at
# launch, not baked in, so the same launcher is correct in whichever container it runs.
#
# Exported two ways because the harnesses take extra instructions differently: claude
# appends the whole toolset note to --append-system-prompt, while codex has no equivalent
# and takes -c developer_instructions=. codex must NOT get the claude-shaped toolset note
# (it has no str_replace_editor), so the browser part is exported on its own too.
RACCOON_BROWSER_NOTE=""
RACCOON_BROWSER_FLAGS=()
if command -v pw >/dev/null 2>&1 && [ -f "$browser_note_src" ]; then
RACCOON_BROWSER_NOTE="\$(cat "$browser_note_src")"
RACCOON_TOOLSET_NOTE="\${RACCOON_TOOLSET_NOTE}
\${RACCOON_BROWSER_NOTE}"
RACCOON_BROWSER_FLAGS=(-c "developer_instructions=\${RACCOON_BROWSER_NOTE}")
fi
export RACCOON_TOOLSET_NOTE RACCOON_BROWSER_NOTE
export RACCOON_HARNESS="$id"
# These launchers exist only in explore, and a refresh that has to CREATE a config
# needs the surface to know the capture hooks belong in it.
export RACCOON_SURFACE=explore
# No RACCOON_SNAPSHOT_DATA here on purpose. capture-snapshot.mjs and save-session-info.mjs
# already share the same default ($HOME/.raccoon/snapshot-data), which is what codex needs
# — it has no CLAUDE_PLUGIN_* to fall back to. Exporting it ALSO overrode the dir for
# claude, whose slash command pins --plugin-data to the plugin dir, so the hook wrote one
# place and capture read another and the recorded session was silently ignored.
$refresh_line
$launch
LAUNCHER
chmod +x "$bin/raccoon-explore-$cli"
echo "harness-setup: launcher raccoon-explore-$cli" >&2
done < <(_harness_query --explore-launchers 2>/dev/null || true)
}
# Alias lines for ~/.bashrc.
harness_alias_lines() {
local id cli launch switchable
local browser_clis=""
while IFS=$'\t' read -r id cli launch; do
[ -n "$cli" ] && [ -n "$launch" ] || continue
echo "alias $cli=\"raccoon-explore-$cli\""
# Same derivation as the launcher: only a harness whose launch line takes
# $RACCOON_TOOLS has a toolset the flag can change.
if [[ "$launch" =~ \$\{?RACCOON_TOOLS\}?([^A-Za-z0-9_]|$) ]]; then
browser_clis="${browser_clis:+$browser_clis }$cli"
fi
done < <(_harness_query --explore-launchers 2>/dev/null || true)
# The browser hint belongs at the shell prompt, not in the launcher. Claude Code takes the
# alternate screen buffer, so anything printed just before exec is hidden for the whole
# session and resurfaces only after quitting — advice arriving exactly too late. Here it
# lands in ordinary scrollback, before any TUI exists, and there is nothing to quit yet.
#
# `pw` is probed at shell start, so one ~/.bashrc is correct in a container with a browser
# and in one without.
[ -n "$browser_clis" ] || return 0
local first="${browser_clis%% *}"
cat <<HINT
if [[ \$- == *i* ]] && [ "\${RACCOON_BROWSER_TASK:-0}" != "1" ] && command -v pw >/dev/null 2>&1; then
echo "browser available (Playwright + Chromium, \\\`pw <script.js>\\\`)."
echo "Authoring a \\\`browser = true\\\` task? Start it with: RACCOON_BROWSER_TASK=1 $first"
fi
HINT
}
# Write each harness's config file from the registry, replacing whatever was there.
#
# The file is OWNED, not merged: TOML has no way to return to the document root after a
# table header, so appending or prepending around foreign content silently reparents
# root-level keys into whichever table happens to precede them. Owning it also means a
# registry change actually reaches a container that was already set up.
harness_write_configs() {
local id config_path blob target tmp
while IFS=$'\t' read -r id config_path blob; do
[ -n "$config_path" ] && [ -n "$blob" ] || continue
# Guarded: a bare failing assignment exits the caller's `set -e` post-create with
# no explanation. A path this cannot expand is one harness's problem, not the
# container's.
target=$(eval "printf '%s' \"$config_path\"") || {
echo "harness-setup: WARNING $id config_path could not be expanded — skipping" >&2
continue
}
mkdir -p "$(dirname "$target")"
tmp="$target.raccoon-tmp"
# Expansion is strict: an unset var would otherwise be written through as the
# literal ${VAR}, which surfaces much later as an unparseable value.
if ! {
echo "# Generated from harness-registry.toml — edits here are overwritten."
printf '%s' "$blob" | base64 -d | python3 -c '
import os, re, sys
text = sys.stdin.read()
missing = sorted(
{m.group(1) for m in re.finditer(r"\$\{(\w+)\}", text) if m.group(1) not in os.environ}
)
if missing:
sys.stderr.write("unset: " + ", ".join(missing) + "\n")
raise SystemExit(1)
sys.stdout.write(os.path.expandvars(text))
'
} > "$tmp"; then
rm -f "$tmp"
echo "harness-setup: WARNING $id config NOT written — a value it needs is unset." >&2
echo "harness-setup: run harness_setup_credentials first (harness_setup_all does)." >&2
continue
fi
mv "$tmp" "$target"
echo "harness-setup: $id config -> $target" >&2
done < <(_harness_query --container-configs --surface "${RACCOON_SURFACE:-authoring}" 2>/dev/null || true)
}
# Link every available skill into each harness's skills_dir, for harnesses that declare one.
# Both container layouts are covered: the explore container holds the snapshot skill under
# plugins/, the authoring container holds the authoring skills under .claude/skills. Whichever
# directories exist here are the ones this container has.
harness_install_skills() {
local sources="${RACCOON_SKILL_SOURCE_DIRS:-/workspace/plugins/create-snapshot/skills /workspace/.claude/skills}"
local id dir target src skill name installed
while IFS=$'\t' read -r id dir; do
[ -n "$dir" ] || continue
target=$(eval "printf '%s' \"$dir\"") || {
echo "harness-setup: WARNING $id skills_dir could not be expanded — skipping" >&2
continue
}
mkdir -p "$target"
installed=0
for src in $sources; do
[ -d "$src" ] || continue
for skill in "$src"/*/; do
[ -f "$skill/SKILL.md" ] || continue
name=$(basename "$skill")
ln -sfn "${skill%/}" "$target/$name"
installed=$((installed + 1))
done
done
echo "harness-setup: $id skills -> $target ($installed linked)" >&2
done < <(_harness_query --skills-dirs 2>/dev/null || true)
}
# The lines that explain a setup failure are printed as it happens, and the devcontainer
# CLI's own stack trace lands on top of them. Close with a banner so the worker has
# something to look for, and something to send us.
_harness_fatal_banner() {
echo "" >&2
echo " ============================================================" >&2
echo " HARNESS SETUP FAILED — this container has no agent CLI." >&2
echo "" >&2
echo " The harness-setup: lines above say why. Anything the" >&2
echo " devcontainer prints after this is a consequence, not the" >&2
echo " cause; send us the harness-setup: lines." >&2
echo " ============================================================" >&2
echo "" >&2
}
harness_setup_all() {
harness_preflight || { _harness_fatal_banner; return 1; }
harness_setup_credentials
harness_write_auth
harness_install_clis
harness_write_configs
harness_install_skills
# Launchers are NOT installed here. They are an Explore concern (that container aliases
# `claude`/`codex` to them), and it passes its own AGENT_CLI_DIR — installing them here
# too wrote every launcher twice, the first time with the wrong editor path, and left an
# unused one in the authoring container.
echo "harness-setup: authoring harnesses" >&2
harness_report || { _harness_fatal_banner; return 1; }
}

View File

@@ -0,0 +1,93 @@
#!/usr/bin/env python3
"""str_replace_editor — CLI-as-MCP wrapper around the vendored EditTool.
This is the "CLI-as-MCP" delivery of the `str_replace_editor` tool: the agent
(which has ONLY the bash tool) invokes this script and passes the tool's
arguments as one JSON object on stdin. The actual editing logic is the vendored
`EditTool` under str_replace_editor_vendor/ (see VENDORED.md) — we add no
behavior, we only:
* instantiate it with run_command_preexec_fn=None (the class's own documented
way to skip its uid/gid-1000 demotion, which would break writes in our
sandbox where the workspace is owned by the agent user); and
* adapt structured stdin-JSON <-> a bash-invokable CLI.
stdin: one JSON object, e.g.
{"command":"view","path":"/workspace/app/models/x.rb"}
{"command":"view","path":"/workspace/x.rb","view_range":[1,40]}
{"command":"str_replace","path":"/workspace/x.rb","old_str":"a","new_str":"b"}
{"command":"create","path":"/workspace/new.rb","file_text":"..."}
{"command":"insert","path":"/workspace/x.rb","insert_line":10,"insert_text":"..."}
stdout: the tool's result text (exit 0). stderr + exit 1: a tool error message.
"""
import asyncio
import json
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from str_replace_editor_vendor.base import ToolError # noqa: E402
from str_replace_editor_vendor.edit import EditTool # noqa: E402
# The keyword-only params the vendored EditTool.__call__ accepts.
_ACCEPTED = {
"command", "path", "file_text", "view_range",
"old_str", "new_str", "insert_text", "insert_line",
}
async def _run(payload: dict):
# Reject unknown keys instead of silently dropping them: a typo like
# `old_string` (vs `old_str`) should be a clear argument error, not a
# confusing failure deeper inside EditTool with the param silently missing.
unknown = set(payload) - _ACCEPTED
if unknown:
raise ToolError(
f"unknown argument(s): {', '.join(sorted(unknown))}. "
f"accepted keys: {', '.join(sorted(_ACCEPTED))}."
)
kwargs = dict(payload)
if "command" not in kwargs or "path" not in kwargs:
raise ToolError("Both `command` and `path` are required.")
# run_command_preexec_fn=None → no uid/gid demotion (see module docstring).
tool = EditTool(run_command_preexec_fn=None)
return await tool(**kwargs)
def main() -> int:
raw = sys.stdin.read()
if not raw.strip():
sys.stderr.write("str_replace_editor: expected a JSON object on stdin\n")
return 2
try:
payload = json.loads(raw)
except json.JSONDecodeError as e:
sys.stderr.write(f"str_replace_editor: invalid JSON on stdin: {e}\n")
return 2
if not isinstance(payload, dict):
sys.stderr.write("str_replace_editor: stdin JSON must be an object\n")
return 2
try:
result = asyncio.run(_run(payload))
except ToolError as e:
sys.stderr.write((e.message or "tool error") + "\n")
return 1
except TypeError as e:
# e.g. an unexpected/duplicate kwarg shape — surface like a tool error.
sys.stderr.write(f"str_replace_editor: bad arguments: {e}\n")
return 1
# EditTool returns a (CLI)Result with .output / .error / .base64_image / .system
if getattr(result, "error", None):
sys.stderr.write(result.error if result.error.endswith("\n") else result.error + "\n")
if getattr(result, "system", None):
sys.stderr.write(f"[system] {result.system}\n")
out = getattr(result, "output", None) or ""
if getattr(result, "base64_image", None):
out += "\n(image content omitted in CLI mode)"
if out:
sys.stdout.write(out if out.endswith("\n") else out + "\n")
return 1 if getattr(result, "error", None) else 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1 @@
"""Vendored verbatim — do not edit. See VENDORED.md for provenance."""

View File

@@ -0,0 +1,49 @@
from dataclasses import dataclass, fields, replace
@dataclass(kw_only=True, frozen=True)
class ToolResult:
"""Represents the result of a tool execution."""
output: str | None = None
error: str | None = None
base64_image: str | None = None
system: str | None = None
def __bool__(self):
return any(getattr(self, field.name) for field in fields(self))
def __add__(self, other: "ToolResult"):
def combine_fields(field: str | None, other_field: str | None, concatenate: bool = True):
if field and other_field:
if concatenate:
return field + other_field
raise ValueError("Cannot combine tool results")
return field or other_field
return ToolResult(
output=combine_fields(self.output, other.output),
error=combine_fields(self.error, other.error),
base64_image=combine_fields(self.base64_image, other.base64_image, False),
system=combine_fields(self.system, other.system),
)
def replace(self, **kwargs):
"""Returns a new ToolResult with the given fields replaced."""
return replace(self, **kwargs)
# QUESTION(simon): What's our intent behind differentiating here?
class CLIResult(ToolResult):
"""A ToolResult that can be rendered as a CLI output."""
class ToolFailure(ToolResult):
"""A ToolResult that represents a failure."""
class ToolError(Exception):
"""Raised when a tool encounters an error."""
def __init__(self, message):
self.message = message

View File

@@ -0,0 +1,476 @@
import asyncio
import base64
import shlex
from collections import deque
from dataclasses import dataclass
from pathlib import Path
from typing import Literal, get_args
from .base import CLIResult, ToolError, ToolResult
from .run import demote, maybe_truncate, run
TRUNCATED_MESSAGE: str = "<response clipped><NOTE>To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.</NOTE>"
Command = Literal[
"view",
"create",
"str_replace",
"insert",
]
SNIPPET_LINES: int = 4
MAX_RESPONSE_LEN: int = 16000
class EditTool:
"""
An filesystem editor tool that allows the agent to view, create, and edit files.
The tool parameters are defined by Anthropic and are not editable.
"""
def __init__(self, run_command_preexec_fn=demote):
"""
Initialize the EditTool.
Args:
run_command_preexec_fn: Function to run in child process before executing
shell commands via the run() utility.
Defaults to demote() which drops privileges to uid/gid 1000.
Pass None to skip preexec, or any callable for custom behavior.
"""
self._run_command_preexec_fn = run_command_preexec_fn
async def __call__(
self,
*,
command: Command,
path: str,
file_text: str | None = None,
view_range: list[int] | None = None,
old_str: str | None = None,
new_str: str | None = None,
insert_text: str | None = None,
insert_line: int | None = None,
):
_path = Path(path)
self.validate_path(command, _path)
if command == "view":
return await self.view(_path, view_range)
elif command == "create":
if file_text is None:
raise ToolError("Parameter `file_text` is required for command: create")
await self.write_file(_path, file_text)
return ToolResult(output=f"File created successfully at: {_path}")
elif command == "str_replace":
if old_str is None:
raise ToolError("Parameter `old_str` is required for command: str_replace")
return await self.str_replace(_path, old_str, new_str)
elif command == "insert":
if insert_line is None:
raise ToolError("Parameter `insert_line` is required for command: insert")
if insert_text is None:
raise ToolError("Parameter `insert_text` is required for command: insert")
return await self.insert(_path, insert_line, insert_text)
raise ToolError(
f"Unrecognized command {command}. The allowed commands for the {self.name} tool are: {', '.join(get_args(Command))}"
)
def validate_path(self, command: str, path: Path):
"""
Check that the path/command combination is valid.
"""
# Check if its an absolute path
if not path.is_absolute():
suggested_path = Path("") / path
raise ToolError(
f"The path {path} is not an absolute path, it should start with `/`. Maybe you meant {suggested_path}?"
)
# Check if path exists
if not path.exists() and command != "create":
raise ToolError(f"The path {path} does not exist. Please provide a valid path.")
if path.exists() and command == "create":
raise ToolError(f"File already exists at: {path}. Cannot overwrite files using command `create`.")
# Check if the path points to a directory
if path.is_dir():
if command != "view":
raise ToolError(
f"The path {path} is a directory and only the `view` command can be used on directories"
)
async def view(self, path: Path, view_range: list[int] | None = None):
"""Implement the view command"""
if path.is_dir():
if view_range:
raise ToolError("The `view_range` parameter is not allowed when `path` points to a directory.")
_, stdout, stderr = await run(
rf"find {path} -maxdepth 2 -not -path '*/\.*'", preexec_fn=self._run_command_preexec_fn
)
if not stderr:
stdout = f"Here's the files and directories up to 2 levels deep in {path}, excluding hidden items:\n{stdout}\n"
return CLIResult(output=stdout, error=stderr)
image_extensions = {'.png', '.jpg', '.jpeg', '.gif', '.bmp', '.tiff', '.tif', '.webp', '.svg', '.ico'}
if path.suffix.lower() in image_extensions:
if view_range:
raise ToolError("The `view_range` parameter is not allowed when `path` points to an image file.")
try:
image_bytes = path.read_bytes()
base64_encoded = base64.b64encode(image_bytes).decode()
return CLIResult(
output=f"Displaying image file: {path}",
base64_image=base64_encoded
)
except Exception as e:
raise ToolError(f"Failed to read image file {path}: {e}") from None
file_content = await self.read_file(path, truncate_after=None)
file_text_lines = file_content.splitlines(keepends=True)
n_lines_file = len(file_text_lines) + (1 if file_content.endswith(("\n", "\r\n", "\r")) else 0)
if view_range:
if len(view_range) != 2 or not all(isinstance(i, int) for i in view_range):
raise ToolError("Invalid `view_range`. It should be a list of two integers.")
init_line, final_line = view_range
if init_line < 1 or init_line > n_lines_file:
raise ToolError(
f"Invalid `view_range`: {view_range}. Its first element `{init_line}` should be within the range of lines of the file: {[1, n_lines_file]}"
)
if final_line > n_lines_file:
raise ToolError(
f"Invalid `view_range`: {view_range}. Its second element `{final_line}` should be smaller than the number of lines in the file: `{n_lines_file}`"
)
if final_line != -1 and final_line < init_line:
raise ToolError(
f"Invalid `view_range`: {view_range}. Its second element `{final_line}` should be larger or equal than its first `{init_line}`"
)
# Extract only the requested lines
if final_line != -1:
selected_lines = file_text_lines[max(view_range[0] - 1, 0) : view_range[1]]
else:
selected_lines = file_text_lines[max(view_range[0] - 1, 0) :]
# Join without modifying the original line endings
file_content = "".join(selected_lines)
file_content = process_view_output_str(
file_text=file_content,
path=str(path),
total_path_lines=n_lines_file,
max_resp_ln=MAX_RESPONSE_LEN,
view_range=(view_range[0], view_range[1]) if view_range else None,
)
return CLIResult(output=file_content)
async def str_replace(self, path: Path, old_str: str, new_str: str | None):
"""Implement the str_replace command, which replaces old_str with new_str in the file content"""
# Read the file content
file_content = await self.read_file(path, truncate_after=None)
new_str = new_str if new_str is not None else ""
# Check if old_str is unique in the file
occurrences = file_content.count(old_str)
if occurrences == 0:
raise ToolError(f"No replacement was performed, old_str `{old_str}` did not appear verbatim in {path}.")
elif occurrences > 1:
file_content_lines = file_content.split("\n")
lines = [idx + 1 for idx, line in enumerate(file_content_lines) if old_str in line]
raise ToolError(
f"No replacement was performed. Multiple occurrences of old_str `{old_str}` in lines {lines}. Please ensure it is unique"
)
# Replace old_str with new_str
new_file_content = file_content.replace(old_str, new_str)
# Write the new content to the file
await self.write_file(path, new_file_content)
# Create a snippet of the edited section
replacement_line = file_content.split(old_str)[0].count("\n")
start_line = max(0, replacement_line - SNIPPET_LINES)
end_line = replacement_line + SNIPPET_LINES + new_str.count("\n")
snippet = "\n".join(new_file_content.split("\n")[start_line : end_line + 1])
# Prepare the success message
success_msg = f"The file {path} has been edited. "
success_msg += self._make_output(snippet, f"a snippet of {path}", start_line + 1)
success_msg += "Review the changes and make sure they are as expected. Edit the file again if necessary."
return CLIResult(output=success_msg)
async def insert(self, path: Path, insert_line: int, new_str: str):
"""Implement the insert command, which inserts new_str at the specified line in the file content."""
file_text = await self.read_file(path, truncate_after=None)
file_text_lines = file_text.split("\n")
n_lines_file = len(file_text_lines)
if insert_line < 0 or insert_line > n_lines_file:
raise ToolError(
f"Invalid `insert_line` parameter: {insert_line}. It should be within the range of lines of the file: {[0, n_lines_file]}"
)
new_str_lines = new_str.split("\n")
new_file_text_lines = file_text_lines[:insert_line] + new_str_lines + file_text_lines[insert_line:]
snippet_lines = (
file_text_lines[max(0, insert_line - SNIPPET_LINES) : insert_line]
+ new_str_lines
+ file_text_lines[insert_line : insert_line + SNIPPET_LINES]
)
new_file_text = "\n".join(new_file_text_lines)
snippet = "\n".join(snippet_lines)
await self.write_file(path, new_file_text)
success_msg = f"The file {path} has been edited. "
success_msg += self._make_output(
snippet,
"a snippet of the edited file",
max(1, insert_line - SNIPPET_LINES + 1),
)
success_msg += "Review the changes and make sure they are as expected (correct indentation, no duplicate lines, etc). Edit the file again if necessary."
return CLIResult(output=success_msg)
async def read_file(self, path: Path, truncate_after: int | None = MAX_RESPONSE_LEN):
"""Read the content of a file from a given path; raise a ToolError if an error occurs."""
try:
code, out, err = await run(
f"cat {shlex.quote(str(path))}", truncate_after=truncate_after, preexec_fn=self._run_command_preexec_fn
)
if code != 0:
raise ToolError(f"Ran into {err} while trying to read {path}")
return out
except Exception as e:
print(e)
raise ToolError(f"Ran into {e} while trying to read {path}") from None
async def write_file(self, path: Path, file: str):
"""Write the content of a file to a given path; raise a ToolError if an error occurs."""
try:
# Write using stdin to avoid argument size limits
process = await asyncio.create_subprocess_shell(
f"cat > {shlex.quote(str(path))}",
stdin=asyncio.subprocess.PIPE,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
preexec_fn=self._run_command_preexec_fn,
)
stdout, stderr = await asyncio.wait_for(
process.communicate(input=file.encode('utf-8')),
timeout=120.0
)
if process.returncode != 0:
raise ToolError(f"Ran into {stderr.decode()} while trying to write to {path}")
except asyncio.TimeoutError:
raise ToolError(f"Timed out while trying to write to {path}")
except Exception as e:
raise ToolError(f"Ran into {e} while trying to write to {path}") from None
def _make_output(
self,
file_content: str,
file_descriptor: str,
init_line: int = 1,
expand_tabs: bool = True,
):
"""Generate output for the CLI based on the content of a file."""
file_content = maybe_truncate(file_content)
if expand_tabs:
file_content = file_content.expandtabs()
file_content = "\n".join([f"{i + init_line:6}\t{line}" for i, line in enumerate(file_content.split("\n"))])
return f"Here's the result of running `cat -n` on {file_descriptor}:\n" + file_content + "\n"
### AUX utilities
def add_line_numbers(text: str, includes_final_line: bool, n_first_line: int = 1) -> str:
"""
Given a string, returns the string with line numbers prepended to each line.
This function:
- Preserves the original line endings (CR, LF, or CRLF) of each line
- Adds a tab-separated line number prefix to each line
- If the text ends with any newline character (\n, \r\n, or \r), adds an
additional empty numbered line to represent the terminal empty line
"""
lines_with_endings = text.splitlines(keepends=True)
result = [f"{ind + n_first_line:6}\t{line_with_ending}" for ind, line_with_ending in enumerate(lines_with_endings)]
# Add an extra empty line with line number if original text ends with newline
if includes_final_line and text.endswith(("\n", "\r\n", "\r")):
result.append(f"{len(lines_with_endings) + n_first_line:6}\t")
return "".join(result)
def process_view_output_str(
file_text: str,
path: str,
total_path_lines: int,
max_resp_ln: int,
view_range: tuple[int, int] | None = None,
) -> str:
# Get header
header = f"Here's the content of {path} with line numbers"
if total_path_lines is not None and view_range is not None:
header += f" (which has a total of {total_path_lines} lines) with view_range={list(view_range)}"
# See if final line is included in the view_range
if view_range is None or view_range[1] == -1 or view_range[1] == total_path_lines:
includes_final_line = True
else:
includes_final_line = False
n_first_line = view_range[0] if view_range is not None else 1
# Truncate if needed
maybe_truncated_str = truncate_from_middle_v2(ss=file_text, max_len=max_resp_ln, n_line_offset=n_first_line - 1)
if isinstance(maybe_truncated_str, str):
# No truncation
file_text_with_line_numbers = add_line_numbers(
file_text,
includes_final_line=includes_final_line,
n_first_line=n_first_line,
)
else:
# Truncation occurred
before_with_line_numbers = add_line_numbers(
text="".join(maybe_truncated_str.as_str(maybe_truncated_str.before_lines)),
includes_final_line=False,
n_first_line=n_first_line,
)
if maybe_truncated_str.single_line:
file_text_with_line_numbers = before_with_line_numbers
else:
after_with_line_numbers = add_line_numbers(
text="".join(maybe_truncated_str.as_str(maybe_truncated_str.after_lines)),
includes_final_line=includes_final_line,
n_first_line=1 + maybe_truncated_str.truncated_end_line,
)
file_text_with_line_numbers = (
before_with_line_numbers + f"\t{maybe_truncated_str.truncation_msg}" + after_with_line_numbers
)
# Add context-aware truncation message
if view_range is not None:
# User already using view_range, suggest adjusting it
truncation_note = "\n<response clipped><NOTE>To save on context only part of the view range has been shown. You can adjust the view_range parameters or use `grep -n` to find specific content.</NOTE>"
else:
# User viewing whole file, suggest view_range or grep
truncation_note = "\n<response clipped><NOTE>To save on context only part of this file has been shown to you. You can use view_range=[start_line, end_line] to see specific sections, or use `grep -n` to find what you're looking for.</NOTE>"
file_text_with_line_numbers += truncation_note
return f"{header}:\n{file_text_with_line_numbers}"
@dataclass
class TruncatedString:
# Blocks
before_lines: list[str]
middle_lines: list[str]
after_lines: list[str]
# Line numbers (starting from 1)
truncated_start_line: int
truncated_end_line: int
# Truncation msg
truncation_msg: str
single_line: bool
def as_str(self, lines: list[str]) -> str:
return "".join(lines)
@property
def full_truncated_str(self) -> str:
return "".join(self.before_lines + [self.truncation_msg] + self.after_lines)
def truncate_from_middle_v2(ss: str, max_len: int, n_line_offset: int = 0) -> "str | TruncatedString":
"""
If no truncation is needed, returns the original string.
If truncation is needed, returns TruncatedString
"""
# No truncation needed
if len(ss) <= max_len:
return ss
# Single line
lines_with_endings = ss.splitlines(True)
if len(lines_with_endings) == 1:
chars_per_side = max(1, max_len // 2)
truncated_char_count = len(ss) - (chars_per_side * 2)
truncation_msg = f"...< truncated {truncated_char_count} characters >..."
before_lines = [ss[:chars_per_side] + truncation_msg + ss[-chars_per_side:]]
return TruncatedString(
before_lines=before_lines,
middle_lines=[],
after_lines=[],
truncated_start_line=1 + n_line_offset,
truncated_end_line=1 + n_line_offset,
truncation_msg=truncation_msg,
single_line=True,
)
# Line truncation
current_len = 0
before_lines = []
middle_lines = deque(lines_with_endings)
after_lines = deque([])
while current_len < max_len and len(middle_lines) > 1:
# Before
before_candidate_line = middle_lines[0]
if len(before_candidate_line) + current_len <= max_len:
before_lines.append(middle_lines.popleft())
current_len += len(before_candidate_line)
else:
break
# After
if len(middle_lines) > 1:
after_candidate_line = middle_lines[-1]
if len(after_candidate_line) + current_len <= max_len:
after_lines.appendleft(middle_lines.pop())
current_len += len(after_candidate_line)
else:
break
# Find truncated lines
first_truncated_line = 1 + len(before_lines) + n_line_offset
last_truncated_line = first_truncated_line + len(middle_lines) - 1
if ss.endswith(("\n", "\r", "\r\n")) and len(after_lines) == 0:
last_truncated_line += 1
# Create truncation msg
if first_truncated_line == last_truncated_line:
truncation_msg = f"< truncated line {first_truncated_line} >"
else:
truncation_msg = f"< truncated lines {first_truncated_line}-{last_truncated_line} >"
if len(after_lines) != 0:
if before_lines[0].endswith("\r\n"):
truncation_msg += "\r\n"
elif before_lines[0].endswith("\r"):
truncation_msg += "\r"
else:
truncation_msg += "\n"
return TruncatedString(
# Blocks
before_lines=before_lines,
middle_lines=list(middle_lines),
after_lines=list(after_lines),
# Line numbers (starting from 1)
truncated_start_line=first_truncated_line,
truncated_end_line=last_truncated_line,
# Truncation msg
truncation_msg=truncation_msg,
single_line=False,
)

View File

@@ -0,0 +1,66 @@
"""Utility to run shell commands asynchronously with a timeout."""
import asyncio # noqa -- swapping to trio would be beneficial, but not blocking atm
import os
TRUNCATED_MESSAGE: str = "<response clipped><NOTE>To save on context only part of this file has been shown to you. You should retry this tool after you have searched inside the file with `grep -n` in order to find the line numbers of what you are looking for.</NOTE>"
MAX_RESPONSE_LEN: int = 16000
def maybe_truncate(content: str, truncate_after: int | None = MAX_RESPONSE_LEN):
"""Truncate content and append a notice if content exceeds the specified length."""
return (
content
if not truncate_after or len(content) <= truncate_after
else content[:truncate_after] + TRUNCATED_MESSAGE
)
def demote():
"""Drop privileges to uid/gid 1000 for security.
This function is intended to be used as a preexec_fn in subprocess calls
to ensure commands run with reduced privileges.
"""
os.setgid(1000)
os.setuid(1000)
async def run(
cmd: str,
timeout: float | None = 120.0, # seconds # noqa: ASYNC109
truncate_after: int | None = MAX_RESPONSE_LEN,
preexec_fn=demote,
):
"""Run a shell command asynchronously with a timeout.
Args:
cmd: Command to execute
timeout: Command timeout in seconds
truncate_after: Maximum response length before truncation
preexec_fn: Function to run in child process before exec (default: demote).
Pass None to skip preexec, or any callable for custom behavior.
Returns:
Tuple of (return_code, stdout, stderr)
"""
process = await asyncio.create_subprocess_shell(
cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
preexec_fn=preexec_fn,
)
try:
stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout)
return (
process.returncode or 0,
maybe_truncate(stdout.decode(), truncate_after=truncate_after),
maybe_truncate(stderr.decode(), truncate_after=truncate_after),
)
except TimeoutError as exc:
try:
process.kill()
except ProcessLookupError:
pass
raise TimeoutError(f"Command '{cmd}' timed out after {timeout} seconds") from exc

View File

@@ -0,0 +1,20 @@
# Your actual toolset (this overrides any earlier tool guidance above)
This harness gives you exactly two ways to act, both through the `Bash` tool:
1. **Shell commands** for everything read-only and for running things: view and search files with `cat`, `sed -n`, `grep -rn`, `find`, `ls`; run tests; run `git`; etc.
2. **A `str_replace_editor` file editor**, which you invoke from Bash by piping ONE JSON object on stdin to `/opt/agent-cli/str_replace_editor`. Use a quoted heredoc so backslashes and quotes survive:
`/opt/agent-cli/str_replace_editor <<'EDITOR'` then a line of JSON then `EDITOR`
The JSON `"command"` field selects the operation:
- `view` — view a file (optionally `"view_range":[start,end]`) or list a directory: `{"command":"view","path":"/abs/file.rb"}`
- `create` — create a NEW file (fails if it exists): `{"command":"create","path":"/abs/new.rb","file_text":"..."}`
- `str_replace` — replace a UNIQUE substring: `{"command":"str_replace","path":"/abs/file.rb","old_str":"...","new_str":"..."}`
- `insert` — insert text after a line: `{"command":"insert","path":"/abs/file.rb","insert_line":N,"insert_text":"..."}`
Paths must be absolute. Inside JSON strings, escape newlines as `\n` and double-quotes as `\"`.
There are **no** `Read`, `Grep`, `Glob`, `Edit`, `Write`, `MultiEdit`, `NotebookEdit`, `Task`, `TodoWrite`, or `AskUserQuestion` tools — `Bash` is your only built-in tool. So disregard the earlier "Prefer the dedicated file/search tools over shell commands" guidance and the Memory section's "use the Write tool" instruction: those tools are not available in this harness. Search and read with shell commands; view, create, and edit files with `str_replace_editor`.
There is also no tool for asking the user an interactive question. If you need to ask the user something, or raise a concern about the request before acting on it, put it in your normal text response.

View File

@@ -0,0 +1,4 @@
## Browser
Chromium is available in this environment via Playwright. `pw <script.js>` runs Node with
`require("playwright")` resolvable (CommonJS — `import` will not find it).

View File

@@ -0,0 +1,7 @@
## Correction to the toolset above: you also have `Read`
This task runs with `Read` in addition to `Bash`, so the statement above that there is no `Read`
tool does not apply here. `Read` renders images — use it to look at a screenshot you have
written to disk. Everything else above still holds: no `Grep`, `Glob`, `Edit`, `Write`,
`MultiEdit`, `NotebookEdit`, `Task`, `TodoWrite` or `AskUserQuestion`, and you still create and
edit files with `str_replace_editor`.