Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion delegate-terminal.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
42 changes: 26 additions & 16 deletions server.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 {}
Expand Down Expand Up @@ -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)' };
}
}

Expand Down
56 changes: 56 additions & 0 deletions test/delegate-terminal.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Loading