From 31c5065d255f087bcfe0ae2bfc77dbce6d13b788 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Tue, 21 Apr 2026 12:36:08 +0200 Subject: [PATCH] Keep protected-main bootstrap commands inside sandbox branches Setup, install, and fix now follow the doctor-style sandbox path when they hit a protected main checkout. The visible base branch stays untouched while maintenance runs in a worktree, sandbox changes are auto-committed, and cleanup only happens after a safe no-op or completed finish flow. The regression file now pins setup, install, fix, and setup auto-finish behavior on protected main, and the active OpenSpec change records the continuation note plus verification progress. Constraint: Visible protected main checkouts must stay unchanged during repo bootstrap and repair flows Rejected: Keep hard-blocking setup/install/fix on protected main | it forces manual overrides before Guardex can bootstrap itself Confidence: high Scope-risk: moderate Directive: Do not reintroduce direct protected-main setup/install/fix writes without updating sandbox cleanup behavior and protected-main regressions together Tested: Direct Node repros for protected-main setup, install, fix, and setup auto-finish; node --check bin/multiagent-safety.js; openspec validate agent-codex-setup-protected-main-sandbox-2026-04-21-12-02 --type change --strict; openspec validate --specs; git diff --check Not-tested: Full npm test (timed out after 120s during node:test suite) --- bin/multiagent-safety.js | 279 +++++++++++++++--- .../.openspec.yaml | 2 + .../proposal.md | 18 ++ .../setup-protected-main-sandbox/spec.md | 22 ++ .../tasks.md | 21 ++ test/install.test.js | 195 +++++++++--- 6 files changed, 460 insertions(+), 77 deletions(-) create mode 100644 openspec/changes/agent-codex-setup-protected-main-sandbox-2026-04-21-12-02/.openspec.yaml create mode 100644 openspec/changes/agent-codex-setup-protected-main-sandbox-2026-04-21-12-02/proposal.md create mode 100644 openspec/changes/agent-codex-setup-protected-main-sandbox-2026-04-21-12-02/specs/setup-protected-main-sandbox/spec.md create mode 100644 openspec/changes/agent-codex-setup-protected-main-sandbox-2026-04-21-12-02/tasks.md diff --git a/bin/multiagent-safety.js b/bin/multiagent-safety.js index d2cb745..3672e28 100755 --- a/bin/multiagent-safety.js +++ b/bin/multiagent-safety.js @@ -1310,11 +1310,32 @@ function resolveSandboxTarget(repoRoot, worktreePath, targetPath) { function buildSandboxSetupArgs(options, sandboxTarget) { const args = ['setup', '--target', sandboxTarget, '--no-global-install', '--no-recursive']; + if (options.dryRun) args.push('--dry-run'); if (options.force) args.push('--force'); if (options.skipAgents) args.push('--skip-agents'); if (options.skipPackageJson) args.push('--skip-package-json'); if (options.skipGitignore) args.push('--no-gitignore'); + if (options.parentWorkspaceView) args.push('--parent-workspace-view'); + return args; +} + +function buildSandboxInstallArgs(options, sandboxTarget) { + const args = ['install', '--target', sandboxTarget]; if (options.dryRun) args.push('--dry-run'); + if (options.force) args.push('--force'); + if (options.skipAgents) args.push('--skip-agents'); + if (options.skipPackageJson) args.push('--skip-package-json'); + if (options.skipGitignore) args.push('--no-gitignore'); + return args; +} + +function buildSandboxFixArgs(options, sandboxTarget) { + const args = ['fix', '--target', sandboxTarget]; + if (options.dryRun) args.push('--dry-run'); + if (options.skipAgents) args.push('--skip-agents'); + if (options.skipPackageJson) args.push('--skip-package-json'); + if (options.skipGitignore) args.push('--no-gitignore'); + if (!options.dropStaleLocks) args.push('--keep-stale-locks'); return args; } @@ -1395,7 +1416,7 @@ function resolveProtectedBaseSandboxStartRef(repoRoot, baseBranch) { if (currentBranchName(repoRoot) === baseBranch) { return null; } - throw new Error(`Unable to find base ref for sandbox bootstrap: ${baseBranch}`); + throw new Error(`Unable to find base ref for protected-base sandbox: ${baseBranch}`); } function startProtectedBaseSandboxFallback(blocked, sandboxSuffix) { @@ -1419,7 +1440,7 @@ function startProtectedBaseSandboxFallback(blocked, sandboxSuffix) { } if (!selectedBranch || !selectedWorktreePath) { - throw new Error('Unable to allocate unique sandbox branch/worktree'); + throw new Error('Unable to allocate unique protected-base sandbox branch/worktree'); } fs.mkdirSync(path.dirname(selectedWorktreePath), { recursive: true }); @@ -1432,7 +1453,7 @@ function startProtectedBaseSandboxFallback(blocked, sandboxSuffix) { throw addResult.error; } if (addResult.status !== 0) { - throw new Error((addResult.stderr || addResult.stdout || 'failed to create sandbox').trim()); + throw new Error((addResult.stderr || addResult.stdout || 'failed to create protected-base sandbox').trim()); } if (!startRef) { @@ -1497,7 +1518,7 @@ function startProtectedBaseSandbox(blocked, { taskName, sandboxSuffix }) { if (!restoreResult.ok) { const detail = [restoreResult.stderr, restoreResult.stdout].filter(Boolean).join('\n').trim(); throw new Error( - `sandbox startup switched protected base checkout and could not restore '${blocked.branch}'.` + + `protected-base sandbox startup switched '${blocked.branch}' and could not restore it.` + (detail ? `\n${detail}` : ''), ); } @@ -1515,10 +1536,10 @@ function cleanupProtectedBaseSandbox(repoRoot, metadata) { const result = { worktree: 'skipped', branch: 'skipped', - note: 'missing sandbox metadata', + note: '', }; - if (!metadata?.worktreePath || !metadata?.branch) { + result.note = 'missing sandbox metadata'; return result; } @@ -1542,17 +1563,15 @@ function cleanupProtectedBaseSandbox(repoRoot, metadata) { } if (gitRefExists(repoRoot, `refs/heads/${metadata.branch}`)) { - const branchDeleteResult = run( - 'git', - ['-C', repoRoot, 'branch', '-D', metadata.branch], - { timeout: 20_000 }, - ); - if (isSpawnFailure(branchDeleteResult)) { - throw branchDeleteResult.error; + const deleteResult = run('git', ['-C', repoRoot, 'branch', '-D', metadata.branch], { + timeout: 20_000, + }); + if (isSpawnFailure(deleteResult)) { + throw deleteResult.error; } - if (branchDeleteResult.status !== 0) { + if (deleteResult.status !== 0) { throw new Error( - (branchDeleteResult.stderr || branchDeleteResult.stdout || 'failed to delete sandbox branch').trim(), + (deleteResult.stderr || deleteResult.stdout || 'failed to delete sandbox branch').trim(), ); } result.branch = 'deleted'; @@ -1560,7 +1579,7 @@ function cleanupProtectedBaseSandbox(repoRoot, metadata) { result.branch = 'missing'; } - result.note = 'sandbox worktree pruned'; + result.note = 'sandbox branch/worktree pruned'; return result; } @@ -1571,7 +1590,7 @@ function parseGitPathList(output) { .filter((line) => line && line !== LOCK_FILE_RELATIVE); } -function collectDoctorChangedPaths(worktreePath) { +function collectSandboxChangedPaths(worktreePath) { const changed = new Set(); const commands = [ ['diff', '--name-only'], @@ -1587,7 +1606,7 @@ function collectDoctorChangedPaths(worktreePath) { return Array.from(changed); } -function collectDoctorDeletedPaths(worktreePath) { +function collectSandboxDeletedPaths(worktreePath) { const deleted = new Set(); const commands = [ ['diff', '--name-only', '--diff-filter=D'], @@ -1602,7 +1621,7 @@ function collectDoctorDeletedPaths(worktreePath) { return Array.from(deleted); } -function claimDoctorChangedLocks(metadata) { +function claimSandboxChangedLocks(metadata, noteLabel) { const lockScript = path.join(metadata.worktreePath, 'scripts', 'agent-file-locks.py'); if (!fs.existsSync(lockScript) || !metadata.branch) { return { @@ -1613,8 +1632,8 @@ function claimDoctorChangedLocks(metadata) { }; } - const changedPaths = collectDoctorChangedPaths(metadata.worktreePath); - const deletedPaths = collectDoctorDeletedPaths(metadata.worktreePath); + const changedPaths = collectSandboxChangedPaths(metadata.worktreePath); + const deletedPaths = collectSandboxDeletedPaths(metadata.worktreePath); if (changedPaths.length > 0) { run('python3', [lockScript, 'claim', '--branch', metadata.branch, ...changedPaths], { cwd: metadata.worktreePath, @@ -1630,13 +1649,13 @@ function claimDoctorChangedLocks(metadata) { return { status: 'claimed', - note: 'claimed locks for doctor auto-commit', + note: `claimed locks for ${noteLabel}`, changedCount: changedPaths.length, deletedCount: deletedPaths.length, }; } -function autoCommitDoctorSandboxChanges(metadata) { +function autoCommitSandboxChanges(metadata, { noteLabel, commitMessage }) { if (!metadata.worktreePath || !metadata.branch) { return { status: 'skipped', @@ -1644,7 +1663,7 @@ function autoCommitDoctorSandboxChanges(metadata) { }; } - claimDoctorChangedLocks(metadata); + claimSandboxChangedLocks(metadata, noteLabel); run('git', ['-C', metadata.worktreePath, 'add', '-A'], { timeout: 20_000 }); const staged = run( 'git', @@ -1655,19 +1674,19 @@ function autoCommitDoctorSandboxChanges(metadata) { if (stagedFiles.length === 0) { return { status: 'no-changes', - note: 'no committable doctor changes found in sandbox', + note: `no committable ${noteLabel} changes found in sandbox`, }; } const commitResult = run( 'git', - ['-C', metadata.worktreePath, 'commit', '-m', 'Auto-finish: gx doctor repairs'], + ['-C', metadata.worktreePath, 'commit', '-m', commitMessage], { timeout: 30_000 }, ); if (commitResult.status !== 0) { return { status: 'failed', - note: 'doctor sandbox auto-commit failed', + note: `${noteLabel} sandbox auto-commit failed`, stdout: commitResult.stdout || '', stderr: commitResult.stderr || '', }; @@ -1675,12 +1694,19 @@ function autoCommitDoctorSandboxChanges(metadata) { return { status: 'committed', - note: 'doctor sandbox repairs committed', - commitMessage: 'Auto-finish: gx doctor repairs', + note: `${noteLabel} sandbox changes committed`, + commitMessage, stagedFiles, }; } +function autoCommitDoctorSandboxChanges(metadata) { + return autoCommitSandboxChanges(metadata, { + noteLabel: 'doctor', + commitMessage: 'Auto-finish: gx doctor repairs', + }); +} + function hasOriginRemote(repoRoot) { return run('git', ['-C', repoRoot, 'remote', 'get-url', 'origin']).status === 0; } @@ -1702,7 +1728,7 @@ function extractAgentBranchFinishPrUrl(output) { return match ? match[1] : ''; } -function doctorFinishFlowIsPending(output) { +function sandboxFinishFlowIsPending(output) { return ( /\[agent-branch-finish\] PR merge not completed yet; leaving PR open\./.test(output) || /\[agent-branch-finish\] Merge pending review\/check policy\. Branch cleanup skipped for now\./.test(output) || @@ -1710,7 +1736,7 @@ function doctorFinishFlowIsPending(output) { ); } -function finishDoctorSandboxBranch(blocked, metadata, options = {}) { +function finishProtectedBaseSandboxBranch(blocked, metadata, options = {}) { const finishScript = path.join(metadata.worktreePath, 'scripts', 'agent-branch-finish.sh'); if (!fs.existsSync(finishScript)) { return { @@ -1756,13 +1782,22 @@ function finishDoctorSandboxBranch(blocked, metadata, options = {}) { const finishResult = run( 'bash', - [finishScript, '--branch', metadata.branch, '--base', blocked.branch, '--via-pr', waitForMergeArg], + [ + finishScript, + '--branch', + metadata.branch, + '--base', + blocked.branch, + '--via-pr', + waitForMergeArg, + ...(options.cleanup ? ['--cleanup'] : []), + ], { cwd: metadata.worktreePath, timeout: finishTimeoutMs }, ); if (isSpawnFailure(finishResult)) { return { status: 'failed', - note: 'doctor sandbox finish flow errored', + note: `${options.noteLabel || 'sandbox'} finish flow errored`, stdout: finishResult.stdout || '', stderr: finishResult.stderr || '', }; @@ -1770,14 +1805,14 @@ function finishDoctorSandboxBranch(blocked, metadata, options = {}) { if (finishResult.status !== 0) { return { status: 'failed', - note: 'doctor sandbox finish flow failed', + note: `${options.noteLabel || 'sandbox'} finish flow failed`, stdout: finishResult.stdout || '', stderr: finishResult.stderr || '', }; } const combinedOutput = `${finishResult.stdout || ''}\n${finishResult.stderr || ''}`; - if (doctorFinishFlowIsPending(combinedOutput)) { + if (sandboxFinishFlowIsPending(combinedOutput)) { return { status: 'pending', note: 'PR created and waiting for merge policy/checks', @@ -1789,12 +1824,19 @@ function finishDoctorSandboxBranch(blocked, metadata, options = {}) { return { status: 'completed', - note: 'doctor sandbox finish flow completed', + note: `${options.noteLabel || 'sandbox'} finish flow completed`, stdout: finishResult.stdout || '', stderr: finishResult.stderr || '', }; } +function finishDoctorSandboxBranch(blocked, metadata, options = {}) { + return finishProtectedBaseSandboxBranch(blocked, metadata, { + ...options, + noteLabel: options.noteLabel || 'doctor sandbox', + }); +} + function syncProtectedBaseDoctorRepairs(options, blocked) { const trackedStatusBefore = options.dryRun ? new Map() : trackedStatusByPath(blocked.repoRoot); const fixPayload = runFixInternal({ @@ -4777,7 +4819,12 @@ function install(rawArgs) { allowProtectedBaseWrite: false, }); - assertProtectedMainWriteAllowed(options, 'install'); + const blocked = protectedBaseWriteBlock(options, { requireBootstrap: false }); + if (blocked) { + runProtectedBaseMaintenanceInSandbox('install', options, blocked); + process.exitCode = 0; + return; + } const payload = runInstallInternal(options); printOperations('Install target', payload, options.dryRun); @@ -4809,7 +4856,12 @@ function fix(rawArgs) { allowProtectedBaseWrite: false, }); - assertProtectedMainWriteAllowed(options, 'fix'); + const blocked = protectedBaseWriteBlock(options, { requireBootstrap: false }); + if (blocked) { + runProtectedBaseMaintenanceInSandbox('fix', options, blocked); + process.exitCode = 0; + return; + } const payload = runFixInternal(options); printOperations('Fix target', payload, options.dryRun); @@ -5025,6 +5077,140 @@ function doctor(rawArgs) { setExitCodeFromScan(scanResult); } +function buildSandboxArgs(commandName, options, sandboxTarget) { + if (commandName === 'setup') { + return buildSandboxSetupArgs(options, sandboxTarget); + } + if (commandName === 'install') { + return buildSandboxInstallArgs(options, sandboxTarget); + } + if (commandName === 'fix') { + return buildSandboxFixArgs(options, sandboxTarget); + } + throw new Error(`Unsupported sandbox command: ${commandName}`); +} + +function sandboxCommitMessage(commandName) { + if (commandName === 'setup') return 'Auto-finish: gx setup bootstrap'; + if (commandName === 'install') return 'Auto-finish: gx install bootstrap'; + if (commandName === 'fix') return 'Auto-finish: gx fix repairs'; + throw new Error(`Unsupported sandbox command: ${commandName}`); +} + +function runProtectedBaseMaintenanceInSandbox(commandName, options, blocked) { + const startResult = startProtectedBaseSandbox(blocked, { + taskName: `${SHORT_TOOL_NAME}-${commandName}`, + sandboxSuffix: `gx-${commandName}`, + }); + const metadata = startResult.metadata; + + console.log( + `[${TOOL_NAME}] ${commandName} detected protected branch '${blocked.branch}'. ` + + `Running in sandbox branch '${metadata.branch || 'agent/'}'.`, + ); + if (startResult.stdout) process.stdout.write(startResult.stdout); + if (startResult.stderr) process.stderr.write(startResult.stderr); + + const sandboxTarget = resolveSandboxTarget(blocked.repoRoot, metadata.worktreePath, options.target); + const nestedResult = run( + process.execPath, + [__filename, ...buildSandboxArgs(commandName, options, sandboxTarget)], + { cwd: metadata.worktreePath }, + ); + if (isSpawnFailure(nestedResult)) { + throw nestedResult.error; + } + if (nestedResult.stdout) process.stdout.write(nestedResult.stdout); + if (nestedResult.stderr) process.stderr.write(nestedResult.stderr); + if (nestedResult.status !== 0) { + throw new Error( + `sandboxed ${commandName} failed for protected branch '${blocked.branch}'. ` + + `Inspect sandbox at ${metadata.worktreePath}`, + ); + } + + let autoCommitResult = { + status: 'skipped', + note: `${commandName} sandbox did not complete successfully`, + }; + let finishResult = { + status: 'skipped', + note: `${commandName} sandbox did not complete successfully`, + }; + let cleanupResult = { + status: 'skipped', + note: `${commandName} sandbox kept for follow-up`, + }; + + if (options.dryRun) { + cleanupResult = cleanupProtectedBaseSandbox(blocked.repoRoot, metadata); + autoCommitResult = { + status: 'skipped', + note: 'dry-run skips sandbox auto-commit', + }; + finishResult = { + status: 'skipped', + note: 'dry-run skips sandbox finish flow', + }; + } else { + autoCommitResult = autoCommitSandboxChanges(metadata, { + noteLabel: commandName, + commitMessage: sandboxCommitMessage(commandName), + }); + if (autoCommitResult.status === 'committed') { + finishResult = finishProtectedBaseSandboxBranch(blocked, metadata, { + cleanup: true, + noteLabel: `${commandName} sandbox`, + }); + if (finishResult.status === 'completed') { + cleanupResult = cleanupProtectedBaseSandbox(blocked.repoRoot, metadata); + } else { + cleanupResult = { + status: 'kept', + note: 'sandbox branch/worktree kept for follow-up', + }; + } + } else if (autoCommitResult.status === 'no-changes') { + cleanupResult = cleanupProtectedBaseSandbox(blocked.repoRoot, metadata); + finishResult = { + status: 'skipped', + note: 'no sandbox changes to finish', + }; + } else if (autoCommitResult.status === 'failed') { + cleanupResult = { + status: 'kept', + note: 'sandbox branch/worktree kept because auto-commit failed', + }; + } + } + + if (autoCommitResult.status === 'committed') { + console.log(`[${TOOL_NAME}] Auto-committed ${commandName} changes in sandbox branch '${metadata.branch}'.`); + } else if (autoCommitResult.status === 'failed') { + console.log(`[${TOOL_NAME}] Sandbox auto-commit failed for ${commandName}; branch left for manual follow-up.`); + } else if (autoCommitResult.status === 'no-changes') { + console.log(`[${TOOL_NAME}] Sandbox ${commandName} produced no file changes to keep.`); + } else if (autoCommitResult.note) { + console.log(`[${TOOL_NAME}] Sandbox auto-commit skipped: ${autoCommitResult.note}.`); + } + + if (finishResult.status === 'completed') { + console.log(`[${TOOL_NAME}] Auto-finish flow completed for sandbox branch '${metadata.branch}'.`); + } else if (finishResult.status === 'pending') { + console.log( + `[${TOOL_NAME}] Auto-finish pending for sandbox branch '${metadata.branch}': ${finishResult.note}.`, + ); + } else if (finishResult.status === 'failed') { + console.log(`[${TOOL_NAME}] Auto-finish flow failed for sandbox branch '${metadata.branch}'.`); + } else if (finishResult.note) { + console.log(`[${TOOL_NAME}] Sandbox finish flow skipped: ${finishResult.note}.`); + } + + if (cleanupResult.note) { + console.log(`[${TOOL_NAME}] Sandbox cleanup: ${cleanupResult.note}.`); + } +} + function review(rawArgs) { const options = parseReviewArgs(rawArgs); const repoRoot = resolveRepoRoot(options.target); @@ -5498,6 +5684,7 @@ function setup(rawArgs) { let aggregateErrors = 0; let aggregateWarnings = 0; let lastScanResult = null; + let sandboxedRepoCount = 0; for (const repoPath of discoveredRepos) { const perRepoOptions = { ...options, target: repoPath }; @@ -5507,12 +5694,10 @@ function setup(rawArgs) { console.log(`[${TOOL_NAME}] ── Setup target: ${repoPath} ──`); } - const blocked = protectedBaseWriteBlock(perRepoOptions); + const blocked = protectedBaseWriteBlock(perRepoOptions, { requireBootstrap: false }); if (blocked) { - const sandboxResult = runSetupInSandbox(perRepoOptions, blocked, repoLabel); - aggregateErrors += sandboxResult.scanResult.errors; - aggregateWarnings += sandboxResult.scanResult.warnings; - lastScanResult = sandboxResult.scanResult; + runProtectedBaseMaintenanceInSandbox('setup', perRepoOptions, blocked); + sandboxedRepoCount += 1; continue; } @@ -5562,6 +5747,14 @@ function setup(rawArgs) { const repoCount = discoveredRepos.length; const suffix = repoCount > 1 ? ` (${repoCount} repos)` : ''; console.log(`[${TOOL_NAME}] ✅ Setup complete.${suffix}`); + if (sandboxedRepoCount > 0) { + console.log( + `[${TOOL_NAME}] Protected-base setup ran through sandbox branches so visible base checkouts stayed unchanged.`, + ); + console.log( + `[${TOOL_NAME}] If auto-finish was skipped or is pending, continue from the printed sandbox branch/worktree path.`, + ); + } console.log(`[${TOOL_NAME}] Copy AI setup prompt with: ${SHORT_TOOL_NAME} prompt`); console.log( `[${TOOL_NAME}] OpenSpec core workflow: /opsx:propose -> /opsx:apply -> /opsx:archive`, diff --git a/openspec/changes/agent-codex-setup-protected-main-sandbox-2026-04-21-12-02/.openspec.yaml b/openspec/changes/agent-codex-setup-protected-main-sandbox-2026-04-21-12-02/.openspec.yaml new file mode 100644 index 0000000..4b8c565 --- /dev/null +++ b/openspec/changes/agent-codex-setup-protected-main-sandbox-2026-04-21-12-02/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-21 diff --git a/openspec/changes/agent-codex-setup-protected-main-sandbox-2026-04-21-12-02/proposal.md b/openspec/changes/agent-codex-setup-protected-main-sandbox-2026-04-21-12-02/proposal.md new file mode 100644 index 0000000..f8f1e04 --- /dev/null +++ b/openspec/changes/agent-codex-setup-protected-main-sandbox-2026-04-21-12-02/proposal.md @@ -0,0 +1,18 @@ +## Why + +- `gx setup`, `gx install`, and `gx fix` still hard-block on protected `main`, even though the CLI/help surface says protected-base maintenance should run through a sandbox branch/worktree. +- The current bootstrap path makes first-time setup on protected `main` awkward: the user has to override the guard or manually start a branch before Guardex can bootstrap the repo. + +## What Changes + +- Run protected-`main` setup/install/fix work inside sandbox branches instead of failing in-place. +- Auto-commit sandboxed maintenance changes, attempt the existing PR finish flow when GitHub auth is available, and only clean the sandbox when it is safe to discard. +- Keep direct `--allow-protected-base-write` as the explicit opt-in for in-place maintenance. + +## Impact + +- Affects `bin/multiagent-safety.js` protected-branch maintenance flow and the protected-main regression coverage in `test/install.test.js`. +- Preserves the existing doctor sandbox path; this change aligns the other bootstrap/repair entrypoints with that model. +- Main risk is cleanup semantics: sandbox branches must not be pruned when auto-finish is skipped, pending, or fails. + +Handoff: continuing this lane in `bin/multiagent-safety.js` and `test/install.test.js` to align protected-main sandbox expectations, rerun focused verification, and finish the OpenSpec validation pass. diff --git a/openspec/changes/agent-codex-setup-protected-main-sandbox-2026-04-21-12-02/specs/setup-protected-main-sandbox/spec.md b/openspec/changes/agent-codex-setup-protected-main-sandbox-2026-04-21-12-02/specs/setup-protected-main-sandbox/spec.md new file mode 100644 index 0000000..d02873c --- /dev/null +++ b/openspec/changes/agent-codex-setup-protected-main-sandbox-2026-04-21-12-02/specs/setup-protected-main-sandbox/spec.md @@ -0,0 +1,22 @@ +## ADDED Requirements + +### Requirement: Protected-main setup/install/fix use sandbox branches +When `gx setup`, `gx install`, or `gx fix` targets a protected `main` checkout, the command SHALL execute the maintenance work in a sandbox branch/worktree instead of writing directly onto the visible protected-base checkout. + +#### Scenario: First-time setup on protected main +- **GIVEN** a repo is on protected `main` and Guardex bootstrap files do not exist yet +- **WHEN** the user runs `gx setup` +- **THEN** Guardex creates a sandbox branch/worktree for the bootstrap run +- **AND** the visible protected `main` checkout stays on `main` with no tracked-file dirt. + +#### Scenario: Protected-main alias commands follow the same sandbox rule +- **GIVEN** a repo is on protected `main` +- **WHEN** the user runs `gx install` or `gx fix` +- **THEN** Guardex runs the requested maintenance command in a sandbox branch/worktree +- **AND** it does not fail only because the visible checkout is on protected `main`. + +#### Scenario: Sandbox cleanup only happens when safe +- **GIVEN** sandboxed maintenance completed on protected `main` +- **WHEN** Guardex cannot auto-finish the sandbox branch through the PR flow +- **THEN** the sandbox branch/worktree remains available for follow-up +- **AND** Guardex only auto-cleans the sandbox when there are no changes to keep or the finish flow completed successfully. diff --git a/openspec/changes/agent-codex-setup-protected-main-sandbox-2026-04-21-12-02/tasks.md b/openspec/changes/agent-codex-setup-protected-main-sandbox-2026-04-21-12-02/tasks.md new file mode 100644 index 0000000..4603859 --- /dev/null +++ b/openspec/changes/agent-codex-setup-protected-main-sandbox-2026-04-21-12-02/tasks.md @@ -0,0 +1,21 @@ +## 1. Specification + +- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-codex-setup-protected-main-sandbox-2026-04-21-12-02`. +- [x] 1.2 Define normative requirements in `specs/setup-protected-main-sandbox/spec.md`. + +## 2. Implementation + +- [x] 2.1 Implement scoped behavior changes. +- [x] 2.2 Add/update focused regression coverage. + +## 3. Verification + +- [x] 3.1 Run targeted project verification commands. +- [x] 3.2 Run `openspec validate agent-codex-setup-protected-main-sandbox-2026-04-21-12-02 --type change --strict`. +- [x] 3.3 Run `openspec validate --specs`. + +## 4. Completion + +- [ ] 4.1 Finish the agent branch via PR merge + cleanup (`gx finish --via-pr --wait-for-merge --cleanup` or `bash scripts/agent-branch-finish.sh --branch --base --via-pr --wait-for-merge --cleanup`). +- [ ] 4.2 Record PR URL + final `MERGED` state in the completion handoff. +- [ ] 4.3 Confirm sandbox cleanup (`git worktree list`, `git branch -a`) or capture a `BLOCKED:` handoff if merge/cleanup is pending. diff --git a/test/install.test.js b/test/install.test.js index d48be6b..436168b 100644 --- a/test/install.test.js +++ b/test/install.test.js @@ -20,8 +20,31 @@ function withGuardexHome(extraEnv = {}) { }; } +function maybeAppendProtectedMainSetupOverride(args, cwd, extraEnv = {}) { + if (!Array.isArray(args) || args[0] !== 'setup') { + return args; + } + if (args.includes('--allow-protected-base-write') || extraEnv.GUARDEX_TEST_FORCE_SANDBOX_SETUP === '1') { + return args; + } + + const branchResult = cp.spawnSync('git', ['branch', '--show-current'], { + cwd, + encoding: 'utf8', + env: withGuardexHome(extraEnv), + }); + if (branchResult.status !== 0) { + return args; + } + if (branchResult.stdout.trim() !== 'main') { + return args; + } + return [...args, '--allow-protected-base-write']; +} + function runNode(args, cwd) { - return cp.spawnSync('node', [cliPath, ...args], { + const finalArgs = maybeAppendProtectedMainSetupOverride(args, cwd); + return cp.spawnSync('node', [cliPath, ...finalArgs], { cwd, encoding: 'utf8', env: withGuardexHome(), @@ -29,7 +52,8 @@ function runNode(args, cwd) { } function runNodeWithEnv(args, cwd, extraEnv) { - return cp.spawnSync('node', [cliPath, ...args], { + const finalArgs = maybeAppendProtectedMainSetupOverride(args, cwd, extraEnv); + return cp.spawnSync('node', [cliPath, ...finalArgs], { cwd, encoding: 'utf8', env: withGuardexHome(extraEnv), @@ -254,6 +278,13 @@ function extractCreatedWorktree(output) { return match[1].trim(); } +function assertSandboxBranch(branchName, sandboxSuffix) { + const pattern = new RegExp( + `^agent\\/(?:gx\\/.+-${escapeRegexLiteral(sandboxSuffix)}|[^/]+\\/${escapeRegexLiteral(sandboxSuffix)}-.+)$`, + ); + assert.match(branchName, pattern); +} + function extractOpenSpecPlanSlug(output) { const match = String(output || '').match(/\[agent-branch-start\] OpenSpec plan: openspec\/plan\/(.+)/); assert.ok(match, `missing OpenSpec plan slug in output: ${output}`); @@ -556,7 +587,11 @@ test('setup refreshes existing managed AGENTS block by default', () => { ].join('\n'); fs.writeFileSync(path.join(repoDir, 'AGENTS.md'), legacyAgents, 'utf8'); - const result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); + const result = runNodeWithEnv( + ['setup', '--target', repoDir, '--no-global-install'], + repoDir, + { GUARDEX_TEST_FORCE_SANDBOX_SETUP: '1' }, + ); assert.equal(result.status, 0, result.stderr || result.stdout); const currentAgents = fs.readFileSync(path.join(repoDir, 'AGENTS.md'), 'utf8'); assert.match(currentAgents, /Project-specific guidance before managed block\./); @@ -728,7 +763,11 @@ Trailing project notes after managed block. `; fs.writeFileSync(path.join(repoDir, 'AGENTS.md'), legacyAgents, 'utf8'); - const result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); + const result = runNodeWithEnv( + ['setup', '--target', repoDir, '--no-global-install'], + repoDir, + { GUARDEX_TEST_FORCE_SANDBOX_SETUP: '1' }, + ); assert.equal(result.status, 0, result.stderr || result.stdout); const nextAgents = fs.readFileSync(path.join(repoDir, 'AGENTS.md'), 'utf8'); @@ -878,59 +917,95 @@ test('review-bot-watch uses explicit codex-agent flags for argument parsing comp assert.match(script, /-- exec \"\$prompt\"/); }); -test('setup refreshes initialized protected main through a sandbox and prunes it', () => { +test('setup on protected main bootstraps in a sandbox and keeps the visible checkout clean', () => { const repoDir = initRepoOnBranch('main'); - const gitignorePath = path.join(repoDir, '.gitignore'); + seedCommit(repoDir); - let result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); + const result = runNodeWithEnv( + ['setup', '--target', repoDir, '--no-global-install'], + repoDir, + { GUARDEX_TEST_FORCE_SANDBOX_SETUP: '1' }, + ); assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /setup detected protected branch 'main'/); - const initialGitignore = fs.readFileSync(gitignorePath, 'utf8'); - fs.writeFileSync(gitignorePath, initialGitignore.replace(/^scripts\/\*\n/m, ''), 'utf8'); - - result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); - assert.equal(result.status, 0, result.stderr || result.stdout); - assert.match(result.stdout, /setup blocked on protected branch 'main' in an initialized repo;/); - assert.match(result.stdout, /sandbox worktree/); + const createdBranch = extractCreatedBranch(result.stdout); + const createdWorktree = extractCreatedWorktree(result.stdout); + assertSandboxBranch(createdBranch, 'gx-setup'); + assert.equal(fs.existsSync(path.join(createdWorktree, 'AGENTS.md')), true); + assert.equal(fs.existsSync(path.join(createdWorktree, 'scripts', 'agent-branch-start.sh')), true); + assert.equal(fs.existsSync(createdWorktree), true, 'setup sandbox should stay available when auto-finish is unavailable'); + assert.equal(fs.existsSync(path.join(repoDir, 'AGENTS.md')), false, 'protected main checkout should stay unchanged'); - const sandboxBranch = extractCreatedBranch(result.stdout); - const sandboxWorktree = extractCreatedWorktree(result.stdout); - assert.equal(fs.existsSync(sandboxWorktree), false, 'setup sandbox worktree should be pruned'); + const rootStatus = runCmd('git', ['status', '--short', '--untracked-files=no'], repoDir); + assert.equal(rootStatus.status, 0, rootStatus.stderr || rootStatus.stdout); + assert.equal(rootStatus.stdout.trim(), '', 'protected main checkout should stay clean'); - const currentBranch = runCmd('git', ['symbolic-ref', '--short', 'HEAD'], repoDir); + const currentBranch = runCmd('git', ['branch', '--show-current'], repoDir); assert.equal(currentBranch.status, 0, currentBranch.stderr || currentBranch.stdout); - assert.equal(currentBranch.stdout.trim(), 'main', 'visible checkout must stay on protected main'); + assert.equal(currentBranch.stdout.trim(), 'main'); +}); - const sandboxBranchCheck = runCmd('git', ['branch', '--list', sandboxBranch], repoDir); - assert.equal(sandboxBranchCheck.status, 0, sandboxBranchCheck.stderr || sandboxBranchCheck.stdout); - assert.equal(sandboxBranchCheck.stdout.trim(), '', 'setup sandbox branch should be pruned'); +test('setup allows explicit protected-main override for in-place maintenance', () => { + const repoDir = initRepoOnBranch('main'); + seedCommit(repoDir); - const refreshedGitignore = fs.readFileSync(gitignorePath, 'utf8'); - assert.match(refreshedGitignore, /^scripts\/\*$/m); + const result = runNode( + ['setup', '--target', repoDir, '--no-global-install', '--allow-protected-base-write'], + repoDir, + ); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.equal(fs.existsSync(path.join(repoDir, 'AGENTS.md')), true); }); -test('setup allows explicit protected-main override for in-place maintenance', () => { +test('install on protected main bootstraps in a sandbox and keeps the visible checkout clean', () => { const repoDir = initRepoOnBranch('main'); + seedCommit(repoDir); - let result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); + const result = runNode(['install', '--target', repoDir], repoDir); assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /install detected protected branch 'main'/); - result = runNode( + const createdBranch = extractCreatedBranch(result.stdout); + const createdWorktree = extractCreatedWorktree(result.stdout); + assertSandboxBranch(createdBranch, 'gx-install'); + assert.equal(fs.existsSync(path.join(createdWorktree, 'AGENTS.md')), true); + assert.equal(fs.existsSync(path.join(repoDir, 'AGENTS.md')), false, 'protected main checkout should stay unchanged'); + + const rootStatus = runCmd('git', ['status', '--short', '--untracked-files=no'], repoDir); + assert.equal(rootStatus.status, 0, rootStatus.stderr || rootStatus.stdout); + assert.equal(rootStatus.stdout.trim(), '', 'protected main checkout should stay clean'); +}); + +test('fix on protected main repairs in a sandbox and keeps the visible checkout clean', () => { + const repoDir = initRepoOnBranch('main'); + seedCommit(repoDir); + + let result = runNode( ['setup', '--target', repoDir, '--no-global-install', '--allow-protected-base-write'], repoDir, ); assert.equal(result.status, 0, result.stderr || result.stdout); -}); -test('install blocks in-place maintenance writes on protected main unless override is set', () => { - const repoDir = initRepoOnBranch('main'); + fs.rmSync(path.join(repoDir, 'AGENTS.md')); - let result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); + result = runNode(['fix', '--target', repoDir], repoDir); assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /fix detected protected branch 'main'/); - result = runNode(['install', '--target', repoDir], repoDir); - assert.equal(result.status, 1, result.stderr || result.stdout); - assert.match(result.stderr, /install blocked on protected branch 'main'/); + const createdBranch = extractCreatedBranch(result.stdout); + const createdWorktree = extractCreatedWorktree(result.stdout); + assertSandboxBranch(createdBranch, 'gx-fix'); + assert.equal(fs.existsSync(path.join(createdWorktree, 'AGENTS.md')), true); + assert.equal(fs.existsSync(path.join(repoDir, 'AGENTS.md')), false, 'protected main checkout should stay unchanged'); + + const rootStatus = runCmd('git', ['status', '--short', '--untracked-files=no'], repoDir); + assert.equal(rootStatus.status, 0, rootStatus.stderr || rootStatus.stdout); + assert.equal(rootStatus.stdout.trim(), '', 'protected main checkout should stay clean'); + + const currentBranch = runCmd('git', ['branch', '--show-current'], repoDir); + assert.equal(currentBranch.status, 0, currentBranch.stderr || currentBranch.stdout); + assert.equal(currentBranch.stdout.trim(), 'main'); }); test('install configures AGENTS managed policy block with GX contract wording', () => { @@ -949,6 +1024,58 @@ test('install configures AGENTS managed policy block with GX contract wording', ); }); +test('setup on protected main auto-finishes and cleans the sandbox when gh is authenticated', () => { + const repoDir = initRepoOnBranch('main'); + seedCommit(repoDir); + attachOriginRemoteForBranch(repoDir, 'main'); + + const { fakePath: fakeGhPath } = createFakeGhScript(` +if [[ "$1" == "auth" && "$2" == "status" ]]; then + exit 0 +fi +if [[ "$1" == "pr" && "$2" == "create" ]]; then + exit 0 +fi +if [[ "$1" == "pr" && "$2" == "view" ]]; then + if [[ " $* " == *" --json url "* ]]; then + echo "https://example.test/pr/setup-autofinish" + exit 0 + fi + echo "unexpected gh pr view args: $*" >&2 + exit 1 +fi +if [[ "$1" == "pr" && "$2" == "merge" ]]; then + exit 0 +fi +echo "unexpected gh args: $*" >&2 +exit 1 +`); + + const result = runNodeWithEnv( + ['setup', '--target', repoDir, '--no-global-install'], + repoDir, + { GUARDEX_GH_BIN: fakeGhPath, GUARDEX_TEST_FORCE_SANDBOX_SETUP: '1' }, + ); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /setup detected protected branch 'main'/); + assert.match(result.stdout, /Auto-committed setup changes in sandbox branch/); + assert.match(result.stdout, /Auto-finish flow completed for sandbox branch/); + assert.match(result.stdout, /Sandbox cleanup: sandbox branch\/worktree pruned\./); + + const createdBranch = extractCreatedBranch(result.stdout); + const createdWorktree = extractCreatedWorktree(result.stdout); + assert.equal(fs.existsSync(createdWorktree), false, 'setup sandbox worktree should be cleaned after successful auto-finish'); + + const branchCheck = runCmd('git', ['branch', '--list', createdBranch], repoDir); + assert.equal(branchCheck.status, 0, branchCheck.stderr || branchCheck.stdout); + assert.equal(branchCheck.stdout.trim(), '', 'setup sandbox branch should be cleaned after successful auto-finish'); + + const rootStatus = runCmd('git', ['status', '--short', '--untracked-files=no'], repoDir); + assert.equal(rootStatus.status, 0, rootStatus.stderr || rootStatus.stdout); + assert.equal(rootStatus.stdout.trim(), '', 'protected main checkout should stay clean'); + assert.equal(fs.existsSync(path.join(repoDir, 'AGENTS.md')), false, 'protected main checkout should stay unchanged'); +}); + test('doctor on protected main auto-runs in a sandbox branch/worktree', () => { const repoDir = initRepoOnBranch('main'); seedCommit(repoDir);