openclaw/scripts/pr
Peter Steinberger 3a4c80c4c5
fix(scripts): PR wrapper leaks materialized trust-anchor dirs in TMPDIR on failures and interrupts (#180848)
* fix(scripts): retire materialized PR anchors on every exit path

Materialized wrappers were retired only after fully clean supervised exits,
leaking code and dependency links on failures, signals, bootstrap exits,
unsupervised commands, and failed execs.

Keep creator ownership across the Bash and supervisor exec chain. Share
FD/path/PID identity checks, retire drained operations independently of PR
lock decisions, and restore cleanup after Bash 3.2 failed execs. Retain code
for undrained operations and sweep at most four old owned anchors at startup
using creator PIDs in new names.

PID reuse conservatively delays sweeping. Legacy names have no liveness
record and may be reclaimed after 24 hours even during a long operation.
Drained cleanup accepts the same fd-closing escapee residual as clean exits;
missing runtime or ownership evidence remains retain-only.

Cover lifecycle exits, crash-retained recovery fixtures, dependency pinning,
and bounded stale sweeping without changing lock recovery semantics.

* docs(scripts): name the stale-anchor sweep residual

A dead creator PID is the sweep's only evidence that an operation ended. Descendants that outlive a crashed or undrained supervisor by more than 24 hours can lose their anchor; their PR lock stays retained. Also replace the busy loop in the forwarded-SIGTERM supervisor test with sleep.
2026-10-22 11:15:59 -07:00

1372 lines
71 KiB
Bash
Executable file

#!/usr/bin/env bash
# Bash 5.3+ can deadlock writing heredoc pipes on macOS before the reader starts.
if [[ ${OSTYPE:-} == darwin* && $BASH != /bin/bash ]] && ((BASH_VERSINFO[0] > 5 || (BASH_VERSINFO[0] == 5 && BASH_VERSINFO[1] >= 3))); then
exec /bin/bash "$0" "$@"
fi
set -euo pipefail
# Install creator cleanup before even the Git preflight or argument validation.
# Physical paths keep helper argv aligned with Node's canonical module URLs.
script_self="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)/$(basename "${BASH_SOURCE[0]}")"
script_parent_dir="$(dirname "$script_self")"
if [ -n "${OPENCLAW_PR_ANCHOR_REPO_ROOT:-}" ] &&
[ "${OPENCLAW_PR_ANCHOR_CREATOR_PID:-}" = "$$" ] && [ "${OPENCLAW_PR_ANCHOR_FD:-}" = 9 ]; then
trap 'node "$script_parent_dir/pr-lib/materialized-anchor.mjs" retire "$script_self" || true' EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 131' QUIT
trap 'exit 143' TERM
fi
# This bootstrap stays inline: no local helper is trusted before code selection.
pr_git() { "${OPENCLAW_PR_GIT:-${GIT_EXEC:-git}}" "$@"; }
pr_run_bounded() (
set +e
# Separate jobs let the deadline reap a hung launcher and its children.
set -m
"$@" &
local tool_pid=$! timer_pid result
(/bin/sleep 10; kill -KILL -- "-$tool_pid" 2>/dev/null) &
timer_pid=$!
wait "$tool_pid" 2>/dev/null
result=$?
kill -KILL -- "-$timer_pid" 2>/dev/null
wait "$timer_pid" 2>/dev/null
exit "$result"
)
GIT_EXEC=$(command -v "${OPENCLAW_PR_GIT:-${GIT_EXEC:-git}}" 2>/dev/null || true)
if [ -n "$GIT_EXEC" ]; then
case "$GIT_EXEC" in /*) ;; *) GIT_EXEC="$PWD/$GIT_EXEC" ;; esac
fi
if [ -z "$GIT_EXEC" ] || ! pr_run_bounded "$GIT_EXEC" --version >/dev/null 2>&1; then
echo "scripts/pr git preflight failed or exceeded 10 seconds: ${GIT_EXEC:-${OPENCLAW_PR_GIT:-git (not found on PATH)}}" >&2
echo "xcode-select -p: $(pr_run_bounded xcode-select -p 2>/dev/null || printf 'unavailable')" >&2
echo "export DEVELOPER_DIR=/Applications/Xcode.app/Contents/Developer or set OPENCLAW_PR_GIT" >&2
exit 1
fi
export GIT_EXEC
export OPENCLAW_PR_GIT="$GIT_EXEC"
# Older trusted wrappers use bare Git before reaching a newer supervisor.
# Keep this Git-only adapter across exec; OS temp reaping owns its lifetime.
# Matching inodes let reentry reuse it.
pr_path_git=$(type -P git 2>/dev/null || true)
case "$pr_path_git" in /*|"") ;; *) pr_path_git="$PWD/$pr_path_git" ;; esac
if [ -z "$pr_path_git" ] || [ ! "$pr_path_git" -ef "$GIT_EXEC" ]; then
pr_git_path_dir=$(mktemp -d "${TMPDIR:-/tmp}/openclaw-pr-git.XXXXXX")
if ! ln -s "$GIT_EXEC" "$pr_git_path_dir/git"; then
rm -rf "$pr_git_path_dir"
exit 1
fi
pr_git_path_dir=$(cd "$pr_git_path_dir" && pwd -P)
export PATH="$pr_git_path_dir:$PATH"
fi
unset pr_path_git pr_git_path_dir
# This wrapper parses GitHub CLI JSON. Caller shells may force ANSI color globally.
export NO_COLOR=1
export CLICOLOR=0
export CLICOLOR_FORCE=0
export FORCE_COLOR=0
unset COLORTERM
# This is the single source of truth for the canonical-wrapper trust boundary.
# Advisory commands may run a mismatched local wrapper only with the explicit
# developer opt-in; landing commands must always use canonical/origin-main code.
# Classification is independent of serialization: ci-dispatch remains locked
# because GitHub exposes neither dispatch deduplication nor a correlation ID.
# PR_SUBCOMMAND_CLASSIFICATIONS_BEGIN
pr_subcommand_classification() {
case "$1" in
ls | ci-dispatch)
printf 'advisory\n'
;;
gc | lock-recover | tooling-recover | review-init | review-checkout-main | review-checkout-pr | review-claim | review-guard | review-artifacts-init | review-validate-artifacts | review-tests | prepare-init | prepare-correction-init | prepare-correction-continue | prepare-correction-review-init | prepare-baseline-refresh | prepare-baseline-successor | prepare-baseline-signing-request | prepare-baseline-signing-continue | prepare-validate-commit | prepare-gates | prepare-push | prepare-sync-head | prepare-run | merge-verify | merge-run | merge-recover | merge-complete)
printf 'landing\n'
;;
*) return 1 ;;
esac
}
# PR_SUBCOMMAND_CLASSIFICATIONS_END
is_locked_pr_command() {
# Advisory trust classification permits local dogfood, but dispatches still
# serialize with landing operations because the remote mutation is not atomic.
if [ "$1" = "ci-dispatch" ]; then
return 0
fi
local classification
classification=$(pr_subcommand_classification "$1") || return 1
[ "$classification" = "landing" ] || return 1
# gc manages per-PR locks itself; lock-recover performs an exact-OID CAS.
[ "$1" != "gc" ] && [ "$1" != "lock-recover" ]
}
dev_wrapper_opt_in=0
if [ "${OPENCLAW_PR_DEV_WRAPPER:-}" = "1" ]; then
dev_wrapper_opt_in=1
fi
if [ "${1-}" = "--dev-wrapper" ]; then
dev_wrapper_opt_in=1
export OPENCLAW_PR_DEV_WRAPPER=1
shift
fi
requested_subcommand="${1-}"
# Only this explicit recovery may select current main over a retained workflow.
# Parse before source selection, then parse normally again before taking a lock.
PR_WORKFLOW_RECOVERY_OUTCOME=""
PR_WORKFLOW_RECOVERY_HEAD=""
pr_read_current_workflow_request() {
local argument found=0
for argument in "$@"; do
[ "$argument" != --current-workflow ] || found=$((found + 1))
done
[ "$found" -ne 0 ] || return 0
[ "$found" -eq 1 ] && [ "${1-}" = merge-recover ] &&
[[ "${2-}" =~ ^[1-9][0-9]*$ ]] && [[ "${3-}" =~ ^[0-9a-f]{40}$ ]] &&
[ "${4-}" = --confirmed-operator-recovery ] || return 2
PR_WORKFLOW_RECOVERY_OUTCOME="$3"
shift 4
local admin=false confirmed=false body=false current=false
while [ "$#" -gt 0 ]; do
case "$1" in
--current-workflow) [ "$current" = false ] || return 2; current=true; shift ;;
--replacement-head)
[ -z "$PR_WORKFLOW_RECOVERY_HEAD" ] && [[ "${2-}" =~ ^[0-9a-f]{40}$ ]] || return 2
PR_WORKFLOW_RECOVERY_HEAD="$2"; shift 2 ;;
--body-file) [ "$body" = false ] && [ -n "${2-}" ] || return 2; body=true; shift 2 ;;
--admin-evidence) [ "$admin" = false ] && [ -n "${2-}" ] || return 2; admin=true; shift 2 ;;
--confirmed-operator-admin) [ "$confirmed" = false ] || return 2; confirmed=true; shift ;;
*) return 2 ;;
esac
done
[ "$current" = true ] && [ "$admin" = "$confirmed" ] &&
{ [ -n "$PR_WORKFLOW_RECOVERY_HEAD" ] || [ "$admin" = true ]; }
}
pr_read_current_workflow_request "$@" || {
echo "--current-workflow requires confirmed merge-recover with an exact outcome and either a replacement head or confirmed same-head admin recovery." >&2
exit 2
}
# This bootstrap cannot load helpers from an unverified extracted tree.
pr_batch_wrapper_git() {
node --input-type=module - "$GIT_EXEC" "$@" <<'EOF_WRAPPER_GIT'
import { spawnSync } from "node:child_process";
import { fstatSync, lstatSync, readSync, realpathSync } from "node:fs";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const [binary, operation, repository, revision, ...paths] = process.argv.slice(2);
function git(directory, args, input) {
const result = spawnSync(binary, ["-C", directory, ...args], {
input, maxBuffer: Infinity, stdio: ["pipe", "pipe", "ignore"],
env: { ...process.env, GIT_NO_LAZY_FETCH: "1" },
});
if (result.error || result.signal || result.status !== 0) throw new Error("Git read failed");
return result.stdout;
}
const oid = /^(?:[a-f0-9]{40}|[a-f0-9]{64})$/;
try {
if (operation.startsWith("workflow-")) {
if (!/^[1-9][0-9]*$/.test(revision)) throw new Error("Invalid workflow PR");
const ref = `refs/openclaw/pr-workflow-bindings/${revision}`;
const run = args => git(repository, args).toString("utf8").trim();
const owner = realpathSync(run(["rev-parse", "--path-format=absolute", "--git-common-dir"]));
const remote = run(["remote", "get-url", "origin"]);
const url = /^[a-z][a-z0-9+.-]*:\/\//i.test(remote) ? new URL(remote) : null;
const scp = !url && /^(?:([^/@:]+)@)?(\[[^\]]+\]|[^/:]+):(.+)$/.exec(remote);
// SCP paths can be relative to an SSH user's home. Preserve that transport,
// user, path case and URL ports instead of conflating different endpoints.
const identity = url ? url.protocol === "file:"
? realpathSync(fileURLToPath(url)) : url.href
: scp ? JSON.stringify(["scp", scp[1] ?? "", scp[2].toLowerCase(), scp[3]])
: realpathSync(resolve(repository, remote));
if (!identity || url && (url.username && url.protocol !== "ssh:" || url.password)) throw new Error("Invalid repository identity");
const read = () => {
const symbolic = spawnSync(binary, ["-C", repository, "symbolic-ref", "--quiet", ref], { stdio: "ignore" });
if (symbolic.error || symbolic.signal || symbolic.status !== 1) throw new Error("Invalid symbolic workflow binding");
const listing = run(["for-each-ref", "--format=%(objectname) %(symref)", ref]);
if (!listing) return null;
if (!oid.test(listing)) throw new Error("Invalid workflow binding ref");
const headers = run(["cat-file", "commit", listing]).split("\n\n", 1)[0].split("\n");
const parents = headers.filter(line => line.startsWith("parent ")).map(line => line.slice(7));
const entries = run(["ls-tree", listing]).split("\n");
if (parents.length !== 1 || entries.length !== 1 || !/^100644 blob [a-f0-9]+\tbinding.json$/.test(entries[0])) {
throw new Error("Invalid workflow binding tree");
}
if (Number(run(["cat-file", "-s", `${listing}:binding.json`])) > 4096) throw new Error("Oversized workflow binding");
const record = JSON.parse(run(["show", `${listing}:binding.json`]));
if (JSON.stringify(Object.keys(record).sort()) !== JSON.stringify(["owner", "pr", "repository", "source", "version"]) ||
record.version !== 1 || record.pr !== revision || record.owner !== owner || record.repository !== identity ||
!oid.test(record.source) || parents[0] !== record.source ||
run(["cat-file", "-t", record.source]) !== "commit") throw new Error("Workflow binding identity mismatch");
return { oid: listing, source: record.source };
};
const requireSource = (source, anchor = "refs/remotes/origin/main") => {
if (!oid.test(source)) throw new Error("Invalid workflow source");
run(["merge-base", "--is-ancestor", source, anchor]);
};
const current = read();
const requireRecovery = (source, outcome, replacementHead, anchor = "refs/remotes/origin/main") => {
const symbolicAnchor = spawnSync(binary, ["-C", repository, "symbolic-ref", "--quiet", anchor], { stdio: "ignore" });
if (symbolicAnchor.error || symbolicAnchor.signal || symbolicAnchor.status !== 1) {
throw new Error("Refusing symbolic or unreadable current workflow anchor");
}
if (!current || !oid.test(outcome) || (replacementHead !== "" && !oid.test(replacementHead)) ||
source !== run(["rev-parse", "--verify", `${anchor}^{commit}`]) ||
!["https://github.com/openclaw/openclaw", "https://github.com/openclaw/openclaw.git",
"git@github.com:openclaw/openclaw.git", "ssh://git@github.com/openclaw/openclaw.git"].includes(run(["config", "--get", "remote.origin.url"]))) {
throw new Error("Current workflow recovery requires official current main and a retained workflow");
}
requireSource(current.source, anchor);
const outcomeRef = `refs/openclaw/pr-merge-outcomes/${revision}`;
const symbolic = spawnSync(binary, ["-C", repository, "symbolic-ref", "--quiet", outcomeRef], { stdio: "ignore" });
if (symbolic.error || symbolic.signal || symbolic.status !== 1 ||
run(["rev-parse", "--verify", outcomeRef]) !== outcome || run(["cat-file", "-t", outcome]) !== "commit") {
throw new Error("Current workflow recovery outcome changed");
}
const record = JSON.parse(run(["show", `${outcome}:outcome.json`]));
const cancelledAuto = replacementHead !== "" && record.route === "auto" &&
record.cancellation?.state === "confirmed";
// This admits current trusted code, not another dispatch. The existing
// provider-rejection owner must still qualify exact HTTP 405 capture bytes
// and revalidate the unchanged head and live admin authority before writing.
const rejectedAdmin = replacementHead === "" && record.route === "admin" &&
record.accepted === false &&
record.priorCiAdmin?.dispatchTransport === "rest";
if (record.version !== 1 || record.pr !== Number(revision) ||
record.base !== "main" || record.phase !== "intent" ||
record.method !== "squash" || record.landed !== null || !(cancelledAuto || rejectedAdmin)) {
throw new Error("Current workflow recovery requires a confirmed cancelled nonqueue auto outcome or an unaccepted same-head REST admin intent");
}
};
if (operation === "workflow-recovery-select" || operation === "workflow-recovery-provenance") {
const [outcome, replacementHead, anchor = "refs/remotes/origin/main"] = paths;
const source = operation === "workflow-recovery-select" ? run(["rev-parse", "--verify", `${anchor}^{commit}`]) : paths[3];
requireRecovery(source, outcome, replacementHead, anchor);
process.stdout.write(operation === "workflow-recovery-select" ? `${current.oid}\t${source}\n` :
JSON.stringify({ binding: current.oid, from: current.source, to: source }));
} else if (operation === "workflow-read" || operation === "workflow-inspect") {
if (current && operation === "workflow-read") requireSource(current.source);
if (current) process.stdout.write(`${current.oid}\t${current.source}\n`);
} else if (operation === "workflow-context-write") {
const [source, binding, creator, bootstrapFlag, outcome = "", replacementHead = ""] = paths;
const bootstrap = bootstrapFlag === "1";
if (outcome) requireRecovery(source, outcome, replacementHead);
if (!oid.test(source) || (current?.oid ?? "") !== binding || !outcome && !bootstrap && current && current.source !== source ||
!/^[1-9][0-9]*$/.test(creator)) throw new Error("Workflow selection changed before handoff");
if (bootstrap) {
if (source !== run(["rev-parse", "--verify", "HEAD^{commit}"])) throw new Error("Canonical bootstrap changed");
} else requireSource(source);
process.stdout.write(JSON.stringify({ version: 1, owner, repository: identity, pr: revision, source, binding, creator, bootstrap,
...(outcome ? { recovery: { outcome, replacementHead } } : {}) }));
} else if (operation === "workflow-context-read") {
const held = fstatSync(8);
if (!held.isFile() || held.uid !== process.getuid() || (held.mode & 0o777) !== 0o400 || held.nlink !== 0 ||
held.size < 1 || held.size > 4096 || `${held.dev}:${held.ino}` !== process.env.OPENCLAW_PR_WORKFLOW_CONTEXT) {
throw new Error("Invalid workflow selection descriptor");
}
const bytes = Buffer.alloc(held.size);
if (readSync(8, bytes, 0, bytes.length, 0) !== bytes.length) throw new Error("Incomplete workflow selection");
const selection = JSON.parse(bytes.toString("utf8"));
const [creator, supervisor, outcome = "", replacementHead = ""] = paths;
if (outcome) {
if (JSON.stringify(selection.recovery) !== JSON.stringify({ outcome, replacementHead }) || selection.bootstrap) {
throw new Error("Workflow recovery selection changed across handoff");
}
requireRecovery(selection.source, outcome, replacementHead);
}
const supervised = selection.creator === supervisor && process.env.OPENCLAW_PR_DEDICATED_PROCESS_GROUP === "1" &&
process.env.OPENCLAW_PR_LOCK_NOTIFY_FD === "3" && process.env.OPENCLAW_PR_LOCK_SUPERVISOR_PID === supervisor;
const keys = ["binding", "bootstrap", "creator", "owner", "pr", "repository", "source", "version", ...(outcome ? ["recovery"] : [])].sort();
if (JSON.stringify(Object.keys(selection).sort()) !== JSON.stringify(keys) ||
selection.version !== 1 || selection.owner !== owner || selection.repository !== identity || selection.pr !== revision ||
typeof selection.bootstrap !== "boolean" ||
!oid.test(selection.source) || (selection.creator !== creator && !supervised) ||
selection.binding !== (current?.oid ?? "") || !outcome && !selection.bootstrap && current && current.source !== selection.source) {
throw new Error("Workflow selection changed across handoff");
}
if (!selection.bootstrap) requireSource(selection.source);
process.stdout.write(`${selection.binding || "-"}\t${selection.source}\t${selection.bootstrap ? "1" : "0"}\n`);
} else if (operation === "workflow-cold-select") {
const [bootstrap, expected, main] = paths;
if (!oid.test(bootstrap) || !oid.test(main) || (current?.oid ?? "") !== expected) {
throw new Error("Workflow binding changed during cold acquisition");
}
let selected;
if (current) {
requireSource(current.source, main);
selected = current.source;
} else {
const ancestry = spawnSync(binary, ["-C", repository, "merge-base", "--is-ancestor", bootstrap, main],
{ stdio: "ignore", env: { ...process.env, GIT_NO_LAZY_FETCH: "1" } });
if (ancestry.error || ancestry.signal || ![0, 1].includes(ancestry.status)) throw new Error("Cannot qualify canonical bootstrap");
selected = ancestry.status === 0 ? bootstrap : main;
}
const closure = source => {
const inventory = run(["show", `${source}:scripts/pr-lib/wrapper-components.txt`]).split("\n");
return git(repository, ["ls-tree", "-r", "-z", "--full-tree", source, "--", "scripts/pr", "scripts/pr-lib", ...inventory]);
};
// Loaded helpers belong to the captured bootstrap, even if files move
// during acquisition. Compare immutable closures before admitting it.
if (!closure(bootstrap).equals(closure(selected))) {
throw new Error(`Cold workflow source differs from the running canonical wrapper ${bootstrap}; selected source ${selected}`);
}
process.stdout.write(selected);
} else if (operation === "workflow-admit") {
const [source, expected, anchor = "refs/remotes/origin/main", outcome = "", replacementHead = ""] = paths;
if (outcome) requireRecovery(source, outcome, replacementHead, anchor);
if (!oid.test(source) || (current?.oid ?? "") !== expected || !outcome && current && current.source !== source) {
throw new Error("Workflow binding changed before admission");
}
requireSource(source, anchor);
if (current) process.stdout.write(current.oid);
else {
const record = JSON.stringify({ version: 1, repository: identity, owner, pr: revision, source });
const blob = git(repository, ["hash-object", "-w", "--stdin"], record).toString().trim();
const tree = git(repository, ["mktree"], `100644 blob ${blob}\tbinding.json\n`).toString().trim();
const commit = git(repository, ["-c", "commit.gpgsign=false", "commit-tree", tree, "-p", source],
"Native PR workflow binding\n").toString().trim();
run(["update-ref", "--no-deref", ref, commit, "0".repeat(source.length)]);
process.stdout.write(commit);
}
} else if (operation === "workflow-retire") {
if (!current || current.oid !== paths[0]) throw new Error("Workflow binding changed before completion");
requireSource(current.source, paths[1]);
run(["update-ref", "--no-deref", "-d", ref, current.oid]);
} else throw new Error("Unknown workflow operation");
} else if (operation === "differences") {
const queries = paths.flatMap(path => [`HEAD:${path}`, `${revision}:${path}`]);
const rows = git(repository, ["cat-file", "--batch-check=%(objectname)"], queries.join("\n") + "\n")
.toString("utf8").split("\n");
if (rows.pop() !== "" || rows.length !== queries.length) throw new Error("Incomplete revisions");
process.stdout.write(paths.filter((_, index) => {
const [head, anchor] = rows.slice(index * 2, index * 2 + 2);
return !oid.test(head) || !oid.test(anchor) || head !== anchor;
}).join("\n"));
} else if (operation === "verify") {
const [directory, ...components] = paths;
const listing = git(repository, ["ls-tree", "-r", "-z", "--full-tree", revision, "--", ...components]);
const text = listing.toString("utf8");
const rows = text.split("\0");
if (!Buffer.from(text).equals(listing) || rows.pop() !== "" || rows.length === 0) {
throw new Error("Incomplete anchor listing");
}
const names = new Set();
const directories = new Set();
const expected = [];
if (!lstatSync(directory).isDirectory()) throw new Error("Invalid extraction root");
for (const row of rows) {
const entry = /^(100644|100755) blob ([a-f0-9]+)\t([^\0]+)$/.exec(row);
if (!entry || !oid.test(entry[2])) throw new Error("Invalid anchor entry");
const [, , hash, path] = entry;
if (names.has(path) || path.split("/").some(part => !part || part === "." || part === "..")) {
throw new Error("Invalid anchor path");
}
let parent = "";
for (const part of path.split("/").slice(0, -1)) {
parent = parent ? `${parent}/${part}` : part;
if (!directories.has(parent)) {
if (!lstatSync(join(directory, parent)).isDirectory()) throw new Error("Non-directory anchor parent");
directories.add(parent);
}
}
if (!lstatSync(join(directory, path)).isFile()) throw new Error("Non-regular anchor file");
names.add(path);
expected.push(hash);
}
if (components.some(path => !names.has(path) && !directories.has(path))) throw new Error("Incomplete anchor components");
// Git's stdin path quoting uses C/octal escapes, not JSON's Unicode escapes.
const input = [...names].map(path => '"' + join(directory, path).replace(/[\x00-\x1f\x7f\\"]/g, char =>
char === "\\" || char === '"' ? "\\" + char : "\\" + char.charCodeAt(0).toString(8).padStart(3, "0"),
) + '"').join("\n") + "\n";
const actual = git(repository, ["hash-object", "--no-filters", "--stdin-paths"], input);
if (!actual.equals(Buffer.from(expected.join("\n") + "\n"))) throw new Error("Anchor bytes differ");
} else {
throw new Error("Unknown wrapper Git operation");
}
} catch (error) {
if (operation.startsWith("workflow-")) console.error(error.message);
process.exitCode = 1;
}
EOF_WRAPPER_GIT
}
PR_WORKFLOW_PR=""
PR_WORKFLOW_BINDING_OID=""
PR_WORKFLOW_SOURCE_REVISION=""
PR_WORKFLOW_BOOTSTRAP=0
PR_WORKFLOW_RECOVERY_PROVENANCE=""
PR_WORKFLOW_TRUST_ANCHOR="refs/remotes/origin/main"
pr_read_workflow_binding() {
local classification binding tracking_status=0
classification=$(pr_subcommand_classification "$requested_subcommand" 2>/dev/null || true)
[ "$classification" = landing ] || return 0
case "$requested_subcommand" in gc | lock-recover) return 0 ;; esac
[[ "${2-}" =~ ^[1-9][0-9]*$ ]] || return 0
PR_WORKFLOW_PR="$2"
if [ -n "${OPENCLAW_PR_WORKFLOW_CONTEXT:-}" ]; then
binding=$(pr_batch_wrapper_git workflow-context-read "$canonical_repo_root" "$PR_WORKFLOW_PR" "$$" "$PPID" \
"$PR_WORKFLOW_RECOVERY_OUTCOME" "$PR_WORKFLOW_RECOVERY_HEAD") || return 1
IFS=$'\t' read -r PR_WORKFLOW_BINDING_OID PR_WORKFLOW_SOURCE_REVISION PR_WORKFLOW_BOOTSTRAP <<< "$binding"
else
if [ -n "$PR_WORKFLOW_RECOVERY_OUTCOME" ]; then
binding=$(pr_batch_wrapper_git workflow-recovery-select "$canonical_repo_root" "$PR_WORKFLOW_PR" \
"$PR_WORKFLOW_RECOVERY_OUTCOME" "$PR_WORKFLOW_RECOVERY_HEAD") || return 1
IFS=$'\t' read -r PR_WORKFLOW_BINDING_OID PR_WORKFLOW_SOURCE_REVISION <<< "$binding"
return 0
fi
if pr_git -C "$canonical_repo_root" symbolic-ref -q refs/remotes/origin/main >/dev/null 2>&1; then
echo "Refusing a symbolic main tracking ref for workflow selection." >&2
return 1
fi
pr_git -C "$canonical_repo_root" show-ref --verify --quiet refs/remotes/origin/main || tracking_status=$?
if [ "$tracking_status" -eq 1 ] && [ "$script_self" = "$canonical_repo_root/scripts/pr" ]; then
# Only canonical code may bootstrap. A retained record is data until the
# locked acquisition authenticates its source against canonical main.
binding=$(pr_batch_wrapper_git workflow-inspect "$canonical_repo_root" "$PR_WORKFLOW_PR") || return 1
IFS=$'\t' read -r PR_WORKFLOW_BINDING_OID PR_WORKFLOW_SOURCE_REVISION <<< "$binding"
PR_WORKFLOW_SOURCE_REVISION=$(pr_git -C "$canonical_repo_root" rev-parse --verify 'HEAD^{commit}') || return 1
PR_WORKFLOW_BOOTSTRAP=1
else
[ "$tracking_status" -le 1 ] || return 1
binding=$(pr_batch_wrapper_git workflow-read "$canonical_repo_root" "$PR_WORKFLOW_PR") || return 1
IFS=$'\t' read -r PR_WORKFLOW_BINDING_OID PR_WORKFLOW_SOURCE_REVISION <<< "$binding"
fi
fi
[ "$PR_WORKFLOW_BINDING_OID" != - ] || PR_WORKFLOW_BINDING_OID=""
if [ "$PR_WORKFLOW_BOOTSTRAP" = 1 ]; then
[ "$script_self" = "$canonical_repo_root/scripts/pr" ] || return 1
fi
if [ -n "${OPENCLAW_PR_WORKFLOW_CONTEXT:-}" ] || [ "$PR_WORKFLOW_BOOTSTRAP" = 1 ]; then
verify_pr_workflow_source "$PR_WORKFLOW_SOURCE_REVISION" "$script_parent_dir/.." || return 1
fi
}
verify_pr_workflow_source() {
local inventory component
local components=(scripts/pr scripts/pr-lib)
inventory=$(pr_git -C "$canonical_repo_root" show "$1:scripts/pr-lib/wrapper-components.txt") || return 1
while IFS= read -r component; do components+=("$component"); done <<< "$inventory"
pr_batch_wrapper_git verify "$canonical_repo_root" "$1" "$2" "${components[@]}"
}
hold_pr_workflow_selection() {
[ -n "$PR_WORKFLOW_PR" ] && [ -z "${OPENCLAW_PR_WORKFLOW_CONTEXT:-}" ] || return 0
verify_pr_workflow_source "$1" "$2" || return 1
local context
context=$(mktemp "${TMPDIR:-/tmp}/openclaw-pr-selection.XXXXXX") || return 1
if ! pr_batch_wrapper_git workflow-context-write "$canonical_repo_root" "$PR_WORKFLOW_PR" \
"$1" "$PR_WORKFLOW_BINDING_OID" "$$" "$PR_WORKFLOW_BOOTSTRAP" \
"$PR_WORKFLOW_RECOVERY_OUTCOME" "$PR_WORKFLOW_RECOVERY_HEAD" > "$context" || ! chmod 400 "$context"; then
rm -f "$context"
return 1
fi
# FD 8 carries selection; FD 9 remains the archive cleanup owner.
# Positional reads keep exec/child reentry independent of shared FD offsets.
exec 8< "$context"
rm "$context"
OPENCLAW_PR_WORKFLOW_CONTEXT=$(node -e 'const s=require("node:fs").fstatSync(8); process.stdout.write(`${s.dev}:${s.ino}`)') || return 1
export OPENCLAW_PR_WORKFLOW_CONTEXT
}
admit_pr_workflow_binding() {
[ -n "$PR_WORKFLOW_PR" ] || return 0
[ "$PR_WORKFLOW_PR" = "$1" ] && [ -n "$PR_WORKFLOW_SOURCE_REVISION" ] || return 1
local qualification_ref="" main=""
if [ "$PR_WORKFLOW_BOOTSTRAP" = 1 ] || [ -n "$PR_WORKFLOW_RECOVERY_OUTCOME" ]; then
qualification_ref="refs/openclaw/pr-workflow-qualification/$1/$PR_OPERATION_LOCK_OWNER_OID"
local ref_status=0 symbolic_status=0 selected
pr_git -C "$canonical_repo_root" symbolic-ref -q "$qualification_ref" >/dev/null 2>&1 || symbolic_status=$?
[ "$symbolic_status" -eq 1 ] || { echo "Refusing symbolic or unreadable qualification ref: $qualification_ref." >&2; return 1; }
pr_git -C "$canonical_repo_root" show-ref --verify --quiet "$qualification_ref" || ref_status=$?
[ "$ref_status" -eq 1 ] || { echo "Workflow qualification ref already exists; preserve $qualification_ref." >&2; return 1; }
mark_pr_operation_side_effects_started || return 1
if ! fetch_canonical_main "$qualification_ref"; then
echo "Cold workflow acquisition failed; inspect $qualification_ref before any cleanup." >&2
return 1
fi
main=$(pr_git -C "$canonical_repo_root" rev-parse --verify "$qualification_ref^{commit}") || return 1
PR_WORKFLOW_TRUST_ANCHOR="$main"
if [ -n "$PR_WORKFLOW_RECOVERY_OUTCOME" ]; then
# Current main is selected only for this operation. Keep the historical
# binding intact so normal reconciliation still uses its admitted source.
verify_pr_workflow_source "$main" "$script_parent_dir/.." || {
echo "Current main workflow changed; rerun recovery from current main. Preserving $qualification_ref." >&2
return 1
}
selected="$main"
elif ! selected=$(pr_batch_wrapper_git workflow-cold-select "$canonical_repo_root" "$1" \
"$PR_WORKFLOW_SOURCE_REVISION" "$PR_WORKFLOW_BINDING_OID" "$main"); then
echo "Cold workflow qualification failed; use the admitted source from a qualified checkout. Preserving $qualification_ref." >&2
return 1
fi
verify_pr_workflow_source "$PR_WORKFLOW_SOURCE_REVISION" "$script_parent_dir/.." || return 1
PR_WORKFLOW_SOURCE_REVISION="$selected"
fi
# Verify again under the operation lock: initial selection may have waited
# behind another command, and only admitted bytes may mint a lasting pin.
verify_pr_workflow_source "$PR_WORKFLOW_SOURCE_REVISION" "$script_parent_dir/.." || return 1
pr_operation_lock_owner_is_current "$canonical_repo_root" "$PR_OPERATION_LOCK_REF" "$PR_OPERATION_LOCK_OWNER_OID" || return 1
mark_pr_operation_side_effects_started || return 1
PR_WORKFLOW_BINDING_OID=$(pr_batch_wrapper_git workflow-admit "$canonical_repo_root" "$1" \
"$PR_WORKFLOW_SOURCE_REVISION" "$PR_WORKFLOW_BINDING_OID" "$PR_WORKFLOW_TRUST_ANCHOR" \
"$PR_WORKFLOW_RECOVERY_OUTCOME" "$PR_WORKFLOW_RECOVERY_HEAD") || return 1
if [ -n "$PR_WORKFLOW_RECOVERY_OUTCOME" ]; then
PR_WORKFLOW_RECOVERY_PROVENANCE=$(pr_batch_wrapper_git workflow-recovery-provenance "$canonical_repo_root" "$1" \
"$PR_WORKFLOW_RECOVERY_OUTCOME" "$PR_WORKFLOW_RECOVERY_HEAD" "$PR_WORKFLOW_TRUST_ANCHOR" "$PR_WORKFLOW_SOURCE_REVISION") || return 1
fi
if [ -n "$qualification_ref" ]; then
# Preserve acquisition evidence until admission succeeds; retire only our
# captured value, never a changed qualification ref.
pr_operation_lock_owner_is_current "$canonical_repo_root" "$PR_OPERATION_LOCK_REF" "$PR_OPERATION_LOCK_OWNER_OID" || return 1
pr_git -C "$canonical_repo_root" update-ref --no-deref -d "$qualification_ref" "$main" || return 1
fi
exec 8<&-
unset OPENCLAW_PR_WORKFLOW_CONTEXT
}
# Keep dependency sourcing independent of wrapper selection. In particular, an
# older anchor still supplies its own package/version policy to this bootstrap.
pr_materialize_dependencies() {
node --input-type=module - "$1" "$canonical_repo_root" "$2" <<'EOF_TOOLING'
import { execFileSync, spawnSync } from "node:child_process";
import { mkdirSync, realpathSync, rmSync, symlinkSync } from "node:fs";
import { delimiter, dirname, join, resolve } from "node:path";
const [owner, canonicalPath, destination] = process.argv.slice(2);
const canonical = realpathSync(canonicalPath);
const gitBinary = process.env.OPENCLAW_PR_GIT;
const git = (root, args) => execFileSync(gitBinary, ["-C", root, ...args], {
encoding: "utf8", timeout: 300_000, stdio: ["ignore", "pipe", "pipe"],
}).trim();
// Refresh steps stream instead: a far-behind root's fetch or fast-forward output
// can overflow the capture buffer after Git has already changed the checkout.
const gitStreamed = (root, args) => execFileSync(gitBinary, ["-C", root, ...args], {
timeout: 300_000, stdio: ["ignore", 2, 2],
});
function config(root, key, type = "--path") {
const result = spawnSync(gitBinary, ["-C", root, "config", type, "--get", key], {
encoding: "utf8", timeout: 300_000,
});
if (result.status === 1) return "";
if (result.status !== 0) throw new Error(`Cannot read Git setting ${key} in ${root}.`);
return result.stdout.trim();
}
function repository(url, root) {
if (/^(?:\.?\.?\/|\/)/.test(url)) return realpathSync(resolve(root, url));
const parsed = new URL(url.replace(/^git@([^:]+):/, "ssh://git@$1/"));
if (parsed.protocol === "file:") return realpathSync(decodeURIComponent(parsed.pathname));
return `${parsed.hostname.toLowerCase()}/${parsed.pathname.replace(/^\/|\/$/g, "").replace(/\.git$/, "").toLowerCase()}`;
}
let root = canonical;
try {
const requested = process.env.OPENCLAW_PR_TOOLING_ROOT || config(canonical, "openclaw.pr.toolingRoot");
if (requested) {
root = realpathSync(resolve(canonical, requested));
if (git(root, ["rev-parse", "--is-inside-work-tree"]) !== "true" ||
realpathSync(git(root, ["rev-parse", "--show-toplevel"])) !== root ||
config(root, "core.sparseCheckout", "--bool") === "true" ||
repository(git(root, ["remote", "get-url", "origin"]), root) !==
repository(git(canonical, ["remote", "get-url", "origin"]), canonical)) {
throw new Error(`Tooling root must be a full Git work tree of this repository: ${root}`);
}
console.error(`scripts/pr tooling root: ${root}`);
}
const materialize = () => {
const result = spawnSync(process.execPath, [owner, join(root, "node_modules"), destination], {
encoding: "utf8", stdio: ["ignore", "inherit", "pipe"],
});
if (result.stderr) process.stderr.write(result.stderr);
if (result.error) throw result.error;
// Older anchored materializers used exit 1 for their version refusal.
// Destination write errors never carry this complete validation diagnostic.
const drift = result.status === 78 || (result.status === 1 &&
/^Installed scripts\/pr dependency '[^\r\n]+' has version [^\r\n]+; the trust anchor requires [^\r\n]+\.$/m.test(result.stderr));
if (result.status !== 0 && !drift) throw new Error(`Dependency materialization failed in ${destination}; tooling root was not refreshed.`);
return result.status === 0;
};
if (!materialize()) {
if (!requested) process.exit(1);
const requireCleanMain = () => {
const branch = git(root, ["branch", "--show-current"]) || "(detached)";
const dirty = git(root, ["status", "--porcelain", "--untracked-files=all", "--ignore-submodules=none"]);
if (root === canonical || branch !== "main" || dirty) {
throw new Error(`Cannot refresh scripts/pr tooling root ${root} (branch=${branch}, ${dirty ? "dirty" : "clean"}${root === canonical ? ", canonical checkout" : ""}). Restore its frozen dependencies manually or set OPENCLAW_PR_TOOLING_ROOT to a separate clean main checkout.`);
}
};
requireCleanMain();
console.error(`Refreshing scripts/pr tooling root ${root} (main): fetch origin main, merge --ff-only origin/main, pnpm install --frozen-lockfile.`);
gitStreamed(root, ["fetch", "origin", "main"]);
// Fetch may run hooks; reread the checkout before changing files.
requireCleanMain();
gitStreamed(root, ["merge", "--ff-only", "--no-stat", "refs/remotes/origin/main"]);
// Package-manager Git children must use the same preflighted binary too.
const bin = join(dirname(destination), ".pr-tooling-bin");
mkdirSync(bin);
try {
symlinkSync(gitBinary, join(bin, "git"));
// A post-merge hook can dirty or switch the checkout despite merge succeeding.
requireCleanMain();
const installed = spawnSync("pnpm", ["-C", root, "install", "--frozen-lockfile"], {
stdio: "inherit", env: { ...process.env, PATH: `${bin}${delimiter}${process.env.PATH ?? ""}` },
});
if (installed.status !== 0) throw new Error(`Frozen dependency installation failed in ${root}.`);
} finally {
rmSync(bin, { recursive: true, force: true });
}
if (!materialize()) throw new Error(`Refreshed scripts/pr tooling root ${root} still does not match the trust anchor; no further refresh was attempted.`);
console.error(`Refreshed and verified scripts/pr tooling root: ${root}`);
}
} catch (error) {
console.error(`scripts/pr tooling root ${root}: ${error.message}`);
process.exitCode = 1;
}
EOF_TOOLING
}
# Select trusted wrapper code independently from the canonical repository root;
# a linked wrapper may be removed by merge-run or gc before supervision ends.
canonical_repo_root="$script_parent_dir/.."
# Bootstrap paths stay fixed so an edited inventory cannot exclude itself from
# the working-tree and anchor checks. The inventory owns the remaining closure.
pr_wrapper_components=(scripts/pr scripts/pr-lib)
# Anchor-exec handoff: this process was exec'd from wrapper bytes materialized
# out of refs/remotes/origin/main by the selection logic below in a previous
# wrapper. The env var conveys repository addressing only — code trust came
# from the parent's per-blob verification of the materialized copy, the same
# verify-then-exec contract as the canonical-checkout substitution. Skip
# re-selection: the running bytes ARE the anchor, and the temp copy has no git
# context of its own to select against.
if [ -n "${OPENCLAW_PR_ANCHOR_REPO_ROOT:-}" ]; then
if ! pr_git -C "$OPENCLAW_PR_ANCHOR_REPO_ROOT" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
echo "OPENCLAW_PR_ANCHOR_REPO_ROOT is not a git work tree: $OPENCLAW_PR_ANCHOR_REPO_ROOT" >&2
exit 1
fi
canonical_repo_root="$OPENCLAW_PR_ANCHOR_REPO_ROOT"
pr_read_workflow_binding "$@"
# Older parents pin only their own dependency set. The anchored child owns
# completing its installation before any loader, supervisor, or lock starts.
pr_materialize_dependencies "$script_parent_dir/pr-lib/materialize-dependencies.mjs" \
"$script_parent_dir/../node_modules"
elif common_git_dir=$(pr_git -C "$script_parent_dir" rev-parse --path-format=absolute --git-common-dir 2>/dev/null); then
canonical_repo_root="$(dirname "$common_git_dir")"
canonical_self="$canonical_repo_root/scripts/$(basename "${BASH_SOURCE[0]}")"
pr_read_workflow_binding "$@"
if [ "$PR_WORKFLOW_BOOTSTRAP" != 1 ] && [ -x "$canonical_self" ] && { [ "$script_self" != "$canonical_self" ] || [ -n "$PR_WORKFLOW_PR" ]; }; then
linked_inventory=$(pr_git -C "$script_parent_dir" show HEAD:scripts/pr-lib/wrapper-components.txt)
while IFS= read -r wrapper_component; do
pr_wrapper_components+=("$wrapper_component")
done <<< "$linked_inventory"
if ! pr_git -C "$script_parent_dir" diff --quiet HEAD -- "${pr_wrapper_components[@]/#/:(top)}"; then
echo "scripts/pr wrapper files have uncommitted changes in this worktree." >&2
echo "Refusing to run unreviewed wrapper code from: $script_parent_dir" >&2
exit 1
fi
linked_wrapper_revision=$(
pr_git -C "$script_parent_dir" rev-parse "${pr_wrapper_components[@]/#/HEAD:}" 2>/dev/null || true
)
canonical_wrapper_revision=$(
pr_git -C "$canonical_repo_root" rev-parse "${pr_wrapper_components[@]/#/HEAD:}" 2>/dev/null || true
)
if [ "$script_self" != "$canonical_self" ] && [ -z "$PR_WORKFLOW_PR" ] && [ -n "$linked_wrapper_revision" ] &&
[ "$linked_wrapper_revision" = "$canonical_wrapper_revision" ] &&
pr_git -C "$canonical_repo_root" diff --quiet HEAD -- "${pr_wrapper_components[@]/#/:(top)}"; then
exec "$canonical_self" "$@"
fi
# The canonical checkout can be parked on another branch or carry local
# edits (release trains move it); maintainer-controlled origin/main is the
# trust anchor then. Matching code can run in place when its command
# does not need the materialized dependency context.
# refs/remotes/... explicitly: bare "origin/main" is a DWIM name that a
# local branch or tag named origin/main could shadow, spoofing the anchor.
anchor_commit="${PR_WORKFLOW_SOURCE_REVISION:-$(pr_git -C "$script_parent_dir" rev-parse --verify refs/remotes/origin/main^{commit} 2>/dev/null || true)}"
anchor_description="origin/main"
anchor_reference="refs/remotes/origin/main"
if [ -n "$PR_WORKFLOW_BINDING_OID" ]; then
anchor_description="retained PR #$PR_WORKFLOW_PR workflow $anchor_commit"
anchor_reference="$anchor_description"
fi
anchor_wrapper_revision=""
if [ -n "$anchor_commit" ] &&
anchor_inventory=$(pr_git -C "$script_parent_dir" show "$anchor_commit:scripts/pr-lib/wrapper-components.txt" 2>/dev/null); then
# A newer anchor can add helpers outside the caller's inventory. Its own
# closure governs canonical substitution as well as extraction below.
anchor_components=(scripts/pr scripts/pr-lib)
while IFS= read -r anchor_component; do
anchor_components+=("$anchor_component")
done <<< "$anchor_inventory"
anchor_wrapper_revision=$(
pr_git -C "$script_parent_dir" rev-parse "${anchor_components[@]/#/$anchor_commit:}" 2>/dev/null || true
)
canonical_wrapper_revision=$(
pr_git -C "$canonical_repo_root" rev-parse "${anchor_components[@]/#/HEAD:}" 2>/dev/null || true
)
fi
linked_matches_anchor=0
if [ -n "$linked_wrapper_revision" ] &&
[ "$linked_wrapper_revision" = "$anchor_wrapper_revision" ]; then
linked_matches_anchor=1
fi
# Node resolves helper imports from their source directory, not the Git owner.
# Reuse the verified materializer for code-only linked PR operations.
if [ "$linked_matches_anchor" != "1" ] ||
{ is_locked_pr_command "$requested_subcommand" && [ ! -d "$script_parent_dir/../node_modules" ]; }; then
wrapper_route_reason="differs from $anchor_description"
if [ "$linked_matches_anchor" = "1" ]; then
wrapper_route_reason="matches $anchor_description but has no node_modules directory"
fi
requested_classification=$(pr_subcommand_classification "$requested_subcommand" 2>/dev/null || true)
if [ "$linked_matches_anchor" != "1" ] && [ "$dev_wrapper_opt_in" = "1" ] && [ "$requested_classification" = "advisory" ]; then
if [ "${OPENCLAW_PR_DEV_WRAPPER_BANNER_SHOWN:-}" != "1" ]; then
local_head_revision=$(pr_git -C "$script_parent_dir" rev-parse HEAD 2>/dev/null || printf 'unknown')
echo "WARNING: running local scripts/pr revision $local_head_revision via dev-wrapper opt-in." >&2
echo "subcommand '$requested_subcommand' is classified advisory." >&2
echo "The local wrapper differs from the canonical checkout and origin/main; landing subcommands remain refused." >&2
export OPENCLAW_PR_DEV_WRAPPER_BANNER_SHOWN=1
fi
else
if [ "$linked_matches_anchor" != "1" ] && [ "$dev_wrapper_opt_in" = "1" ] && [ -n "$requested_classification" ]; then
echo "subcommand '$requested_subcommand' is classified $requested_classification; dev-wrapper opt-in is unavailable." >&2
fi
# Worktrees routinely sit on a base that predates (or carries) wrapper
# changes relative to main. When the canonical checkout is byte-identical
# to the fetched origin/main anchor, exec-ing it runs exactly the trusted
# anchor code; announce the substitution so it is never silent.
if [ "$script_self" != "$canonical_self" ] && [ -n "$anchor_wrapper_revision" ] &&
[ "$canonical_wrapper_revision" = "$anchor_wrapper_revision" ] &&
pr_git -C "$canonical_repo_root" diff --quiet HEAD -- "${anchor_components[@]/#/:(top)}"; then
hold_pr_workflow_selection "$anchor_commit" "$canonical_repo_root" || exit 1
echo "scripts/pr wrapper in this worktree $wrapper_route_reason; running the canonical checkout's wrapper (matches the $anchor_description trust anchor): $canonical_repo_root" >&2
exec "$canonical_self" "$@"
fi
# The canonical checkout cannot supply trusted code and dependency context.
# Materialize the anchor wrapper bytes directly
# from refs/remotes/origin/main and exec that copy: trust flows from
# the fetched ref itself, so a queued PR stops paying a rebase + CI lap
# for unrelated wrapper drift on main. Requires the anchor entrypoint
# to understand the handoff; older anchors fall through to the refusal.
# Archive emits paths relative to its cwd, so run it (and the
# verification listing) from the worktree toplevel to keep component
# paths intact in the materialized tree.
anchor_git_root=$(pr_git -C "$script_parent_dir" rev-parse --show-toplevel 2>/dev/null || true)
if [ -n "$anchor_wrapper_revision" ] && [ -n "$anchor_git_root" ] &&
anchor_exec_dir=$(mktemp -d "${TMPDIR:-/tmp}/openclaw-pr-anchor.$$.XXXXXX" 2>/dev/null); then
# Hold the directory we created across exec, not an environment pathname.
# FD 9 is reserved for the supervisor; bootstrap children are awaited.
trap 'rm -rf "$anchor_exec_dir"' EXIT
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 131' QUIT
trap 'exit 143' TERM
exec 9< "$anchor_exec_dir"
anchor_extraction_valid=0
# BSD tar can stop at the end marker before Git finishes writing padding,
# causing SIGPIPE under pipefail. Require producer completion before reading.
if pr_git -C "$anchor_git_root" archive "$anchor_commit" -- \
"${anchor_components[@]}" > "$anchor_exec_dir/anchor.tar" 2>/dev/null &&
tar -xf "$anchor_exec_dir/anchor.tar" -C "$anchor_exec_dir" 2>/dev/null; then
rm "$anchor_exec_dir/anchor.tar"
# Verify every extracted file byte-matches its anchor blob so an
# archive/extract fault can never smuggle different code past the
# trust boundary. The temp dir is 0700 and exec'd immediately —
# the same TOCTOU class as the canonical-substitution exec above.
if pr_batch_wrapper_git verify "$anchor_git_root" "$anchor_commit" \
"$anchor_exec_dir" "${anchor_components[@]}"; then
anchor_extraction_valid=1
fi
fi
if [ "$anchor_extraction_valid" = "1" ] &&
grep -q "OPENCLAW_PR_ANCHOR_REPO_ROOT" "$anchor_exec_dir/scripts/pr" 2>/dev/null; then
# Only third-party tooling comes from the selected installed tree.
# Pin package directories: a later install may replace top-level aliases.
# A whole node_modules link could load unanchored workspace source.
anchor_dependency_owner="$anchor_exec_dir/scripts/pr-lib/materialize-dependencies.mjs"
# Older anchors still rely on their parent to prepare dependencies.
# Keep that bootstrap in this caller, using the extracted manifest.
if [ ! -f "$anchor_dependency_owner" ]; then
anchor_dependency_owner="$script_parent_dir/pr-lib/materialize-dependencies.mjs"
fi
if ! pr_materialize_dependencies "$anchor_dependency_owner" "$anchor_exec_dir/node_modules"
then
exit 1
fi
# Bash re-reads this copy: successful exec hands cleanup to the
# same creator PID, in anchored bash or its drained supervisor.
# Older anchors without that handoff are reclaimed by the stale sweep.
hold_pr_workflow_selection "$anchor_commit" "$anchor_exec_dir" || exit 1
echo "scripts/pr wrapper in this worktree $wrapper_route_reason; running wrapper code materialized from the $anchor_reference trust anchor at revision $(pr_git -C "$script_parent_dir" rev-parse --short "$anchor_commit")." >&2
# Bash 3.2 clears traps even when exec fails; errexit must be off
# until we reinstall cleanup on the failure-only continuation.
shopt -s execfail
set +e
OPENCLAW_PR_ANCHOR_REPO_ROOT="$canonical_repo_root" \
OPENCLAW_PR_ANCHOR_CREATOR_PID="$$" OPENCLAW_PR_ANCHOR_FD=9 \
exec "$anchor_exec_dir/scripts/pr" "$@"
trap 'rm -rf "$anchor_exec_dir"' EXIT
echo "Unable to execute the materialized scripts/pr wrapper." >&2
exit 1
fi
rm -rf "$anchor_exec_dir"
trap - EXIT HUP INT QUIT TERM
exec 9<&-
fi
# HEAD blobs are authoritative here: the uncommitted-wrapper guard above
# already exited for any staged or unstaged edit to these paths, so
# the working tree matches HEAD and this list matches what was rejected.
# An absent anchor differs at every path without launching another Git read.
differing_wrapper_components=("${pr_wrapper_components[@]}")
if [ -n "$anchor_commit" ] &&
differing_components=$(pr_batch_wrapper_git differences "$script_parent_dir" \
"$anchor_commit" "${pr_wrapper_components[@]}"); then
differing_wrapper_components=()
while IFS= read -r wrapper_component; do
[ -z "$wrapper_component" ] || differing_wrapper_components+=("$wrapper_component")
done <<< "$differing_components"
fi
if [ "$linked_matches_anchor" = "1" ]; then
echo "scripts/pr wrapper matches origin/main, but its linked checkout has no node_modules directory." >&2
else
echo "scripts/pr implementation differs between this worktree and the canonical checkout, and does not match origin/main." >&2
fi
echo "differing wrapper components vs origin/main: ${differing_wrapper_components[*]}" >&2
echo "Refusing to silently substitute canonical wrapper code from: $canonical_repo_root" >&2
echo "Run scripts/pr from a checkout with installed dependencies whose wrapper matches the canonical checkout or a fetched origin/main." >&2
exit 1
fi
fi
fi
fi
# Keep the verified owner available after cleanup removes a linked wrapper.
canonical_repo_root="$(cd "$canonical_repo_root" && pwd -P)"
readonly canonical_repo_root
if [ -n "$PR_WORKFLOW_PR" ]; then
# A pin is admission state, not caller-selected code. The operation lock
# fences the verified closure and initial record before any PR side effect.
PR_WORKFLOW_SOURCE_REVISION="${PR_WORKFLOW_SOURCE_REVISION:-${anchor_commit:-$(pr_git -C "$canonical_repo_root" rev-parse --verify refs/remotes/origin/main^{commit})}}"
hold_pr_workflow_selection "$PR_WORKFLOW_SOURCE_REVISION" "$script_parent_dir/.." || exit 1
fi
is_main_only_pr_command() {
case "$1" in
prepare-init | prepare-correction-init | prepare-correction-continue | prepare-correction-review-init | prepare-baseline-refresh | prepare-baseline-successor | prepare-baseline-signing-request | prepare-baseline-signing-continue | prepare-validate-commit | prepare-gates | prepare-push | prepare-sync-head | prepare-run | merge-verify | merge-run | merge-recover) return 0 ;;
*) return 1 ;;
esac
}
is_supervised_pr_process() {
[ "${OPENCLAW_PR_DEDICATED_PROCESS_GROUP:-}" = "1" ] &&
[ "${OPENCLAW_PR_LOCK_NOTIFY_FD:-}" = "3" ] &&
[ "${OPENCLAW_PR_LOCK_SUPERVISOR_PID:-}" = "$PPID" ]
}
# The supervisor changes cwd to the canonical repo. Resolve explicit body and
# evidence paths relative to the operator's caller before crossing that
# boundary; do not read them.
if [ "${1-}" = tooling-recover ]; then
[ "$#" -ge 3 ] || { echo "tooling-recover requires a PR and plan" >&2; exit 2; }
recovery_plan=$(node -e 'process.stdout.write(require("node:path").resolve(process.argv[1]))' -- "$3") || exit 1
set -- "$1" "$2" "$recovery_plan" "${@:4}"
fi
if [ "${1-}" = merge-run ] || [ "${1-}" = merge-recover ] || [ "${1-}" = prepare-baseline-refresh ] || [ "${1-}" = prepare-baseline-successor ] || [ "${1-}" = prepare-baseline-signing-request ] || [ "${1-}" = prepare-baseline-signing-continue ]; then
merge_cli_args=() merge_cli_body_next=false
for merge_cli_arg in "$@"; do
if [ "$merge_cli_body_next" = true ] && [ -n "$merge_cli_arg" ]; then
merge_cli_arg=$(node -e 'process.stdout.write(require("node:path").resolve(process.argv[1]))' -- "$merge_cli_arg") || exit 1
fi
merge_cli_args+=("$merge_cli_arg")
merge_cli_body_next=false
if [ "$merge_cli_arg" = --body-file ] || [ "$merge_cli_arg" = --legacy-refusal ] || [ "$merge_cli_arg" = --pre-dispatch-refusal ] || [ "$merge_cli_arg" = --admin-evidence ] || [ "$merge_cli_arg" = --resolutions ] || [ "$merge_cli_arg" = --signed-commit ]; then merge_cli_body_next=true; fi
done
set -- "${merge_cli_args[@]}"
fi
if [ "${1-}" = "gc" ] || is_locked_pr_command "${1-}"; then
if is_supervised_pr_process; then
# operation-lock.sh consumes the one-shot marker when it installs the
# leader-only completion trap, before PR command tools can inherit it.
:
else
unset OPENCLAW_PR_DEDICATED_PROCESS_GROUP
unset OPENCLAW_PR_LOCK_NOTIFY_FD
unset OPENCLAW_PR_LOCK_SUPERVISOR_PID
command -v node >/dev/null 2>&1 || { echo "Missing required command: node" >&2; exit 1; }
shopt -s execfail
set +e
exec node "$script_parent_dir/pr-lib/process-group-runner.mjs" "$canonical_repo_root" "$script_self" "$@"
if [ "${OPENCLAW_PR_ANCHOR_CREATOR_PID:-}" = "$$" ] && [ "${OPENCLAW_PR_ANCHOR_FD:-}" = 9 ]; then
trap 'node "$script_parent_dir/pr-lib/materialized-anchor.mjs" retire "$script_self" || true' EXIT
fi
echo "Unable to execute the scripts/pr supervisor." >&2
exit 1
fi
fi
# shellcheck disable=SC1091
source "$script_parent_dir/lib/plain-gh.sh"
usage() {
cat <<USAGE
Usage:
scripts/pr [--dev-wrapper] <subcommand> ...
scripts/pr ls
scripts/pr gc [--pr <number>] [--dry-run]
scripts/pr lock-recover <PR> <OWNER_OID> --confirmed-no-running-tools
scripts/pr tooling-recover <PR> <plan> --plan-sha256 <SHA256> --confirmed-quiescent
scripts/pr review-init <PR>
scripts/pr review-checkout-main <PR>
scripts/pr review-checkout-pr <PR>
scripts/pr review-claim <PR>
scripts/pr review-guard <PR>
scripts/pr review-artifacts-init <PR>
scripts/pr review-validate-artifacts <PR>
scripts/pr review-tests <PR> <test-file> [<test-file> ...]
scripts/pr prepare-init <PR>
scripts/pr prepare-correction-init <PR>
scripts/pr prepare-correction-continue <PR> --expected-head <SHA> --expected-tree <OID> --incoming-head <SHA> --incoming-review <OID> --publication-receipt <push|sync> --publication-oid <OID>
scripts/pr prepare-correction-review-init <PR>
scripts/pr prepare-baseline-refresh <PR> --expected-head <SHA> --baseline <SHA> [--resolutions <file>]
scripts/pr prepare-baseline-successor <PR> --expected-head <SHA> --expected-tree <OID> --expected-review <OID> --predecessor-head <SHA> --predecessor-binding <OID> --baseline <SHA> [--resolutions <file>]
scripts/pr prepare-baseline-signing-request <PR> --expected-head <SHA> --baseline <SHA> --signer <fingerprint> [--resolutions <file>] [successor identity options]
scripts/pr prepare-baseline-signing-continue <PR> --request <OID> --signed-commit <file>
scripts/pr prepare-validate-commit <PR>
scripts/pr prepare-gates <PR>
scripts/pr prepare-push <PR> [--resume-crabbox-run <Actions run ID>]
Resume observes the selected protected publisher only; never pushes or dispatches.
scripts/pr prepare-sync-head <PR>
scripts/pr prepare-run <PR>
scripts/pr ci-dispatch <PR> [--backend crabbox]
scripts/pr merge-verify <PR>
scripts/pr merge-run <PR> [--auto-merge] [--body-file <path>] [--admin-evidence <path> --confirmed-operator-admin]
OPENCLAW_PR_MERGE_METHOD=merge|rebase preserves the PR commit series.
--auto-merge selects pinned immediate squash for CLEAN, auto for BEHIND/BLOCKED (MERGEABLE only).
OPENCLAW_PR_GATES_REMOTE=github prepare-run defers required CI to this auto-merge handoff.
OPENCLAW_PR_AUTO_MERGE=1 is equivalent.
--body-file snapshots a regular UTF-8 file relative to the caller, replacing squash prose.
Empty files are valid. Explicit/source co-authors and preview credit backed by tree-changing PR commits or PR authorship are retained; known machine credit is excluded. Queue merges reject it.
--admin-evidence admits reviewed prior-green conflict repairs or attributed pre-existing CI failures, with active organization-admin authority.
Exact evidence is required; branch-caused failures, security checks, and enforced reviews remain blocking.
This requires --confirmed-operator-admin and immediate squash without auto-merge.
Repeated merge-run reconciles retained outcomes; it never retries an uncertain dispatch.
scripts/pr merge-recover <PR> <OUTCOME_OID> --confirmed-operator-recovery [--replacement-head <SHA> [--auto-merge]] [--current-workflow] [--merged-head <SHA>] [--cancel-auto [--observed-head <SHA>]] [--body-file <path>] [--legacy-refusal <evidence-directory>] [--pre-dispatch-refusal <evidence-directory>] [--admin-evidence <path> --confirmed-operator-admin]
Explicitly authorize one new attempt after inspecting the retained outcome and remote history.
Defaults to the retained head; replacement requires an explicitly selected full lowercase 40-character SHA,
freshly authored exact-head review/preparation and completed CI proof. Standing landing authority covers
a replacement repairing the same scope; new scope or another method needs authorization. Never rebases automatically.
Ordinary recovery keeps prior evidence and requires the same method with immediate non-admin admission.
Paired admin flags permit an explicitly selected head after confirmed auto cancellation, including the retained SHA.
They also permit same-head recovery without --replacement-head for the complete known prior-CI REST base-modified HTTP 405 rejection.
This reruns current admin admission and retains the original captures; other admin failures remain fenced.
--current-workflow explicitly selects current official main for either cancelled-auto replacement or
same-head qualified HTTP 405 admin recovery; the retained binding remains unchanged.
--replacement-head <SHA> --auto-merge retires an unaccepted prior-CI REST admin intent fenced by a different
exact current head, then requests ordinary current-head auto-merge. It requires exact-head github_pending
preparation and all normal required-check gates; it never reuses prior admin evidence.
--cancel-auto instead retires the exact accepted or uncertain non-queue auto request before head repair.
Cancels a matching active request once, or records an already absent request without sending cancellation.
--observed-head <SHA> selects a changed OPEN head whose accepted auto request is already absent.
It records retirement only after stable observations; armed requests and concurrent drift stay refused.
Repeated cancellation reconciles only. Use its current outcome OID for later reviewed/prepared recovery.
--merged-head <SHA> records the receipt of an accepted auto request that GitHub merged on a pushed descendant
head after inspecting the PR timeline. It requires the exact authoritative merged head and never dispatches.
scripts/pr merge-complete <PR> <OUTCOME_OID> --confirmed-operator-completion
Finish a verified merge receipt after cleanup; never dispatches a merge or deletes resources.
Posts a first completion comment only from merged; uncertain comment attempts are lookup-only.
--dev-wrapper permits a mismatched local wrapper only for subcommands
classified advisory. OPENCLAW_PR_DEV_WRAPPER=1 is equivalent.
Required commands: git, gh, jq, rg (ripgrep), pnpm, node.
When macOS blocks ps, PR operation locks require Python 3 with ctypes on PATH.
USAGE
}
require_cmds() {
local missing=()
local cmd
for cmd in gh jq rg pnpm node; do
if ! command -v "$cmd" >/dev/null 2>&1; then
missing+=("$cmd")
fi
done
if ! resolve_plain_gh_bin >/dev/null; then
missing+=("gh")
fi
if [ "${#missing[@]}" -gt 0 ]; then
echo "Missing required command(s): ${missing[*]}" >&2
if [[ " ${missing[*]} " = *" rg "* ]]; then
echo "Install ripgrep and retry: https://github.com/BurntSushi/ripgrep#installation" >&2
fi
exit 1
fi
}
require_main_target_pr() {
local pr="$1"
local base base_json
pr_observe "$pr" || exit 1
base_json="$PR_OBSERVATION"
base=$(pr_view_string_field "$base_json" "baseRefName" "$pr" "Retry the scripts/pr command.") || exit 1
if [ "$base" != "main" ]; then
echo "scripts/pr prepare and merge commands only support PRs targeting main; PR #$pr targets $base." >&2
echo "Use the reviewed release-branch landing flow for non-main PRs." >&2
exit 1
fi
}
# shellcheck disable=SC1091
source "$script_parent_dir/pr-lib/worktree.sh"
# shellcheck disable=SC1091
source "$script_parent_dir/pr-lib/operation-lock.sh"
# shellcheck disable=SC1091
source "$script_parent_dir/pr-lib/common.sh"
# shellcheck disable=SC1091
source "$script_parent_dir/pr-lib/changelog.sh"
# shellcheck disable=SC1091
source "$script_parent_dir/pr-lib/gates.sh"
# shellcheck disable=SC1091
source "$script_parent_dir/pr-lib/push.sh"
# shellcheck disable=SC1091
source "$script_parent_dir/pr-lib/review.sh"
# shellcheck disable=SC1091
source "$script_parent_dir/pr-lib/prepare-core.sh"
# shellcheck disable=SC1091
source "$script_parent_dir/pr-lib/merge.sh"
main() {
if [ "$#" -lt 1 ]; then
usage
exit 2
fi
local cmd="${1-}"
shift || true
if [ "$cmd" = "lock-recover" ]; then
local pr="${1-}"
local owner_oid="${2-}"
local confirmation="${3-}"
[ -n "$pr" ] && [ -n "$owner_oid" ] && [ "$#" -eq 3 ] || { usage; exit 2; }
recover_pr_operation_lock "$pr" "$owner_oid" "$confirmation"
return
fi
case "$cmd" in
ls) ;;
gc)
local gc_dry_run=false gc_pr=""
while [ "$#" -gt 0 ]; do
case "$1" in
--dry-run)
[ "$gc_dry_run" = false ] || { usage; exit 2; }
gc_dry_run=true
shift
;;
--pr)
[ -z "$gc_pr" ] && [ "$#" -ge 2 ] && is_canonical_pr_number "$2" || { usage; exit 2; }
gc_pr="$2"
shift 2
;;
*) usage; exit 2 ;;
esac
done
;;
tooling-recover)
[ "$#" -eq 5 ] && is_canonical_pr_number "$1" && [ "$3" = --plan-sha256 ] &&
[[ "$4" =~ ^[0-9a-f]{64}$ ]] && [ "$5" = --confirmed-quiescent ] || { usage; exit 2; }
;;
review-tests)
[ "$#" -ge 2 ] || { usage; exit 2; }
;;
merge-complete)
[ "$#" -eq 3 ] && is_canonical_pr_number "$1" &&
[[ "$2" =~ ^[0-9a-f]{40}$ ]] && [ "$3" = --confirmed-operator-completion ] || { usage; exit 2; }
;;
merge-run | merge-recover)
local merge_pr="${1-}" auto_merge=false recovery_oid="" merged_head="" observed_head="" replacement_head="" body_path="" legacy_directory="" cancel_auto=false refusal_directory="" admin_evidence="" confirmed_admin=false
[ -n "$merge_pr" ] || { usage; exit 2; }
shift
if [ "$cmd" = merge-recover ]; then
[ "$#" -ge 2 ] && is_canonical_pr_number "$merge_pr" &&
[[ "$1" =~ ^[0-9a-f]{40}$ ]] && [ "$2" = --confirmed-operator-recovery ] || { usage; exit 2; }
recovery_oid="$1"
shift 2
fi
while [ "$#" -gt 0 ]; do
case "$1" in
--current-workflow)
[ "$cmd" = merge-recover ] && [ -n "$PR_WORKFLOW_RECOVERY_OUTCOME" ] || { usage; exit 2; }
shift
;;
--cancel-auto)
[ "$cmd" = merge-recover ] && [ "$cancel_auto" = false ] || { usage; exit 2; }
cancel_auto=true
shift
;;
--observed-head)
[ "$cmd" = merge-recover ] && [ "$#" -ge 2 ] &&
[[ "$2" =~ ^[0-9a-f]{40}$ ]] && [ -z "$observed_head" ] || { usage; exit 2; }
observed_head="$2"
shift 2
;;
--admin-evidence)
[ "$#" -ge 2 ] && [ -n "$2" ] && [ -z "$admin_evidence" ] || { usage; exit 2; }
admin_evidence="$2"
shift 2
;;
--confirmed-operator-admin)
[ "$confirmed_admin" = false ] || { usage; exit 2; }
confirmed_admin=true
shift
;;
--auto-merge)
[ "$auto_merge" = false ] || { usage; exit 2; }
auto_merge=true
shift
;;
--body-file)
[ "$#" -ge 2 ] && [ -n "$2" ] && [ -z "$body_path" ] || { usage; exit 2; }
body_path="$2"
shift 2
;;
--pre-dispatch-refusal)
[ "$cmd" = merge-recover ] && [ "$#" -ge 2 ] && [ -n "$2" ] && [ -z "$refusal_directory" ] || { usage; exit 2; }
refusal_directory="$2"
shift 2
;;
--legacy-refusal)
[ "$cmd" = merge-recover ] && [ "$#" -ge 2 ] && [ -n "$2" ] && [ -z "$legacy_directory" ] || { usage; exit 2; }
legacy_directory="$2"
shift 2
;;
--merged-head)
[ "$cmd" = merge-recover ] && [ "$#" -ge 2 ] &&
[[ "$2" =~ ^[0-9a-f]{40}$ ]] && [ -z "$merged_head" ] || { usage; exit 2; }
merged_head="$2"
shift 2
;;
--replacement-head)
[ "$cmd" = merge-recover ] && [ "$#" -ge 2 ] &&
[[ "$2" =~ ^[0-9a-f]{40}$ ]] && [ -z "$replacement_head" ] || { usage; exit 2; }
replacement_head="$2"
shift 2
;;
*) usage; exit 2 ;;
esac
done
if [ "$cmd" = merge-run ] && [ "${OPENCLAW_PR_AUTO_MERGE:-}" = 1 ]; then
auto_merge=true
fi
if [ -n "$admin_evidence" ] || [ "$confirmed_admin" = true ]; then
[ -n "$admin_evidence" ] && [ "$confirmed_admin" = true ] && [ "$auto_merge" = false ] &&
[ -z "$legacy_directory$refusal_directory" ] && [ "$cancel_auto" = false ] &&
[ "${OPENCLAW_PR_MERGE_METHOD:-squash}" = squash ] || { usage; exit 2; }
fi
if [ "$cmd" = merge-recover ] && [ "$auto_merge" = true ]; then
[ -n "$replacement_head" ] && [ -z "$body_path$legacy_directory$refusal_directory$admin_evidence" ] &&
[ "$confirmed_admin" = false ] && [ "$cancel_auto" = false ] || { usage; exit 2; }
fi
if [ -n "$merged_head" ]; then
[ -z "$replacement_head$body_path$legacy_directory$refusal_directory$admin_evidence" ] &&
[ "$cancel_auto" = false ] && [ "$auto_merge" = false ] && [ "$confirmed_admin" = false ] || { usage; exit 2; }
fi
[ -z "$legacy_directory" ] || [ -n "$replacement_head" ] || { usage; exit 2; }
[ -z "$refusal_directory" ] || [ -z "$legacy_directory" ] || { usage; exit 2; }
[ "$cancel_auto" = false ] || [ -z "$replacement_head$body_path$legacy_directory$refusal_directory" ] || { usage; exit 2; }
[ -z "$observed_head" ] || [ "$cancel_auto" = true ] || { usage; exit 2; }
set -- "$merge_pr"
;;
prepare-push)
if [ "$#" -ne 1 ]; then
[ "$#" -eq 3 ] && [ "$2" = --resume-crabbox-run ] &&
[[ "$3" =~ ^[1-9][0-9]*$ ]] &&
{ [ "${#3}" -lt 16 ] || { [ "${#3}" -eq 16 ] && [[ "$3" < 9007199254740992 ]]; }; } || { usage; exit 2; }
fi
;;
ci-dispatch)
[ "$#" -eq 1 ] || { [ "$#" -eq 3 ] && [ "$2" = --backend ] && [ "$3" = crabbox ]; } || { usage; exit 2; }
;;
review-init | review-checkout-main | review-checkout-pr | review-claim | review-guard | review-artifacts-init | review-validate-artifacts | prepare-init | prepare-correction-init | prepare-correction-continue | prepare-correction-review-init | prepare-baseline-refresh | prepare-baseline-successor | prepare-baseline-signing-request | prepare-baseline-signing-continue | prepare-validate-commit | prepare-gates | prepare-sync-head | prepare-run | merge-verify)
[ "$#" -ge 1 ] || { usage; exit 2; }
;;
*)
usage
exit 2
;;
esac
# Reject contradictory instructions before PR reads, locks, or preparation can
# retire valid evidence. prepare-push can refresh a head and rerun gates too.
case "$cmd" in
prepare-run | prepare-gates | prepare-push)
resolve_pr_gates_remote_mode >/dev/null || return $?
;;
esac
require_cmds
case "$cmd" in
review-validate-artifacts) review_artifact_preflight "${1-}" || return 1 ;;
prepare-init | prepare-run) review_artifact_preflight "${1-}" true || return 1 ;;
prepare-correction-init) review_artifact_preflight "${1-}" correction || return 1 ;;
esac
# merge-run reconciles retained outcomes before reading disposable artifacts or base guards.
if [ "$cmd" != merge-run ] && is_main_only_pr_command "$cmd"; then
require_main_target_pr "${1-}"
fi
if is_locked_pr_command "$cmd"; then
local locked_pr="${1-}"
acquire_pr_operation_lock "$locked_pr"
begin_pr_operation_validation_phase
# Temp-backed shell redirections must fail while the supervisor can auto-release.
validate_pr_temp_storage
if [ "$PR_WORKFLOW_BOOTSTRAP" = 1 ] && [ "$cmd" = prepare-push ] && [ "${2:-}" = --resume-crabbox-run ]; then
echo "Publisher resume requires an authenticated workflow source; restore its main tracking anchor before resuming. No acquisition attempted." >&2
return 1
fi
[ -z "$PR_WORKFLOW_PR" ] || ensure_gh_api_auth
admit_pr_workflow_binding "$locked_pr"
trap 'exit 129' HUP
trap 'exit 130' INT
trap 'exit 131' QUIT
trap 'exit 143' TERM
fi
case "$cmd" in
tooling-recover)
mark_pr_operation_side_effects_started
node "$script_parent_dir/pr-lib/materialize-dependencies.mjs" --recover "$1" "$2" "$4" \
"$(repo_root)" "$PR_OPERATION_LOCK_REF" "$PR_OPERATION_LOCK_OWNER_OID"
;;
ls)
list_pr_worktrees
;;
gc)
gc_pr_worktrees "$gc_dry_run" "$gc_pr"
;;
review-init)
local pr="${1-}"
[ -n "$pr" ] || { usage; exit 2; }
review_init "$pr"
;;
review-checkout-main)
local pr="${1-}"
[ -n "$pr" ] || { usage; exit 2; }
review_checkout_main "$pr"
;;
review-checkout-pr)
local pr="${1-}"
[ -n "$pr" ] || { usage; exit 2; }
review_checkout_pr "$pr"
;;
review-claim)
local pr="${1-}"
[ -n "$pr" ] || { usage; exit 2; }
review_claim "$pr"
;;
review-guard)
local pr="${1-}"
[ -n "$pr" ] || { usage; exit 2; }
review_guard "$pr"
;;
review-artifacts-init)
local pr="${1-}"
[ -n "$pr" ] || { usage; exit 2; }
review_artifacts_init "$pr"
;;
review-validate-artifacts)
local pr="${1-}"
[ -n "$pr" ] || { usage; exit 2; }
review_validate_artifacts "$pr"
;;
review-tests)
local pr="${1-}"
[ -n "$pr" ] || { usage; exit 2; }
shift || true
review_tests "$pr" "$@"
;;
prepare-correction-init)
[ "$#" -eq 1 ] || { usage; exit 2; }
prepare_init "$1" "${PR_OBSERVATION:-}" correction
;;
prepare-baseline-refresh)
prepare_baseline_refresh "$@"
;;
prepare-baseline-successor)
prepare_baseline_successor "$@"
;;
prepare-baseline-signing-request)
prepare_baseline_signing_request "$@"
;;
prepare-baseline-signing-continue)
prepare_baseline_signing_continue "$@"
;;
prepare-correction-continue)
prepare_correction_continue "$@"
;;
prepare-correction-review-init)
[ "$#" -eq 1 ] || { usage; exit 2; }
prepare_correction_review_init "$1"
;;
prepare-init)
local pr="${1-}"
[ -n "$pr" ] || { usage; exit 2; }
prepare_init "$pr" "${PR_OBSERVATION:-}"
;;
prepare-validate-commit)
local pr="${1-}"
[ -n "$pr" ] || { usage; exit 2; }
prepare_validate_commit "$pr"
;;
prepare-gates)
local pr="${1-}"
[ -n "$pr" ] || { usage; exit 2; }
prepare_gates "$pr"
;;
prepare-push)
local pr="${1-}"
[ -n "$pr" ] || { usage; exit 2; }
prepare_push "$pr" "" "${3:-}"
;;
prepare-sync-head)
local pr="${1-}"
[ -n "$pr" ] || { usage; exit 2; }
prepare_sync_head "$pr"
;;
prepare-run)
local pr="${1-}"
[ -n "$pr" ] || { usage; exit 2; }
prepare_run "$pr" "${PR_OBSERVATION:-}"
;;
ci-dispatch)
local pr="${1-}"
[ -n "$pr" ] || { usage; exit 2; }
shift
ci_dispatch "$pr" "$@"
;;
merge-verify)
local pr="${1-}"
[ -n "$pr" ] || { usage; exit 2; }
merge_verify "$pr" '{"replacementHead":"","autoMergeRequested":false,"qualifiedRefusal":false,"observation":null}'
;;
merge-run | merge-recover)
merge_run "$merge_pr" "$auto_merge" "$recovery_oid" "$replacement_head" "$body_path" "$legacy_directory" "$cancel_auto" "$refusal_directory" "$admin_evidence" "$confirmed_admin" "$merged_head" "$observed_head"
;;
merge-complete)
merge_complete "$1" "$2"
;;
*)
usage
exit 2
;;
esac
}
main "$@"