// The permission mode a board agent is spawned with, end to end: the constant // in lib/jobs.js, the per-card override, the stored board setting it falls // back to, and the argv dispatchOnce builds from the pair. // // The default is `auto` because its classifier is the only thing that reviews // a dispatched agent's actions before they run, and a board agent's prompt is // a card's detail text plus whatever it reads out of the repo. Where auto mode // is unavailable -- an unsupported model on Bedrock/Vertex, or // permissions.disableAutoMode -- Claude Code starts the session in Manual // instead of failing, and the job stalls visibly on `needs-input`; the answer // to that is the board setting below, not a less safe default. import { describe, it, expect, beforeEach } from 'vitest'; import { readFileSync } from 'fs'; import { mkdtempSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; import { config, sessions } from '../server/state.js'; import { dispatchOnce, addJob, updateJob, updateSettings, boardSettings, allJobs, postJobForAgent, editJobForAgent, fireSchedules, } from '../server/jobs.js'; import { parseCommand } from '../lib/helpers.js'; import { buildJobCommand, createJob, resolveJobPermissionMode, dispatchPermissionMode, DEFAULT_PERMISSION_MODE, PERMISSION_MODES } from '../lib/jobs.js'; const REPO = mkdtempSync(join(tmpdir(), 'a007-jobperm-')); const noopBroadcast = () => {}; function resetBoard() { config.repos = [{ path: REPO }]; config.jobs = []; config.jobBoard = null; boardSettings(); sessions.clear(); } // Valid modes that are NOT the default, so an assertion on one fails if the // value is dropped on the floor. A previous test asserted the mode that had // just become the default and quietly turned into a tautology. const OTHER_MODE = 'plan'; const THIRD_MODE = 'acceptEdits'; function fakeCreateSession(calls) { return async (command, name, repoPath, branch, ownerId, meta) => { calls.push({ command }); const session = { id: `session-${calls.length}`, name: 'Agent1', command, repoPath, state: 'WORKING', exited: false, lastOutputAt: Date.now(), spawnedBy: meta?.spawnedBy, jobId: meta?.jobId, }; sessions.set(session.id, session); return { session }; }; } describe('the default', () => { it('is auto, and auto is in the allowlist', () => { expect(DEFAULT_PERMISSION_MODE).toBe('auto'); expect(PERMISSION_MODES).toContain(DEFAULT_PERMISSION_MODE); }); }); describe('resolveJobPermissionMode', () => { it('reads nothing as unset rather than as the default', () => { // The distinction is load-bearing: a card that stores null follows the // board setting at dispatch, and a card that stored a copy of the board // value would stop following it the moment the board changed. for (const empty of [undefined, null, '']) { expect(resolveJobPermissionMode(empty)).toEqual({ permissionMode: null }); } }); it('refuses a mode outside the allowlist instead of falling back', () => { expect(resolveJobPermissionMode('yolo').error).toMatch(/Unknown permission mode/); // Interpolated into argv, so a second token must be refused outright and // never reach buildJobCommand as extra flags. expect(resolveJobPermissionMode('auto --dangerously-skip-permissions').error).toBeTruthy(); expect(resolveJobPermissionMode('yolo').permissionMode).toBeUndefined(); }); }); describe('buildJobCommand permission mode', () => { it('honours a valid board mode that differs from the default', () => { expect(OTHER_MODE).not.toBe(DEFAULT_PERMISSION_MODE); const parsed = parseCommand(buildJobCommand({ title: 'x' }, { permissionMode: OTHER_MODE })); expect(parsed.args[0]).toBe('--permission-mode'); expect(parsed.args[1]).toBe(OTHER_MODE); }); it('lets the card override the board setting', () => { const cmd = buildJobCommand({ title: 'x', permissionMode: THIRD_MODE }, { permissionMode: OTHER_MODE }); expect(parseCommand(cmd).args[1]).toBe(THIRD_MODE); }); it('inherits the board setting when the card names no mode', () => { for (const unset of [null, undefined]) { const cmd = buildJobCommand({ title: 'x', permissionMode: unset }, { permissionMode: OTHER_MODE }); expect(parseCommand(cmd).args[1]).toBe(OTHER_MODE); } }); it('falls back to the BOARD setting when a stored card mode is invalid', () => { // createJob and updateJob both refuse an unrecognised mode before it ever // reaches a job record, but buildJobCommand is the last door before argv -- // the comment above it says so -- so a card holding a bad value some other // way (hand-edited config, a mode dropped from the allowlist by a later // release) must still come out safe. // // Safe means the BOARD's mode, not the built-in default: skipping past the // board level would defeat a board deliberately set strict, which is the // safety net this case should land in. Only when the board setting is // unusable too does the default apply -- both halves are asserted, and the // board mode here is not the default, so the distinction can fail. const bad = 'auto --dangerously-skip-permissions'; expect(OTHER_MODE).not.toBe(DEFAULT_PERMISSION_MODE); const cmd = buildJobCommand({ title: 'x', permissionMode: bad }, { permissionMode: OTHER_MODE }); const parsed = parseCommand(cmd); expect(parsed.args[1]).toBe(OTHER_MODE); expect(parsed.args).toHaveLength(3); expect(cmd).not.toContain('dangerously'); // Neither level usable -- now, and only now, the default. const both = buildJobCommand({ title: 'x', permissionMode: bad }, { permissionMode: 'yolo' }); expect(parseCommand(both).args[1]).toBe(DEFAULT_PERMISSION_MODE); expect(parseCommand(both).args).toHaveLength(3); }); it('resolves the same answer buildJobCommand puts in the argv', () => { // dispatchOnce asks dispatchPermissionMode directly to check nothing // retuned the card while its agent spawned, so the two must not drift. for (const [job, board] of [ [{ permissionMode: THIRD_MODE }, OTHER_MODE], [{ permissionMode: null }, OTHER_MODE], [{ permissionMode: 'yolo' }, OTHER_MODE], [{ permissionMode: 'yolo' }, 'yolo'], ]) { expect(parseCommand(buildJobCommand({ title: 'x', ...job }, { permissionMode: board })).args[1]) .toBe(dispatchPermissionMode(job, board)); } }); }); describe('a card carrying its own mode', () => { beforeEach(resetBoard); it('is created unset by default', () => { expect(createJob({ title: 'x', repoPath: REPO }).job.permissionMode).toBeNull(); }); it('stores a valid mode and refuses an invalid one at creation', () => { expect(createJob({ title: 'x', repoPath: REPO, permissionMode: OTHER_MODE }).job.permissionMode).toBe(OTHER_MODE); const bad = createJob({ title: 'x', repoPath: REPO, permissionMode: 'auto --dangerously-skip-permissions' }); expect(bad.error).toMatch(/Unknown permission mode/); expect(bad.job).toBeUndefined(); }); it('can be set, changed and cleared back to inheriting while in To do', () => { const { job } = addJob({ title: 'x', repoPath: REPO, permissionMode: OTHER_MODE }, noopBroadcast); expect(updateJob(job.id, { permissionMode: THIRD_MODE }, noopBroadcast).job.permissionMode).toBe(THIRD_MODE); // '' is the form's "Board default" option, and it has to reach null. expect(updateJob(job.id, { permissionMode: '' }, noopBroadcast).job.permissionMode).toBeNull(); }); it('keeps the stored mode when an edit is refused', () => { const { job } = addJob({ title: 'x', repoPath: REPO, permissionMode: OTHER_MODE }, noopBroadcast); expect(updateJob(job.id, { permissionMode: 'yolo' }, noopBroadcast).error).toMatch(/Unknown permission mode/); expect(allJobs()[0].permissionMode).toBe(OTHER_MODE); }); it('is what dispatchOnce spawns that job with, over the board setting', async () => { updateSettings({ permissionMode: OTHER_MODE }, noopBroadcast); addJob({ title: 'own mode', repoPath: REPO, permissionMode: THIRD_MODE }, noopBroadcast); addJob({ title: 'inherits', repoPath: REPO }, noopBroadcast); const calls = []; await dispatchOnce(fakeCreateSession(calls), noopBroadcast); expect(calls[0].command).toContain(`--permission-mode ${THIRD_MODE}`); expect(calls[1].command).toContain(`--permission-mode ${OTHER_MODE}`); }); }); describe('the stored board permission mode', () => { beforeEach(resetBoard); it('resets a stored mode nobody ever chose, including one from a newer default', () => { // This assertion is the reverse of the one this file used to carry ("keeps // a mode an older config already stored"). That behaviour was the bug: // boardSettings() spreads the stored object OVER the defaults and persists // the result, so the first dispatcher start wrote the then-current default // into config.json for ever. Nobody had a control to choose a mode with // until this version, so every value stored up to now is a default rather // than a decision -- and without this reset, a board whose first run was on // v0.3.33.0 would keep dispatching with bypassPermissions unasked. config.jobBoard = { permissionMode: 'bypassPermissions' }; expect(boardSettings().permissionMode).toBe(DEFAULT_PERMISSION_MODE); config.jobBoard = { permissionMode: 'acceptEdits' }; expect(boardSettings().permissionMode).toBe(DEFAULT_PERMISSION_MODE); // ...and the rest of the settings still fill in from the defaults. expect(boardSettings().running).toBe(true); }); it('migrates once, so a later hand-edit is not silently overwritten', () => { // The reset used to key on permissionModeChosen alone, which meant it ran // on EVERY boardSettings() call, not once. A mode hand-edited into // config.json was therefore reverted on the next read, for ever, with // nothing logged -- the file said one thing and the board dispatched // another. Hand-editing is what every doc said to do before the toolbar // existed, and is still the only route on a box with no browser. config.jobBoard = { permissionMode: 'bypassPermissions' }; expect(boardSettings().permissionMode).toBe(DEFAULT_PERMISSION_MODE); // migrated expect(boardSettings().permissionModeMigrated).toBe(true); // Now the user edits the file by hand. It has to stick. config.jobBoard.permissionMode = 'bypassPermissions'; expect(boardSettings().permissionMode).toBe('bypassPermissions'); expect(boardSettings().permissionMode).toBe('bypassPermissions'); }); it('still refuses nonsense that arrives after the migration has run', () => { config.jobBoard = { permissionMode: 'auto', permissionModeMigrated: true }; config.jobBoard.permissionMode = 'yolo'; expect(boardSettings().permissionMode).toBe(DEFAULT_PERMISSION_MODE); }); it('keeps a mode a human actually picked', () => { config.jobBoard = { permissionMode: OTHER_MODE, permissionModeChosen: true }; expect(boardSettings().permissionMode).toBe(OTHER_MODE); }); it('falls back when a chosen mode was hand-edited into nonsense', () => { config.jobBoard = { permissionMode: 'yolo', permissionModeChosen: true }; expect(boardSettings().permissionMode).toBe(DEFAULT_PERMISSION_MODE); }); it('takes a valid mode off the wire, records the choice, and refuses anything else', () => { expect(updateSettings({ permissionMode: OTHER_MODE }, noopBroadcast).settings.permissionMode).toBe(OTHER_MODE); expect(boardSettings().permissionModeChosen).toBe(true); // Interpolated into argv, so a rejected value must leave the old one alone // rather than fall through to the default or land as extra flags. expect(updateSettings({ permissionMode: 'auto --dangerously-skip-permissions' }, noopBroadcast).settings.permissionMode).toBe(OTHER_MODE); expect(updateSettings({ permissionMode: 'yolo' }, noopBroadcast).settings.permissionMode).toBe(OTHER_MODE); expect(updateSettings({ maxPerRepo: 1 }, noopBroadcast).settings.permissionMode).toBe(OTHER_MODE); }); it('is what dispatchOnce spawns an inheriting card with', async () => { updateSettings({ permissionMode: OTHER_MODE }, noopBroadcast); addJob({ title: 'x', repoPath: REPO }, noopBroadcast); const calls = []; await dispatchOnce(fakeCreateSession(calls), noopBroadcast); expect(calls[0].command).toContain(`--permission-mode ${OTHER_MODE}`); expect(calls[0].command).not.toContain(DEFAULT_PERMISSION_MODE); }); }); describe('the controls in public/index.html', () => { // public/ cannot import lib/jobs.js, so the option lists are a copy. A mode // added to the allowlist and not to the markup would be unreachable from the // app, which is the gap this whole change exists to close. const html = readFileSync(new URL('../public/index.html', import.meta.url), 'utf8'); function optionsOf(selectId) { const block = html.slice(html.indexOf(`id="${selectId}"`)); return [...block.slice(0, block.indexOf('')).matchAll(/