diff --git a/README.md b/README.md index 42f2c6c..ba77a5d 100644 --- a/README.md +++ b/README.md @@ -287,7 +287,8 @@ and asks `[y/N]` whether to update immediately (default is `N`). - Interactive setup: prompts for Y/N approval before global OMX/OpenSpec/codex-auth install. - Interactive prompt is strict (`[y/n]`) and waits for explicit answer. - Non-interactive setup: skips global installs by default; use `--yes-global-install` to force. -- In already-initialized repos, `setup` / `install` / `fix` / `doctor` block writes on protected `main` by default; start an agent branch first. Use `--allow-protected-base-write` only for emergency in-place maintenance. +- In already-initialized repos, `setup` / `install` / `fix` block writes on protected `main` by default; start an agent branch first. Use `--allow-protected-base-write` only for emergency in-place maintenance. +- `gx doctor` on protected `main` auto-starts an isolated `agent/gx/...-gx-doctor` worktree branch and applies repairs there. - `scripts/codex-agent.sh` now auto-runs finish automation after a Codex session when `origin` exists: auto-commit changed files, run PR/merge cleanup, and prune merged worktrees. If conflicts remain, it keeps the sandbox and prompts for a conflict-resolution review pass. diff --git a/bin/multiagent-safety.js b/bin/multiagent-safety.js index 8ac20ed..64ead41 100755 --- a/bin/multiagent-safety.js +++ b/bin/multiagent-safety.js @@ -288,7 +288,8 @@ NOTES - Short alias: ${SHORT_TOOL_NAME} - ${SHORT_TOOL_NAME} init is an alias of ${SHORT_TOOL_NAME} setup - ${TOOL_NAME} setup asks for Y/N approval before global installs - - In initialized repos, setup/install/fix/doctor block in-place writes on protected main by default + - In initialized repos, setup/install/fix block in-place writes on protected main by default + - doctor auto-starts a sandbox agent branch/worktree when run on protected main - Legacy command aliases are still supported: ${LEGACY_NAMES.join(', ')}`); if (outsideGitRepo) { @@ -671,34 +672,139 @@ function hasGuardexBootstrapFiles(repoRoot) { return required.every((relativePath) => fs.existsSync(path.join(repoRoot, relativePath))); } -function assertProtectedMainWriteAllowed(options, commandName) { +function protectedBaseWriteBlock(options) { if (options.dryRun || options.allowProtectedBaseWrite) { - return; + return null; } const repoRoot = resolveRepoRoot(options.target); if (!hasGuardexBootstrapFiles(repoRoot)) { - return; + return null; } const branch = currentBranchName(repoRoot); if (branch !== 'main') { - return; + return null; } const protectedBranches = readProtectedBranches(repoRoot); if (!protectedBranches.includes(branch)) { + return null; + } + + return { + repoRoot, + branch, + }; +} + +function assertProtectedMainWriteAllowed(options, commandName) { + const blocked = protectedBaseWriteBlock(options); + if (!blocked) { return; } throw new Error( - `${commandName} blocked on protected branch '${branch}' in an initialized repo.\n` + - `Keep local '${branch}' pull-only: start an agent branch/worktree first:\n` + + `${commandName} blocked on protected branch '${blocked.branch}' in an initialized repo.\n` + + `Keep local '${blocked.branch}' pull-only: start an agent branch/worktree first:\n` + ` bash scripts/agent-branch-start.sh "" "codex"\n` + `Override once only when intentional: --allow-protected-base-write`, ); } +function extractAgentBranchStartMetadata(output) { + const branchMatch = String(output || '').match(/^\[agent-branch-start\] Created branch: (.+)$/m); + const worktreeMatch = String(output || '').match(/^\[agent-branch-start\] Worktree: (.+)$/m); + return { + branch: branchMatch ? branchMatch[1].trim() : '', + worktreePath: worktreeMatch ? worktreeMatch[1].trim() : '', + }; +} + +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}`); + } + if (!relativeTarget || relativeTarget === '.') { + return worktreePath; + } + return path.join(worktreePath, relativeTarget); +} + +function buildSandboxDoctorArgs(options, sandboxTarget) { + const args = ['doctor', '--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'); + if (options.json) args.push('--json'); + return args; +} + +function runDoctorInSandbox(options, blocked) { + const startScript = path.join(blocked.repoRoot, 'scripts', 'agent-branch-start.sh'); + if (!fs.existsSync(startScript)) { + throw new Error( + `doctor sandbox fallback is unavailable because '${startScript}' is missing.\n` + + `Run '${SHORT_TOOL_NAME} setup --allow-protected-base-write' once to restore branch-start tooling.`, + ); + } + + const startResult = run('bash', [ + startScript, + '--task', + `${SHORT_TOOL_NAME}-doctor`, + '--agent', + SHORT_TOOL_NAME, + '--base', + blocked.branch, + ], { cwd: blocked.repoRoot }); + if (startResult.error) { + throw startResult.error; + } + if (startResult.status !== 0) { + throw new Error((startResult.stderr || startResult.stdout || 'failed to start doctor sandbox').trim()); + } + + const metadata = extractAgentBranchStartMetadata(startResult.stdout); + if (!metadata.worktreePath) { + throw new Error(`Failed to parse sandbox worktree from agent-branch-start output:\n${startResult.stdout}`); + } + + const sandboxTarget = resolveSandboxTarget(blocked.repoRoot, metadata.worktreePath, options.target); + const nestedResult = run( + process.execPath, + [__filename, ...buildSandboxDoctorArgs(options, sandboxTarget)], + { cwd: metadata.worktreePath }, + ); + if (nestedResult.error) { + throw nestedResult.error; + } + + if (options.json) { + if (nestedResult.stdout) process.stdout.write(nestedResult.stdout); + if (nestedResult.stderr) process.stderr.write(nestedResult.stderr); + } else { + console.log( + `[${TOOL_NAME}] doctor detected protected branch '${blocked.branch}'. ` + + `Running repairs in sandbox branch '${metadata.branch || 'agent/'}'.`, + ); + if (startResult.stdout) process.stdout.write(startResult.stdout); + if (startResult.stderr) process.stderr.write(startResult.stderr); + if (nestedResult.stdout) process.stdout.write(nestedResult.stdout); + if (nestedResult.stderr) process.stderr.write(nestedResult.stderr); + } + + if (typeof nestedResult.status === 'number') { + process.exitCode = nestedResult.status; + return; + } + process.exitCode = 1; +} + function parseTargetFlag(rawArgs, defaultTarget = process.cwd()) { const remaining = []; let target = defaultTarget; @@ -1894,6 +2000,12 @@ function doctor(rawArgs) { allowProtectedBaseWrite: false, }); + const blocked = protectedBaseWriteBlock(options); + if (blocked) { + runDoctorInSandbox(options, blocked); + return; + } + assertProtectedMainWriteAllowed(options, 'doctor'); const fixPayload = runFixInternal(options); const scanResult = runScanInternal({ target: options.target, json: false }); diff --git a/test/install.test.js b/test/install.test.js index 40bddae..cc0391c 100644 --- a/test/install.test.js +++ b/test/install.test.js @@ -289,6 +289,42 @@ test('install blocks in-place maintenance writes on protected main unless overri assert.match(result.stderr, /install blocked on protected branch 'main'/); }); +test('doctor on protected main auto-runs in a sandbox branch/worktree', () => { + const repoDir = initRepoOnBranch('main'); + seedCommit(repoDir); + attachOriginRemoteForBranch(repoDir, 'main'); + + let result = runNode(['setup', '--target', repoDir, '--no-global-install'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + 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); + result = runCmd('git', ['push', 'origin', 'main'], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + + fs.rmSync(path.join(repoDir, 'scripts', 'agent-branch-finish.sh')); + + result = runNode(['doctor', '--target', repoDir], repoDir); + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /doctor detected protected branch 'main'/); + const createdBranch = extractCreatedBranch(result.stdout); + const createdWorktree = extractCreatedWorktree(result.stdout); + assert.match(createdBranch, /^agent\/gx\/.+-gx-doctor$/); + assert.equal(fs.existsSync(path.join(createdWorktree, 'scripts', 'agent-branch-finish.sh')), true); + + 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('setup pre-commit blocks codex session commits on non-agent branches by default', () => { const repoDir = initRepo();