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
99 changes: 99 additions & 0 deletions server/ws/bunTmpdir.js
Original file line number Diff line number Diff line change
@@ -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 } : {};
}
146 changes: 146 additions & 0 deletions server/ws/bunTmpdir.test.js
Original file line number Diff line number Diff line change
@@ -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 });
}
});
10 changes: 10 additions & 0 deletions server/ws/sessionManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand Down