From 564e89e445816f508416c79d7e4fb45ad06b8a99 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Apr 2026 13:23:18 +0000 Subject: [PATCH 1/5] fix: bug: generator emits invalid env var name FORGE_BOT_USER_^^ when service name contains hyphen (#852) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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_` 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) --- lib/load-project.sh | 22 ++++++++++++ tests/lib-load-project.bats | 67 +++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) diff --git a/lib/load-project.sh b/lib/load-project.sh index 5ad23cc..e42d6dc 100755 --- a/lib/load-project.sh +++ b/lib/load-project.sh @@ -85,8 +85,22 @@ if mirrors: # 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_ 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 @@ -152,8 +166,16 @@ for name, config in agents.items(): " "$_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 diff --git a/tests/lib-load-project.bats b/tests/lib-load-project.bats index 89e82be..f0c583a 100644 --- a/tests/lib-load-project.bats +++ b/tests/lib-load-project.bats @@ -184,3 +184,70 @@ EOF [ "$status" -ne 0 ] [[ "$output" == *"invalid agent name"* ]] } + +# ------------------------------------------------------------------------- +# #852 defence: the export loops must warn-and-skip invalid identifiers +# rather than tank `set -euo pipefail`. Hire-agent's up-front reject +# (tests above) is the primary line of defence, but a hand-edited TOML — +# e.g. [mirrors] my-mirror = "…" or a quoted [agents."weird name"] — can +# still produce invalid shell identifiers downstream. The guard keeps +# the factory loading the rest of the file instead of crash-looping. +# ------------------------------------------------------------------------- + +@test "[mirrors] dashed key: warn-and-skip, does not crash under set -e" { + cat > "$TOML" <&1 + echo \"GOOD=\${MIRROR_GOOD:-MISSING}\" + " + + # Whole load did not abort under set -e. + [ "$status" -eq 0 ] + # The valid mirror still loads. + [[ "$output" == *"GOOD=https://example.com/good"* ]] + # The invalid one triggers a warning; load continues instead of crashing. + [[ "$output" == *"skipping invalid shell identifier"* ]] + [[ "$output" == *"MIRROR_BAD-NAME"* ]] +} + +@test "[agents.*] quoted section with space: warn-and-skip, does not crash" { + # TOML permits quoted keys with arbitrary characters. A hand-edited + # `[agents."weird name"]` would survive the Python .replace('-', '_') + # (because it has no dash) but still contains a space, which would + # yield AGENT_WEIRD NAME_BASE_URL — not a valid identifier. + cat > "$TOML" <<'EOF' +name = "test" +repo = "test-owner/test-repo" +forge_url = "http://localhost:3000" + +[agents.llama] +base_url = "http://10.10.10.1:8081" +model = "qwen" + +[agents."weird name"] +base_url = "http://10.10.10.1:8082" +model = "qwen-bad" +EOF + + run bash -c " + set -euo pipefail + source '${ROOT}/lib/load-project.sh' '$TOML' 2>&1 + echo \"LLAMA=\${AGENT_LLAMA_BASE_URL:-MISSING}\" + " + + # The sane sibling must still be loaded despite the malformed neighbour. + [ "$status" -eq 0 ] + [[ "$output" == *"LLAMA=http://10.10.10.1:8081"* ]] + # The invalid agent's identifier triggers a warning and is skipped. + [[ "$output" == *"skipping invalid shell identifier"* ]] +} From 4d3345216c08bc5641749fb1de69c47e024649ca Mon Sep 17 00:00:00 2001 From: Agent Date: Thu, 16 Apr 2026 13:22:38 +0000 Subject: [PATCH 2/5] fix: Compose generator should detect duplicate service names at generate-time (#850) --- lib/generators.sh | 61 ++++++- tests/smoke-init.sh | 43 +++++ tests/test-duplicate-service-detection.sh | 210 ++++++++++++++++++++++ 3 files changed, 312 insertions(+), 2 deletions(-) create mode 100755 tests/test-duplicate-service-detection.sh diff --git a/lib/generators.sh b/lib/generators.sh index 87d997b..5413f54 100644 --- a/lib/generators.sh +++ b/lib/generators.sh @@ -66,6 +66,28 @@ _get_primary_woodpecker_repo_id() { echo "$max_id" } +# Track service names for duplicate detection +declare -A _seen_services +declare -A _service_sources + +# Record a service name and its source; return 0 if unique, 1 if duplicate +_record_service() { + local service_name="$1" + local source="$2" + + if [ -n "${_seen_services[$service_name]:-}" ]; then + local original_source="${_service_sources[$service_name]}" + echo "ERROR: Duplicate service name '$service_name' detected —" >&2 + echo " '$service_name' emitted twice — from $original_source and from $source" >&2 + echo " Remove one of the conflicting activations to proceed." >&2 + return 1 + fi + + _seen_services[$service_name]=1 + _service_sources[$service_name]="$source" + return 0 +} + # Parse project TOML for local-model agents and emit compose services. # Writes service definitions to stdout; caller handles insertion into compose file. _generate_local_model_services() { @@ -97,6 +119,16 @@ _generate_local_model_services() { POLL_INTERVAL) poll_interval_val="$value" ;; ---) if [ -n "$service_name" ] && [ -n "$base_url" ]; then + # Record service for duplicate detection using the full service name + local full_service_name="agents-${service_name}" + local toml_basename + toml_basename=$(basename "$toml") + if ! _record_service "$full_service_name" "[agents.$service_name] in projects/$toml_basename"; then + # Duplicate detected — clean up and abort + rm -f "$temp_file" + return 1 + fi + # Per-agent FORGE_TOKEN / FORGE_PASS lookup (#834 Gap 3). # Two hired llama agents must not share the same Forgejo identity, # so we key the env-var lookup by forge_user (which hire-agent.sh @@ -109,11 +141,14 @@ _generate_local_model_services() { # the service caused `disinto up` (without COMPOSE_PROFILES) # to treat the hired container as an orphan and silently # remove it via --remove-orphans. + # Compute the actual service name that will be emitted + local full_service_name="agents-${service_name}" + local user_upper user_upper=$(echo "$forge_user" | tr 'a-z-' 'A-Z_') cat >> "$temp_file" <> "$compose_file" <<'LLAMAEOF' agents-llama: @@ -740,7 +794,10 @@ COMPOSEEOF fi # Append local-model agent services if any are configured - _generate_local_model_services "$compose_file" + if ! _generate_local_model_services "$compose_file"; then + echo "ERROR: Failed to generate local-model agent services. See errors above." >&2 + return 1 + fi # Resolve the Claude CLI binary path and persist as CLAUDE_BIN_DIR in .env. # docker-compose.yml references ${CLAUDE_BIN_DIR} so the value must be set. diff --git a/tests/smoke-init.sh b/tests/smoke-init.sh index 306f7ee..2642f61 100644 --- a/tests/smoke-init.sh +++ b/tests/smoke-init.sh @@ -423,6 +423,49 @@ export CLAUDE_SHARED_DIR="$ORIG_CLAUDE_SHARED_DIR" export CLAUDE_CONFIG_DIR="$ORIG_CLAUDE_CONFIG_DIR" rm -rf /tmp/smoke-claude-shared /tmp/smoke-home-claude +# ── 8. Test duplicate service name detection ────────────────────────────── +echo "=== 8/8 Testing duplicate service name detection ===" + +# Clean up for duplicate test +rm -f "${FACTORY_ROOT}/projects/duplicate-test.toml" + +# Create a TOML that would conflict with ENABLE_LLAMA_AGENT +cat > "${FACTORY_ROOT}/projects/duplicate-test.toml" <<'TOMLEOF' +name = "duplicate-test" +description = "Test project for duplicate service detection" + +[ci] +woodpecker_repo_id = "999" + +[agents.llama] +base_url = "http://localhost:8080" +model = "qwen:latest" +roles = ["dev"] +forge_user = "llama-bot" +TOMLEOF + +# Run disinto init with ENABLE_LLAMA_AGENT=1 +# This should fail because [agents.llama] conflicts with ENABLE_LLAMA_AGENT +export ENABLE_LLAMA_AGENT="1" +export FORGE_URL="http://localhost:3000" +export SMOKE_FORGE_URL="$FORGE_URL" +export FORGE_ADMIN_PASS="smoke-test-password-123" +export SKIP_PUSH=true + +if bash "${FACTORY_ROOT}/bin/disinto" init \ + "duplicate-test" \ + --bare --yes \ + --forge-url "$FORGE_URL" \ + --repo-root "/tmp/smoke-test-repo" 2>&1 | grep -q "Duplicate service name 'agents-llama'"; then + pass "Duplicate service detection: correctly detected conflict between ENABLE_LLAMA_AGENT and [agents.llama]" +else + fail "Duplicate service detection: should have detected conflict between ENABLE_LLAMA_AGENT and [agents.llama]" +fi + +# Clean up +rm -f "${FACTORY_ROOT}/projects/duplicate-test.toml" +unset ENABLE_LLAMA_AGENT + # ── Summary ────────────────────────────────────────────────────────────────── echo "" if [ "$FAILED" -ne 0 ]; then diff --git a/tests/test-duplicate-service-detection.sh b/tests/test-duplicate-service-detection.sh new file mode 100755 index 0000000..11fde86 --- /dev/null +++ b/tests/test-duplicate-service-detection.sh @@ -0,0 +1,210 @@ +#!/usr/bin/env bash +# tests/test-duplicate-service-detection.sh — Unit test for duplicate service detection +# +# Tests that the compose generator correctly detects duplicate service names +# between ENABLE_LLAMA_AGENT=1 and [agents.llama] TOML configuration. + +set -euo pipefail + +# Get the absolute path to the disinto root +DISINTO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TEST_DIR=$(mktemp -d) +trap "rm -rf \"\$TEST_DIR\"" EXIT + +FAILED=0 + +fail() { printf 'FAIL: %s\n' "$*" >&2; FAILED=1; } +pass() { printf 'PASS: %s\n' "$*"; } + +# Test 1: Duplicate between ENABLE_LLAMA_AGENT and [agents.llama] +echo "=== Test 1: Duplicate between ENABLE_LLAMA_AGENT and [agents.llama] ===" + +# Create projects directory and test project TOML with an agent named "llama" +mkdir -p "${TEST_DIR}/projects" +cat > "${TEST_DIR}/projects/test-project.toml" <<'TOMLEOF' +name = "test-project" +description = "Test project for duplicate detection" + +[ci] +woodpecker_repo_id = "123" + +[agents.llama] +base_url = "http://localhost:8080" +model = "qwen:latest" +roles = ["dev"] +forge_user = "llama-bot" +TOMLEOF + +# Create a minimal compose file +cat > "${TEST_DIR}/docker-compose.yml" <<'COMPOSEEOF' +# Test compose file +services: + agents: + image: test:latest + command: echo "hello" + +volumes: + test-data: + +networks: + test-net: +COMPOSEEOF + +# Set up the test environment +export FACTORY_ROOT="${TEST_DIR}" +export PROJECT_NAME="test-project" +export ENABLE_LLAMA_AGENT="1" +export FORGE_TOKEN="" +export FORGE_PASS="" +export CLAUDE_TIMEOUT="7200" +export POLL_INTERVAL="300" +export GARDENER_INTERVAL="21600" +export ARCHITECT_INTERVAL="21600" +export PLANNER_INTERVAL="43200" +export SUPERVISOR_INTERVAL="1200" + +# Source the generators module and run the compose generator directly +source "${DISINTO_ROOT}/lib/generators.sh" + +# Delete the compose file to force regeneration +rm -f "${TEST_DIR}/docker-compose.yml" + +# Run the compose generator directly +if _generate_compose_impl 3000 false 2>&1 | tee "${TEST_DIR}/output.txt"; then + # Check if the output contains the duplicate error message + if grep -q "Duplicate service name 'agents-llama'" "${TEST_DIR}/output.txt"; then + pass "Duplicate detection: correctly detected conflict between ENABLE_LLAMA_AGENT and [agents.llama]" + else + fail "Duplicate detection: should have detected conflict between ENABLE_LLAMA_AGENT and [agents.llama]" + cat "${TEST_DIR}/output.txt" >&2 + fi +else + # Generator should fail with non-zero exit code + if grep -q "Duplicate service name 'agents-llama'" "${TEST_DIR}/output.txt"; then + pass "Duplicate detection: correctly detected conflict and returned non-zero exit code" + else + fail "Duplicate detection: should have failed with duplicate error" + cat "${TEST_DIR}/output.txt" >&2 + fi +fi + +# Test 2: No duplicate when only ENABLE_LLAMA_AGENT is set (no conflicting TOML) +echo "" +echo "=== Test 2: No duplicate when only ENABLE_LLAMA_AGENT is set ===" + +# Remove the projects directory created in Test 1 +rm -rf "${TEST_DIR}/projects" + +# Create a fresh compose file +cat > "${TEST_DIR}/docker-compose.yml" <<'COMPOSEEOF' +# Test compose file +services: + agents: + image: test:latest + +volumes: + test-data: + +networks: + test-net: +COMPOSEEOF + +# Set ENABLE_LLAMA_AGENT +export ENABLE_LLAMA_AGENT="1" + +# Delete the compose file to force regeneration +rm -f "${TEST_DIR}/docker-compose.yml" + +if _generate_compose_impl 3000 false 2>&1 | tee "${TEST_DIR}/output2.txt"; then + if grep -q "Duplicate" "${TEST_DIR}/output2.txt"; then + fail "No duplicate: should not detect duplicate when only ENABLE_LLAMA_AGENT is set" + else + pass "No duplicate: correctly generated compose without duplicates" + fi +else + # Non-zero exit is fine if there's a legitimate reason (e.g., missing files) + if grep -q "Duplicate" "${TEST_DIR}/output2.txt"; then + fail "No duplicate: should not detect duplicate when only ENABLE_LLAMA_AGENT is set" + else + pass "No duplicate: generator failed for other reason (acceptable)" + fi +fi + +# Test 3: Duplicate between two TOML agents with same name +echo "" +echo "=== Test 3: Duplicate between two TOML agents with same name ===" + +rm -f "${TEST_DIR}/docker-compose.yml" + +# Create projects directory for Test 3 +mkdir -p "${TEST_DIR}/projects" + +cat > "${TEST_DIR}/projects/project1.toml" <<'TOMLEOF' +name = "project1" +description = "First project" + +[ci] +woodpecker_repo_id = "1" + +[agents.llama] +base_url = "http://localhost:8080" +model = "qwen:latest" +roles = ["dev"] +forge_user = "llama-bot1" +TOMLEOF + +cat > "${TEST_DIR}/projects/project2.toml" <<'TOMLEOF' +name = "project2" +description = "Second project" + +[ci] +woodpecker_repo_id = "2" + +[agents.llama] +base_url = "http://localhost:8080" +model = "qwen:latest" +roles = ["dev"] +forge_user = "llama-bot2" +TOMLEOF + +cat > "${TEST_DIR}/docker-compose.yml" <<'COMPOSEEOF' +# Test compose file +services: + agents: + image: test:latest + +volumes: + test-data: + +networks: + test-net: +COMPOSEEOF + +unset ENABLE_LLAMA_AGENT + +# Delete the compose file to force regeneration +rm -f "${TEST_DIR}/docker-compose.yml" + +if _generate_compose_impl 3000 false 2>&1 | tee "${TEST_DIR}/output3.txt"; then + if grep -q "Duplicate service name 'agents-llama'" "${TEST_DIR}/output3.txt"; then + pass "Duplicate detection: correctly detected conflict between two [agents.llama] blocks" + else + fail "Duplicate detection: should have detected conflict between two [agents.llama] blocks" + cat "${TEST_DIR}/output3.txt" >&2 + fi +else + if grep -q "Duplicate service name 'agents-llama'" "${TEST_DIR}/output3.txt"; then + pass "Duplicate detection: correctly detected conflict and returned non-zero exit code" + else + fail "Duplicate detection: should have failed with duplicate error" + cat "${TEST_DIR}/output3.txt" >&2 + fi +fi + +# Summary +echo "" +if [ "$FAILED" -ne 0 ]; then + echo "=== TESTS FAILED ===" + exit 1 +fi +echo "=== ALL TESTS PASSED ===" From a434d9724b54d2a4c6fa27a1b0cc23493c062089 Mon Sep 17 00:00:00 2001 From: Agent Date: Thu, 16 Apr 2026 13:37:55 +0000 Subject: [PATCH 3/5] Allow known duplicate test patterns for duplicate service detection tests --- .woodpecker/detect-duplicates.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.woodpecker/detect-duplicates.py b/.woodpecker/detect-duplicates.py index 0485833..981a9f0 100644 --- a/.woodpecker/detect-duplicates.py +++ b/.woodpecker/detect-duplicates.py @@ -294,6 +294,9 @@ def main() -> int: "9f6ae8e7811575b964279d8820494eb0": "Verification helper: for loop done pattern", # Standard lib source block shared across formula-driven agent run scripts "330e5809a00b95ade1a5fce2d749b94b": "Standard lib source block (env.sh, formula-session.sh, worktree.sh, guard.sh, agent-sdk.sh)", + # Test files for duplicate service detection (#850) + "334967b8b4f1a8d3b0b9b8e0912f3bfb": "TOML [agents.llama] block in smoke-init.sh and test-duplicate-service-detection.sh", + "d82f30077e5bb23b5fc01db003033d5d": "TOML base_url/model/roles in smoke-init.sh and test-duplicate-service-detection.sh", } if not sh_files: From 2da32d474ab54fb880450fbb78062b8c9951e831 Mon Sep 17 00:00:00 2001 From: Agent Date: Thu, 16 Apr 2026 13:22:38 +0000 Subject: [PATCH 4/5] fix: Compose generator should detect duplicate service names at generate-time (#850) --- lib/generators.sh | 61 ++++++- tests/smoke-init.sh | 43 +++++ tests/test-duplicate-service-detection.sh | 210 ++++++++++++++++++++++ 3 files changed, 312 insertions(+), 2 deletions(-) create mode 100755 tests/test-duplicate-service-detection.sh diff --git a/lib/generators.sh b/lib/generators.sh index 87d997b..5413f54 100644 --- a/lib/generators.sh +++ b/lib/generators.sh @@ -66,6 +66,28 @@ _get_primary_woodpecker_repo_id() { echo "$max_id" } +# Track service names for duplicate detection +declare -A _seen_services +declare -A _service_sources + +# Record a service name and its source; return 0 if unique, 1 if duplicate +_record_service() { + local service_name="$1" + local source="$2" + + if [ -n "${_seen_services[$service_name]:-}" ]; then + local original_source="${_service_sources[$service_name]}" + echo "ERROR: Duplicate service name '$service_name' detected —" >&2 + echo " '$service_name' emitted twice — from $original_source and from $source" >&2 + echo " Remove one of the conflicting activations to proceed." >&2 + return 1 + fi + + _seen_services[$service_name]=1 + _service_sources[$service_name]="$source" + return 0 +} + # Parse project TOML for local-model agents and emit compose services. # Writes service definitions to stdout; caller handles insertion into compose file. _generate_local_model_services() { @@ -97,6 +119,16 @@ _generate_local_model_services() { POLL_INTERVAL) poll_interval_val="$value" ;; ---) if [ -n "$service_name" ] && [ -n "$base_url" ]; then + # Record service for duplicate detection using the full service name + local full_service_name="agents-${service_name}" + local toml_basename + toml_basename=$(basename "$toml") + if ! _record_service "$full_service_name" "[agents.$service_name] in projects/$toml_basename"; then + # Duplicate detected — clean up and abort + rm -f "$temp_file" + return 1 + fi + # Per-agent FORGE_TOKEN / FORGE_PASS lookup (#834 Gap 3). # Two hired llama agents must not share the same Forgejo identity, # so we key the env-var lookup by forge_user (which hire-agent.sh @@ -109,11 +141,14 @@ _generate_local_model_services() { # the service caused `disinto up` (without COMPOSE_PROFILES) # to treat the hired container as an orphan and silently # remove it via --remove-orphans. + # Compute the actual service name that will be emitted + local full_service_name="agents-${service_name}" + local user_upper user_upper=$(echo "$forge_user" | tr 'a-z-' 'A-Z_') cat >> "$temp_file" <> "$compose_file" <<'LLAMAEOF' agents-llama: @@ -740,7 +794,10 @@ COMPOSEEOF fi # Append local-model agent services if any are configured - _generate_local_model_services "$compose_file" + if ! _generate_local_model_services "$compose_file"; then + echo "ERROR: Failed to generate local-model agent services. See errors above." >&2 + return 1 + fi # Resolve the Claude CLI binary path and persist as CLAUDE_BIN_DIR in .env. # docker-compose.yml references ${CLAUDE_BIN_DIR} so the value must be set. diff --git a/tests/smoke-init.sh b/tests/smoke-init.sh index 306f7ee..2642f61 100644 --- a/tests/smoke-init.sh +++ b/tests/smoke-init.sh @@ -423,6 +423,49 @@ export CLAUDE_SHARED_DIR="$ORIG_CLAUDE_SHARED_DIR" export CLAUDE_CONFIG_DIR="$ORIG_CLAUDE_CONFIG_DIR" rm -rf /tmp/smoke-claude-shared /tmp/smoke-home-claude +# ── 8. Test duplicate service name detection ────────────────────────────── +echo "=== 8/8 Testing duplicate service name detection ===" + +# Clean up for duplicate test +rm -f "${FACTORY_ROOT}/projects/duplicate-test.toml" + +# Create a TOML that would conflict with ENABLE_LLAMA_AGENT +cat > "${FACTORY_ROOT}/projects/duplicate-test.toml" <<'TOMLEOF' +name = "duplicate-test" +description = "Test project for duplicate service detection" + +[ci] +woodpecker_repo_id = "999" + +[agents.llama] +base_url = "http://localhost:8080" +model = "qwen:latest" +roles = ["dev"] +forge_user = "llama-bot" +TOMLEOF + +# Run disinto init with ENABLE_LLAMA_AGENT=1 +# This should fail because [agents.llama] conflicts with ENABLE_LLAMA_AGENT +export ENABLE_LLAMA_AGENT="1" +export FORGE_URL="http://localhost:3000" +export SMOKE_FORGE_URL="$FORGE_URL" +export FORGE_ADMIN_PASS="smoke-test-password-123" +export SKIP_PUSH=true + +if bash "${FACTORY_ROOT}/bin/disinto" init \ + "duplicate-test" \ + --bare --yes \ + --forge-url "$FORGE_URL" \ + --repo-root "/tmp/smoke-test-repo" 2>&1 | grep -q "Duplicate service name 'agents-llama'"; then + pass "Duplicate service detection: correctly detected conflict between ENABLE_LLAMA_AGENT and [agents.llama]" +else + fail "Duplicate service detection: should have detected conflict between ENABLE_LLAMA_AGENT and [agents.llama]" +fi + +# Clean up +rm -f "${FACTORY_ROOT}/projects/duplicate-test.toml" +unset ENABLE_LLAMA_AGENT + # ── Summary ────────────────────────────────────────────────────────────────── echo "" if [ "$FAILED" -ne 0 ]; then diff --git a/tests/test-duplicate-service-detection.sh b/tests/test-duplicate-service-detection.sh new file mode 100755 index 0000000..11fde86 --- /dev/null +++ b/tests/test-duplicate-service-detection.sh @@ -0,0 +1,210 @@ +#!/usr/bin/env bash +# tests/test-duplicate-service-detection.sh — Unit test for duplicate service detection +# +# Tests that the compose generator correctly detects duplicate service names +# between ENABLE_LLAMA_AGENT=1 and [agents.llama] TOML configuration. + +set -euo pipefail + +# Get the absolute path to the disinto root +DISINTO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +TEST_DIR=$(mktemp -d) +trap "rm -rf \"\$TEST_DIR\"" EXIT + +FAILED=0 + +fail() { printf 'FAIL: %s\n' "$*" >&2; FAILED=1; } +pass() { printf 'PASS: %s\n' "$*"; } + +# Test 1: Duplicate between ENABLE_LLAMA_AGENT and [agents.llama] +echo "=== Test 1: Duplicate between ENABLE_LLAMA_AGENT and [agents.llama] ===" + +# Create projects directory and test project TOML with an agent named "llama" +mkdir -p "${TEST_DIR}/projects" +cat > "${TEST_DIR}/projects/test-project.toml" <<'TOMLEOF' +name = "test-project" +description = "Test project for duplicate detection" + +[ci] +woodpecker_repo_id = "123" + +[agents.llama] +base_url = "http://localhost:8080" +model = "qwen:latest" +roles = ["dev"] +forge_user = "llama-bot" +TOMLEOF + +# Create a minimal compose file +cat > "${TEST_DIR}/docker-compose.yml" <<'COMPOSEEOF' +# Test compose file +services: + agents: + image: test:latest + command: echo "hello" + +volumes: + test-data: + +networks: + test-net: +COMPOSEEOF + +# Set up the test environment +export FACTORY_ROOT="${TEST_DIR}" +export PROJECT_NAME="test-project" +export ENABLE_LLAMA_AGENT="1" +export FORGE_TOKEN="" +export FORGE_PASS="" +export CLAUDE_TIMEOUT="7200" +export POLL_INTERVAL="300" +export GARDENER_INTERVAL="21600" +export ARCHITECT_INTERVAL="21600" +export PLANNER_INTERVAL="43200" +export SUPERVISOR_INTERVAL="1200" + +# Source the generators module and run the compose generator directly +source "${DISINTO_ROOT}/lib/generators.sh" + +# Delete the compose file to force regeneration +rm -f "${TEST_DIR}/docker-compose.yml" + +# Run the compose generator directly +if _generate_compose_impl 3000 false 2>&1 | tee "${TEST_DIR}/output.txt"; then + # Check if the output contains the duplicate error message + if grep -q "Duplicate service name 'agents-llama'" "${TEST_DIR}/output.txt"; then + pass "Duplicate detection: correctly detected conflict between ENABLE_LLAMA_AGENT and [agents.llama]" + else + fail "Duplicate detection: should have detected conflict between ENABLE_LLAMA_AGENT and [agents.llama]" + cat "${TEST_DIR}/output.txt" >&2 + fi +else + # Generator should fail with non-zero exit code + if grep -q "Duplicate service name 'agents-llama'" "${TEST_DIR}/output.txt"; then + pass "Duplicate detection: correctly detected conflict and returned non-zero exit code" + else + fail "Duplicate detection: should have failed with duplicate error" + cat "${TEST_DIR}/output.txt" >&2 + fi +fi + +# Test 2: No duplicate when only ENABLE_LLAMA_AGENT is set (no conflicting TOML) +echo "" +echo "=== Test 2: No duplicate when only ENABLE_LLAMA_AGENT is set ===" + +# Remove the projects directory created in Test 1 +rm -rf "${TEST_DIR}/projects" + +# Create a fresh compose file +cat > "${TEST_DIR}/docker-compose.yml" <<'COMPOSEEOF' +# Test compose file +services: + agents: + image: test:latest + +volumes: + test-data: + +networks: + test-net: +COMPOSEEOF + +# Set ENABLE_LLAMA_AGENT +export ENABLE_LLAMA_AGENT="1" + +# Delete the compose file to force regeneration +rm -f "${TEST_DIR}/docker-compose.yml" + +if _generate_compose_impl 3000 false 2>&1 | tee "${TEST_DIR}/output2.txt"; then + if grep -q "Duplicate" "${TEST_DIR}/output2.txt"; then + fail "No duplicate: should not detect duplicate when only ENABLE_LLAMA_AGENT is set" + else + pass "No duplicate: correctly generated compose without duplicates" + fi +else + # Non-zero exit is fine if there's a legitimate reason (e.g., missing files) + if grep -q "Duplicate" "${TEST_DIR}/output2.txt"; then + fail "No duplicate: should not detect duplicate when only ENABLE_LLAMA_AGENT is set" + else + pass "No duplicate: generator failed for other reason (acceptable)" + fi +fi + +# Test 3: Duplicate between two TOML agents with same name +echo "" +echo "=== Test 3: Duplicate between two TOML agents with same name ===" + +rm -f "${TEST_DIR}/docker-compose.yml" + +# Create projects directory for Test 3 +mkdir -p "${TEST_DIR}/projects" + +cat > "${TEST_DIR}/projects/project1.toml" <<'TOMLEOF' +name = "project1" +description = "First project" + +[ci] +woodpecker_repo_id = "1" + +[agents.llama] +base_url = "http://localhost:8080" +model = "qwen:latest" +roles = ["dev"] +forge_user = "llama-bot1" +TOMLEOF + +cat > "${TEST_DIR}/projects/project2.toml" <<'TOMLEOF' +name = "project2" +description = "Second project" + +[ci] +woodpecker_repo_id = "2" + +[agents.llama] +base_url = "http://localhost:8080" +model = "qwen:latest" +roles = ["dev"] +forge_user = "llama-bot2" +TOMLEOF + +cat > "${TEST_DIR}/docker-compose.yml" <<'COMPOSEEOF' +# Test compose file +services: + agents: + image: test:latest + +volumes: + test-data: + +networks: + test-net: +COMPOSEEOF + +unset ENABLE_LLAMA_AGENT + +# Delete the compose file to force regeneration +rm -f "${TEST_DIR}/docker-compose.yml" + +if _generate_compose_impl 3000 false 2>&1 | tee "${TEST_DIR}/output3.txt"; then + if grep -q "Duplicate service name 'agents-llama'" "${TEST_DIR}/output3.txt"; then + pass "Duplicate detection: correctly detected conflict between two [agents.llama] blocks" + else + fail "Duplicate detection: should have detected conflict between two [agents.llama] blocks" + cat "${TEST_DIR}/output3.txt" >&2 + fi +else + if grep -q "Duplicate service name 'agents-llama'" "${TEST_DIR}/output3.txt"; then + pass "Duplicate detection: correctly detected conflict and returned non-zero exit code" + else + fail "Duplicate detection: should have failed with duplicate error" + cat "${TEST_DIR}/output3.txt" >&2 + fi +fi + +# Summary +echo "" +if [ "$FAILED" -ne 0 ]; then + echo "=== TESTS FAILED ===" + exit 1 +fi +echo "=== ALL TESTS PASSED ===" From db009e3a089e287eb964c2cc55950775f63d5b3c Mon Sep 17 00:00:00 2001 From: Agent Date: Thu, 16 Apr 2026 13:37:55 +0000 Subject: [PATCH 5/5] Allow known duplicate test patterns for duplicate service detection tests --- .woodpecker/detect-duplicates.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.woodpecker/detect-duplicates.py b/.woodpecker/detect-duplicates.py index 0485833..981a9f0 100644 --- a/.woodpecker/detect-duplicates.py +++ b/.woodpecker/detect-duplicates.py @@ -294,6 +294,9 @@ def main() -> int: "9f6ae8e7811575b964279d8820494eb0": "Verification helper: for loop done pattern", # Standard lib source block shared across formula-driven agent run scripts "330e5809a00b95ade1a5fce2d749b94b": "Standard lib source block (env.sh, formula-session.sh, worktree.sh, guard.sh, agent-sdk.sh)", + # Test files for duplicate service detection (#850) + "334967b8b4f1a8d3b0b9b8e0912f3bfb": "TOML [agents.llama] block in smoke-init.sh and test-duplicate-service-detection.sh", + "d82f30077e5bb23b5fc01db003033d5d": "TOML base_url/model/roles in smoke-init.sh and test-duplicate-service-detection.sh", } if not sh_files: