diff --git a/bin/disinto b/bin/disinto index 69e34dd..dc56f39 100755 --- a/bin/disinto +++ b/bin/disinto @@ -1789,7 +1789,6 @@ _validate_env_vars() { while IFS='|' read -r service_name forge_user base_url _api_key; do [ -n "$service_name" ] || continue [ -n "$forge_user" ] || continue - [ -n "$base_url" ] || continue # Derive variable names (user -> USER_UPPER) local user_upper @@ -1809,7 +1808,7 @@ _validate_env_vars() { errors=$((errors + 1)) fi - # Check backend URL or API key + # Check backend URL or API key (conditional based on base_url presence) if [ -n "$base_url" ]; then # Local model: needs ANTHROPIC_BASE_URL if [ -z "${env_vars[ANTHROPIC_BASE_URL]:-}" ]; then diff --git a/lib/hire-agent.sh b/lib/hire-agent.sh index 91e7250..149845b 100644 --- a/lib/hire-agent.sh +++ b/lib/hire-agent.sh @@ -30,6 +30,29 @@ disinto_hire_an_agent() { echo "Usage: disinto hire-an-agent [--formula ] [--local-model ] [--model ] [--poll-interval ]" >&2 exit 1 fi + + # Validate agent name before any side effects (Forgejo user creation, TOML + # write, token issuance). The name flows through several systems that have + # stricter rules than the raw TOML spec: + # - load-project.sh emits shell vars keyed by the name (dashes are mapped + # to underscores via tr 'a-z-' 'A-Z_') + # - generators.sh emits a docker-compose service name `agents-` and + # uppercases it for env var keys (#852 tracks the `^^` bug; we keep the + # grammar tight here so that fix can happen without re-validation) + # - Forgejo usernames are lowercase alnum + dash + # Constraint: start with a lowercase letter, contain only [a-z0-9-], end + # with a lowercase letter or digit (no trailing dash), no consecutive + # dashes. Rejecting at hire-time prevents unparseable TOML sections like + # [agents.dev-qwen2] from landing on disk and crashing load-project.sh on + # the next `disinto up` (#862). + if ! [[ "$agent_name" =~ ^[a-z]([a-z0-9]|-[a-z0-9])*$ ]]; then + echo "Error: invalid agent name '${agent_name}'" >&2 + echo " Agent names must match: ^[a-z]([a-z0-9]|-[a-z0-9])*$" >&2 + echo " (lowercase letters/digits/single dashes, starts with letter, ends with alphanumeric)" >&2 + echo " Examples: dev, dev-qwen2, review-qwen, planner" >&2 + exit 1 + fi + shift 2 # Parse flags @@ -239,8 +262,10 @@ disinto_hire_an_agent() { # Local model agent: write ANTHROPIC_BASE_URL local backend_var="ANTHROPIC_BASE_URL" local backend_val="$local_model" + local escaped_val + escaped_val=$(printf '%s\n' "$backend_val" | sed 's/[&/\]/\\&/g') if grep -q "^${backend_var}=" "$env_file" 2>/dev/null; then - sed -i "s|^${backend_var}=.*|${backend_var}=${backend_val}|" "$env_file" + sed -i "s|^${backend_var}=.*|${backend_var}=${escaped_val}|" "$env_file" echo " ${backend_var} updated" else printf '%s=%s\n' "$backend_var" "$backend_val" >> "$env_file" diff --git a/lib/load-project.sh b/lib/load-project.sh index 0745276..5ad23cc 100755 --- a/lib/load-project.sh +++ b/lib/load-project.sh @@ -129,20 +129,26 @@ agents = cfg.get('agents', {}) for name, config in agents.items(): if not isinstance(config, dict): continue + # Normalize the TOML section key into a valid shell identifier fragment. + # TOML allows dashes in bare keys (e.g. [agents.dev-qwen2]), but POSIX + # shell var names cannot contain '-'. Match the 'tr a-z- A-Z_' convention + # used in hire-agent.sh (#834) and generators.sh (#852) so the var names + # stay consistent across the stack. + safe = name.upper().replace('-', '_') # Emit variables in uppercase with the agent name if 'base_url' in config: - print(f'AGENT_{name.upper()}_BASE_URL={config[\"base_url\"]}') + print(f'AGENT_{safe}_BASE_URL={config[\"base_url\"]}') if 'model' in config: - print(f'AGENT_{name.upper()}_MODEL={config[\"model\"]}') + print(f'AGENT_{safe}_MODEL={config[\"model\"]}') if 'api_key' in config: - print(f'AGENT_{name.upper()}_API_KEY={config[\"api_key\"]}') + print(f'AGENT_{safe}_API_KEY={config[\"api_key\"]}') if 'roles' in config: roles = ' '.join(config['roles']) if isinstance(config['roles'], list) else config['roles'] - print(f'AGENT_{name.upper()}_ROLES={roles}') + print(f'AGENT_{safe}_ROLES={roles}') if 'forge_user' in config: - print(f'AGENT_{name.upper()}_FORGE_USER={config[\"forge_user\"]}') + print(f'AGENT_{safe}_FORGE_USER={config[\"forge_user\"]}') if 'compact_pct' in config: - print(f'AGENT_{name.upper()}_COMPACT_PCT={config[\"compact_pct\"]}') + print(f'AGENT_{safe}_COMPACT_PCT={config[\"compact_pct\"]}') " "$_PROJECT_TOML" 2>/dev/null) || true if [ -n "$_AGENT_VARS" ]; then diff --git a/tests/lib-load-project.bats b/tests/lib-load-project.bats new file mode 100644 index 0000000..89e82be --- /dev/null +++ b/tests/lib-load-project.bats @@ -0,0 +1,186 @@ +#!/usr/bin/env bats +# ============================================================================= +# tests/lib-load-project.bats — Regression guard for the #862 fix. +# +# TOML allows dashes in bare keys, so `[agents.dev-qwen2]` is a valid section +# header. Before #862, load-project.sh translated the section name into a +# shell variable name via Python's `.upper()` alone, which kept the dash and +# produced `AGENT_DEV-QWEN2_BASE_URL`. `export "AGENT_DEV-QWEN2_..."` is +# rejected by bash ("not a valid identifier"), and with `set -euo pipefail` +# anywhere up-stack that error aborts load-project.sh — effectively crashing +# the factory on the N+1 run after a dashed agent was hired. +# +# The fix normalizes via `.upper().replace('-', '_')`, matching the +# `tr 'a-z-' 'A-Z_'` convention already used in hire-agent.sh and +# generators.sh. +# ============================================================================= + +setup() { + ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)" + TOML="${BATS_TEST_TMPDIR}/test.toml" +} + +@test "dashed [agents.*] section name parses without error" { + cat > "$TOML" < "$TOML" < "$TOML" <