Merge pull request #1338 from gsd-build/fix/worktree-edit-permissions

fix: add permissionMode to worktree agents
This commit is contained in:
Tom Boucher
2026-03-23 14:26:47 -04:00
committed by GitHub
12 changed files with 312 additions and 27 deletions

View File

@@ -339,3 +339,56 @@ describe('DISCUSS: discussion log generation', () => {
);
});
});
// ─── Worktree Permission Mode (#1334) ───────────────────────────────────────
describe('PERM: worktree agents have permissionMode: acceptEdits', () => {
// Agents spawned with isolation="worktree" need permissionMode: acceptEdits
// to avoid per-directory edit permission prompts in the worktree path.
// See: anthropics/claude-code#29110, anthropics/claude-code#28041
const WORKTREE_AGENTS = ['gsd-executor', 'gsd-debugger'];
for (const agent of WORKTREE_AGENTS) {
test(`${agent} has permissionMode: acceptEdits`, () => {
const content = fs.readFileSync(path.join(AGENTS_DIR, agent + '.md'), 'utf-8');
const frontmatter = content.split('---')[1] || '';
assert.ok(
frontmatter.includes('permissionMode: acceptEdits'),
`${agent} must have permissionMode: acceptEdits — worktree agents need this to avoid ` +
`per-directory edit permission prompts (see #1334)`
);
});
}
test('worktree-spawned agents are covered', () => {
// Verify that agents referenced with isolation="worktree" in workflows
// are included in the WORKTREE_AGENTS list above
const dirs = [WORKFLOWS_DIR, COMMANDS_DIR];
const worktreeAgentTypes = new Set();
for (const dir of dirs) {
if (!fs.existsSync(dir)) continue;
const files = fs.readdirSync(dir).filter(f => f.endsWith('.md'));
for (const file of files) {
const content = fs.readFileSync(path.join(dir, file), 'utf-8');
// Find patterns like: subagent_type="gsd-executor" ... isolation="worktree"
// These can span multiple lines in Task() calls
const taskBlocks = content.match(/Task\([^)]*isolation="worktree"[^)]*\)/gs) || [];
for (const block of taskBlocks) {
const typeMatch = block.match(/subagent_type="([^"]+)"/);
if (typeMatch) {
worktreeAgentTypes.add(typeMatch[1]);
}
}
}
}
for (const agentType of worktreeAgentTypes) {
assert.ok(
WORKTREE_AGENTS.includes(agentType),
`${agentType} is spawned with isolation="worktree" but not in WORKTREE_AGENTS list — ` +
`add permissionMode: acceptEdits to its frontmatter and update this test`
);
}
});
});

View File

