Acceptance items 1-4 landed previously: the primary compose emission (FORGE_BOT_USER_*) was fixed in #849 by re-keying on forge_user via `tr 'a-z-' 'A-Z_'`, and the load-project.sh AGENT_* Python emitter was normalized via `.upper().replace('-', '_')` in #862. Together they produce `FORGE_BOT_USER_DEV_QWEN2` and `AGENT_DEV_QWEN2_BASE_URL` for `[agents.dev-qwen2]` with `forge_user = "dev-qwen2"`. This patch closes acceptance item 5 — the defence-in-depth warn-and-skip in load-project.sh's two export loops. Hire-agent's up-front reject is the primary line of defence (a validated `^[a-z]([a-z0-9]|-[a-z0-9])*$` agent name can't produce a bad identifier), but a hand-edited TOML can still smuggle invalid keys through: - `[mirrors] my-mirror = "…"` — the `MIRROR_<NAME>` emitter only upper-cases, so `MY-MIRROR` retains its dash and fails `export`. - `[agents."weird name"]` — quoted TOML keys bypass the bare-key grammar entirely, so spaces and other disallowed shell chars reach the export loop unchanged. Before this change, either case would abort load-project.sh under `set -euo pipefail` — the exact failure mode the original #852 crash-loop was diagnosed from. Now each loop validates `$_key` against `^[A-Za-z_][A-Za-z0-9_]*$` and warn-skips offenders so siblings still load. - `lib/load-project.sh` — regex guard + WARNING on stderr in both `_PROJECT_VARS` and `_AGENT_VARS` export loops. - `tests/lib-load-project.bats` — two regressions: dashed mirror key, quoted agent section with space. Both assert (a) the load does not abort and (b) sane siblings still load. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
185 lines
6.9 KiB
Bash
Executable file
185 lines
6.9 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# load-project.sh — Load project config from a TOML file into env vars
|
|
#
|
|
# Usage (source, don't execute):
|
|
# source lib/load-project.sh projects/harb.toml
|
|
#
|
|
# Exports:
|
|
# PROJECT_NAME, FORGE_REPO, FORGE_API, FORGE_WEB, FORGE_URL,
|
|
# PROJECT_REPO_ROOT, PRIMARY_BRANCH, WOODPECKER_REPO_ID,
|
|
# PROJECT_CONTAINERS, CHECK_PRS, CHECK_DEV_AGENT,
|
|
# CHECK_PIPELINE_STALL, CI_STALE_MINUTES,
|
|
# MIRROR_NAMES, MIRROR_URLS, MIRROR_<NAME> (per configured mirror)
|
|
#
|
|
# If no argument given, does nothing (allows poll scripts to work with
|
|
# plain .env fallback for backwards compatibility).
|
|
|
|
_PROJECT_TOML="${1:-}"
|
|
|
|
if [ -z "$_PROJECT_TOML" ] || [ ! -f "$_PROJECT_TOML" ]; then
|
|
return 0 2>/dev/null || exit 0
|
|
fi
|
|
|
|
# Parse TOML to shell variable assignments via Python
|
|
_PROJECT_VARS=$(python3 -c "
|
|
import sys, tomllib
|
|
|
|
with open(sys.argv[1], 'rb') as f:
|
|
cfg = tomllib.load(f)
|
|
|
|
def emit(key, val):
|
|
if isinstance(val, bool):
|
|
print(f'{key}={str(val).lower()}')
|
|
elif isinstance(val, list):
|
|
print(f'{key}={\" \".join(str(v) for v in val)}')
|
|
else:
|
|
print(f'{key}={val}')
|
|
|
|
# Top-level
|
|
emit('PROJECT_NAME', cfg.get('name', ''))
|
|
emit('FORGE_REPO', cfg.get('repo', ''))
|
|
emit('FORGE_URL', cfg.get('forge_url', ''))
|
|
|
|
if 'repo_root' in cfg:
|
|
emit('PROJECT_REPO_ROOT', cfg['repo_root'])
|
|
if 'ops_repo_root' in cfg:
|
|
emit('OPS_REPO_ROOT', cfg['ops_repo_root'])
|
|
if 'ops_repo' in cfg:
|
|
emit('FORGE_OPS_REPO', cfg['ops_repo'])
|
|
if 'primary_branch' in cfg:
|
|
emit('PRIMARY_BRANCH', cfg['primary_branch'])
|
|
|
|
# [ci] section
|
|
ci = cfg.get('ci', {})
|
|
if 'woodpecker_repo_id' in ci:
|
|
emit('WOODPECKER_REPO_ID', ci['woodpecker_repo_id'])
|
|
if 'stale_minutes' in ci:
|
|
emit('CI_STALE_MINUTES', ci['stale_minutes'])
|
|
|
|
# [services] section
|
|
svc = cfg.get('services', {})
|
|
if 'containers' in svc:
|
|
emit('PROJECT_CONTAINERS', svc['containers'])
|
|
|
|
# [monitoring] section
|
|
mon = cfg.get('monitoring', {})
|
|
for key in ['check_prs', 'check_dev_agent', 'check_pipeline_stall']:
|
|
if key in mon:
|
|
emit(key.upper(), mon[key])
|
|
|
|
# [mirrors] section
|
|
mirrors = cfg.get('mirrors', {})
|
|
for name, url in mirrors.items():
|
|
emit(f'MIRROR_{name.upper()}', url)
|
|
if mirrors:
|
|
emit('MIRROR_NAMES', list(mirrors.keys()))
|
|
emit('MIRROR_URLS', list(mirrors.values()))
|
|
" "$_PROJECT_TOML" 2>/dev/null) || {
|
|
echo "WARNING: failed to parse project TOML: $_PROJECT_TOML" >&2
|
|
return 1 2>/dev/null || exit 1
|
|
}
|
|
|
|
# Export parsed variables.
|
|
# Inside the agents container (DISINTO_CONTAINER=1), compose already sets the
|
|
# correct FORGE_URL (http://forgejo:3000) and path vars for the container
|
|
# environment. The TOML carries host-perspective values (localhost, /home/admin/…)
|
|
# that would break container API calls and path resolution. Skip overriding
|
|
# any env var that is already set when running inside the container.
|
|
#
|
|
# #852 defence: validate that $_key is a legal shell identifier before
|
|
# `export`. A hand-edited TOML can smuggle in keys that survive the
|
|
# Python emitter but fail `export`'s identifier rule — e.g.
|
|
# `[mirrors] my-mirror = "..."` becomes `MIRROR_MY-MIRROR` because the
|
|
# MIRROR_<NAME> emitter only upper-cases, it does not dash-to-underscore.
|
|
# Without this guard `export "MIRROR_MY-MIRROR=…"` returns non-zero, and
|
|
# under `set -euo pipefail` in the caller the whole file aborts — which
|
|
# is how the original #852 crash-loop presented. Warn-and-skip keeps
|
|
# the rest of the TOML loadable.
|
|
while IFS='=' read -r _key _val; do
|
|
[ -z "$_key" ] && continue
|
|
if ! [[ "$_key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
|
|
echo "WARNING: load-project: skipping invalid shell identifier from TOML: $_key" >&2
|
|
continue
|
|
fi
|
|
if [ "${DISINTO_CONTAINER:-}" = "1" ] && [ -n "${!_key:-}" ]; then
|
|
continue
|
|
fi
|
|
export "$_key=$_val"
|
|
done <<< "$_PROJECT_VARS"
|
|
|
|
# Derive FORGE_API and FORGE_WEB from forge_url + repo
|
|
# FORGE_URL: TOML forge_url > existing FORGE_URL > default
|
|
export FORGE_URL="${FORGE_URL:-http://localhost:3000}"
|
|
if [ -n "$FORGE_REPO" ]; then
|
|
export FORGE_API_BASE="${FORGE_URL}/api/v1"
|
|
export FORGE_API="${FORGE_API_BASE}/repos/${FORGE_REPO}"
|
|
export FORGE_WEB="${FORGE_URL}/${FORGE_REPO}"
|
|
# Extract repo owner (first path segment of owner/repo)
|
|
export FORGE_REPO_OWNER="${FORGE_REPO%%/*}"
|
|
fi
|
|
|
|
# PROJECT_REPO_ROOT and OPS_REPO_ROOT: no fallback derivation from USER/HOME.
|
|
# These must be set by the entrypoint (container) or the TOML (host CLI).
|
|
# Inside the container, the entrypoint exports the correct paths before agent
|
|
# scripts source env.sh; the TOML's host-perspective paths are skipped by the
|
|
# DISINTO_CONTAINER guard above.
|
|
|
|
# Derive FORGE_OPS_REPO if not explicitly set
|
|
if [ -z "${FORGE_OPS_REPO:-}" ] && [ -n "${FORGE_REPO:-}" ]; then
|
|
export FORGE_OPS_REPO="${FORGE_REPO}-ops"
|
|
fi
|
|
|
|
# Parse [agents.*] sections for local-model agents
|
|
# Exports AGENT_<NAME>_BASE_URL, AGENT_<NAME>_MODEL, AGENT_<NAME>_API_KEY,
|
|
# AGENT_<NAME>_ROLES, AGENT_<NAME>_FORGE_USER, AGENT_<NAME>_COMPACT_PCT
|
|
if command -v python3 &>/dev/null; then
|
|
_AGENT_VARS=$(python3 -c "
|
|
import sys, tomllib
|
|
|
|
with open(sys.argv[1], 'rb') as f:
|
|
cfg = tomllib.load(f)
|
|
|
|
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_{safe}_BASE_URL={config[\"base_url\"]}')
|
|
if 'model' in config:
|
|
print(f'AGENT_{safe}_MODEL={config[\"model\"]}')
|
|
if 'api_key' in config:
|
|
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_{safe}_ROLES={roles}')
|
|
if 'forge_user' in config:
|
|
print(f'AGENT_{safe}_FORGE_USER={config[\"forge_user\"]}')
|
|
if 'compact_pct' in config:
|
|
print(f'AGENT_{safe}_COMPACT_PCT={config[\"compact_pct\"]}')
|
|
" "$_PROJECT_TOML" 2>/dev/null) || true
|
|
|
|
if [ -n "$_AGENT_VARS" ]; then
|
|
# #852 defence: same warn-and-skip guard as the main loop above. The
|
|
# Python emitter already normalizes dashed agent names (#862), but a
|
|
# quoted TOML section like `[agents."weird name"]` could still produce
|
|
# an invalid identifier. Fail loudly but keep other agents loadable.
|
|
while IFS='=' read -r _key _val; do
|
|
[ -z "$_key" ] && continue
|
|
if ! [[ "$_key" =~ ^[A-Za-z_][A-Za-z0-9_]*$ ]]; then
|
|
echo "WARNING: load-project: skipping invalid shell identifier from [agents.*]: $_key" >&2
|
|
continue
|
|
fi
|
|
export "$_key=$_val"
|
|
done <<< "$_AGENT_VARS"
|
|
fi
|
|
unset _AGENT_VARS
|
|
fi
|
|
|
|
unset _PROJECT_TOML _PROJECT_VARS _key _val
|