diff --git a/workspace-template/Dockerfile b/workspace-template/Dockerfile index 05f1c296..18cccd7f 100644 --- a/workspace-template/Dockerfile +++ b/workspace-template/Dockerfile @@ -38,6 +38,17 @@ COPY policies/ ./policies/ RUN ln -s /app/a2a_cli.py /usr/local/bin/a2a && chmod +x /app/a2a_cli.py /app/a2a_mcp_server.py && \ ln -s /app/molecule_ai_status.py /usr/local/bin/molecule-monorepo-status && chmod +x /app/molecule_ai_status.py +# gh wrapper — auto-prefixes PR / issue titles with the agent role + appends +# a body footer. Every agent in the template shares one GitHub PAT so plain +# `gh pr list` can't distinguish workspaces; the wrapper reads GIT_AUTHOR_NAME +# (set by the platform provisioner, "Molecule AI ") and rewrites the +# title/body accordingly. Fails open when the env is missing. Anything that +# isn't `gh pr create` or `gh issue create` passes through untouched. +# /usr/local/bin is earlier in PATH than /usr/bin/gh so this shadows the +# real binary without renaming it. +COPY scripts/gh-wrapper.sh /usr/local/bin/gh +RUN chmod +x /usr/local/bin/gh + # Dirs and permissions RUN mkdir -p /workspace /plugins /home/agent/.claude /home/agent/.config /home/agent/.local && \ chown -R agent:agent /app /home/agent /workspace diff --git a/workspace-template/scripts/gh-wrapper.sh b/workspace-template/scripts/gh-wrapper.sh new file mode 100644 index 00000000..c6d872fe --- /dev/null +++ b/workspace-template/scripts/gh-wrapper.sh @@ -0,0 +1,132 @@ +#!/usr/bin/env bash +# gh wrapper — auto-prefixes PR + issue titles with the agent role and +# appends an "Opened by: Molecule AI " footer to bodies. Shadows +# the real `gh` binary (installed at /usr/bin/gh) because /usr/local/bin +# is earlier in PATH in the workspace image. +# +# Why: every agent in the molecule-dev template shares one GitHub token +# (the CEO's PAT), so `gh pr list` shows every PR as authored by the +# same human user. This wrapper preserves the real gh behaviour while +# injecting the agent's identity into the PR/issue metadata so the +# list + body reveal WHICH agent opened each item. Commit authors are +# already per-agent via GIT_AUTHOR_NAME (shipped in the provisioner); +# this handles the PR/issue surface layer the commit layer can't reach. +# +# Role is derived from GIT_AUTHOR_NAME which the platform sets to +# "Molecule AI " at container provision time. If GIT_AUTHOR_NAME +# is missing or doesn't follow the expected prefix, the wrapper passes +# through unmodified — fail-open so no call is ever BLOCKED by this +# script. +# +# Behaviour table: +# +# gh pr create --title "fix: foo" ... +# → title becomes "[Frontend Engineer] fix: foo" +# → body gets "\n\n---\n_Opened by: Molecule AI Frontend Engineer_\n" appended +# +# gh issue create --title "..." ... +# → same title + body transforms +# +# gh +# → passes through untouched +# +# Idempotence: if the title already starts with "[" + any characters + "]", +# the wrapper does NOT re-prefix. Rerunning `gh pr edit` won't layer +# multiple "[Role] [Role] ..." prefixes. Same for body footer — we check +# for the exact "Opened by: Molecule AI" marker and skip if present. + +set -euo pipefail + +REAL_GH=/usr/bin/gh +if [[ ! -x "$REAL_GH" ]]; then + # Fallback: find the real gh wherever it landed. + REAL_GH=$(command -v /usr/bin/gh /opt/gh/bin/gh /usr/local/bin/gh-original 2>/dev/null | head -1) + if [[ -z "$REAL_GH" ]]; then + echo "gh-wrapper: real gh binary not found" >&2 + exit 127 + fi +fi + +# Extract the agent role from GIT_AUTHOR_NAME ("Molecule AI "). +# If missing or malformed, skip all transforms. +role="" +if [[ -n "${GIT_AUTHOR_NAME:-}" && "${GIT_AUTHOR_NAME}" == "Molecule AI "* ]]; then + role="${GIT_AUTHOR_NAME#Molecule AI }" +fi + +# Subcommand must be pr or issue, followed by `create`, to trigger the +# transform. Everything else is a passthrough. +if [[ $# -lt 2 || ( "$1" != "pr" && "$1" != "issue" ) || "$2" != "create" ]]; then + exec "$REAL_GH" "$@" +fi + +if [[ -z "$role" ]]; then + # No role detected — behave exactly like real gh. Don't eat arguments + # trying to be clever. + exec "$REAL_GH" "$@" +fi + +# Walk the args, rewriting --title / --body in place. Preserve every +# other flag untouched. Accept both "--title X" and "--title=X" forms. +new_args=() +i=1 +while (( i <= $# )); do + arg="${!i}" + case "$arg" in + --title) + next_i=$((i + 1)) + val="${!next_i:-}" + if [[ "$val" == \[*\]* ]]; then + # Already prefixed — leave alone. + new_args+=("$arg" "$val") + else + new_args+=("$arg" "[$role] $val") + fi + i=$((i + 2)) + continue + ;; + --title=*) + val="${arg#--title=}" + if [[ "$val" == \[*\]* ]]; then + new_args+=("$arg") + else + new_args+=("--title=[$role] $val") + fi + i=$((i + 1)) + continue + ;; + --body) + next_i=$((i + 1)) + val="${!next_i:-}" + if [[ "$val" == *"Opened by: Molecule AI"* ]]; then + new_args+=("$arg" "$val") + else + new_args+=("$arg" "${val} + +--- +_Opened by: Molecule AI ${role}_") + fi + i=$((i + 2)) + continue + ;; + --body=*) + val="${arg#--body=}" + if [[ "$val" == *"Opened by: Molecule AI"* ]]; then + new_args+=("$arg") + else + new_args+=("--body=${val} + +--- +_Opened by: Molecule AI ${role}_") + fi + i=$((i + 1)) + continue + ;; + *) + new_args+=("$arg") + i=$((i + 1)) + ;; + esac +done + +exec "$REAL_GH" "${new_args[@]}" diff --git a/workspace-template/tests/test_gh_wrapper.sh b/workspace-template/tests/test_gh_wrapper.sh new file mode 100644 index 00000000..f7887533 --- /dev/null +++ b/workspace-template/tests/test_gh_wrapper.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# Smoke-test the gh-wrapper behaviour with a fake gh binary that echoes +# back its argv. Runs entirely in-process (no Docker), so it's cheap to +# run per-CI-job. Tests the behaviour table in scripts/gh-wrapper.sh. +# +# Invoked by CI's Python Lint & Test job via a subprocess shell-out, or +# locally via `bash tests/test_gh_wrapper.sh`. + +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WRAPPER="$HERE/../scripts/gh-wrapper.sh" + +if [[ ! -x "$WRAPPER" ]]; then + echo "FAIL: wrapper not executable: $WRAPPER" >&2 + exit 1 +fi + +# Fake gh: prints every arg on its own line, prefixed by "ARG:". Lets +# tests introspect what the wrapper passed through. +FAKE_GH_DIR=$(mktemp -d) +trap 'rm -rf "$FAKE_GH_DIR"' EXIT +cat > "$FAKE_GH_DIR/gh" <<'EOF' +#!/usr/bin/env bash +for a in "$@"; do + printf 'ARG:%s\n' "$a" +done +EOF +chmod +x "$FAKE_GH_DIR/gh" + +# Make the wrapper use the fake gh by overriding the hardcoded path via +# a temporary symlink trick: copy the wrapper to a temp location and +# sed-replace the REAL_GH default with our fake. +WRAPPER_UNDER_TEST=$(mktemp) +trap 'rm -f "$WRAPPER_UNDER_TEST"' EXIT +sed "s|REAL_GH=/usr/bin/gh|REAL_GH=$FAKE_GH_DIR/gh|" "$WRAPPER" > "$WRAPPER_UNDER_TEST" +chmod +x "$WRAPPER_UNDER_TEST" + +pass=0 +fail=0 + +assert_contains() { + local name="$1" haystack="$2" needle="$3" + if [[ "$haystack" == *"$needle"* ]]; then + pass=$((pass + 1)) + echo " PASS: $name" + else + fail=$((fail + 1)) + echo " FAIL: $name" >&2 + echo " expected to contain: $needle" >&2 + echo " got: $haystack" >&2 + fi +} + +assert_not_contains() { + local name="$1" haystack="$2" needle="$3" + if [[ "$haystack" == *"$needle"* ]]; then + fail=$((fail + 1)) + echo " FAIL: $name — should not contain: $needle" >&2 + echo " got: $haystack" >&2 + else + pass=$((pass + 1)) + echo " PASS: $name" + fi +} + +echo "--- passthrough (no subcommand transform) ---" +out=$(GIT_AUTHOR_NAME="Molecule AI Frontend Engineer" "$WRAPPER_UNDER_TEST" pr list --state open) +assert_contains "pr list passthrough" "$out" "ARG:list" +assert_not_contains "pr list no prefix" "$out" "[Frontend" + +echo "--- pr create with role ---" +out=$(GIT_AUTHOR_NAME="Molecule AI Backend Engineer" "$WRAPPER_UNDER_TEST" pr create --title "fix: auth" --body "Short description") +assert_contains "pr create title prefix" "$out" "ARG:[Backend Engineer] fix: auth" +assert_contains "pr create body footer" "$out" "_Opened by: Molecule AI Backend Engineer_" + +echo "--- issue create with = form ---" +out=$(GIT_AUTHOR_NAME="Molecule AI PM" "$WRAPPER_UNDER_TEST" issue create --title="bug: foo" --body="details") +assert_contains "issue create --title= prefix" "$out" "ARG:--title=[PM] bug: foo" +assert_contains "issue create --body= footer" "$out" "_Opened by: Molecule AI PM_" + +echo "--- idempotent title re-prefix ---" +out=$(GIT_AUTHOR_NAME="Molecule AI DevRel Engineer" "$WRAPPER_UNDER_TEST" pr create --title "[DevRel Engineer] already prefixed") +assert_not_contains "no double prefix" "$out" "[DevRel Engineer] [DevRel Engineer]" + +echo "--- idempotent body footer ---" +already="original body + +--- +_Opened by: Molecule AI UIUX Designer_" +out=$(GIT_AUTHOR_NAME="Molecule AI UIUX Designer" "$WRAPPER_UNDER_TEST" pr create --title "x" --body "$already") +# Count how many times the footer marker appears — should be exactly 1. +count=$(echo "$out" | grep -c "_Opened by: Molecule AI UIUX Designer_" || true) +if [[ "$count" -eq 1 ]]; then + pass=$((pass + 1)); echo " PASS: footer not double-appended" +else + fail=$((fail + 1)); echo " FAIL: footer count=$count (want 1)" >&2 +fi + +echo "--- missing GIT_AUTHOR_NAME — passes through ---" +out=$(unset GIT_AUTHOR_NAME; "$WRAPPER_UNDER_TEST" pr create --title "fix: foo") +assert_not_contains "no role means no prefix" "$out" "[M" +assert_contains "raw title survives" "$out" "ARG:fix: foo" + +echo "--- wrong prefix in GIT_AUTHOR_NAME — passes through ---" +out=$(GIT_AUTHOR_NAME="Some Random Human" "$WRAPPER_UNDER_TEST" pr create --title "fix: foo") +assert_not_contains "non-Molecule author means no prefix" "$out" "[S" +assert_contains "raw title survives (wrong prefix)" "$out" "ARG:fix: foo" + +echo +echo "================================" +echo "gh-wrapper: $pass passed, $fail failed" +echo "================================" +[[ $fail -eq 0 ]]