From ad6084c2db0dd3721009c23212da18bca810d617 Mon Sep 17 00:00:00 2001 From: Nekono Nana KAKKO KARI <3267314+nananek@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:15:17 +0900 Subject: [PATCH 1/4] Fix opencode TUI startup when /tmp is mounted noexec (BUN_TMPDIR override) --- server/ws/bunTmpdir.js | 93 ++++++++++++++++++++++++++++++ server/ws/bunTmpdir.test.js | 112 ++++++++++++++++++++++++++++++++++++ server/ws/sessionManager.js | 10 ++++ 3 files changed, 215 insertions(+) create mode 100644 server/ws/bunTmpdir.js create mode 100644 server/ws/bunTmpdir.test.js diff --git a/server/ws/bunTmpdir.js b/server/ws/bunTmpdir.js new file mode 100644 index 0000000..768c995 --- /dev/null +++ b/server/ws/bunTmpdir.js @@ -0,0 +1,93 @@ +// 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 } 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 lines; + try { + lines = readFileSync('/proc/self/mounts', 'utf-8').split('\n'); + } catch { + return false; + } + return mountHasNoexec(tmpdir, 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. +export function bunTmpdirEnv() { + 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..5fdd9d2 --- /dev/null +++ b/server/ws/bunTmpdir.test.js @@ -0,0 +1,112 @@ +// 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 (an +// exec TMPDIR must yield no override; a relative or nonexistent TMPDIR must +// report not-noexec without touching /proc). + +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { 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 returns null (and bunTmpdirEnv nothing) when TMPDIR is exec', () => { + const before = process.env.TMPDIR; + const dir = mkdtempSync(join(tmpdir(), 'ccserver-bun-tmpdir-test-')); + process.env.TMPDIR = dir; + try { + assert.equal(bunTmpdirOverride(), null); + assert.deepEqual(bunTmpdirEnv(), {}); + } finally { + if (before === undefined) delete process.env.TMPDIR; + else process.env.TMPDIR = before; + 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) { From 3065fe811eb606ef0b49a358d541a35e5e0d2f6d Mon Sep 17 00:00:00 2001 From: Nekono Nana KAKKO KARI <3267314+nananek@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:24:45 +0900 Subject: [PATCH 2/4] Resolve symlinked TMPDIR before noexec check; make override test mount-aware isTmpNoexec compared TMPDIR to /proc/self/mounts mountpoints verbatim, so a symlinked TMPDIR (e.g. /tmp -> /var/tmp on a noexec mount) escaped detection and Bun kept dying. Resolve with realpathSync first -- the mount table lists real paths only. bunTmpdirOverride's test assumed the host TMPDIR is exec and asserted null unconditionally, which would fail on exactly the noexec hosts this feature targets. It now derives the expectation from the live mount table via mountHasNoexec, so it is deterministic everywhere. --- server/ws/bunTmpdir.js | 7 ++++--- server/ws/bunTmpdir.test.js | 26 +++++++++++++++++++------- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/server/ws/bunTmpdir.js b/server/ws/bunTmpdir.js index 768c995..0562ee1 100644 --- a/server/ws/bunTmpdir.js +++ b/server/ws/bunTmpdir.js @@ -8,7 +8,7 @@ // Pure helpers (parseMountOptions / mountHasNoexec) are exported for unit // testing; the wrappers read the live environment. -import { readFileSync, mkdirSync, existsSync } from 'node:fs'; +import { readFileSync, mkdirSync, existsSync, realpathSync } from 'node:fs'; import { homedir } from 'node:os'; import { join, isAbsolute } from 'node:path'; @@ -57,13 +57,14 @@ export function isTmpNoexec() { if (process.platform !== 'linux') return false; const tmpdir = process.env.TMPDIR || '/tmp'; if (!isAbsolute(tmpdir) || !existsSync(tmpdir)) return false; - let lines; + let real, lines; try { + real = realpathSync(tmpdir); lines = readFileSync('/proc/self/mounts', 'utf-8').split('\n'); } catch { return false; } - return mountHasNoexec(tmpdir, lines); + return mountHasNoexec(real, lines); } // The replacement Bun temp dir, matching upstream launcher PR #26134. diff --git a/server/ws/bunTmpdir.test.js b/server/ws/bunTmpdir.test.js index 5fdd9d2..f65a207 100644 --- a/server/ws/bunTmpdir.test.js +++ b/server/ws/bunTmpdir.test.js @@ -1,12 +1,13 @@ // 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 (an -// exec TMPDIR must yield no override; a relative or nonexistent TMPDIR must -// report not-noexec without touching /proc). +// 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 } from 'node:fs'; +import { mkdtempSync, rmSync, readFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { parseMountOptions, mountHasNoexec, isTmpNoexec, bunTmpdirOverride, bunTmpdirEnv } from './bunTmpdir.js'; @@ -97,13 +98,24 @@ test('isTmpNoexec is false for a nonexistent TMPDIR', () => { } }); -test('bunTmpdirOverride returns null (and bunTmpdirEnv nothing) when TMPDIR is exec', () => { +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 { - assert.equal(bunTmpdirOverride(), null); - assert.deepEqual(bunTmpdirEnv(), {}); + // 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). + 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 expected = mountHasNoexec(dir, lines) ? dir : null; + assert.equal(bunTmpdirOverride(), expected); + assert.deepEqual(bunTmpdirEnv(), expected ? { BUN_TMPDIR: dir } : {}); } finally { if (before === undefined) delete process.env.TMPDIR; else process.env.TMPDIR = before; From 1204d9c65b42966c082e65cbf8702f383596b924 Mon Sep 17 00:00:00 2001 From: Nekono Nana KAKKO KARI <3267314+nananek@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:29:08 +0900 Subject: [PATCH 3/4] Fix override test to expect the real BUN_TMPDIR path on noexec hosts bunTmpdirOverride returns ~/.cache/opencode/tmp, not the test's mkdtemp dir, so on exactly the noexec hosts this feature targets the mount-aware test from 3065fe8 failed. Expect the actual override path, and realpath the mkdtemp dir first so a symlinked host TMPDIR (/tmp -> /var/tmp) is compared the same way isTmpNoexec compares it. --- server/ws/bunTmpdir.test.js | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/server/ws/bunTmpdir.test.js b/server/ws/bunTmpdir.test.js index f65a207..e0d01d9 100644 --- a/server/ws/bunTmpdir.test.js +++ b/server/ws/bunTmpdir.test.js @@ -7,8 +7,8 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; -import { mkdtempSync, rmSync, readFileSync } from 'node:fs'; -import { tmpdir } from 'node:os'; +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'; @@ -106,16 +106,19 @@ test('bunTmpdirOverride matches the real mount state of the TMPDIR', () => { // 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). + // 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 expected = mountHasNoexec(dir, lines) ? dir : null; + 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: dir } : {}); + assert.deepEqual(bunTmpdirEnv(), expected ? { BUN_TMPDIR: overrideDir } : {}); } finally { if (before === undefined) delete process.env.TMPDIR; else process.env.TMPDIR = before; From 967b5f77443753c72b3b11c128b0553d64a04e0b Mon Sep 17 00:00:00 2001 From: Nekono Nana KAKKO KARI <3267314+nananek@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:34:12 +0900 Subject: [PATCH 4/4] Respect an explicit BUN_TMPDIR instead of overriding it on noexec hosts bunTmpdirEnv clobbered a user-set BUN_TMPDIR whenever the host TMPDIR was noexec. Bun prefers BUN_TMPDIR over TMPDIR, so an explicit value already sidesteps the noexec problem -- and the override could even turn a working configuration into a dead launch when HOME is not writable (the fallback mkdir fails and the launch falls back to the noexec TMPDIR). Leave an existing BUN_TMPDIR untouched. --- server/ws/bunTmpdir.js | 7 ++++++- server/ws/bunTmpdir.test.js | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/server/ws/bunTmpdir.js b/server/ws/bunTmpdir.js index 0562ee1..ac08f2e 100644 --- a/server/ws/bunTmpdir.js +++ b/server/ws/bunTmpdir.js @@ -87,8 +87,13 @@ export function bunTmpdirOverride() { } // The env fragment for pty env assembly: { BUN_TMPDIR: dir } when an override -// is needed, {} otherwise -- so callers just spread it. +// 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 index e0d01d9..b9e18d2 100644 --- a/server/ws/bunTmpdir.test.js +++ b/server/ws/bunTmpdir.test.js @@ -125,3 +125,22 @@ test('bunTmpdirOverride matches the real mount state of the TMPDIR', () => { 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 }); + } +});