@@ -136,6 +136,12 @@ describe('dispatcher error paths', () => {
assert.ok(result.error.includes('Unknown todo subcommand'), `Expected "Unknown todo subcommand" in stderr, got: ${result.error}`);
});
test('uat unknown subcommand errors', () => {
const result = runGsdTools('uat bogus', tmpDir);
assert.strictEqual(result.success, false, 'Should exit non-zero');
assert.ok(result.error.includes('Unknown uat subcommand'), `Expected "Unknown uat subcommand" in stderr, got: ${result.error}`);
});
// Unknown subcommand: init
test('init unknown workflow errors', () => {
const result = runGsdTools('init bogus', tmpDir);

View File

@@ -951,16 +951,15 @@ describe('cmdInitMapCodebase', () => {
assert.strictEqual(output.codebase_dir_exists, true);
});
test('map-codebase workflow lists OpenCode as having Task tool support (#1316)', () => {
test('map-codebase workflow does not list OpenCode under runtimes without Task tool (#1316)', () => {
const workflow = fs.readFileSync(
path.join(__dirname, '..', 'get-shit-done', 'workflows', 'map-codebase.md'), 'utf8'
);
// OpenCode must appear in the "with Task tool" line, not the "WITHOUT" line
const withLine = workflow.split('\n').find(l => l.includes('Runtimes with Task tool'));
const withoutLine = workflow.split('\n').find(l => l.includes('WITHOUT Task tool'));
assert.ok(withLine, 'workflow should have a "Runtimes with Task tool" line');
assert.ok(withoutLine, 'workflow should have a "WITHOUT Task tool" line');
assert.ok(withLine.includes('OpenCode'), 'OpenCode must be listed under runtimes WITH Task tool');
// OpenCode must NOT appear in the "WITHOUT Task tool" / "NOT available" condition
const withoutLine = workflow.split('\n').find(l =>
l.includes('NOT available') || l.includes('WITHOUT Task tool')
);
assert.ok(withoutLine, 'workflow should have a line about Task tool NOT being available');
assert.ok(!withoutLine.includes('OpenCode'), 'OpenCode must NOT be listed under runtimes WITHOUT Task tool');
});
});

View File

@@ -14,6 +14,7 @@ const {
requireSafePath,
scanForInjection,
sanitizeForPrompt,
sanitizeForDisplay,
safeJsonParse,
validatePhaseNumber,
validateFieldName,
@@ -244,6 +245,19 @@ describe('sanitizeForPrompt', () => {
});
});
describe('sanitizeForDisplay', () => {
test('removes protocol leak lines', () => {
const input = 'Visible line\nuser to=all:final code something bad\nAnother line';
const result = sanitizeForDisplay(input);
assert.equal(result, 'Visible line\nAnother line');
});
test('keeps normal user-facing copy intact', () => {
const input = 'Type `pass` or describe what\\\'s wrong.';
assert.equal(sanitizeForDisplay(input), input);
});
});
// ─── Shell Safety ───────────────────────────────────────────────────────────
describe('validateShellArg', () => {

View File

@@ -2,6 +2,8 @@
* GSD Tools Tests - UAT Audit
*/
'use strict';
const { test, describe, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert');
const fs = require('fs');
@@ -324,3 +326,83 @@ All checks passed.
assert.strictEqual(output.summary.total_files, 0);
});
});
describe('uat render-checkpoint', () => {
let tmpDir;
let uatPath;
beforeEach(() => {
tmpDir = createTempProject();
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-test-phase');
fs.mkdirSync(phaseDir, { recursive: true });
uatPath = path.join(phaseDir, '01-UAT.md');
});
afterEach(() => {
cleanup(tmpDir);
});
test('renders the current checkpoint as raw output', () => {
fs.writeFileSync(uatPath, `---
status: testing
phase: 01-test-phase
---
## Current Test
number: 2
name: Submit form validation
expected: |
Empty submit keeps controls visible.
Validation error copy is shown.
awaiting: user response
`);
const result = runGsdTools(['uat', 'render-checkpoint', '--file', '.planning/phases/01-test-phase/01-UAT.md', '--raw'], tmpDir);
assert.strictEqual(result.success, true, `render-checkpoint failed: ${result.error}`);
assert.ok(result.output.includes('**Test 2: Submit form validation**'));
assert.ok(result.output.includes('Empty submit keeps controls visible.'));
assert.ok(result.output.includes("Type `pass` or describe what's wrong."));
});
test('strips protocol leak lines from current test copy', () => {
fs.writeFileSync(uatPath, `---
status: testing
phase: 01-test-phase
---
## Current Test
number: 6
name: Locale copy
expected: |
English strings render correctly.
user to=all:final code 彩票平台招商 pass
Chinese strings render correctly.
awaiting: user response
`);
const result = runGsdTools(['uat', 'render-checkpoint', '--file', '.planning/phases/01-test-phase/01-UAT.md', '--raw'], tmpDir);
assert.strictEqual(result.success, true, `render-checkpoint failed: ${result.error}`);
assert.ok(!result.output.includes('user to=all:final code'));
assert.ok(!result.output.includes('彩票平台'));
assert.ok(result.output.includes('English strings render correctly.'));
assert.ok(result.output.includes('Chinese strings render correctly.'));
});
test('fails when testing is already complete', () => {
fs.writeFileSync(uatPath, `---
status: complete
phase: 01-test-phase
---
## Current Test
[testing complete]
`);
const result = runGsdTools(['uat', 'render-checkpoint', '--file', '.planning/phases/01-test-phase/01-UAT.md'], tmpDir);
assert.strictEqual(result.success, false, 'Should fail when no current test exists');
assert.ok(result.error.includes('already complete'));
});
});