From b2529c8262d0bcfd8c958c353ac95b47a865700b Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Mon, 13 Apr 2026 23:23:39 +0200 Subject: [PATCH] Keep local/base checkouts stable by forbidding in-place agent branch switches Some repositories still carry legacy branch-starter behavior that can switch the visible checkout to an agent branch. This change enforces worktree-only agent branch creation, updates AGENTS policy text, and hardens template codex-agent startup to detect unsafe starter output, restore the original branch, and create a safe fallback sandbox worktree automatically. Constraint: Must preserve branch/worktree-first workflow without breaking existing codex-agent entrypoints Rejected: Keep optional --in-place mode behind --allow-in-place | still allows accidental visible-checkout branch switches Confidence: high Scope-risk: moderate Reversibility: clean Directive: Do not reintroduce in-place starter paths; active local/base checkout must remain unchanged during agent startup Tested: bash -n scripts/agent-branch-start.sh scripts/codex-agent.sh templates/scripts/agent-branch-start.sh templates/scripts/codex-agent.sh; node --test test/install.test.js; npm test --- AGENTS.md | 2 + README.md | 4 + scripts/agent-branch-start.sh | 45 +------- templates/AGENTS.multiagent-safety.md | 1 + templates/scripts/agent-branch-start.sh | 45 +------- templates/scripts/codex-agent.sh | 147 +++++++++++++++++++++++- test/install.test.js | 74 +++++++++++- 7 files changed, 234 insertions(+), 84 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index aeada97..8f5b9eb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,6 +25,7 @@ This AGENTS.md is the top-level operating contract for this repository. - Treat `main` and any currently checked-out base branch as read-only workspaces. - Every new session must start by creating an isolated agent branch/worktree via `scripts/agent-branch-start.sh` before making edits. - If edits are found on `main`/base by mistake, immediately move them to a dedicated agent branch/worktree before continuing. +- In-place agent branching is disallowed; keep the visible local/base checkout unchanged and do all edits in dedicated agent worktrees. - Prefer deletion over addition. - Reuse existing patterns before introducing new abstractions. - No new dependencies without explicit request. @@ -91,6 +92,7 @@ OMX runtime state typically lives under `.omx/`: - Before deleting/replacing code, each agent must read the latest session comments/handoffs first and confirm the target code is in their owned scope. - If ownership is unclear or overlaps, stop that edit, post a blocker comment, and let the leader/integrator reassign scope. - For git isolation, each agent must start on a dedicated branch via `scripts/agent-branch-start.sh "" ""`. +- In-place branch mode is disallowed: never switch the active local/base checkout to an agent branch. - Do not implement changes directly on `main` or other base branches; all edits must happen on dedicated agent branches/worktrees. - If the current local branch already contains accidental edits, move them to an agent branch/worktree first, then continue implementation. - Treat the base branch (`main` or the user's current local base branch) as read-only while the agent branch is active. diff --git a/README.md b/README.md index 6f854d2..e142cfe 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,9 @@ gx status # setup and repair gx setup gx doctor +# setup + repair another repo without switching your current repo checkout +gx setup --target /path/to/repo +gx doctor --target /path/to/repo # protected branch management gx protect list @@ -147,6 +150,7 @@ Note: the monitor dispatches Codex through explicit `--task/--agent/--base` flag - Optional repo override for manual VS Code protected-branch writes: `git config multiagent.allowVscodeProtectedBranchWrites true`. - Codex/agent sessions stay blocked on protected branches and must use `agent/*` branch + PR workflow. - On protected `main`, `gx doctor` auto-runs in a sandbox agent branch/worktree. +- In-place agent branching is disabled; `scripts/agent-branch-start.sh` always creates a separate worktree to keep your visible local/base branch unchanged. - `scripts/agent-branch-start.sh` hydrates `scripts/codex-agent.sh` into new sandbox worktrees when missing, so auto-finish launcher flow stays available. ## Configure protected branches diff --git a/scripts/agent-branch-start.sh b/scripts/agent-branch-start.sh index 40367b2..5010151 100755 --- a/scripts/agent-branch-start.sh +++ b/scripts/agent-branch-start.sh @@ -5,8 +5,6 @@ TASK_NAME="task" AGENT_NAME="agent" BASE_BRANCH="" BASE_BRANCH_EXPLICIT=0 -WORKTREE_MODE=1 -ALLOW_IN_PLACE=0 WORKTREE_ROOT_REL=".omx/agent-worktrees" POSITIONAL_ARGS=() @@ -25,13 +23,10 @@ while [[ $# -gt 0 ]]; do BASE_BRANCH_EXPLICIT=1 shift 2 ;; - --in-place) - WORKTREE_MODE=0 - shift - ;; - --allow-in-place) - ALLOW_IN_PLACE=1 - shift + --in-place|--allow-in-place) + echo "[agent-branch-start] In-place branch mode is disabled." >&2 + echo "[agent-branch-start] This command always creates an isolated worktree to keep your active checkout unchanged." >&2 + exit 1 ;; --worktree-root) WORKTREE_ROOT_REL="${2:-.omx/agent-worktrees}" @@ -47,7 +42,7 @@ while [[ $# -gt 0 ]]; do ;; -*) echo "[agent-branch-start] Unknown option: $1" >&2 - echo "Usage: $0 [task] [agent] [base] [--in-place --allow-in-place] [--worktree-root ]" >&2 + echo "Usage: $0 [task] [agent] [base] [--worktree-root ]" >&2 exit 1 ;; *) @@ -59,7 +54,7 @@ done if [[ "${#POSITIONAL_ARGS[@]}" -gt 3 ]]; then echo "[agent-branch-start] Too many positional arguments." >&2 - echo "Usage: $0 [task] [agent] [base] [--in-place --allow-in-place] [--worktree-root ]" >&2 + echo "Usage: $0 [task] [agent] [base] [--worktree-root ]" >&2 exit 1 fi @@ -237,34 +232,6 @@ while git show-ref --verify --quiet "refs/heads/${branch_name}"; do branch_suffix=$((branch_suffix + 1)) done -if [[ "$WORKTREE_MODE" -eq 0 ]]; then - if [[ "$ALLOW_IN_PLACE" -ne 1 ]]; then - echo "[agent-branch-start] --in-place is blocked by default to prevent accidental edits on protected branches." >&2 - echo "[agent-branch-start] If you really need it, pass both: --in-place --allow-in-place" >&2 - exit 1 - fi - - if ! git diff --quiet || ! git diff --cached --quiet; then - echo "[agent-branch-start] Working tree is not clean. Commit/stash changes before starting an in-place branch." >&2 - exit 1 - fi - - current_branch="$(git rev-parse --abbrev-ref HEAD)" - if [[ "$current_branch" != "$BASE_BRANCH" ]]; then - git checkout "$BASE_BRANCH" - fi - - if git show-ref --verify --quiet "refs/remotes/origin/${BASE_BRANCH}"; then - git pull --ff-only origin "$BASE_BRANCH" - fi - - git checkout -b "$branch_name" - git -C "$repo_root" config "branch.${branch_name}.musafetyBase" "$BASE_BRANCH" >/dev/null 2>&1 || true - echo "[agent-branch-start] Created in-place branch: ${branch_name}" - echo "$branch_name" - exit 0 -fi - worktree_root="${repo_root}/${WORKTREE_ROOT_REL}" mkdir -p "$worktree_root" worktree_path="${worktree_root}/${branch_name//\//__}" diff --git a/templates/AGENTS.multiagent-safety.md b/templates/AGENTS.multiagent-safety.md index d833654..ea8ce82 100644 --- a/templates/AGENTS.multiagent-safety.md +++ b/templates/AGENTS.multiagent-safety.md @@ -10,6 +10,7 @@ - Before deleting/replacing code, each agent must read the latest session comments/handoffs first and confirm the target code is in their owned scope. - If ownership is unclear or overlaps, stop that edit, post a blocker comment, and let the leader/integrator reassign scope. - For git isolation, each agent must start on a dedicated branch via `scripts/agent-branch-start.sh "" ""`. +- In-place branch mode is disallowed: never switch the active local/base checkout to an agent branch. - Treat the base branch (`main` or the user's current local base branch) as read-only while the agent branch is active. - Agent completion defaults to `scripts/codex-agent.sh`, which auto-finishes the branch (auto-commit changed files, push/create PR, attempt merge, and pull the local base branch after merge). - Auto-finish now waits for required checks/merge and then cleans merged sandbox branch/worktree by default. diff --git a/templates/scripts/agent-branch-start.sh b/templates/scripts/agent-branch-start.sh index 40367b2..5010151 100755 --- a/templates/scripts/agent-branch-start.sh +++ b/templates/scripts/agent-branch-start.sh @@ -5,8 +5,6 @@ TASK_NAME="task" AGENT_NAME="agent" BASE_BRANCH="" BASE_BRANCH_EXPLICIT=0 -WORKTREE_MODE=1 -ALLOW_IN_PLACE=0 WORKTREE_ROOT_REL=".omx/agent-worktrees" POSITIONAL_ARGS=() @@ -25,13 +23,10 @@ while [[ $# -gt 0 ]]; do BASE_BRANCH_EXPLICIT=1 shift 2 ;; - --in-place) - WORKTREE_MODE=0 - shift - ;; - --allow-in-place) - ALLOW_IN_PLACE=1 - shift + --in-place|--allow-in-place) + echo "[agent-branch-start] In-place branch mode is disabled." >&2 + echo "[agent-branch-start] This command always creates an isolated worktree to keep your active checkout unchanged." >&2 + exit 1 ;; --worktree-root) WORKTREE_ROOT_REL="${2:-.omx/agent-worktrees}" @@ -47,7 +42,7 @@ while [[ $# -gt 0 ]]; do ;; -*) echo "[agent-branch-start] Unknown option: $1" >&2 - echo "Usage: $0 [task] [agent] [base] [--in-place --allow-in-place] [--worktree-root ]" >&2 + echo "Usage: $0 [task] [agent] [base] [--worktree-root ]" >&2 exit 1 ;; *) @@ -59,7 +54,7 @@ done if [[ "${#POSITIONAL_ARGS[@]}" -gt 3 ]]; then echo "[agent-branch-start] Too many positional arguments." >&2 - echo "Usage: $0 [task] [agent] [base] [--in-place --allow-in-place] [--worktree-root ]" >&2 + echo "Usage: $0 [task] [agent] [base] [--worktree-root ]" >&2 exit 1 fi @@ -237,34 +232,6 @@ while git show-ref --verify --quiet "refs/heads/${branch_name}"; do branch_suffix=$((branch_suffix + 1)) done -if [[ "$WORKTREE_MODE" -eq 0 ]]; then - if [[ "$ALLOW_IN_PLACE" -ne 1 ]]; then - echo "[agent-branch-start] --in-place is blocked by default to prevent accidental edits on protected branches." >&2 - echo "[agent-branch-start] If you really need it, pass both: --in-place --allow-in-place" >&2 - exit 1 - fi - - if ! git diff --quiet || ! git diff --cached --quiet; then - echo "[agent-branch-start] Working tree is not clean. Commit/stash changes before starting an in-place branch." >&2 - exit 1 - fi - - current_branch="$(git rev-parse --abbrev-ref HEAD)" - if [[ "$current_branch" != "$BASE_BRANCH" ]]; then - git checkout "$BASE_BRANCH" - fi - - if git show-ref --verify --quiet "refs/remotes/origin/${BASE_BRANCH}"; then - git pull --ff-only origin "$BASE_BRANCH" - fi - - git checkout -b "$branch_name" - git -C "$repo_root" config "branch.${branch_name}.musafetyBase" "$BASE_BRANCH" >/dev/null 2>&1 || true - echo "[agent-branch-start] Created in-place branch: ${branch_name}" - echo "$branch_name" - exit 0 -fi - worktree_root="${repo_root}/${WORKTREE_ROOT_REL}" mkdir -p "$worktree_root" worktree_path="${worktree_root}/${branch_name//\//__}" diff --git a/templates/scripts/codex-agent.sh b/templates/scripts/codex-agent.sh index c35bccb..38ada4a 100755 --- a/templates/scripts/codex-agent.sh +++ b/templates/scripts/codex-agent.sh @@ -125,6 +125,106 @@ if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then fi repo_root="$(git rev-parse --show-toplevel)" +sanitize_slug() { + local raw="$1" + local fallback="${2:-task}" + local slug + slug="$(printf '%s' "$raw" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g; s/^-+//; s/-+$//; s/-{2,}/-/g')" + if [[ -z "$slug" ]]; then + slug="$fallback" + fi + printf '%s' "$slug" +} + +resolve_start_base_branch() { + if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 && -n "$BASE_BRANCH" ]]; then + printf '%s' "$BASE_BRANCH" + return 0 + fi + + local configured_base + configured_base="$(git -C "$repo_root" config --get multiagent.baseBranch || true)" + if [[ -n "$configured_base" ]]; then + printf '%s' "$configured_base" + return 0 + fi + + local current_branch + current_branch="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" + if [[ -n "$current_branch" && "$current_branch" != "HEAD" ]]; then + printf '%s' "$current_branch" + return 0 + fi + + printf 'dev' +} + +resolve_start_ref() { + local base_branch="$1" + git -C "$repo_root" fetch origin "$base_branch" --quiet >/dev/null 2>&1 || true + if git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${base_branch}"; then + printf 'origin/%s' "$base_branch" + return 0 + fi + if git -C "$repo_root" show-ref --verify --quiet "refs/heads/${base_branch}"; then + printf '%s' "$base_branch" + return 0 + fi + return 1 +} + +restore_repo_branch_if_changed() { + local expected_branch="$1" + if [[ -z "$expected_branch" || "$expected_branch" == "HEAD" ]]; then + return 0 + fi + local current_branch + current_branch="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" + if [[ -z "$current_branch" || "$current_branch" == "$expected_branch" ]]; then + return 0 + fi + git -C "$repo_root" checkout "$expected_branch" >/dev/null 2>&1 +} + +start_sandbox_fallback() { + local base_branch start_ref timestamp task_slug agent_slug branch_name_base branch_name suffix + local worktree_root worktree_path + + base_branch="$(resolve_start_base_branch)" + if ! start_ref="$(resolve_start_ref "$base_branch")"; then + echo "[codex-agent] Unable to resolve base ref for fallback sandbox start: ${base_branch}" >&2 + return 1 + fi + + timestamp="$(date +%Y%m%d-%H%M%S)" + task_slug="$(sanitize_slug "$TASK_NAME" "task")" + agent_slug="$(sanitize_slug "$AGENT_NAME" "agent")" + branch_name_base="agent/${agent_slug}/${timestamp}-${task_slug}" + branch_name="$branch_name_base" + suffix=2 + while git -C "$repo_root" show-ref --verify --quiet "refs/heads/${branch_name}"; do + branch_name="${branch_name_base}-${suffix}" + suffix=$((suffix + 1)) + done + + worktree_root="${repo_root}/.omx/agent-worktrees" + mkdir -p "$worktree_root" + worktree_path="${worktree_root}/${branch_name//\//__}" + if [[ -e "$worktree_path" ]]; then + echo "[codex-agent] Fallback worktree path already exists: $worktree_path" >&2 + return 1 + fi + + git -C "$repo_root" worktree add -b "$branch_name" "$worktree_path" "$start_ref" >/dev/null + git -C "$repo_root" config "branch.${branch_name}.musafetyBase" "$base_branch" >/dev/null 2>&1 || true + if git -C "$repo_root" show-ref --verify --quiet "refs/remotes/origin/${base_branch}"; then + git -C "$worktree_path" branch --set-upstream-to="origin/${base_branch}" "$branch_name" >/dev/null 2>&1 || true + fi + + printf '[agent-branch-start] Created branch: %s\n' "$branch_name" + printf '[agent-branch-start] Worktree: %s\n' "$worktree_path" +} + if [[ ! -x "${repo_root}/scripts/agent-branch-start.sh" ]]; then echo "[codex-agent] Missing scripts/agent-branch-start.sh. Run: gx setup" >&2 exit 1 @@ -135,12 +235,53 @@ if [[ "$BASE_BRANCH_EXPLICIT" -eq 1 ]]; then start_args+=("$BASE_BRANCH") fi -start_output="$(bash "${repo_root}/scripts/agent-branch-start.sh" "${start_args[@]}")" -printf '%s\n' "$start_output" +initial_repo_branch="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" +start_output="" +start_status=0 +set +e +start_output="$(bash "${repo_root}/scripts/agent-branch-start.sh" "${start_args[@]}" 2>&1)" +start_status=$? +set -e worktree_path="$(printf '%s\n' "$start_output" | sed -n 's/^\[agent-branch-start\] Worktree: //p' | tail -n1)" +current_repo_branch="$(git -C "$repo_root" rev-parse --abbrev-ref HEAD 2>/dev/null || true)" +resolved_repo_root="$(cd "$repo_root" && pwd -P)" +resolved_worktree_path="" +if [[ -n "$worktree_path" && -d "$worktree_path" ]]; then + resolved_worktree_path="$(cd "$worktree_path" && pwd -P)" +fi + +fallback_reason="" +if [[ "$start_status" -ne 0 ]]; then + fallback_reason="starter exited with status ${start_status}" +elif [[ -z "$worktree_path" ]]; then + fallback_reason="starter did not report worktree path" +elif [[ -n "$resolved_worktree_path" && "$resolved_worktree_path" == "$resolved_repo_root" ]]; then + fallback_reason="starter pointed to active checkout path" +elif [[ -n "$initial_repo_branch" && -n "$current_repo_branch" && "$current_repo_branch" != "$initial_repo_branch" ]]; then + fallback_reason="starter switched active checkout branch" +fi + +if [[ -n "$fallback_reason" ]]; then + if ! restore_repo_branch_if_changed "$initial_repo_branch"; then + echo "[codex-agent] agent-branch-start changed the active checkout branch and restore failed." >&2 + echo "[codex-agent] Run 'gx setup --target ${repo_root}' and 'gx doctor --target ${repo_root}', then retry." >&2 + exit 1 + fi + if [[ -n "$start_output" ]]; then + printf '%s\n' "$start_output" >&2 + fi + echo "[codex-agent] Unsafe starter output (${fallback_reason}); creating sandbox worktree directly." >&2 + start_output="$(start_sandbox_fallback)" + printf '%s\n' "$start_output" + worktree_path="$(printf '%s\n' "$start_output" | sed -n 's/^\[agent-branch-start\] Worktree: //p' | tail -n1)" +else + printf '%s\n' "$start_output" +fi + if [[ -z "$worktree_path" ]]; then - echo "[codex-agent] Could not determine sandbox worktree path from agent-branch-start output." >&2 + echo "[codex-agent] Could not determine sandbox worktree path from sandbox startup output." >&2 + echo "[codex-agent] Run 'gx setup --target ${repo_root}' and 'gx doctor --target ${repo_root}', then retry." >&2 exit 1 fi diff --git a/test/install.test.js b/test/install.test.js index d9f69cf..d5b5f37 100644 --- a/test/install.test.js +++ b/test/install.test.js @@ -701,7 +701,7 @@ test('setup pre-commit allows codex managed guardrail commits on protected main assert.match(result.stderr, /\[guardex-preedit-guard\] Codex edit\/commit detected on a protected branch\./); }); -test('setup agent-branch-start requires --allow-in-place when using --in-place', () => { +test('setup agent-branch-start rejects in-place flags to keep local checkout unchanged', () => { const repoDir = initRepo(); let result = runNode(['setup', '--target', repoDir], repoDir); @@ -711,8 +711,12 @@ test('setup agent-branch-start requires --allow-in-place when using --in-place', result = runCmd('bash', ['scripts/agent-branch-start.sh', 'demo', 'bot', 'dev', '--in-place'], repoDir); assert.notEqual(result.status, 0, result.stdout); - assert.match(result.stderr, /--in-place is blocked by default/); - assert.match(result.stderr, /--in-place --allow-in-place/); + assert.match(result.stderr, /In-place branch mode is disabled/); + assert.match(result.stderr, /always creates an isolated worktree/); + + result = runCmd('bash', ['scripts/agent-branch-start.sh', 'demo', 'bot', 'dev', '--allow-in-place'], repoDir); + assert.notEqual(result.status, 0, result.stdout); + assert.match(result.stderr, /In-place branch mode is disabled/); }); test('setup agent-branch-start includes active codex snapshot slug in branch name', () => { @@ -1289,6 +1293,70 @@ test('codex-agent launches codex inside a fresh sandbox worktree and keeps branc assert.equal(branchResult.status, 0, 'agent branch should remain after default codex-agent run'); }); +test('codex-agent restores local branch and falls back to safe worktree start when starter script switches in-place', () => { + const repoDir = initRepo(); + seedCommit(repoDir); + + const setupResult = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); + assert.equal(setupResult.status, 0, setupResult.stderr || setupResult.stdout); + let result = runCmd('git', ['add', '.'], repoDir); + assert.equal(result.status, 0, result.stderr); + result = runCmd('git', ['commit', '-m', 'apply gx setup'], repoDir, { + ALLOW_COMMIT_ON_PROTECTED_BRANCH: '1', + }); + assert.equal(result.status, 0, result.stderr || result.stdout); + + fs.writeFileSync( + path.join(repoDir, 'scripts', 'agent-branch-start.sh'), + '#!/usr/bin/env bash\n' + + 'set -euo pipefail\n' + + 'branch_name="agent/legacy/in-place-start"\n' + + 'git checkout -B "$branch_name" >/dev/null\n' + + 'echo "[agent-branch-start] Created in-place branch: ${branch_name}"\n', + 'utf8', + ); + fs.chmodSync(path.join(repoDir, 'scripts', 'agent-branch-start.sh'), 0o755); + + const fakeBin = fs.mkdtempSync(path.join(os.tmpdir(), 'musafety-fake-codex-fallback-')); + const fakeCodexPath = path.join(fakeBin, 'codex'); + fs.writeFileSync( + fakeCodexPath, + `#!/usr/bin/env bash\n` + + `pwd > "${'${MUSAFETY_TEST_CODEX_CWD}'}"\n` + + `echo "$@" > "${'${MUSAFETY_TEST_CODEX_ARGS}'}"\n`, + 'utf8', + ); + fs.chmodSync(fakeCodexPath, 0o755); + + const cwdMarker = path.join(repoDir, '.codex-agent-cwd-fallback'); + const argsMarker = path.join(repoDir, '.codex-agent-args-fallback'); + const launch = runCmd( + 'bash', + ['scripts/codex-agent.sh', 'fallback-task', 'planner', 'dev', '--model', 'gpt-5.4-mini'], + repoDir, + { + PATH: `${fakeBin}:${process.env.PATH}`, + MUSAFETY_TEST_CODEX_CWD: cwdMarker, + MUSAFETY_TEST_CODEX_ARGS: argsMarker, + }, + ); + assert.equal(launch.status, 0, launch.stderr || launch.stdout); + const combinedOutput = `${launch.stdout}\n${launch.stderr}`; + assert.match(combinedOutput, /Unsafe starter output/); + assert.match(combinedOutput, /\[agent-branch-start\] Created branch: agent\/planner\//); + + const launchedCwd = fs.readFileSync(cwdMarker, 'utf8').trim(); + assert.match( + launchedCwd, + new RegExp(`${escapeRegexLiteral(repoDir)}/\\.omx/agent-worktrees/agent__planner__`), + ); + assert.notEqual(launchedCwd, repoDir); + + const currentBranch = runCmd('git', ['branch', '--show-current'], repoDir); + assert.equal(currentBranch.status, 0, currentBranch.stderr || currentBranch.stdout); + assert.equal(currentBranch.stdout.trim(), 'dev'); +}); + test('codex-agent supports --codex-bin override before positional arguments', () => { const repoDir = initRepo(); seedCommit(repoDir);