- conductor-policy.json (tracked, strictly validated): enabled switch, path allowlist globs, gating suites - the autonomy decision lives in a declarative file the owner controls - scripts/conductor-apply.sh <runId> [--dry-run]: succeeded-run check -> clean target tree -> diff from worker workspace -> allowlist -> syntax gates (node/bash/json) -> apply -> policy suites -> attribution commit; ANY failure reverts the tree; push is never automatic - scripts/test-conductor.sh: 17 sandbox cases covering every gate incl. suite-failure auto-revert and disabled policy - policy defaults: scripts/docs/tasks/missions/adapters + README; all three suites gate Closes #34
140 lines
5.8 KiB
Bash
Executable File
140 lines
5.8 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Conductor auto-apply: integrate a worker's patch under the declared policy.
|
|
#
|
|
# Usage: scripts/conductor-apply.sh <runId> [--dry-run]
|
|
#
|
|
# Policy (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 <runId> [--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 conductor-policy.json ] || fail "no conductor-policy.json in target" 2
|
|
|
|
# ---- policy (strict) ----
|
|
POLICY_JSON="$(node -e '
|
|
const fs = require("fs");
|
|
const p = JSON.parse(fs.readFileSync("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 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."
|