diff --git a/bin/multiagent-safety.js b/bin/multiagent-safety.js index 69c7bed4..89606fa7 100755 --- a/bin/multiagent-safety.js +++ b/bin/multiagent-safety.js @@ -1204,6 +1204,40 @@ function assertProtectedMainWriteAllowed(options, commandName) { ); } +function runSetupBootstrapInternal(options) { + const installPayload = runInstallInternal(options); + installPayload.operations.push( + ensureSetupProtectedBranches(installPayload.repoRoot, Boolean(options.dryRun)), + ); + + let parentWorkspace = null; + if (options.parentWorkspaceView) { + installPayload.operations.push( + ensureParentWorkspaceView(installPayload.repoRoot, Boolean(options.dryRun)), + ); + if (!options.dryRun) { + parentWorkspace = buildParentWorkspaceView(installPayload.repoRoot); + } + } + + const fixPayload = runFixInternal({ + target: installPayload.repoRoot, + dryRun: options.dryRun, + force: options.force, + dropStaleLocks: true, + skipAgents: options.skipAgents, + skipPackageJson: options.skipPackageJson, + skipGitignore: options.skipGitignore, + allowProtectedBaseWrite: options.allowProtectedBaseWrite, + }); + + return { + installPayload, + fixPayload, + parentWorkspace, + }; +} + function extractAgentBranchStartMetadata(output) { const branchMatch = String(output || '').match(/^\[agent-branch-start\] Created branch: (.+)$/m); const worktreeMatch = String(output || '').match(/^\[agent-branch-start\] Worktree: (.+)$/m); @@ -1217,7 +1251,7 @@ function resolveSandboxTarget(repoRoot, worktreePath, targetPath) { const resolvedTarget = path.resolve(targetPath); const relativeTarget = path.relative(repoRoot, resolvedTarget); if (relativeTarget.startsWith('..') || path.isAbsolute(relativeTarget)) { - throw new Error(`doctor target must stay inside repo root when sandboxing: ${resolvedTarget}`); + throw new Error(`sandbox target must stay inside repo root: ${resolvedTarget}`); } if (!relativeTarget || relativeTarget === '.') { return worktreePath; @@ -1225,6 +1259,16 @@ function resolveSandboxTarget(repoRoot, worktreePath, targetPath) { return path.join(worktreePath, relativeTarget); } +function buildSandboxSetupArgs(options, sandboxTarget) { + const args = ['setup', '--target', sandboxTarget, '--no-global-install', '--no-recursive']; + 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.dryRun) args.push('--dry-run'); + return args; +} + function buildSandboxDoctorArgs(options, sandboxTarget) { const args = ['doctor', '--target', sandboxTarget]; if (options.dryRun) args.push('--dry-run'); @@ -1269,7 +1313,7 @@ function ensureRepoBranch(repoRoot, branch) { return { ok: true, changed: true }; } -function doctorSandboxBranchPrefix() { +function protectedBaseSandboxBranchPrefix() { const now = new Date(); const stamp = [ now.getUTCFullYear(), @@ -1283,7 +1327,7 @@ function doctorSandboxBranchPrefix() { return `agent/gx/${stamp}`; } -function doctorSandboxWorktreePath(repoRoot, branchName) { +function protectedBaseSandboxWorktreePath(repoRoot, branchName) { return path.join(repoRoot, '.omx', 'agent-worktrees', branchName.replace(/\//g, '__')); } @@ -1291,7 +1335,7 @@ function gitRefExists(repoRoot, ref) { return run('git', ['-C', repoRoot, 'show-ref', '--verify', '--quiet', ref]).status === 0; } -function resolveDoctorSandboxStartRef(repoRoot, baseBranch) { +function resolveProtectedBaseSandboxStartRef(repoRoot, baseBranch) { run('git', ['-C', repoRoot, 'fetch', 'origin', baseBranch, '--quiet'], { timeout: 20_000 }); if (gitRefExists(repoRoot, `refs/remotes/origin/${baseBranch}`)) { return `origin/${baseBranch}`; @@ -1299,18 +1343,21 @@ function resolveDoctorSandboxStartRef(repoRoot, baseBranch) { if (gitRefExists(repoRoot, `refs/heads/${baseBranch}`)) { return baseBranch; } - throw new Error(`Unable to find base ref for sandbox doctor: ${baseBranch}`); + if (currentBranchName(repoRoot) === baseBranch) { + return null; + } + throw new Error(`Unable to find base ref for sandbox bootstrap: ${baseBranch}`); } -function startDoctorSandboxFallback(blocked) { - const branchPrefix = doctorSandboxBranchPrefix(); +function startProtectedBaseSandboxFallback(blocked, sandboxSuffix) { + const branchPrefix = protectedBaseSandboxBranchPrefix(); let selectedBranch = ''; let selectedWorktreePath = ''; for (let attempt = 0; attempt < 30; attempt += 1) { - const suffix = attempt === 0 ? 'gx-doctor' : `${attempt + 1}-gx-doctor`; + const suffix = attempt === 0 ? sandboxSuffix : `${attempt + 1}-${sandboxSuffix}`; const candidateBranch = `${branchPrefix}-${suffix}`; - const candidateWorktreePath = doctorSandboxWorktreePath(blocked.repoRoot, candidateBranch); + const candidateWorktreePath = protectedBaseSandboxWorktreePath(blocked.repoRoot, candidateBranch); if (gitRefExists(blocked.repoRoot, `refs/heads/${candidateBranch}`)) { continue; } @@ -1323,20 +1370,36 @@ function startDoctorSandboxFallback(blocked) { } if (!selectedBranch || !selectedWorktreePath) { - throw new Error('Unable to allocate unique sandbox branch/worktree for doctor'); + throw new Error('Unable to allocate unique sandbox branch/worktree'); } fs.mkdirSync(path.dirname(selectedWorktreePath), { recursive: true }); - const startRef = resolveDoctorSandboxStartRef(blocked.repoRoot, blocked.branch); - const addResult = run( - 'git', - ['-C', blocked.repoRoot, 'worktree', 'add', '-b', selectedBranch, selectedWorktreePath, startRef], - ); + const startRef = resolveProtectedBaseSandboxStartRef(blocked.repoRoot, blocked.branch); + const addArgs = startRef + ? ['-C', blocked.repoRoot, 'worktree', 'add', '-b', selectedBranch, selectedWorktreePath, startRef] + : ['-C', blocked.repoRoot, 'worktree', 'add', '--orphan', selectedWorktreePath]; + const addResult = run('git', addArgs); if (isSpawnFailure(addResult)) { throw addResult.error; } if (addResult.status !== 0) { - throw new Error((addResult.stderr || addResult.stdout || 'failed to create doctor sandbox').trim()); + throw new Error((addResult.stderr || addResult.stdout || 'failed to create sandbox').trim()); + } + + if (!startRef) { + const renameResult = run( + 'git', + ['-C', selectedWorktreePath, 'branch', '-m', selectedBranch], + { timeout: 20_000 }, + ); + if (isSpawnFailure(renameResult)) { + throw renameResult.error; + } + if (renameResult.status !== 0) { + throw new Error( + (renameResult.stderr || renameResult.stdout || 'failed to name orphan sandbox branch').trim(), + ); + } } return { @@ -1351,16 +1414,16 @@ function startDoctorSandboxFallback(blocked) { }; } -function startDoctorSandbox(blocked) { +function startProtectedBaseSandbox(blocked, { taskName, sandboxSuffix }) { const startScript = path.join(blocked.repoRoot, 'scripts', 'agent-branch-start.sh'); if (!fs.existsSync(startScript)) { - return startDoctorSandboxFallback(blocked); + return startProtectedBaseSandboxFallback(blocked, sandboxSuffix); } const startResult = run('bash', [ startScript, '--task', - `${SHORT_TOOL_NAME}-doctor`, + taskName, '--agent', SHORT_TOOL_NAME, '--base', @@ -1370,7 +1433,7 @@ function startDoctorSandbox(blocked) { throw startResult.error; } if (startResult.status !== 0) { - return startDoctorSandboxFallback(blocked); + return startProtectedBaseSandboxFallback(blocked, sandboxSuffix); } const metadata = extractAgentBranchStartMetadata(startResult.stdout); @@ -1385,11 +1448,11 @@ function startDoctorSandbox(blocked) { if (!restoreResult.ok) { const detail = [restoreResult.stderr, restoreResult.stdout].filter(Boolean).join('\n').trim(); throw new Error( - `doctor sandbox startup switched protected base checkout and could not restore '${blocked.branch}'.` + + `sandbox startup switched protected base checkout and could not restore '${blocked.branch}'.` + (detail ? `\n${detail}` : ''), ); } - return startDoctorSandboxFallback(blocked); + return startProtectedBaseSandboxFallback(blocked, sandboxSuffix); } return { @@ -1399,6 +1462,59 @@ function startDoctorSandbox(blocked) { }; } +function cleanupProtectedBaseSandbox(repoRoot, metadata) { + const result = { + worktree: 'skipped', + branch: 'skipped', + note: 'missing sandbox metadata', + }; + + if (!metadata?.worktreePath || !metadata?.branch) { + return result; + } + + if (fs.existsSync(metadata.worktreePath)) { + const removeResult = run( + 'git', + ['-C', repoRoot, 'worktree', 'remove', '--force', metadata.worktreePath], + { timeout: 30_000 }, + ); + if (isSpawnFailure(removeResult)) { + throw removeResult.error; + } + if (removeResult.status !== 0) { + throw new Error( + (removeResult.stderr || removeResult.stdout || 'failed to remove sandbox worktree').trim(), + ); + } + result.worktree = 'removed'; + } else { + result.worktree = 'missing'; + } + + 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; + } + if (branchDeleteResult.status !== 0) { + throw new Error( + (branchDeleteResult.stderr || branchDeleteResult.stdout || 'failed to delete sandbox branch').trim(), + ); + } + result.branch = 'deleted'; + } else { + result.branch = 'missing'; + } + + result.note = 'sandbox worktree pruned'; + return result; +} + function parseGitPathList(output) { return String(output || '') .split('\n') @@ -1658,7 +1774,10 @@ function syncProtectedBaseDoctorRepairs(options, blocked) { } function runDoctorInSandbox(options, blocked) { - const startResult = startDoctorSandbox(blocked); + const startResult = startProtectedBaseSandbox(blocked, { + taskName: `${SHORT_TOOL_NAME}-doctor`, + sandboxSuffix: 'gx-doctor', + }); const metadata = startResult.metadata; const sandboxTarget = resolveSandboxTarget(blocked.repoRoot, metadata.worktreePath, options.target); @@ -1912,6 +2031,80 @@ function runDoctorInSandbox(options, blocked) { process.exitCode = 1; } +function runSetupInSandbox(options, blocked, repoLabel = '') { + const startResult = startProtectedBaseSandbox(blocked, { + taskName: `${SHORT_TOOL_NAME}-setup`, + sandboxSuffix: 'gx-setup', + }); + const metadata = startResult.metadata; + + if (startResult.stdout) process.stdout.write(startResult.stdout); + if (startResult.stderr) process.stderr.write(startResult.stderr); + console.log( + `[${TOOL_NAME}] setup blocked on protected branch '${blocked.branch}' in an initialized repo; ` + + 'refreshing through a sandbox worktree and syncing managed bootstrap files back locally.', + ); + + const sandboxTarget = resolveSandboxTarget(blocked.repoRoot, metadata.worktreePath, options.target); + const nestedResult = run( + process.execPath, + [__filename, ...buildSandboxSetupArgs(options, sandboxTarget)], + { cwd: metadata.worktreePath }, + ); + if (isSpawnFailure(nestedResult)) { + throw nestedResult.error; + } + if (nestedResult.status !== 0) { + if (nestedResult.stdout) process.stdout.write(nestedResult.stdout); + if (nestedResult.stderr) process.stderr.write(nestedResult.stderr); + throw new Error( + `sandboxed setup failed for protected branch '${blocked.branch}'. ` + + `Inspect sandbox at ${metadata.worktreePath}`, + ); + } + + const syncOptions = { + ...options, + target: blocked.repoRoot, + recursive: false, + allowProtectedBaseWrite: true, + }; + const { installPayload, fixPayload, parentWorkspace } = runSetupBootstrapInternal(syncOptions); + printOperations(`Setup/install${repoLabel}`, installPayload, syncOptions.dryRun); + printOperations(`Setup/fix${repoLabel}`, fixPayload, syncOptions.dryRun); + if (!syncOptions.dryRun && parentWorkspace) { + console.log(`[${TOOL_NAME}] Parent workspace view: ${parentWorkspace.workspacePath}`); + } + + const scanResult = runScanInternal({ target: blocked.repoRoot, json: false }); + const currentBaseBranch = currentBranchName(scanResult.repoRoot); + const autoFinishSummary = autoFinishReadyAgentBranches(scanResult.repoRoot, { + baseBranch: currentBaseBranch, + dryRun: syncOptions.dryRun, + }); + printScanResult(scanResult, false); + if (autoFinishSummary.enabled) { + console.log( + `[${TOOL_NAME}] Auto-finish sweep (base=${currentBaseBranch}): attempted=${autoFinishSummary.attempted}, completed=${autoFinishSummary.completed}, skipped=${autoFinishSummary.skipped}, failed=${autoFinishSummary.failed}`, + ); + for (const detail of autoFinishSummary.details) { + console.log(`[${TOOL_NAME}] ${detail}`); + } + } else if (autoFinishSummary.details.length > 0) { + console.log(`[${TOOL_NAME}] ${autoFinishSummary.details[0]}`); + } + + const cleanupResult = cleanupProtectedBaseSandbox(blocked.repoRoot, metadata); + console.log( + `[${TOOL_NAME}] Protected-base setup sandbox cleanup: ${cleanupResult.note} ` + + `(worktree=${cleanupResult.worktree}, branch=${cleanupResult.branch}).`, + ); + + return { + scanResult, + }; +} + function parseTargetFlag(rawArgs, defaultTarget = process.cwd()) { const remaining = []; let target = defaultTarget; @@ -5184,31 +5377,24 @@ function setup(rawArgs) { console.log(`[${TOOL_NAME}] ── Setup target: ${repoPath} ──`); } - assertProtectedMainWriteAllowed(perRepoOptions, 'setup'); - const installPayload = runInstallInternal(perRepoOptions); - installPayload.operations.push(ensureSetupProtectedBranches(installPayload.repoRoot, Boolean(perRepoOptions.dryRun))); - if (perRepoOptions.parentWorkspaceView) { - installPayload.operations.push(ensureParentWorkspaceView(installPayload.repoRoot, Boolean(perRepoOptions.dryRun))); + const blocked = protectedBaseWriteBlock(perRepoOptions); + if (blocked) { + const sandboxResult = runSetupInSandbox(perRepoOptions, blocked, repoLabel); + aggregateErrors += sandboxResult.scanResult.errors; + aggregateWarnings += sandboxResult.scanResult.warnings; + lastScanResult = sandboxResult.scanResult; + continue; } - printOperations(`Setup/install${repoLabel}`, installPayload, perRepoOptions.dryRun); - const fixPayload = runFixInternal({ - target: repoPath, - dryRun: perRepoOptions.dryRun, - force: perRepoOptions.force, - dropStaleLocks: true, - skipAgents: perRepoOptions.skipAgents, - skipPackageJson: perRepoOptions.skipPackageJson, - skipGitignore: perRepoOptions.skipGitignore, - }); + const { installPayload, fixPayload, parentWorkspace } = runSetupBootstrapInternal(perRepoOptions); + printOperations(`Setup/install${repoLabel}`, installPayload, perRepoOptions.dryRun); printOperations(`Setup/fix${repoLabel}`, fixPayload, perRepoOptions.dryRun); if (perRepoOptions.dryRun) { continue; } - if (perRepoOptions.parentWorkspaceView) { - const parentWorkspace = buildParentWorkspaceView(installPayload.repoRoot); + if (parentWorkspace) { console.log(`[${TOOL_NAME}] Parent workspace view: ${parentWorkspace.workspacePath}`); } diff --git a/openspec/changes/agent-codex-setup-protected-main-sandbox-2026-04-21-12-20/proposal.md b/openspec/changes/agent-codex-setup-protected-main-sandbox-2026-04-21-12-20/proposal.md new file mode 100644 index 00000000..0ac87421 --- /dev/null +++ b/openspec/changes/agent-codex-setup-protected-main-sandbox-2026-04-21-12-20/proposal.md @@ -0,0 +1,16 @@ +## Why + +- `gx setup` is the bootstrap entrypoint, but rerunning it on an already-initialized protected `main` hard-blocks instead of using the safer sandbox path that `gx doctor` already has. +- That makes bootstrap refreshes awkward in exactly the state where users expect Guardex to preserve the visible base checkout. + +## What Changes + +- Reuse the protected-branch sandbox path for `gx setup` after initialization. +- Sync the managed Guardex bootstrap files back into the protected base workspace after sandboxed setup succeeds. +- Prune the temporary sandbox worktree/branch after the local bootstrap sync completes. +- Add focused regression coverage for protected-`main` setup refresh behavior. + +## Impact + +- `gx setup` becomes usable as a refresh/bootstrap command on protected `main` without requiring `--allow-protected-base-write`. +- Scope stays limited to managed bootstrap surfaces and targeted setup tests. diff --git a/openspec/changes/agent-codex-setup-protected-main-sandbox-2026-04-21-12-20/specs/setup-protected-main-sandbox/spec.md b/openspec/changes/agent-codex-setup-protected-main-sandbox-2026-04-21-12-20/specs/setup-protected-main-sandbox/spec.md new file mode 100644 index 00000000..d2eccdbb --- /dev/null +++ b/openspec/changes/agent-codex-setup-protected-main-sandbox-2026-04-21-12-20/specs/setup-protected-main-sandbox/spec.md @@ -0,0 +1,14 @@ +## ADDED Requirements + +### Requirement: protected-main setup refresh uses a sandbox worktree + +After a repo is already bootstrapped, `gx setup` SHALL avoid hard-blocking on protected `main` and SHALL reuse an isolated sandbox worktree to perform the managed refresh. + +#### Scenario: rerunning setup on initialized protected main + +- **GIVEN** a repo on protected `main` that already has Guardex bootstrap files +- **WHEN** the user runs `gx setup --target ` +- **THEN** the command succeeds without requiring `--allow-protected-base-write` +- **AND** the visible base checkout remains on `main` +- **AND** the managed Guardex bootstrap files are refreshed in the base workspace +- **AND** the temporary sandbox worktree/branch is pruned before setup exits diff --git a/openspec/changes/agent-codex-setup-protected-main-sandbox-2026-04-21-12-20/tasks.md b/openspec/changes/agent-codex-setup-protected-main-sandbox-2026-04-21-12-20/tasks.md new file mode 100644 index 00000000..e183117e --- /dev/null +++ b/openspec/changes/agent-codex-setup-protected-main-sandbox-2026-04-21-12-20/tasks.md @@ -0,0 +1,22 @@ +## 1. Spec + +- [x] 1.1 Finalize proposal scope and acceptance criteria for `agent-codex-setup-protected-main-sandbox-2026-04-21-12-20`. +- [x] 1.2 Define normative requirements in `specs/setup-protected-main-sandbox/spec.md`. + +## 2. Implementation + +- [x] 2.1 Route protected-`main` setup refreshes through the sandbox bootstrap path instead of hard-blocking. +- [x] 2.2 Sync the managed setup outputs back into the protected base workspace and clean up the temporary sandbox. +- [x] 2.3 Add/update focused regression coverage in `test/install.test.js`. + +## 3. Verification + +- [x] 3.1 Run `node --check bin/multiagent-safety.js`. +- [x] 3.2 Run `node --test --test-name-pattern="setup .*protected main" test/install.test.js`. +- [x] 3.3 Run `openspec validate agent-codex-setup-protected-main-sandbox-2026-04-21-12-20 --type change --strict`. + +## 4. Cleanup (mandatory; run before claiming completion) + +- [ ] 4.1 Run `bash scripts/agent-branch-finish.sh --branch agent/codex/setup-protected-main-sandbox-2026-04-21-12-08 --base main --via-pr --wait-for-merge --cleanup`. +- [ ] 4.2 Record the PR URL and final merge state (`MERGED`) in the completion handoff. +- [ ] 4.3 Confirm the sandbox worktree is gone and no agent refs remain for the branch. diff --git a/test/install.test.js b/test/install.test.js index d9aa4445..5748730c 100644 --- a/test/install.test.js +++ b/test/install.test.js @@ -817,16 +817,35 @@ test('review-bot-watch uses explicit codex-agent flags for argument parsing comp assert.match(script, /-- exec \"\$prompt\"/); }); -test('setup blocks in-place maintenance writes on protected main after initialization', () => { +test('setup refreshes initialized protected main through a sandbox and prunes it', () => { const repoDir = initRepoOnBranch('main'); + const gitignorePath = path.join(repoDir, '.gitignore'); let result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); assert.equal(result.status, 0, result.stderr || result.stdout); + 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, 1, result.stderr || result.stdout); - assert.match(result.stderr, /setup blocked on protected branch 'main'/); - assert.match(result.stderr, /agent-branch-start\.sh/); + 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 sandboxBranch = extractCreatedBranch(result.stdout); + const sandboxWorktree = extractCreatedWorktree(result.stdout); + assert.equal(fs.existsSync(sandboxWorktree), false, 'setup sandbox worktree should be pruned'); + + const currentBranch = runCmd('git', ['symbolic-ref', '--short', 'HEAD'], repoDir); + assert.equal(currentBranch.status, 0, currentBranch.stderr || currentBranch.stdout); + assert.equal(currentBranch.stdout.trim(), 'main', 'visible checkout must stay on protected 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'); + + const refreshedGitignore = fs.readFileSync(gitignorePath, 'utf8'); + assert.match(refreshedGitignore, /^scripts\/\*$/m); }); test('setup allows explicit protected-main override for in-place maintenance', () => {