diff --git a/delegate-terminal.js b/delegate-terminal.js index 1096591..9f84e71 100644 --- a/delegate-terminal.js +++ b/delegate-terminal.js @@ -150,4 +150,50 @@ function winTerminalArgs(tmpBat) { return ['/c', 'start', '"Delegate"', 'cmd.exe', '/k', `"${tmpBat}"`]; } -module.exports = { shellEscape, buildTerminalCommand, winTerminalArgs }; +/** + * Check whether a command-line executable exists in $PATH. + * Uses POSIX `command -v` via /bin/sh. + */ +function hasCommand(cmd) { + if (typeof cmd !== 'string' || !/^[a-zA-Z0-9._-]+$/.test(cmd)) return false; + try { + const { spawnSync } = require('node:child_process'); + const r = spawnSync('/bin/sh', ['-c', `command -v ${cmd}`], { stdio: 'ignore' }); + return !r.error && r.status === 0; + } catch { + return false; + } +} + +/** + * Check whether the current environment lacks a graphical desktop display. + * On Linux, absence of both $DISPLAY and $WAYLAND_DISPLAY indicates a headless + * server, SSH session without X forwarding, or Docker container. + */ +function isHeadless(env = process.env, platform = process.platform) { + if (platform === 'linux') { + return !env.DISPLAY && !env.WAYLAND_DISPLAY; + } + return false; +} + +/** + * Terminal candidate configurations for Linux. + * Each entry specifies the executable name and its argument structure. + */ +function getLinuxTerminalCandidates(shellCommand) { + return [ + { name: 'gnome-terminal', args: ['--', 'bash', '-c', shellCommand] }, + { name: 'xterm', args: ['-e', 'bash', '-c', shellCommand] }, + { name: 'konsole', args: ['-e', 'bash', '-c', shellCommand] }, + ]; +} + +module.exports = { + shellEscape, + buildTerminalCommand, + winTerminalArgs, + hasCommand, + isHeadless, + getLinuxTerminalCandidates, +}; diff --git a/server.js b/server.js index 5b77f71..160999d 100644 --- a/server.js +++ b/server.js @@ -114,7 +114,7 @@ const UNATTENDED_MAX_TURNS = 30; const { isTransientOverload, shouldRetryOverload, detectUsageLimit, taskStatusForStop } = require('./rate-limit-utils'); const { detectAuthError, authErrorNotice } = require('./auth-errors'); -const { buildTerminalCommand: buildDelegateCommand, winTerminalArgs } = require('./delegate-terminal'); +const { buildTerminalCommand: buildDelegateCommand, winTerminalArgs, hasCommand, isHeadless, getLinuxTerminalCandidates } = require('./delegate-terminal'); const { isAgentSuccess, shouldAutoContinue, agentStopReason } = require('./multi-agent-result'); const { resolveAgentCommands, supportsTerminal, mergeAgentDefaults, parseNewIdOutput, @@ -8760,17 +8760,21 @@ app.post('/api/sessions/:id/open-terminal', (req, res) => { // execSync would kill xterm after the timeout; spawnProc+unref lets it live. const safeWorkdir = workdir.replace(/'/g, "'\\''"); fullCmd = `cd '${safeWorkdir}' && unset CLAUDECODE; claude --resume ${safeSid}`; - const termCandidates = [ - ['gnome-terminal', ['--', 'bash', '-c', `${fullCmd}; exec bash`]], - ['xterm', ['-e', 'bash', '-c', `${fullCmd}; exec bash`]], - ['konsole', ['-e', 'bash', '-c', fullCmd]], - ]; - for (const [cmd, args] of termCandidates) { - try { - const p = spawnProc(cmd, args, { detached: true, stdio: 'ignore' }); - p.unref(); - ok = true; break; - } catch {} + if (!isHeadless()) { + const termCandidates = [ + ['gnome-terminal', ['--', 'bash', '-c', `${fullCmd}; exec bash`]], + ['xterm', ['-e', 'bash', '-c', `${fullCmd}; exec bash`]], + ['konsole', ['-e', 'bash', '-c', fullCmd]], + ]; + for (const [cmd, args] of termCandidates) { + if (!hasCommand(cmd)) continue; + try { + const p = spawnProc(cmd, args, { detached: true, stdio: 'ignore' }); + p.on('error', () => {}); + p.unref(); + ok = true; break; + } catch {} + } } } } catch {} @@ -10859,14 +10863,20 @@ function openTerminal(shellCommand) { } } else { // Linux — try common terminal emulators - const terminals = ['gnome-terminal', 'xterm', 'konsole']; - for (const term of terminals) { + if (isHeadless()) { + return { ok: false, error: 'Cannot open graphical terminal: running in headless/Docker environment with no display ($DISPLAY or $WAYLAND_DISPLAY).' }; + } + const candidates = getLinuxTerminalCandidates(shellCommand); + for (const { name: term, args } of candidates) { + if (!hasCommand(term)) continue; try { - spawnProc(term, ['--', 'bash', '-c', shellCommand], { detached: true, stdio: 'ignore' }).unref(); + const p = spawnProc(term, args, { detached: true, stdio: 'ignore' }); + p.on('error', () => {}); + p.unref(); return { ok: true }; } catch { continue; } } - return { ok: false, error: 'No supported terminal emulator found' }; + return { ok: false, error: 'No supported graphical terminal emulator found (tried gnome-terminal, xterm, konsole)' }; } } diff --git a/test/delegate-terminal.test.js b/test/delegate-terminal.test.js index 680c56a..aba9cf8 100644 --- a/test/delegate-terminal.test.js +++ b/test/delegate-terminal.test.js @@ -283,5 +283,61 @@ check('dropping one placeholder leaves the others intact', () => { "claude --effort 'high' -p 'hi'"); }); +// ── Issue #108: Safe terminal spawning in Docker / headless Linux ────────────── +console.log('terminal availability & headless checks (#108)'); + +const { hasCommand, isHeadless, getLinuxTerminalCandidates } = require('../delegate-terminal'); + +check('isHeadless identifies headless Linux without DISPLAY or WAYLAND_DISPLAY', () => { + assert.strictEqual(isHeadless({}, 'linux'), true); + assert.strictEqual(isHeadless({ DISPLAY: '' }, 'linux'), true); + assert.strictEqual(isHeadless({ DISPLAY: ':0' }, 'linux'), false); + assert.strictEqual(isHeadless({ WAYLAND_DISPLAY: 'wayland-0' }, 'linux'), false); + assert.strictEqual(isHeadless({}, 'darwin'), false); + assert.strictEqual(isHeadless({}, 'win32'), false); +}); + +check('hasCommand finds standard commands and rejects non-existent/invalid ones', () => { + assert.strictEqual(hasCommand('node'), true); + assert.strictEqual(hasCommand('non_existent_binary_xyz_12345'), false); + assert.strictEqual(hasCommand('; rm -rf /'), false); + assert.strictEqual(hasCommand(''), false); + assert.strictEqual(hasCommand(null), false); +}); + +check('getLinuxTerminalCandidates provides valid arguments for standard emulators', () => { + const cands = getLinuxTerminalCandidates('echo hello'); + assert.strictEqual(cands.length, 3); + const gnome = cands.find(c => c.name === 'gnome-terminal'); + const xterm = cands.find(c => c.name === 'xterm'); + const konsole = cands.find(c => c.name === 'konsole'); + assert.ok(gnome && gnome.args.includes('--')); + assert.ok(xterm && xterm.args.includes('-e'), 'xterm requires -e'); + assert.ok(konsole && konsole.args.includes('-e')); +}); + +// Guard in server.js: openTerminal & /api/sessions/:id/open-terminal must check isHeadless and hasCommand +{ + const fs = require('fs'); + const path = require('path'); + const SRV = fs.readFileSync(path.join(__dirname, '..', 'server.js'), 'utf8'); + + check('openTerminal in server.js has headless guard', () => { + const fnStart = SRV.indexOf('function openTerminal('); + const fnBody = SRV.slice(fnStart, SRV.indexOf('\nfunction startDelegationWatcher')); + assert.ok(/isHeadless\(\)/.test(fnBody), 'openTerminal must check isHeadless()'); + assert.ok(/hasCommand\(term\)/.test(fnBody), 'openTerminal must check hasCommand() before spawn'); + assert.ok(/p\.on\('error'/.test(fnBody), 'openTerminal must catch spawn error event'); + }); + + check('/api/sessions/:id/open-terminal in server.js has headless guard', () => { + const epStart = SRV.indexOf("app.post('/api/sessions/:id/open-terminal'"); + const epBody = SRV.slice(epStart, SRV.indexOf("app.post('/api/sessions/:id/catch-up'")); + assert.ok(/isHeadless\(\)/.test(epBody), 'open-terminal endpoint must check isHeadless()'); + assert.ok(/hasCommand\(cmd\)/.test(epBody), 'open-terminal endpoint must check hasCommand() before spawn'); + assert.ok(/p\.on\('error'/.test(epBody), 'open-terminal endpoint must catch spawn error event'); + }); +} + if (failed) { console.log(`\n${failed} test(s) failed`); process.exit(1); } console.log('\nAll delegate-terminal tests passed');