diff --git a/server/ws/bunTmpdir.js b/server/ws/bunTmpdir.js new file mode 100644 index 0000000..ac08f2e --- /dev/null +++ b/server/ws/bunTmpdir.js @@ -0,0 +1,99 @@ +// opencode runs on the Bun runtime. Bun unpacks its embedded `libopentui.so` +// into TMPDIR (default /tmp) and dlopens it at startup, so when /tmp is +// mounted noexec the mmap(PROT_EXEC) fails and the TUI dies immediately +// (opencode issues #26136 / #27580). ccserver works around it the same way as +// the upstream launcher PR #26134: when the host TMPDIR is on a noexec mount, +// point BUN_TMPDIR at ~/.cache/opencode/tmp so Bun's unpack succeeds. +// +// Pure helpers (parseMountOptions / mountHasNoexec) are exported for unit +// testing; the wrappers read the live environment. + +import { readFileSync, mkdirSync, existsSync, realpathSync } from 'node:fs'; +import { homedir } from 'node:os'; +import { join, isAbsolute } from 'node:path'; + +// Unescape the octal escapes /proc/self/mounts uses for mountpoint +// characters that can't appear literally (space \040, tab \011, newline +// \012, backslash \134) so the mountpoint compares equal to the real path. +function unescapeMountPoint(mp) { + return mp.replace(/\\([0-7]{3})/g, (_, oct) => String.fromCharCode(parseInt(oct, 8))); +} + +// Extract (mountpoint, options) pairs from /proc/self/mounts-style lines. +// Fields are whitespace-separated: device mountpoint fstype options dump pass. +export function parseMountOptions(lines) { + const out = []; + for (const line of lines) { + const trimmed = line.trim(); + if (!trimmed) continue; + const parts = trimmed.split(/\s+/); + if (parts.length < 4) continue; + out.push({ mountpoint: unescapeMountPoint(parts[1]), options: parts[3] }); + } + return out; +} + +// Longest-prefix mount match for `path` (the kernel picks the most specific +// mount), then check whether that mount's options contain `noexec`. +export function mountHasNoexec(path, lines) { + let best = null; + for (const { mountpoint, options } of parseMountOptions(lines)) { + const prefix = mountpoint.endsWith('/') ? mountpoint : `${mountpoint}/`; + if (path === mountpoint || path.startsWith(prefix)) { + if (!best || mountpoint.length > best.mountpoint.length) { + best = { mountpoint, options }; + } + } + } + if (!best) return false; + return best.options.split(',').includes('noexec'); +} + +// true only on Linux when the resolved TMPDIR (default /tmp) sits on a +// noexec mount. Relative or nonexistent TMPDIRs report false -- that is the +// historical behavior (no override), and a path Bun itself can't use is not +// one to mirror into BUN_TMPDIR. +export function isTmpNoexec() { + if (process.platform !== 'linux') return false; + const tmpdir = process.env.TMPDIR || '/tmp'; + if (!isAbsolute(tmpdir) || !existsSync(tmpdir)) return false; + let real, lines; + try { + real = realpathSync(tmpdir); + lines = readFileSync('/proc/self/mounts', 'utf-8').split('\n'); + } catch { + return false; + } + return mountHasNoexec(real, lines); +} + +// The replacement Bun temp dir, matching upstream launcher PR #26134. +function bunTmpDir() { + return join(homedir(), '.cache', 'opencode', 'tmp'); +} + +// Returns the BUN_TMPDIR value when the host TMPDIR is noexec (creating the +// directory), or null otherwise -- the caller then launches exactly as +// before. mkdir failure quietly falls back to the historical /tmp launch. +export function bunTmpdirOverride() { + if (!isTmpNoexec()) return null; + const dir = bunTmpDir(); + try { + mkdirSync(dir, { recursive: true }); + } catch { + return null; + } + return dir; +} + +// The env fragment for pty env assembly: { BUN_TMPDIR: dir } when an override +// is needed, {} otherwise -- so callers just spread it. An explicit BUN_TMPDIR +// from the environment is left alone: Bun uses it over TMPDIR, so the noexec +// TMPDIR problem does not apply there, and overriding it could turn a working +// user configuration into a dead launch (e.g. when HOME is not writable and +// the fallback mkdir fails). +export function bunTmpdirEnv() { + if (process.env.BUN_TMPDIR) return {}; + const dir = bunTmpdirOverride(); + return dir ? { BUN_TMPDIR: dir } : {}; +} diff --git a/server/ws/bunTmpdir.test.js b/server/ws/bunTmpdir.test.js new file mode 100644 index 0000000..b9e18d2 --- /dev/null +++ b/server/ws/bunTmpdir.test.js @@ -0,0 +1,146 @@ +// Unit tests for the noexec-TMPDIR -> BUN_TMPDIR switch (see bunTmpdir.js). +// The pure helpers are tested with synthetic /proc/self/mounts lines; the +// env-reading wrappers only get cases that are deterministic everywhere (a +// relative or nonexistent TMPDIR must report not-noexec without touching +// /proc, and the override must match the real mount state of the test +// TMPDIR). + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync, readFileSync, realpathSync } from 'node:fs'; +import { homedir, tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { parseMountOptions, mountHasNoexec, isTmpNoexec, bunTmpdirOverride, bunTmpdirEnv } from './bunTmpdir.js'; + +test('parseMountOptions extracts mountpoint and options from real-style lines', () => { + const lines = [ + 'sysfs /sys sysfs rw,nosuid,nodev,noexec,relatime 0 0', + 'proc /proc proc rw,nosuid,nodev,noexec,relatime 0 0', + 'tmpfs /tmp tmpfs rw,nosuid,nodev,inode64 0 0', + '/dev/sda1 / rw,relatime 0 0', + ]; + const mounts = parseMountOptions(lines); + assert.equal(mounts.length, 4); + assert.deepEqual(mounts[0], { mountpoint: '/sys', options: 'rw,nosuid,nodev,noexec,relatime' }); + assert.deepEqual(mounts[2], { mountpoint: '/tmp', options: 'rw,nosuid,nodev,inode64' }); +}); + +test('parseMountOptions unescapes octal escapes in mountpoints', () => { + const mounts = parseMountOptions(['/dev/sda1 /tmp/My\\040Folder ext4 rw,noexec 0 0']); + assert.equal(mounts[0].mountpoint, '/tmp/My Folder'); + assert.equal(mounts[0].options, 'rw,noexec'); +}); + +test('parseMountOptions skips blank and malformed lines', () => { + const mounts = parseMountOptions(['', ' ', 'garbage', 'overlay /mnt overlay rw 0 0']); + assert.equal(mounts.length, 1); + assert.deepEqual(mounts[0], { mountpoint: '/mnt', options: 'rw' }); +}); + +test('mountHasNoexec detects noexec on the matched mount', () => { + const lines = [ + '/dev/sda1 / rw,relatime 0 0', + 'tmpfs /tmp tmpfs rw,nosuid,nodev,noexec,relatime,inode64 0 0', + ]; + assert.equal(mountHasNoexec('/tmp', lines), true); + assert.equal(mountHasNoexec('/tmp/foo/bar', lines), true); + assert.equal(mountHasNoexec('/', lines), false); + assert.equal(mountHasNoexec('/usr', lines), false); +}); + +test('mountHasNoexec tolerates option order and surrounding options', () => { + for (const options of ['noexec,rw', 'rw,noexec,relatime', 'rw,relatime,noexec']) { + assert.equal(mountHasNoexec('/tmp', [`tmpfs /tmp tmpfs ${options} 0 0`]), true, `options: ${options}`); + } + assert.equal(mountHasNoexec('/tmp', ['tmpfs /tmp tmpfs rw,nosuid,nodev 0 0']), false); +}); + +test('mountHasNoexec uses the longest-prefix mount, not the first match', () => { + const lines = [ + '/dev/sda1 / rw,relatime 0 0', + 'tmpfs /tmp tmpfs rw,nosuid,nodev,noexec,relatime 0 0', + '/dev/sdb1 /tmp/foo ext4 rw,nosuid 0 0', + ]; + // /tmp/foo is the longest match for paths under it and is not noexec. + assert.equal(mountHasNoexec('/tmp/foo', lines), false); + assert.equal(mountHasNoexec('/tmp/foo/x', lines), false); + // Everything else under /tmp falls back to the noexec /tmp mount. + assert.equal(mountHasNoexec('/tmp', lines), true); + assert.equal(mountHasNoexec('/tmp/bar', lines), true); + // A mountpoint with a trailing slash matches like any other. + assert.equal(mountHasNoexec('/tmp/foo', ['tmpfs /tmp/ tmpfs rw,noexec 0 0']), true); +}); + +test('mountHasNoexec returns false for unknown paths', () => { + assert.equal(mountHasNoexec('/nonexistent', ['/dev/sda1 / rw,relatime 0 0']), false); + assert.equal(mountHasNoexec('/tmpfoo', ['tmpfs /tmp tmpfs rw,noexec 0 0']), false); +}); + +test('isTmpNoexec is false for a relative TMPDIR', () => { + const before = process.env.TMPDIR; + process.env.TMPDIR = 'relative/tmp'; + try { + assert.equal(isTmpNoexec(), false); + } finally { + if (before === undefined) delete process.env.TMPDIR; + else process.env.TMPDIR = before; + } +}); + +test('isTmpNoexec is false for a nonexistent TMPDIR', () => { + const before = process.env.TMPDIR; + process.env.TMPDIR = join(tmpdir(), 'ccserver-no-such-tmpdir'); + try { + assert.equal(isTmpNoexec(), false); + } finally { + if (before === undefined) delete process.env.TMPDIR; + else process.env.TMPDIR = before; + } +}); + +test('bunTmpdirOverride matches the real mount state of the TMPDIR', () => { + const before = process.env.TMPDIR; + const dir = mkdtempSync(join(tmpdir(), 'ccserver-bun-tmpdir-test-')); + process.env.TMPDIR = dir; + try { + // The mkdtemp dir sits on whatever mount the host TMPDIR does, so the + // expected outcome is computed from the live mount table instead of + // assuming exec (which would fail exactly on the noexec hosts this + // feature targets). The dir is realpath-resolved like isTmpNoexec does: + // the mount table lists real paths only, so a symlinked host TMPDIR + // (e.g. /tmp -> /var/tmp) must not break the comparison. + let lines = []; + try { + lines = readFileSync('/proc/self/mounts', 'utf-8').split('\n'); + } catch { + // Non-linux or unreadable: no mount info, so no override is expected. + } + const overrideDir = join(homedir(), '.cache', 'opencode', 'tmp'); + const expected = mountHasNoexec(realpathSync(dir), lines) ? overrideDir : null; + assert.equal(bunTmpdirOverride(), expected); + assert.deepEqual(bunTmpdirEnv(), expected ? { BUN_TMPDIR: overrideDir } : {}); + } finally { + if (before === undefined) delete process.env.TMPDIR; + else process.env.TMPDIR = before; + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('bunTmpdirEnv leaves an explicit BUN_TMPDIR alone', () => { + const beforeTmp = process.env.TMPDIR; + const beforeBun = process.env.BUN_TMPDIR; + const dir = mkdtempSync(join(tmpdir(), 'ccserver-bun-tmpdir-test-')); + process.env.TMPDIR = dir; + process.env.BUN_TMPDIR = join(dir, 'user-choice'); + try { + // Bun prefers BUN_TMPDIR over TMPDIR, so a user-set value makes the + // noexec check moot -- whatever the mount state, it must be preserved. + assert.deepEqual(bunTmpdirEnv(), {}); + } finally { + if (beforeTmp === undefined) delete process.env.TMPDIR; + else process.env.TMPDIR = beforeTmp; + if (beforeBun === undefined) delete process.env.BUN_TMPDIR; + else process.env.BUN_TMPDIR = beforeBun; + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/server/ws/sessionManager.js b/server/ws/sessionManager.js index 82993aa..b315b9c 100644 --- a/server/ws/sessionManager.js +++ b/server/ws/sessionManager.js @@ -7,6 +7,7 @@ import { fileURLToPath } from 'node:url'; import { buildSandboxSpawn, resolveApp, sandboxAvailable, loadSandboxConfig } from './sandbox.js'; import { buildMcpConfigArgsAndEnv } from './mcpConfig.js'; import { shouldInjectNotify, notifyEnabled, getNotifySockPath, notifyBrokerRunning } from './notify.js'; +import { bunTmpdirEnv } from './bunTmpdir.js'; import { isValidApp, appResumeArgs, @@ -263,6 +264,15 @@ export function createSession({ cwd, cols, rows, claudeSessionId, shell, sandbox // scrolls natively, and its own drag-selection + copy-on-select writes // to the browser clipboard via OSC 52 (handled client-side). ...mcpEnv, + // /tmp being mounted noexec makes Bun fail to unpack + dlopen its + // embedded libopentui.so, so opencode's TUI dies at startup (opencode + // #26136/#27580). Direct host launches switch BUN_TMPDIR to + // ~/.cache/opencode/tmp when the host TMPDIR is noexec. Sandboxed + // launches don't: the sandbox's /tmp is a fresh tmpfs that is always + // executable, and the host-side cache dir is not bound into bwrap (with + // a fresh HOME it would not even exist), so setting it there would + // break what it is meant to fix. + ...(shell || sessionApp !== 'opencode' || useSandbox ? {} : bunTmpdirEnv()), }, }); } catch (err) {