#!/usr/bin/env bash # Conductor auto-apply: integrate a worker's patch under the declared policy. # # Usage: scripts/conductor-apply.sh [--dry-run] # # Policy (roles/conductor-policy.json in the target repo, strictly validated): # autoApply.enabled master switch # autoApply.allowedPaths glob allowlist ('dir/**' = everything under dir) # autoApply.suites suite scripts that must pass AFTER applying # # Gate sequence: succeeded run record -> clean target tree -> diff extracted # from the worker workspace -> allowlist -> syntax gates (node/bash/json) -> # apply -> policy suites -> commit with attribution. ANY failure reverts the # working tree and exits nonzero. Push is never automatic. # # Environment: # MOSAIC_APPLY_TARGET repo root to apply into (default: this project) set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" TARGET_ROOT="${MOSAIC_APPLY_TARGET:-$(cd "$SCRIPT_DIR/.." && pwd)}" RUN_ID="${1:?usage: conductor-apply.sh [--dry-run]}" DRY_RUN="no" [ "${2:-}" = "--dry-run" ] && DRY_RUN="yes" cd "$TARGET_ROOT" fail() { echo "conductor-apply: $*" >&2; exit "${2:-1}"; } [ -d .git ] || fail "target is not a git repository: $TARGET_ROOT" 4 [ -f roles/conductor-policy.json ] || fail "no roles/conductor-policy.json in target" 2 # ---- policy (strict) ---- POLICY_JSON="$(node -e ' const fs = require("fs"); const p = JSON.parse(fs.readFileSync("roles/conductor-policy.json", "utf8")); if (p.policyVersion !== 1) process.exit(3); if (!p.autoApply || typeof p.autoApply.enabled !== "boolean" || !Array.isArray(p.autoApply.allowedPaths) || !Array.isArray(p.autoApply.suites)) process.exit(3); for (const g of p.autoApply.allowedPaths) { if (typeof g !== "string" || !/^[A-Za-z0-9_.*/-]+$/.test(g) || g.startsWith("/") || g.includes("..")) process.exit(3); } console.log(JSON.stringify(p.autoApply)); ')" || fail "invalid roles/conductor-policy.json" 2 ENABLED="$(node -e 'console.log(JSON.parse(process.argv[1]).enabled)' "$POLICY_JSON")" [ "$ENABLED" = "true" ] || fail "auto-apply is disabled by policy" 2 # ---- run record ---- DATA_ROOT="$(node scripts/mosaic-config.mjs validate | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>console.log(JSON.parse(d).dataRoot))')" RESULT_FILE="$DATA_ROOT/runs/$RUN_ID/result.json" [ -f "$RESULT_FILE" ] || fail "run not found: $RUN_ID" 4 node -e ' const r = JSON.parse(require("fs").readFileSync(process.argv[1], "utf8")); process.exit(r.status === "succeeded" ? 0 : 1); ' "$RESULT_FILE" || fail "run $RUN_ID did not succeed; refusing to auto-apply" WORKSPACE_NAME="$(node -e 'const r=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));console.log(r.workspace||"")' "$RESULT_FILE")" [ -n "$WORKSPACE_NAME" ] || fail "run has no workspace; nothing to integrate" 4 WORKSPACE="$DATA_ROOT/workspaces/$WORKSPACE_NAME" [ -d "$WORKSPACE/.git" ] || fail "workspace is not a git clone: $WORKSPACE" 4 # ---- extract diff (tracked + intent-to-add) ---- git -C "$WORKSPACE" add -N . >/dev/null 2>&1 || true DIFF_FILE="$(mktemp)" trap 'rm -f "$DIFF_FILE"' EXIT git -C "$WORKSPACE" diff > "$DIFF_FILE" if [ ! -s "$DIFF_FILE" ]; then fail "workspace has no changes to apply" fi # ---- allowlist ---- mapfile -t CHANGED < <(git -C "$WORKSPACE" diff --name-only) GLOBS="$(node -e 'const a=JSON.parse(process.argv[1]).allowedPaths;console.log(a.join("\n"))' "$POLICY_JSON")" REFUSED="" for f in "${CHANGED[@]}"; do ok="no" while IFS= read -r g; do [ -z "$g" ] && continue case "$f" in $g) ok="yes"; break ;; esac done <<< "$GLOBS" [ "$ok" = "yes" ] || REFUSED="$REFUSED $f" done if [ -n "$REFUSED" ]; then echo "conductor-apply: refusing - files outside policy allowlist:$REFUSED" >&2 echo "conductor-apply: patch preserved at $DIFF_FILE for manual review" >&2 exit 1 fi # ---- syntax gates (on workspace files, pre-apply) ---- for f in "${CHANGED[@]}"; do case "$f" in *.mjs) node --check "$WORKSPACE/$f" || fail "syntax gate failed (node): $f" 1 ;; *.sh) bash -n "$WORKSPACE/$f" || fail "syntax gate failed (bash): $f" 1 ;; *.json) node -e 'JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"))' "$WORKSPACE/$f" || fail "syntax gate failed (json): $f" 1 ;; esac done if [ "$DRY_RUN" = "yes" ]; then echo "conductor-apply (dry-run): would apply $(echo "${#CHANGED[@]}") file(s) from $RUN_ID:" printf ' %s\n' "${CHANGED[@]}" echo "conductor-apply (dry-run): suites would run: $(node -e 'console.log(JSON.parse(process.argv[1]).suites.join(", "))' "$POLICY_JSON")" exit 0 fi # ---- apply ---- [ -z "$(git status --porcelain)" ] || fail "target tree is not clean; refusing to mix states" git apply "$DIFF_FILE" || fail "git apply failed" # ---- policy suites ---- SUITES="$(node -e 'console.log(JSON.parse(process.argv[1]).suites.join(" "))' "$POLICY_JSON")" SUITES_OK="yes" for s in $SUITES; do case "$s" in test-[a-z]*) : ;; # shape guard; existence checked next *) echo "conductor-apply: refusing suspicious suite name: $s" >&2; SUITES_OK="no"; break ;; esac [ -x "scripts/$s.sh" ] || { echo "conductor-apply: suite script missing: scripts/$s.sh" >&2; SUITES_OK="no"; break; } if ! bash "scripts/$s.sh" >/dev/null 2>&1; then echo "conductor-apply: suite failed: $s" >&2 SUITES_OK="no" break fi done if [ "$SUITES_OK" != "yes" ]; then git apply -R "$DIFF_FILE" && echo "conductor-apply: changes REVERTED (suites failed)" >&2 exit 1 fi # ---- commit with attribution ---- git add -A git commit -q -m "feat(worker): auto-applied patch from run $RUN_ID Authored-by: pi worker (run $RUN_ID, workspace $WORKSPACE_NAME) Applied-under: conductor-policy v1 (allowlist + syntax gates + suites)" echo "conductor-apply: applied and committed run $RUN_ID ($(echo "${#CHANGED[@]}") file(s)); suites: $SUITES" echo "conductor-apply: NOT pushed - push remains an explicit act."