From 6589c761ba6248bac4dfd2367840663b1e7c148b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Apr 2026 13:21:30 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20refactor:=20lib/env.sh=20=E2=80=94?= =?UTF-8?q?=20split=20into=20a=20defined-surface=20shared=20lib;=20entrypo?= =?UTF-8?q?ints=20own=20context-specific=20paths=20(#674)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- bin/disinto | 7 ++++ docker/agents/entrypoint.sh | 24 +++++++++++++- docker/edge/entrypoint-edge.sh | 11 +++++-- docker/reproduce/entrypoint-reproduce.sh | 6 ++++ lib/env.sh | 42 ++++++++++++++++++++---- lib/load-project.sh | 21 +++--------- 6 files changed, 86 insertions(+), 25 deletions(-) diff --git a/bin/disinto b/bin/disinto index ed66e4c..d480496 100755 --- a/bin/disinto +++ b/bin/disinto @@ -24,6 +24,13 @@ set -euo pipefail FACTORY_ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +# Ensure USER and HOME are set — preconditions for lib/env.sh (#674). +# On the host these are normally provided by the shell; defensive defaults +# handle edge cases (cron, minimal containers). +export USER="${USER:-$(id -un)}" +export HOME="${HOME:-$(eval echo "~${USER}")}" + source "${FACTORY_ROOT}/lib/env.sh" source "${FACTORY_ROOT}/lib/ops-setup.sh" # setup_ops_repo, migrate_ops_repo source "${FACTORY_ROOT}/lib/hire-agent.sh" diff --git a/docker/agents/entrypoint.sh b/docker/agents/entrypoint.sh index d80327a..5259205 100644 --- a/docker/agents/entrypoint.sh +++ b/docker/agents/entrypoint.sh @@ -101,8 +101,11 @@ configure_tea_login() { log "Agent container starting" -# Set USER for scripts that source lib/env.sh (e.g., OPS_REPO_ROOT default) +# Set USER and HOME for scripts that source lib/env.sh. +# These are preconditions required by lib/env.sh's surface contract. +# gosu agent inherits the parent's env, so exports here propagate to all children. export USER=agent +export HOME=/home/agent # Verify Claude CLI is available (expected via volume mount from host). if ! command -v claude &>/dev/null; then @@ -343,6 +346,25 @@ while true; do # The flock on session.lock already serializes claude -p calls. for toml in "${DISINTO_DIR}"/projects/*.toml; do [ -f "$toml" ] || continue + + # Parse project name and primary branch from TOML so env.sh preconditions + # are satisfied when agent scripts source it (#674). + _toml_vals=$(python3 -c " +import tomllib, sys +with open(sys.argv[1], 'rb') as f: + cfg = tomllib.load(f) +print(cfg.get('name', '')) +print(cfg.get('primary_branch', 'main')) +" "$toml" 2>/dev/null || true) + _pname=$(sed -n '1p' <<< "$_toml_vals") + _pbranch=$(sed -n '2p' <<< "$_toml_vals") + [ -n "$_pname" ] || { log "WARNING: could not parse project name from ${toml} — skipping"; continue; } + + export PROJECT_NAME="$_pname" + export PROJECT_REPO_ROOT="/home/agent/repos/${_pname}" + export OPS_REPO_ROOT="/home/agent/repos/${_pname}-ops" + export PRIMARY_BRANCH="${_pbranch:-main}" + log "Processing project TOML: ${toml}" # --- Fast agents: run in background, wait before slow agents --- diff --git a/docker/edge/entrypoint-edge.sh b/docker/edge/entrypoint-edge.sh index c557d3e..2a23cf9 100755 --- a/docker/edge/entrypoint-edge.sh +++ b/docker/edge/entrypoint-edge.sh @@ -1,8 +1,9 @@ #!/usr/bin/env bash set -euo pipefail -# Set USER before sourcing env.sh (Alpine doesn't set USER) -export USER="${USER:-root}" +# Set USER and HOME before sourcing env.sh — preconditions for lib/env.sh (#674). +export USER="${USER:-agent}" +export HOME="${HOME:-/home/agent}" FORGE_URL="${FORGE_URL:-http://forgejo:3000}" @@ -138,6 +139,12 @@ if [ -n "${EDGE_TUNNEL_HOST:-}" ]; then fi fi +# Set project context vars for scripts that source lib/env.sh (#674). +# These satisfy env.sh's preconditions for edge-container scripts. +export PROJECT_REPO_ROOT="${PROJECT_REPO_ROOT:-/opt/disinto}" +export PRIMARY_BRANCH="${PRIMARY_BRANCH:-main}" +export OPS_REPO_ROOT="${OPS_REPO_ROOT:-/home/agent/repos/${PROJECT_NAME:-disinto}-ops}" + # Start dispatcher in background bash /opt/disinto/docker/edge/dispatcher.sh & diff --git a/docker/reproduce/entrypoint-reproduce.sh b/docker/reproduce/entrypoint-reproduce.sh index 70be607..b33cbf7 100644 --- a/docker/reproduce/entrypoint-reproduce.sh +++ b/docker/reproduce/entrypoint-reproduce.sh @@ -84,6 +84,10 @@ export DISINTO_CONTAINER=1 export HOME="${HOME:-/home/agent}" export USER="${USER:-agent}" +# Set project context vars for lib/env.sh surface contract (#674). +# PROJECT_NAME and PROJECT_REPO_ROOT are set below after TOML parsing. +export PRIMARY_BRANCH="${PRIMARY_BRANCH:-main}" + # Configure git credential helper so reproduce/triage agents can clone/push # without needing tokens embedded in remote URLs (#604). if [ -f "${DISINTO_DIR}/lib/git-creds.sh" ]; then @@ -107,6 +111,8 @@ with open(sys.argv[1], 'rb') as f: export PROJECT_NAME PROJECT_REPO_ROOT="/home/agent/repos/${PROJECT_NAME}" +export PROJECT_REPO_ROOT +export OPS_REPO_ROOT="${OPS_REPO_ROOT:-/home/agent/repos/${PROJECT_NAME}-ops}" if [ "$AGENT_TYPE" = "triage" ]; then log "Starting triage-agent for issue #${ISSUE_NUMBER} (project: ${PROJECT_NAME})" diff --git a/lib/env.sh b/lib/env.sh index 503aebb..e6ce002 100755 --- a/lib/env.sh +++ b/lib/env.sh @@ -1,12 +1,41 @@ #!/usr/bin/env bash +# ============================================================================= # env.sh — Load environment and shared utilities # Source this at the top of every script: source "$(dirname "$0")/lib/env.sh" +# +# SURFACE CONTRACT +# +# Required preconditions — the entrypoint (or caller) MUST set these before +# sourcing this file: +# USER — OS user name (e.g. "agent", "johba") +# HOME — home directory (e.g. "/home/agent") +# +# Required when PROJECT_TOML is set (i.e. agent scripts loading a project): +# PROJECT_REPO_ROOT — absolute path to the project git clone +# PRIMARY_BRANCH — default branch name (e.g. "main") +# OPS_REPO_ROOT — absolute path to the ops repo clone +# (these are normally populated by load-project.sh from the TOML) +# +# What this file sets / exports: +# FACTORY_ROOT, DISINTO_LOG_DIR +# .env / .env.enc secrets (FORGE_TOKEN, etc.) +# FORGE_API, FORGE_WEB, TEA_LOGIN, FORGE_OPS_REPO (derived from FORGE_URL/FORGE_REPO) +# Per-agent tokens (FORGE_REVIEW_TOKEN, FORGE_GARDENER_TOKEN, …) +# CLAUDE_SHARED_DIR, CLAUDE_CONFIG_DIR +# Helper functions: log(), validate_url(), forge_api(), forge_api_all(), +# woodpecker_api(), wpdb(), memory_guard() +# ============================================================================= set -euo pipefail # Resolve script root (parent of lib/) FACTORY_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +# ── Precondition assertions ────────────────────────────────────────────────── +# These must be set by the entrypoint before sourcing this file. +: "${USER:?must be set by entrypoint before sourcing lib/env.sh}" +: "${HOME:?must be set by entrypoint before sourcing lib/env.sh}" + # Container detection: when running inside the agent container, DISINTO_CONTAINER # is set by docker-compose.yml. Adjust paths so phase files, logs, and thread # maps land on the persistent volume instead of /tmp (which is ephemeral). @@ -72,7 +101,6 @@ fi # PATH: foundry, node, system export PATH="${HOME}/.local/bin:${HOME}/.foundry/bin:${HOME}/.nvm/versions/node/v22.20.0/bin:/usr/local/bin:/usr/bin:/bin:${PATH}" -export HOME="${HOME:-/home/debian}" # Load project TOML if PROJECT_TOML is set (by poll scripts that accept project arg) if [ -n "${PROJECT_TOML:-}" ] && [ -f "$PROJECT_TOML" ]; then @@ -112,12 +140,14 @@ fi export TEA_LOGIN export PROJECT_NAME="${PROJECT_NAME:-${FORGE_REPO##*/}}" -export PROJECT_REPO_ROOT="${PROJECT_REPO_ROOT:-/home/${USER}/${PROJECT_NAME}}" -export PRIMARY_BRANCH="${PRIMARY_BRANCH:-master}" -# Ops repo: operational data (vault items, journals, evidence, prerequisites). -# Default convention: sibling directory named {project}-ops. -export OPS_REPO_ROOT="${OPS_REPO_ROOT:-/home/${USER}/${PROJECT_NAME}-ops}" +# Project-specific paths: no guessing from USER/HOME — must be set by +# the entrypoint or loaded from PROJECT_TOML (via load-project.sh above). +if [ -n "${PROJECT_TOML:-}" ]; then + : "${PROJECT_REPO_ROOT:?must be set by entrypoint or PROJECT_TOML before sourcing lib/env.sh}" + : "${PRIMARY_BRANCH:?must be set by entrypoint or PROJECT_TOML before sourcing lib/env.sh}" + : "${OPS_REPO_ROOT:?must be set by entrypoint or PROJECT_TOML before sourcing lib/env.sh}" +fi # Forge repo slug for the ops repo (used by agents that commit to ops). export FORGE_OPS_REPO="${FORGE_OPS_REPO:-${FORGE_REPO:+${FORGE_REPO}-ops}}" diff --git a/lib/load-project.sh b/lib/load-project.sh index 134461c..27a426c 100755 --- a/lib/load-project.sh +++ b/lib/load-project.sh @@ -103,22 +103,11 @@ if [ -n "$FORGE_REPO" ]; then export FORGE_REPO_OWNER="${FORGE_REPO%%/*}" fi -# Derive PROJECT_REPO_ROOT if not explicitly set -if [ -z "${PROJECT_REPO_ROOT:-}" ] && [ -n "${PROJECT_NAME:-}" ]; then - export PROJECT_REPO_ROOT="/home/${USER}/${PROJECT_NAME}" -fi - -# Derive OPS_REPO_ROOT if not explicitly set -if [ -z "${OPS_REPO_ROOT:-}" ] && [ -n "${PROJECT_NAME:-}" ]; then - export OPS_REPO_ROOT="/home/${USER}/${PROJECT_NAME}-ops" -fi - -# Inside the container, always derive repo paths from PROJECT_NAME — the TOML -# carries host-perspective paths that do not exist in the container filesystem. -if [ "${DISINTO_CONTAINER:-}" = "1" ] && [ -n "${PROJECT_NAME:-}" ]; then - export PROJECT_REPO_ROOT="/home/agent/repos/${PROJECT_NAME}" - export OPS_REPO_ROOT="/home/agent/repos/${PROJECT_NAME}-ops" -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 From 3f66defae94bc8627ab153b81bd8f867be16b669 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 11 Apr 2026 13:39:18 +0000 Subject: [PATCH 2/2] docs: update lib/AGENTS.md for env.sh preconditions and load-project.sh container path changes (#674) Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/AGENTS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/AGENTS.md b/lib/AGENTS.md index 9f81115..f69fea9 100644 --- a/lib/AGENTS.md +++ b/lib/AGENTS.md @@ -6,11 +6,11 @@ sourced as needed. | File | What it provides | Sourced by | |---|---|---| -| `lib/env.sh` | Loads `.env`, sets `FACTORY_ROOT`, exports project config (`FORGE_REPO`, `PROJECT_NAME`, etc.), defines `log()`, `forge_api()`, `forge_api_all()` (paginates all pages; accepts optional second TOKEN parameter, defaults to `$FORGE_TOKEN`; handles invalid/empty JSON responses gracefully — returns empty on parse error instead of crashing), `woodpecker_api()`, `wpdb()`, `memory_guard()` (skips agent if RAM < threshold). Auto-loads project TOML if `PROJECT_TOML` is set. Exports per-agent tokens (`FORGE_PLANNER_TOKEN`, `FORGE_GARDENER_TOKEN`, `FORGE_VAULT_TOKEN`, `FORGE_SUPERVISOR_TOKEN`, `FORGE_PREDICTOR_TOKEN`) — each falls back to `$FORGE_TOKEN` if not set. **Vault-only token guard (AD-006)**: `unset GITHUB_TOKEN CLAWHUB_TOKEN` so agents never hold external-action tokens — only the runner container receives them. **Container note**: when `DISINTO_CONTAINER=1`, `.env` is NOT re-sourced — compose already injects env vars (including `FORGE_URL=http://forgejo:3000`) and re-sourcing would clobber them. **Save/restore scope (#364)**: only `FORGE_URL` is preserved across `.env` re-sourcing (compose injects `http://forgejo:3000`, `.env` has `http://localhost:3000`). `FORGE_TOKEN` is NOT preserved so refreshed tokens in `.env` take effect immediately. **Required env var**: `FORGE_PASS` — bot password for git HTTP push (Forgejo 11.x rejects API tokens for `git push`, #361). | Every agent | +| `lib/env.sh` | Loads `.env`, sets `FACTORY_ROOT`, exports project config (`FORGE_REPO`, `PROJECT_NAME`, etc.), defines `log()`, `forge_api()`, `forge_api_all()` (paginates all pages; accepts optional second TOKEN parameter, defaults to `$FORGE_TOKEN`; handles invalid/empty JSON responses gracefully — returns empty on parse error instead of crashing), `woodpecker_api()`, `wpdb()`, `memory_guard()` (skips agent if RAM < threshold). Auto-loads project TOML if `PROJECT_TOML` is set. Exports per-agent tokens (`FORGE_PLANNER_TOKEN`, `FORGE_GARDENER_TOKEN`, `FORGE_VAULT_TOKEN`, `FORGE_SUPERVISOR_TOKEN`, `FORGE_PREDICTOR_TOKEN`) — each falls back to `$FORGE_TOKEN` if not set. **Vault-only token guard (AD-006)**: `unset GITHUB_TOKEN CLAWHUB_TOKEN` so agents never hold external-action tokens — only the runner container receives them. **Container note**: when `DISINTO_CONTAINER=1`, `.env` is NOT re-sourced — compose already injects env vars (including `FORGE_URL=http://forgejo:3000`) and re-sourcing would clobber them. **Save/restore scope (#364)**: only `FORGE_URL` is preserved across `.env` re-sourcing (compose injects `http://forgejo:3000`, `.env` has `http://localhost:3000`). `FORGE_TOKEN` is NOT preserved so refreshed tokens in `.env` take effect immediately. **Required env var**: `FORGE_PASS` — bot password for git HTTP push (Forgejo 11.x rejects API tokens for `git push`, #361). **Hard preconditions (#674)**: `USER` and `HOME` must be exported by the entrypoint before sourcing. When `PROJECT_TOML` is set, `PROJECT_REPO_ROOT`, `PRIMARY_BRANCH`, and `OPS_REPO_ROOT` must also be set (by entrypoint or TOML). | Every agent | | `lib/ci-helpers.sh` | `ci_passed()` — returns 0 if CI state is "success" (or no CI configured). `ci_required_for_pr()` — returns 0 if PR has code files (CI required), 1 if non-code only (CI not required). `is_infra_step()` — returns 0 if a single CI step failure matches infra heuristics (clone/git exit 128, any exit 137, log timeout patterns). `classify_pipeline_failure()` — returns "infra \" if any failed Woodpecker step matches infra heuristics via `is_infra_step()`, else "code". `ensure_priority_label()` — looks up (or creates) the `priority` label and returns its ID; caches in `_PRIORITY_LABEL_ID`. `ci_commit_status ` — queries Woodpecker directly for CI state, falls back to forge commit status API. `ci_pipeline_number ` — returns the Woodpecker pipeline number for a commit, falls back to parsing forge status `target_url`. `ci_promote ` — promotes a pipeline to a named Woodpecker environment (vault-gated deployment: vault approves, vault-fire calls this — vault redesign in progress, see #73-#77). `ci_get_logs [--step ]` — reads CI logs from Woodpecker SQLite database via `lib/ci-log-reader.py`; outputs last 200 lines to stdout. Requires mounted woodpecker-data volume at /woodpecker-data. | dev-poll, review-poll, review-pr | | `lib/ci-debug.sh` | CLI tool for Woodpecker CI: `list`, `status`, `logs`, `failures` subcommands. Not sourced — run directly. | Humans / dev-agent (tool access) | | `lib/ci-log-reader.py` | Python tool: reads CI logs from Woodpecker SQLite database. ` [--step ]` — returns last 200 lines from failed steps (or specified step). Used by `ci_get_logs()` in ci-helpers.sh. Requires `WOODPECKER_DATA_DIR` (default: /woodpecker-data). | ci-helpers.sh | -| `lib/load-project.sh` | Parses a `projects/*.toml` file into env vars (`PROJECT_NAME`, `FORGE_REPO`, `WOODPECKER_REPO_ID`, monitoring toggles, mirror config, etc.). Also exports `FORGE_REPO_OWNER` (the owner component of `FORGE_REPO`, e.g. `disinto-admin` from `disinto-admin/disinto`). **Container path derivation**: `PROJECT_REPO_ROOT` and `OPS_REPO_ROOT` are derived at runtime when `DISINTO_CONTAINER=1` — hardcoded to `/home/agent/repos/$PROJECT_NAME` and `/home/agent/repos/$PROJECT_NAME-ops` respectively — not read from the TOML. This ensures correct paths inside containers where host paths in the TOML would be wrong. | env.sh (when `PROJECT_TOML` is set) | +| `lib/load-project.sh` | Parses a `projects/*.toml` file into env vars (`PROJECT_NAME`, `FORGE_REPO`, `WOODPECKER_REPO_ID`, monitoring toggles, mirror config, etc.). Also exports `FORGE_REPO_OWNER` (the owner component of `FORGE_REPO`, e.g. `disinto-admin` from `disinto-admin/disinto`). Reads `repo_root` and `ops_repo_root` from the TOML for host-CLI callers. **Container path handling (#674)**: no longer derives `PROJECT_REPO_ROOT` or `OPS_REPO_ROOT` inside the script — container entrypoints export the correct paths before agent scripts source `env.sh`, and the `DISINTO_CONTAINER` guard (line 90) skips TOML overrides when those vars are already set. | env.sh (when `PROJECT_TOML` is set) | | `lib/parse-deps.sh` | Extracts dependency issue numbers from an issue body (stdin → stdout, one number per line). Matches `## Dependencies` / `## Depends on` / `## Blocked by` sections and inline `depends on #N` / `blocked by #N` patterns. Inline scan skips fenced code blocks to prevent false positives from code examples in issue bodies. Not sourced — executed via `bash lib/parse-deps.sh`. | dev-poll | | `lib/formula-session.sh` | `acquire_run_lock()`, `load_formula()`, `load_formula_or_profile()`, `build_context_block()`, `ensure_ops_repo()`, `ops_commit_and_push()`, `build_prompt_footer()`, `build_sdk_prompt_footer()`, `formula_worktree_setup()`, `formula_prepare_profile_context()`, `formula_lessons_block()`, `profile_write_journal()`, `profile_load_lessons()`, `ensure_profile_repo()`, `_profile_has_repo()`, `_count_undigested_journals()`, `_profile_digest_journals()`, `_profile_commit_and_push()`, `resolve_agent_identity()`, `build_graph_section()`, `build_scratch_instruction()`, `read_scratch_context()`, `cleanup_stale_crashed_worktrees()` — shared helpers for formula-driven polling-loop agents (lock, .profile repo management, prompt assembly, worktree setup). Memory guard is provided by `memory_guard()` in `lib/env.sh` (not duplicated here). `resolve_agent_identity()` — sets `FORGE_TOKEN`, `AGENT_IDENTITY`, `FORGE_REMOTE` from per-agent token env vars and FORGE_URL remote detection. `build_graph_section()` generates the structural-analysis section (runs `lib/build-graph.py`, formats JSON output) — previously duplicated in planner-run.sh and predictor-run.sh, now shared here. `cleanup_stale_crashed_worktrees()` — thin wrapper around `worktree_cleanup_stale()` from `lib/worktree.sh` (kept for backwards compatibility). | planner-run.sh, predictor-run.sh, gardener-run.sh, supervisor-run.sh, dev-agent.sh | | `lib/guard.sh` | `check_active(agent_name)` — reads `$FACTORY_ROOT/state/.{agent_name}-active`; exits 0 (skip) if the file is absent. Factory is off by default — state files must be created to enable each agent. **Logs a message to stderr** when skipping (`[check_active] SKIP: state file not found`), so agent dropout is visible in loop logs. Sourced by dev-poll.sh, review-poll.sh, predictor-run.sh, supervisor-run.sh. | polling-loop entry points |