From d5a518743d60d8c7a7bdf2880d88f625fa301173 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:22:08 -0700 Subject: [PATCH 1/7] Isolate public OpenCode provider egress on Linux Co-authored-by: kai Agent-Model: codex/gpt-6-sol/medium Agent-Model: codex/gpt-6.1-sol/medium --- cli/__tests__/adapters.opencode.test.mjs | 28 +++-- cli/__tests__/bwrap.test.mjs | 17 ++++ cli/__tests__/opencode.bwrap-egress.test.mjs | 102 +++++++++++++++++++ cli/src/lib/adapters/opencode.js | 24 +++-- cli/src/lib/provider-proxy.js | 30 ++++-- cli/src/lib/sandbox/bwrap.js | 20 +++- cli/src/lib/sandbox/opencode-loopback.mjs | 55 ++++++++++ docs/agents/LOCAL_CLI_WRAPPER.md | 9 ++ 8 files changed, 262 insertions(+), 23 deletions(-) create mode 100644 cli/__tests__/opencode.bwrap-egress.test.mjs create mode 100644 cli/src/lib/sandbox/opencode-loopback.mjs diff --git a/cli/__tests__/adapters.opencode.test.mjs b/cli/__tests__/adapters.opencode.test.mjs index d1fd43ff8c..f5c60ea692 100644 --- a/cli/__tests__/adapters.opencode.test.mjs +++ b/cli/__tests__/adapters.opencode.test.mjs @@ -957,12 +957,18 @@ describe('opencode external provider config', () => { _platform: 'linux', _detectBwrap: () => ({ available: true, path: '/usr/bin/bwrap' }), _spawnImpl: spawn.impl, - _wrapArgvWithBwrap: (argv, _environment, opts) => { + _wrapArgvWithBwrap: (argv, wrappedEnvironment, opts) => { + expect(wrappedEnvironment.sandbox.network.policy).toBe('restricted'); expect(opts.readOnlyPaths).toEqual([ expect.stringMatching(/commonly-opencode-/), '/usr/local/bin/opencode', process.execPath, + expect.stringMatching(/opencode-loopback\.mjs$/), ]); + expect(opts.optionalReadOnlyPaths).toEqual(['/usr/sbin', '/sbin']); + expect(opts.enableLoopback).toBe(true); + expect(argv.slice(0, 2)).toEqual([process.execPath, expect.stringMatching(/opencode-loopback\.mjs$/)]); + expect(argv[2]).toMatch(/provider-proxy\.sock$/); expect(opts.readOnlyPaths).not.toContain(realKey); expect(opts.readOnlyPaths).not.toContain(realAuthPath); return ['/usr/bin/bwrap', '--test', ...argv]; @@ -971,17 +977,19 @@ describe('opencode external provider config', () => { }); }); - test('refuses a public bwrap provider when restricted networking cannot reach the loopback proxy', async () => { + test('public bwrap uses its isolated proxy even when restricted networking is requested', async () => { await withTemp(async (root) => { const workspace = join(root, 'workspace'); + const keyFile = join(root, 'provider-key'); await mkdir(workspace, { recursive: true }); + await writeFile(keyFile, 'provider-key', { mode: 0o600 }); const spawn = makeSpawnImpl(); - await expect(opencode.spawn('public provider turn', { + await opencode.spawn('public provider turn', { cwd: workspace, env: { PATH: process.env.PATH }, environment: { model: 'gpt-5.4', - provider: { id: 'litellm', baseURL: 'https://llm.example.test/v1', keyFile: '/unused/provider-key' }, + provider: { id: 'litellm', baseURL: 'https://llm.example.test/v1', keyFile }, sandbox: { trust: 'public', mode: 'bwrap', network: { policy: 'restricted' }, }, @@ -991,8 +999,14 @@ describe('opencode external provider config', () => { _platform: 'linux', _detectBwrap: () => ({ available: true, path: '/usr/bin/bwrap' }), _spawnImpl: spawn.impl, - })).rejects.toThrow(/restricted cannot reach the loopback proxy/); - expect(spawn.calls).toHaveLength(0); + _wrapArgvWithBwrap: (argv, environment, opts) => { + expect(environment.sandbox.network.policy).toBe('restricted'); + expect(opts.enableLoopback).toBe(true); + expect(argv[2]).toMatch(/provider-proxy\.sock$/); + return ['/usr/bin/bwrap', '--test', ...argv]; + }, + }); + expect(spawn.calls).toHaveLength(1); }); }); @@ -1554,7 +1568,9 @@ describe('opencode adapter — spawn()', () => { expect(opts.workspacePath).toBe(workspace); expect(opts.readOnlyPaths).toEqual([ expect.any(String), '/usr/local/bin/opencode', process.execPath, + expect.stringMatching(/opencode-loopback\.mjs$/), ]); + expect(opts.optionalReadOnlyPaths).toEqual(['/usr/sbin', '/sbin']); expect(opts.readOnlyPaths).not.toContain(keyFile); expect(opts.readOnlyPaths).not.toContain(join(xdgData, 'opencode', 'auth.json')); expect(opts).not.toHaveProperty('loopbackNetworkPorts'); diff --git a/cli/__tests__/bwrap.test.mjs b/cli/__tests__/bwrap.test.mjs index 3474168b5d..52f28dd9f9 100644 --- a/cli/__tests__/bwrap.test.mjs +++ b/cli/__tests__/bwrap.test.mjs @@ -120,6 +120,23 @@ describe('wrapArgvWithBwrap', () => { expect(argv).not.toContain('--share-net'); }); + itLinux('allows loopback setup only inside an isolated network namespace', () => { + const argv = wrapArgvWithBwrap( + ['node', 'bridge.mjs'], + { sandbox: { network: { policy: 'restricted' } } }, + { workspacePath: '/tmp/ws', enableLoopback: true }, + ); + expect(argv).toContain('--unshare-net'); + expect(argv).not.toContain('--share-net'); + expect(argv.slice(argv.indexOf('--uid'), argv.indexOf('--uid') + 4)) + .toEqual(['--uid', '0', '--gid', '0']); + expect(argv.slice(argv.indexOf('--cap-add'), argv.indexOf('--cap-add') + 4)) + .toEqual(['--cap-add', 'CAP_NET_ADMIN', '--cap-add', 'CAP_SETPCAP']); + expect(() => wrapArgvWithBwrap( + ['node', 'bridge.mjs'], {}, { workspacePath: '/tmp/ws', enableLoopback: true }, + )).toThrow(/requires an isolated network namespace/); + }); + itLinux('appends user read-outside paths as --ro-bind-try', () => { const argv = wrapArgvWithBwrap( ['claude'], diff --git a/cli/__tests__/opencode.bwrap-egress.test.mjs b/cli/__tests__/opencode.bwrap-egress.test.mjs new file mode 100644 index 0000000000..c17c124adf --- /dev/null +++ b/cli/__tests__/opencode.bwrap-egress.test.mjs @@ -0,0 +1,102 @@ +import { spawn, spawnSync } from 'child_process'; +import { createServer } from 'http'; +import { chmod, mkdir, mkdtemp, rm, stat, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { fileURLToPath } from 'url'; +import { detectBwrap, wrapArgvWithBwrap } from '../src/lib/sandbox/bwrap.js'; +import { startProviderProxy } from '../src/lib/provider-proxy.js'; + +const LOOPBACK_BRIDGE = fileURLToPath(new URL('../src/lib/sandbox/opencode-loopback.mjs', import.meta.url)); +const PORT = 8765; +const run = (cmd, args) => new Promise((resolve, reject) => { + const child = spawn(cmd, args, { timeout: 15_000 }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.on('error', reject); + child.on('close', (code) => resolve({ code, stdout, stderr })); +}); +const listen = (server) => new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => resolve(server.address().port)); +}); +const close = (server) => new Promise((resolve) => server.close(resolve)); + +test('public bwrap reaches its provider proxy but refuses other host and internet ports', async () => { + if (process.platform !== 'linux' || !detectBwrap().available + || spawnSync('ip', ['-V']).status !== 0) return; + + const root = await mkdtemp(join(tmpdir(), 'commonly-egress-')); + const privateRoot = await mkdtemp(join(tmpdir(), 'commonly-egress-key-')); + const workspace = join(root, 'workspace'); + const proxyDir = join(root, 'proxy'); + const keyFile = join(privateRoot, 'key'); + const tokenFile = join(proxyDir, 'token'); + const socketPath = join(proxyDir, 'proxy.sock'); + const childFile = join(workspace, 'probe.mjs'); + await Promise.all([mkdir(workspace), mkdir(proxyDir)]); + await writeFile(keyFile, 'upstream-key', { mode: 0o600 }); + await chmod(keyFile, 0o600); + const upstream = createServer((req, res) => { + if (req.url !== '/v1/models' || req.headers.authorization !== 'Bearer upstream-key') { + res.writeHead(403).end(); + } else res.writeHead(200).end('models'); + }); + const unrelated = createServer((_req, res) => res.writeHead(200).end('wrong-port')); + let proxy; + try { + const upstreamPort = await listen(upstream); + const unrelatedPort = await listen(unrelated); + proxy = await startProviderProxy({ + provider: { baseURL: `http://127.0.0.1:${upstreamPort}/v1` }, + keyFile, tokenFile, socketPath, isolatedPort: PORT, + }); + expect((await stat(socketPath)).mode & 0o777).toBe(0o600); + await writeFile(childFile, ` + import { readFileSync } from 'fs'; + import { get } from 'http'; + import { connect } from 'net'; + import { spawnSync } from 'child_process'; + const token = readFileSync(process.argv[2], 'utf8'); + const request = (port, path, headers = {}) => new Promise((resolve) => { + const req = get({ hostname: '127.0.0.1', port, path, headers, timeout: 1000 }, (res) => { + res.resume(); res.on('end', () => resolve(res.statusCode)); + }); + req.on('timeout', () => req.destroy()); + req.on('error', () => resolve(0)); + }); + const internet = await new Promise((resolve) => { + const conn = connect({ host: '8.8.8.8', port: 53, timeout: 1000 }); + conn.on('connect', () => { conn.destroy(); resolve(false); }); + conn.on('timeout', () => { conn.destroy(); resolve(true); }); + conn.on('error', () => resolve(true)); + }); + const allowed = await request(${PORT}, '/v1/models', { authorization: 'Bearer ' + token }); + const otherHostPort = await request(${unrelatedPort}, '/'); + const netAdminDropped = spawnSync('ip', ['link', 'set', 'lo', 'down']).status !== 0; + const capabilitiesDropped = /^CapEff:\\s*0+$/m.test(readFileSync('/proc/self/status', 'utf8')); + process.stdout.write(JSON.stringify({ allowed, otherHostPort, internet, netAdminDropped, capabilitiesDropped })); + `); + const argv = wrapArgvWithBwrap([ + process.execPath, LOOPBACK_BRIDGE, socketPath, String(PORT), + process.execPath, childFile, tokenFile, + ], { sandbox: { network: { policy: 'restricted' } } }, { + workspacePath: workspace, + readOnlyPaths: [proxyDir, process.execPath, LOOPBACK_BRIDGE], + optionalReadOnlyPaths: ['/usr/sbin', '/sbin'], + enableLoopback: true, + }); + const result = await run(argv[0], argv.slice(1)); + expect(result).toMatchObject({ code: 0, stderr: '' }); + expect(JSON.parse(result.stdout)).toEqual({ + allowed: 200, otherHostPort: 0, internet: true, + netAdminDropped: true, capabilitiesDropped: true, + }); + } finally { + await proxy?.close(); + await Promise.all([close(upstream), close(unrelated)]); + await rm(root, { recursive: true, force: true }); + await rm(privateRoot, { recursive: true, force: true }); + } +}, 25_000); diff --git a/cli/src/lib/adapters/opencode.js b/cli/src/lib/adapters/opencode.js index 8776761de4..a1711c26ec 100644 --- a/cli/src/lib/adapters/opencode.js +++ b/cli/src/lib/adapters/opencode.js @@ -9,8 +9,8 @@ * stay in the host adapter and reach the configured provider through a * per-spawn loopback proxy; OpenCode receives only that proxy's bearer. * This protects the key, not provider spend: the model can use its seat's key - * budget, and bwrap's existing shared networking is not a per-port egress - * fence. + * budget. On Linux the process has its own network namespace, with a single + * loopback bridge to the host proxy's Unix socket. * Config is generated per spawn. OpenCode merges config sources, therefore the * global config directory is isolated and project config is explicitly off; * the environment spec is the sole source of MCP servers for this run. @@ -34,6 +34,7 @@ import { writeFile, } from 'fs/promises'; import { homedir, tmpdir } from 'os'; +import { fileURLToPath } from 'url'; import { dirname, join, resolve as pathResolve, } from 'path'; @@ -55,6 +56,8 @@ import { isShippedCommonlyMcpCommand } from '../declared-mcp-guard.js'; import { isAnswerableToolError } from '../public-tool-error.js'; const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000; +const ISOLATED_PROXY_PORT = 8765; +const LOOPBACK_BRIDGE = fileURLToPath(new URL('../sandbox/opencode-loopback.mjs', import.meta.url)); const TERMINATION_GRACE_MS = 5000; // Bump only with a passing real sandbox-exec smoke against the new version. const TESTED_OPENCODE_VERSIONS = Object.freeze(['1.18.35', '1.19.0']); @@ -658,10 +661,6 @@ export default { if (isPublic && !provider) { throw new Error('public OpenCode seats require environment.provider; the implicit public tier and operator auth.json are unsupported for public trust'); } - if (isPublic && sandboxMode === 'bwrap' && provider - && sandbox?.network?.policy === 'restricted') { - throw new Error('public OpenCode provider proxy requires shared bwrap networking; sandbox.network.policy=restricted cannot reach the loopback proxy'); - } const providerKeyFile = await validateProviderEnvironment(ctx.environment, ctx.cwd); const sourceEnv = ctx.env || process.env; @@ -736,6 +735,10 @@ export default { provider, keyFile: providerKeyFile, tokenFile: join(tempDir, 'provider-proxy-token'), + ...(sandboxMode === 'bwrap' ? { + socketPath: join(tempDir, 'provider-proxy.sock'), + isolatedPort: ISOLATED_PROXY_PORT, + } : {}), }); } const stateHome = isPublic ? join(openCodeHome.root, 'state') : null; @@ -805,10 +808,14 @@ export default { } const bwrap = (ctx._detectBwrap || detectBwrap)(); if (!bwrap.available) throw new Error(`public OpenCode agents require bwrap: ${bwrap.error}`); - const wrapped = (ctx._wrapArgvWithBwrap || wrapArgvWithBwrap)([binary, ...args], { + const wrapped = (ctx._wrapArgvWithBwrap || wrapArgvWithBwrap)([ + process.execPath, LOOPBACK_BRIDGE, + providerProxy.socketPath, String(ISOLATED_PROXY_PORT), binary, ...args, + ], { ...ctx.environment, sandbox: { ...sandbox, + network: { ...sandbox?.network, policy: 'restricted' }, filesystem: { ...(sandbox?.filesystem || {}), 'write-outside': [ @@ -824,8 +831,11 @@ export default { tempDir, binary, process.execPath, + LOOPBACK_BRIDGE, ...mcpReadDirectories, ], + optionalReadOnlyPaths: ['/usr/sbin', '/sbin'], + enableLoopback: true, }); [cmd, ...spawnArgs] = wrapped; } else if (isPublic) { diff --git a/cli/src/lib/provider-proxy.js b/cli/src/lib/provider-proxy.js index 7cf05f48c2..9101fcb780 100644 --- a/cli/src/lib/provider-proxy.js +++ b/cli/src/lib/provider-proxy.js @@ -3,7 +3,7 @@ import { randomBytes, timingSafeEqual } from 'crypto'; import { constants as fsConstants, realpathSync } from 'fs'; import { createServer, request as httpRequest } from 'http'; import { request as httpsRequest } from 'https'; -import { chmod, lstat, open, writeFile } from 'fs/promises'; +import { chmod, lstat, open, unlink, writeFile } from 'fs/promises'; import { isAbsolute, relative, sep } from 'path'; const isPathWithin = (parent, candidate) => { @@ -68,9 +68,13 @@ const readProviderKey = async (keyFile, adapterLabel) => { }; export const startProviderProxy = async ({ - provider, keyFile, tokenFile, adapterLabel = 'OpenCode', + provider, keyFile, tokenFile, socketPath, isolatedPort, adapterLabel = 'OpenCode', }) => { const upstream = new URL(provider.baseURL); + if (socketPath && (!isAbsolute(socketPath) + || !Number.isInteger(isolatedPort) || isolatedPort < 1 || isolatedPort > 65535)) { + throw new Error(`${adapterLabel} isolated proxy requires an absolute socket path and valid port`); + } if (!['http:', 'https:'].includes(upstream.protocol) || upstream.username || upstream.password || upstream.search || upstream.hash) { throw new Error(`${adapterLabel} provider.baseURL must be an http(s) URL without embedded credentials or query data`); @@ -204,19 +208,33 @@ export const startProviderProxy = async ({ await new Promise((resolve, reject) => { server.once('error', reject); - server.listen(0, '127.0.0.1', () => { + const started = () => { server.removeListener('error', reject); resolve(); - }); + }; + if (socketPath) server.listen(socketPath, started); + else server.listen(0, '127.0.0.1', started); }); - const { port } = server.address(); + if (socketPath) { + try { + await chmod(socketPath, 0o600); + } catch (err) { + await new Promise((resolve) => server.close(resolve)); + throw err; + } + } + const port = socketPath ? isolatedPort : server.address().port; const proxyBaseURL = new URL(upstream.pathname, `http://127.0.0.1:${port}`).toString(); return { baseURL: proxyBaseURL, tokenFile, + socketPath, port, close: () => new Promise((resolve) => { - server.close(() => resolve()); + server.close(async () => { + if (socketPath) await unlink(socketPath).catch(() => {}); + resolve(); + }); server.closeAllConnections?.(); }), }; diff --git a/cli/src/lib/sandbox/bwrap.js b/cli/src/lib/sandbox/bwrap.js index b83bf157a8..1a967d9c32 100644 --- a/cli/src/lib/sandbox/bwrap.js +++ b/cli/src/lib/sandbox/bwrap.js @@ -86,10 +86,9 @@ export const detectBwrap = ({ spawn = spawnSync, platform = process.platform } = * * Network policy honesty (ADR-008 §invariant #4 informs this): * - unrestricted → --share-net (host networking inside the namespace) - * - restricted → --unshare-net (NO network at all). Host-allowlist - * filtering needs an outbound proxy or per-host TLS interception, neither - * of which is in Phase 1 scope. Attach-time emits a loud warning so the - * user sees the gap before they think they're protected. + * - restricted → --unshare-net (no external network). OpenCode may opt + * into a private loopback bridge to its per-spawn Unix-socket proxy. + * Host-allowlist filtering is not supplied by bwrap itself. * * The expansion order matches `bwrap`'s left-to-right argument application: * isolation flags first, then binds (later binds win on collision), then the @@ -112,10 +111,17 @@ export const wrapArgvWithBwrap = (innerArgv, env, opts = {}) => { const networkFlags = policy === 'restricted' ? ['--unshare-net'] : ['--share-net']; + if (opts.enableLoopback && policy !== 'restricted') { + throw new Error('bwrap loopback bridge requires an isolated network namespace'); + } const flags = [ '--unshare-all', ...networkFlags, + ...(opts.enableLoopback ? [ + '--uid', '0', '--gid', '0', + '--cap-add', 'CAP_NET_ADMIN', '--cap-add', 'CAP_SETPCAP', + ] : []), '--die-with-parent', '--new-session', '--proc', '/proc', @@ -148,6 +154,12 @@ export const wrapArgvWithBwrap = (innerArgv, env, opts = {}) => { } flags.push('--ro-bind', p, p); } + for (const p of opts.optionalReadOnlyPaths || []) { + if (!p || !isAbsolute(p)) { + throw new Error('wrapArgvWithBwrap opts.optionalReadOnlyPaths entries must be absolute paths'); + } + flags.push('--ro-bind-try', p, p); + } flags.push('--bind', opts.workspacePath, opts.workspacePath); flags.push('--chdir', opts.workspacePath); diff --git a/cli/src/lib/sandbox/opencode-loopback.mjs b/cli/src/lib/sandbox/opencode-loopback.mjs new file mode 100644 index 0000000000..98cf5555bc --- /dev/null +++ b/cli/src/lib/sandbox/opencode-loopback.mjs @@ -0,0 +1,55 @@ +/** + * Runs inside OpenCode's private bwrap network namespace. The only route to + * the host is the one Unix socket bound into this namespace for this spawn. + */ +import { spawn, spawnSync } from 'child_process'; +import { existsSync } from 'fs'; +import { createServer, connect } from 'net'; + +const [socketPath, portText, binary, ...args] = process.argv.slice(2); +const port = Number(portText); +if (!socketPath || !binary || !Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error('OpenCode loopback bridge requires socket, port, and executable'); +} + +const ip = ['/usr/sbin/ip', '/sbin/ip', '/usr/bin/ip', '/bin/ip'].find(existsSync); +if (!ip) throw new Error('OpenCode isolated loopback requires iproute2'); +const up = spawnSync(ip, ['link', 'set', 'lo', 'up'], { encoding: 'utf8' }); +if (up.error || up.status !== 0) { + throw new Error(`OpenCode isolated loopback could not start: ${up.error?.message || up.stderr?.trim() || up.status}`); +} +const setpriv = ['/usr/bin/setpriv', '/bin/setpriv'].find(existsSync); +if (!setpriv) throw new Error('OpenCode isolated loopback requires setpriv'); + +const sockets = new Set(); +const bridge = createServer((local) => { + const proxy = connect(socketPath); + sockets.add(local); + sockets.add(proxy); + local.on('close', () => sockets.delete(local)); + proxy.on('close', () => sockets.delete(proxy)); + local.on('error', () => proxy.destroy()); + proxy.on('error', () => local.destroy()); + local.pipe(proxy).pipe(local); +}); +bridge.on('error', (err) => { throw err; }); +bridge.listen(port, '127.0.0.1', () => { + // Only this bridge needs CAP_NET_ADMIN to raise lo and CAP_SETPCAP to drop + // both capabilities before starting the model process. + const child = spawn(setpriv, [ + '--bounding-set=-net_admin,-setpcap', + '--inh-caps=-net_admin,-setpcap', + '--ambient-caps=-net_admin,-setpcap', + binary, ...args, + ], { stdio: 'inherit', env: process.env }); + const forward = (signal) => { if (!child.killed) child.kill(signal); }; + process.on('SIGTERM', () => forward('SIGTERM')); + process.on('SIGINT', () => forward('SIGINT')); + child.on('error', (err) => { + process.stderr.write(`${err.message}\n`); + }); + child.on('close', (code, signal) => { + for (const socket of sockets) socket.destroy(); + bridge.close(() => { process.exitCode = code ?? (signal === 'SIGTERM' ? 143 : 1); }); + }); +}); diff --git a/docs/agents/LOCAL_CLI_WRAPPER.md b/docs/agents/LOCAL_CLI_WRAPPER.md index ae0c5c12c1..2c3593d6e2 100644 --- a/docs/agents/LOCAL_CLI_WRAPPER.md +++ b/docs/agents/LOCAL_CLI_WRAPPER.md @@ -215,6 +215,15 @@ If you want to purge an agent's identity entirely (admin-only), contact a Common See [ADR-005 §Adapter pattern](../adr/ADR-005-local-cli-wrapper-driver.md) for how to add a new one. +Public OpenCode seats on Linux require `bwrap`, `iproute2` (`ip`), and +`setpriv` (util-linux). The adapter starts a private network namespace. A +short-lived bridge brings up only its loopback interface and forwards its +provider port to the host's per-spawn Unix-socket proxy. The OpenCode child +starts after the bridge drops its namespace capabilities. Other host ports and +internet addresses are unreachable; missing isolation tools stop the turn. +Run `cli/__tests__/opencode.bwrap-egress.test.mjs` on a Linux host that permits +bubblewrap namespaces to check the socket path and refused connections. + --- ## Memory bridge From 8b6dd0741cd4e10d90d2655a96e0b7b965fecb31 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:33:26 -0700 Subject: [PATCH 2/7] Preserve Commonly MCP through isolated OpenCode egress Co-authored-by: kai Agent-Model: codex/gpt-6-sol/medium Agent-Model: codex/gpt-6.1-sol/medium --- .github/workflows/opencode-bwrap-egress.yml | 38 +++++++++ cli/__tests__/fixtures/opencode-mcp-probe.mjs | 66 ++++++++++++++++ cli/__tests__/opencode.bwrap-egress.test.mjs | 25 +++++- cli/src/lib/adapters/opencode.js | 20 ++++- cli/src/lib/instance-proxy.js | 55 +++++++++++++ cli/src/lib/sandbox/opencode-loopback.mjs | 65 +++++++++------ docs/agents/LOCAL_CLI_WRAPPER.md | 2 +- scripts/verify-opencode-bwrap-mcp.mjs | 79 +++++++++++++++++++ 8 files changed, 316 insertions(+), 34 deletions(-) create mode 100644 .github/workflows/opencode-bwrap-egress.yml create mode 100644 cli/__tests__/fixtures/opencode-mcp-probe.mjs create mode 100644 cli/src/lib/instance-proxy.js create mode 100644 scripts/verify-opencode-bwrap-mcp.mjs diff --git a/.github/workflows/opencode-bwrap-egress.yml b/.github/workflows/opencode-bwrap-egress.yml new file mode 100644 index 0000000000..3658338c05 --- /dev/null +++ b/.github/workflows/opencode-bwrap-egress.yml @@ -0,0 +1,38 @@ +name: OpenCode bwrap egress + +on: + pull_request: + paths: + - 'cli/src/lib/adapters/opencode.js' + - 'cli/src/lib/provider-proxy.js' + - 'cli/src/lib/instance-proxy.js' + - 'cli/src/lib/sandbox/opencode-loopback.mjs' + - 'cli/__tests__/opencode.bwrap-egress.test.mjs' + - 'cli/__tests__/fixtures/opencode-mcp-probe.mjs' + - 'scripts/verify-opencode-bwrap-mcp.mjs' + - '.github/workflows/opencode-bwrap-egress.yml' + +permissions: + contents: read + +jobs: + isolated-egress: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Exercise provider and Commonly MCP in a disposable namespace + run: | + docker run --rm --privileged \ + --mount "type=bind,src=$PWD,dst=/source,readonly" node:22 bash -lc ' + set -euo pipefail + apt-get update -qq + apt-get install -y -qq bubblewrap iproute2 >/dev/null + mkdir -p /work + cp -R /source/cli /source/commonly-mcp /source/scripts /work/ + rm -rf /work/cli/node_modules /work/commonly-mcp/node_modules + npm ci --prefix /work/cli --silent + npm ci --prefix /work/commonly-mcp --silent + chown -R node:node /work + su node -s /bin/bash -c "cd /work/cli && node --experimental-vm-modules node_modules/.bin/jest __tests__/opencode.bwrap-egress.test.mjs --runInBand" + su node -s /bin/bash -c "node /work/scripts/verify-opencode-bwrap-mcp.mjs" + ' diff --git a/cli/__tests__/fixtures/opencode-mcp-probe.mjs b/cli/__tests__/fixtures/opencode-mcp-probe.mjs new file mode 100644 index 0000000000..787f4c1fbc --- /dev/null +++ b/cli/__tests__/fixtures/opencode-mcp-probe.mjs @@ -0,0 +1,66 @@ +#!/usr/bin/env node +import { readFileSync } from 'fs'; +import { spawn } from 'child_process'; + +if (process.argv.includes('--version')) { + process.stdout.write('1.19.0\n'); + process.exit(0); +} + +const config = JSON.parse(readFileSync(process.env.OPENCODE_CONFIG, 'utf8')); +const provider = config.provider.local.options; +const token = readFileSync(provider.apiKey.match(/^\{file:(.*)\}$/)[1], 'utf8'); +const response = await fetch(`${provider.baseURL}/models`, { + headers: { authorization: `Bearer ${token}` }, +}); +const mcp = config.mcp.commonly; +const child = spawn(mcp.command[0], mcp.command.slice(1), { + env: { ...process.env, ...mcp.environment }, + stdio: ['pipe', 'pipe', 'inherit'], +}); +let buffer = ''; +let serial = 0; +const pending = new Map(); +child.stdout.on('data', (chunk) => { + buffer += chunk; + let newline; + while ((newline = buffer.indexOf('\n')) >= 0) { + const message = JSON.parse(buffer.slice(0, newline)); + buffer = buffer.slice(newline + 1); + if (message.id) pending.get(message.id)?.(message); + } +}); +const rpc = (method, params) => new Promise((resolve, reject) => { + const id = ++serial; + const timer = setTimeout(() => reject(new Error(`MCP RPC timed out: ${method}`)), 5000); + pending.set(id, (result) => { + clearTimeout(timer); + pending.delete(id); + resolve(result); + }); + child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`); +}); +await rpc('initialize', { + protocolVersion: '2024-11-05', capabilities: {}, + clientInfo: { name: 'opencode-egress-probe', version: '1' }, +}); +child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method: 'notifications/initialized' })}\n`); +const tools = await rpc('tools/list', {}); +const context = await rpc('tools/call', { + name: 'commonly_get_context', arguments: { podId: 'probe' }, +}); +const post = await rpc('tools/call', { + name: 'commonly_post_message', arguments: { podId: 'probe', content: 'probe' }, +}); +child.stdin.end(); +await new Promise((resolve) => child.on('close', resolve)); +process.stdout.write(`${JSON.stringify({ + type: 'text', sessionID: 'probe', + part: { type: 'text', text: JSON.stringify({ + provider: response.status, + mcpTools: tools.result.tools.length, + apiUrl: mcp.environment.COMMONLY_API_URL, + context: context.result, + post: post.result, + }) }, +})}\n`); diff --git a/cli/__tests__/opencode.bwrap-egress.test.mjs b/cli/__tests__/opencode.bwrap-egress.test.mjs index c17c124adf..cfde9a7d80 100644 --- a/cli/__tests__/opencode.bwrap-egress.test.mjs +++ b/cli/__tests__/opencode.bwrap-egress.test.mjs @@ -6,9 +6,11 @@ import { join } from 'path'; import { fileURLToPath } from 'url'; import { detectBwrap, wrapArgvWithBwrap } from '../src/lib/sandbox/bwrap.js'; import { startProviderProxy } from '../src/lib/provider-proxy.js'; +import { startInstanceProxy } from '../src/lib/instance-proxy.js'; const LOOPBACK_BRIDGE = fileURLToPath(new URL('../src/lib/sandbox/opencode-loopback.mjs', import.meta.url)); const PORT = 8765; +const INSTANCE_PORT = 8766; const run = (cmd, args) => new Promise((resolve, reject) => { const child = spawn(cmd, args, { timeout: 15_000 }); let stdout = ''; @@ -34,6 +36,7 @@ test('public bwrap reaches its provider proxy but refuses other host and interne const keyFile = join(privateRoot, 'key'); const tokenFile = join(proxyDir, 'token'); const socketPath = join(proxyDir, 'proxy.sock'); + const instanceSocketPath = join(proxyDir, 'instance.sock'); const childFile = join(workspace, 'probe.mjs'); await Promise.all([mkdir(workspace), mkdir(proxyDir)]); await writeFile(keyFile, 'upstream-key', { mode: 0o600 }); @@ -45,14 +48,26 @@ test('public bwrap reaches its provider proxy but refuses other host and interne }); const unrelated = createServer((_req, res) => res.writeHead(200).end('wrong-port')); let proxy; + let instanceProxy; + const instance = createServer((req, res) => { + if (req.url === '/api/agents/runtime/pods/pod-1/context' + && req.headers.authorization === 'Bearer seat-token') res.writeHead(200).end('context'); + else res.writeHead(403).end(); + }); try { const upstreamPort = await listen(upstream); const unrelatedPort = await listen(unrelated); + const instancePort = await listen(instance); proxy = await startProviderProxy({ provider: { baseURL: `http://127.0.0.1:${upstreamPort}/v1` }, keyFile, tokenFile, socketPath, isolatedPort: PORT, }); expect((await stat(socketPath)).mode & 0o777).toBe(0o600); + instanceProxy = await startInstanceProxy({ + instanceUrl: `http://127.0.0.1:${instancePort}`, + socketPath: instanceSocketPath, + }); + expect((await stat(instanceSocketPath)).mode & 0o777).toBe(0o600); await writeFile(childFile, ` import { readFileSync } from 'fs'; import { get } from 'http'; @@ -73,13 +88,16 @@ test('public bwrap reaches its provider proxy but refuses other host and interne conn.on('error', () => resolve(true)); }); const allowed = await request(${PORT}, '/v1/models', { authorization: 'Bearer ' + token }); + const context = await request(${INSTANCE_PORT}, '/api/agents/runtime/pods/pod-1/context', + { authorization: 'Bearer seat-token' }); const otherHostPort = await request(${unrelatedPort}, '/'); const netAdminDropped = spawnSync('ip', ['link', 'set', 'lo', 'down']).status !== 0; const capabilitiesDropped = /^CapEff:\\s*0+$/m.test(readFileSync('/proc/self/status', 'utf8')); - process.stdout.write(JSON.stringify({ allowed, otherHostPort, internet, netAdminDropped, capabilitiesDropped })); + process.stdout.write(JSON.stringify({ allowed, context, otherHostPort, internet, netAdminDropped, capabilitiesDropped })); `); const argv = wrapArgvWithBwrap([ process.execPath, LOOPBACK_BRIDGE, socketPath, String(PORT), + instanceSocketPath, String(INSTANCE_PORT), process.execPath, childFile, tokenFile, ], { sandbox: { network: { policy: 'restricted' } } }, { workspacePath: workspace, @@ -90,12 +108,13 @@ test('public bwrap reaches its provider proxy but refuses other host and interne const result = await run(argv[0], argv.slice(1)); expect(result).toMatchObject({ code: 0, stderr: '' }); expect(JSON.parse(result.stdout)).toEqual({ - allowed: 200, otherHostPort: 0, internet: true, + allowed: 200, context: 200, otherHostPort: 0, internet: true, netAdminDropped: true, capabilitiesDropped: true, }); } finally { await proxy?.close(); - await Promise.all([close(upstream), close(unrelated)]); + await instanceProxy?.close(); + await Promise.all([close(upstream), close(unrelated), close(instance)]); await rm(root, { recursive: true, force: true }); await rm(privateRoot, { recursive: true, force: true }); } diff --git a/cli/src/lib/adapters/opencode.js b/cli/src/lib/adapters/opencode.js index a1711c26ec..cd77742804 100644 --- a/cli/src/lib/adapters/opencode.js +++ b/cli/src/lib/adapters/opencode.js @@ -9,8 +9,8 @@ * stay in the host adapter and reach the configured provider through a * per-spawn loopback proxy; OpenCode receives only that proxy's bearer. * This protects the key, not provider spend: the model can use its seat's key - * budget. On Linux the process has its own network namespace, with a single - * loopback bridge to the host proxy's Unix socket. + * budget. On Linux the process has its own network namespace, with loopback + * bridges to the fixed provider and Commonly instance origins. * Config is generated per spawn. OpenCode merges config sources, therefore the * global config directory is isolated and project config is explicitly off; * the environment spec is the sole source of MCP servers for this run. @@ -54,9 +54,11 @@ import { resolvePublicSandboxMode } from '../sandbox/mode.js'; import { resolveProviderKeyFile, startProviderProxy } from '../provider-proxy.js'; import { isShippedCommonlyMcpCommand } from '../declared-mcp-guard.js'; import { isAnswerableToolError } from '../public-tool-error.js'; +import { startInstanceProxy } from '../instance-proxy.js'; const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000; const ISOLATED_PROXY_PORT = 8765; +const ISOLATED_INSTANCE_PORT = 8766; const LOOPBACK_BRIDGE = fileURLToPath(new URL('../sandbox/opencode-loopback.mjs', import.meta.url)); const TERMINATION_GRACE_MS = 5000; // Bump only with a passing real sandbox-exec smoke against the new version. @@ -726,6 +728,7 @@ export default { }); let openCodeHome = null; let providerProxy = null; + let instanceProxy = null; try { openCodeHome = await prepareOpenCodeDataHome(ctx, { publicSeat: isPublic }); if (isPublic && provider) { @@ -741,6 +744,12 @@ export default { } : {}), }); } + if (isPublic && sandboxMode === 'bwrap' && ctx.instanceUrl) { + instanceProxy = await startInstanceProxy({ + instanceUrl: ctx.instanceUrl, + socketPath: join(tempDir, 'instance-proxy.sock'), + }); + } const stateHome = isPublic ? join(openCodeHome.root, 'state') : null; const cacheHome = isPublic ? join(openCodeHome.root, 'cache') : null; const tempWork = isPublic ? join(openCodeHome.root, 'tmp') : join(tempDir, 'tmp'); @@ -753,7 +762,8 @@ export default { const mcpReadDirectories = []; const mcp = buildMcpConfig(ctx.environment?.mcp, { runtimeToken: ctx.runtimeToken, - instanceUrl: ctx.instanceUrl, + instanceUrl: instanceProxy + ? `http://127.0.0.1:${ISOLATED_INSTANCE_PORT}` : ctx.instanceUrl, credentialFile: credential?.path || null, publicSeat: isPublic, unavailableMcp, @@ -810,7 +820,8 @@ export default { if (!bwrap.available) throw new Error(`public OpenCode agents require bwrap: ${bwrap.error}`); const wrapped = (ctx._wrapArgvWithBwrap || wrapArgvWithBwrap)([ process.execPath, LOOPBACK_BRIDGE, - providerProxy.socketPath, String(ISOLATED_PROXY_PORT), binary, ...args, + providerProxy.socketPath, String(ISOLATED_PROXY_PORT), + instanceProxy?.socketPath || '-', String(ISOLATED_INSTANCE_PORT), binary, ...args, ], { ...ctx.environment, sandbox: { @@ -874,6 +885,7 @@ export default { ...(result.usage ? { usage: result.usage } : {}), }; } finally { + try { await instanceProxy?.close(); } catch { /* ignore */ } try { await providerProxy?.close(); } catch { /* ignore */ } try { await rm(tempDir, { recursive: true, force: true }); } catch { /* ignore */ } } diff --git a/cli/src/lib/instance-proxy.js b/cli/src/lib/instance-proxy.js new file mode 100644 index 0000000000..ee944ace7e --- /dev/null +++ b/cli/src/lib/instance-proxy.js @@ -0,0 +1,55 @@ +/** A per-spawn Unix-socket relay to one declared Commonly instance origin. */ +import { createServer, request as httpRequest } from 'http'; +import { request as httpsRequest } from 'https'; +import { chmod, unlink } from 'fs/promises'; + +export const startInstanceProxy = async ({ instanceUrl, socketPath }) => { + const target = new URL(instanceUrl); + if (!['http:', 'https:'].includes(target.protocol) + || target.username || target.password || target.search || target.hash + || target.pathname !== '/') { + throw new Error('Commonly instance proxy requires a bare http(s) origin'); + } + const transport = target.protocol === 'https:' ? httpsRequest : httpRequest; + const server = createServer((incoming, outgoing) => { + const path = incoming.url || ''; + if (!path.startsWith('/') || path.startsWith('//') + || incoming.method === 'CONNECT' || incoming.headers.upgrade) { + incoming.resume(); + outgoing.writeHead(400).end(); + return; + } + const headers = { ...incoming.headers, host: target.host }; + delete headers.connection; + delete headers['proxy-authorization']; + delete headers['proxy-connection']; + const upstream = transport(target, { + method: incoming.method, + path, + headers, + }, (response) => { + outgoing.writeHead(response.statusCode || 502, response.headers); + response.pipe(outgoing); + }); + upstream.on('error', () => { + if (!outgoing.headersSent) outgoing.writeHead(502); + outgoing.end(); + }); + incoming.pipe(upstream); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(socketPath, () => { + server.removeListener('error', reject); + resolve(); + }); + }); + await chmod(socketPath, 0o600); + return { + socketPath, + close: async () => { + await new Promise((resolve) => server.close(resolve)); + await unlink(socketPath).catch((err) => { if (err.code !== 'ENOENT') throw err; }); + }, + }; +}; diff --git a/cli/src/lib/sandbox/opencode-loopback.mjs b/cli/src/lib/sandbox/opencode-loopback.mjs index 98cf5555bc..c3ca184a06 100644 --- a/cli/src/lib/sandbox/opencode-loopback.mjs +++ b/cli/src/lib/sandbox/opencode-loopback.mjs @@ -1,15 +1,19 @@ /** * Runs inside OpenCode's private bwrap network namespace. The only route to - * the host is the one Unix socket bound into this namespace for this spawn. + * the host is through the two fixed-origin Unix sockets bound for this spawn. */ import { spawn, spawnSync } from 'child_process'; import { existsSync } from 'fs'; import { createServer, connect } from 'net'; -const [socketPath, portText, binary, ...args] = process.argv.slice(2); +const [socketPath, portText, instanceSocketPath, instancePortText, binary, ...args] = process.argv.slice(2); const port = Number(portText); -if (!socketPath || !binary || !Number.isInteger(port) || port < 1 || port > 65535) { - throw new Error('OpenCode loopback bridge requires socket, port, and executable'); +const instancePort = Number(instancePortText); +if (!socketPath || !instanceSocketPath || !binary + || !Number.isInteger(port) || port < 1 || port > 65535 + || !Number.isInteger(instancePort) || instancePort < 1 || instancePort > 65535 + || port === instancePort) { + throw new Error('OpenCode loopback bridge requires distinct provider and instance ports'); } const ip = ['/usr/sbin/ip', '/sbin/ip', '/usr/bin/ip', '/bin/ip'].find(existsSync); @@ -22,8 +26,8 @@ const setpriv = ['/usr/bin/setpriv', '/bin/setpriv'].find(existsSync); if (!setpriv) throw new Error('OpenCode isolated loopback requires setpriv'); const sockets = new Set(); -const bridge = createServer((local) => { - const proxy = connect(socketPath); +const makeBridge = (path) => createServer((local) => { + const proxy = connect(path); sockets.add(local); sockets.add(proxy); local.on('close', () => sockets.delete(local)); @@ -32,24 +36,33 @@ const bridge = createServer((local) => { proxy.on('error', () => local.destroy()); local.pipe(proxy).pipe(local); }); -bridge.on('error', (err) => { throw err; }); -bridge.listen(port, '127.0.0.1', () => { - // Only this bridge needs CAP_NET_ADMIN to raise lo and CAP_SETPCAP to drop - // both capabilities before starting the model process. - const child = spawn(setpriv, [ - '--bounding-set=-net_admin,-setpcap', - '--inh-caps=-net_admin,-setpcap', - '--ambient-caps=-net_admin,-setpcap', - binary, ...args, - ], { stdio: 'inherit', env: process.env }); - const forward = (signal) => { if (!child.killed) child.kill(signal); }; - process.on('SIGTERM', () => forward('SIGTERM')); - process.on('SIGINT', () => forward('SIGINT')); - child.on('error', (err) => { - process.stderr.write(`${err.message}\n`); - }); - child.on('close', (code, signal) => { - for (const socket of sockets) socket.destroy(); - bridge.close(() => { process.exitCode = code ?? (signal === 'SIGTERM' ? 143 : 1); }); - }); +const providerBridge = makeBridge(socketPath); +const instanceBridge = instanceSocketPath === '-' ? null : makeBridge(instanceSocketPath); +providerBridge.on('error', (err) => { throw err; }); +instanceBridge?.on('error', (err) => { throw err; }); +providerBridge.listen(port, '127.0.0.1', () => { + const startChild = () => { + // Only this bridge needs CAP_NET_ADMIN to raise lo and CAP_SETPCAP to drop + // both capabilities before starting the model process. + const child = spawn(setpriv, [ + '--bounding-set=-net_admin,-setpcap', + '--inh-caps=-net_admin,-setpcap', + '--ambient-caps=-net_admin,-setpcap', + binary, ...args, + ], { stdio: 'inherit', env: process.env }); + const forward = (signal) => { if (!child.killed) child.kill(signal); }; + process.on('SIGTERM', () => forward('SIGTERM')); + process.on('SIGINT', () => forward('SIGINT')); + child.on('error', (err) => { + process.stderr.write(`${err.message}\n`); + }); + child.on('close', (code, signal) => { + for (const socket of sockets) socket.destroy(); + providerBridge.close(); + if (instanceBridge) instanceBridge.close(() => { process.exitCode = code ?? (signal === 'SIGTERM' ? 143 : 1); }); + else process.exitCode = code ?? (signal === 'SIGTERM' ? 143 : 1); + }); + }; + if (instanceBridge) instanceBridge.listen(instancePort, '127.0.0.1', startChild); + else startChild(); }); diff --git a/docs/agents/LOCAL_CLI_WRAPPER.md b/docs/agents/LOCAL_CLI_WRAPPER.md index 2c3593d6e2..5a4d8e02a5 100644 --- a/docs/agents/LOCAL_CLI_WRAPPER.md +++ b/docs/agents/LOCAL_CLI_WRAPPER.md @@ -218,7 +218,7 @@ See [ADR-005 §Adapter pattern](../adr/ADR-005-local-cli-wrapper-driver.md) for Public OpenCode seats on Linux require `bwrap`, `iproute2` (`ip`), and `setpriv` (util-linux). The adapter starts a private network namespace. A short-lived bridge brings up only its loopback interface and forwards its -provider port to the host's per-spawn Unix-socket proxy. The OpenCode child +provider port and declared Commonly instance port to fixed-origin host Unix-socket proxies. The OpenCode child starts after the bridge drops its namespace capabilities. Other host ports and internet addresses are unreachable; missing isolation tools stop the turn. Run `cli/__tests__/opencode.bwrap-egress.test.mjs` on a Linux host that permits diff --git a/scripts/verify-opencode-bwrap-mcp.mjs b/scripts/verify-opencode-bwrap-mcp.mjs new file mode 100644 index 0000000000..16329e92f1 --- /dev/null +++ b/scripts/verify-opencode-bwrap-mcp.mjs @@ -0,0 +1,79 @@ +/** Actual OpenCode adapter + stdio Commonly MCP transport in a Linux namespace. */ +import assert from 'assert/strict'; +import { createServer } from 'http'; +import { chmod, cp, mkdir, mkdtemp, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { fileURLToPath } from 'url'; +import opencode from '../cli/src/lib/adapters/opencode.js'; +import { commonlyMcpServer } from '../cli/src/lib/default-environment.js'; + +const repo = fileURLToPath(new URL('../', import.meta.url)); +const root = await mkdtemp(join(tmpdir(), 'commonly-bwrap-mcp-')); +const workspace = join(root, 'workspace'); +const mcpHome = join(root, 'mcp-home'); +const packageDir = join(mcpHome, '0.3.13/node_modules/@commonlyai/mcp'); +await mkdir(workspace); +await mkdir(packageDir, { recursive: true }); +await cp(join(repo, 'commonly-mcp'), packageDir, { recursive: true }); +await writeFile(join(mcpHome, 'current'), '0.3.13\n'); +await writeFile(join(mcpHome, '.warm.lock'), 'probe'); +process.env.COMMONLY_MCP_HOME = mcpHome; +const binary = join(workspace, 'opencode'); +await cp(join(repo, 'cli/__tests__/fixtures/opencode-mcp-probe.mjs'), binary); +await chmod(binary, 0o755); +const keyFile = join(root, 'key'); +await writeFile(keyFile, 'fake-provider-key', { mode: 0o600 }); + +let apiHits = 0; +let providerHits = 0; +const api = createServer((req, res) => { + apiHits += 1; + assert.equal(req.headers.authorization, 'Bearer cm_agent_mcp_probe'); + res.setHeader('content-type', 'application/json'); + res.end(JSON.stringify({ ok: true, path: req.url })); +}); +const provider = createServer((req, res) => { + providerHits += 1; + assert.equal(req.headers.authorization, 'Bearer fake-provider-key'); + res.setHeader('content-type', 'application/json'); + res.end('{"data":[]}'); +}); +const listen = (server) => new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => resolve(server.address().port)); +}); +const close = (server) => new Promise((resolve) => server.close(resolve)); +const apiUrl = `http://127.0.0.1:${await listen(api)}`; +const providerUrl = `http://127.0.0.1:${await listen(provider)}/v1`; +try { + const result = await opencode.spawn('MCP egress probe', { + cwd: workspace, + agentName: 'mcp-probe', + runtimeToken: 'cm_agent_mcp_probe', + instanceUrl: apiUrl, + env: { PATH: process.env.PATH }, + timeoutMs: 20_000, + _binaryPath: binary, + _opencodeHomeRoot: join(root, 'state'), + environment: { + model: 'test', + provider: { id: 'local', baseURL: providerUrl, keyFile }, + sandbox: { trust: 'public', mode: 'bwrap' }, + mcp: [commonlyMcpServer()], + }, + }); + const observed = JSON.parse(result.text); + assert.equal(observed.provider, 200); + assert.ok(observed.mcpTools > 0); + assert.equal(observed.apiUrl, 'http://127.0.0.1:8766'); + assert.equal(observed.context.isError, undefined); + assert.equal(observed.post.isError, undefined); + assert.equal(apiHits, 2); + assert.equal(providerHits, 1); + process.stdout.write(`OpenCode bwrap provider + Commonly MCP: ${observed.mcpTools} tools, ${apiHits} API calls\n`); +} finally { + api.closeAllConnections(); + provider.closeAllConnections(); + await Promise.all([close(api), close(provider)]); + await rm(root, { recursive: true, force: true }); +} From acfb7b16c657cd558a116f8027d2cba3603883ec Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:35:42 -0700 Subject: [PATCH 3/7] Require namespace arm on Linux CI runner Co-authored-by: kai Agent-Model: codex/gpt-6-sol/medium Agent-Model: codex/gpt-6.1-sol/medium --- .github/workflows/opencode-bwrap-egress.yml | 6 +++--- cli/__tests__/opencode.bwrap-egress.test.mjs | 10 ++++++++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/.github/workflows/opencode-bwrap-egress.yml b/.github/workflows/opencode-bwrap-egress.yml index 3658338c05..a5e79de51f 100644 --- a/.github/workflows/opencode-bwrap-egress.yml +++ b/.github/workflows/opencode-bwrap-egress.yml @@ -17,12 +17,12 @@ permissions: jobs: isolated-egress: - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 - name: Exercise provider and Commonly MCP in a disposable namespace run: | - docker run --rm --privileged \ + docker run --rm --privileged --security-opt apparmor=unconfined \ --mount "type=bind,src=$PWD,dst=/source,readonly" node:22 bash -lc ' set -euo pipefail apt-get update -qq @@ -33,6 +33,6 @@ jobs: npm ci --prefix /work/cli --silent npm ci --prefix /work/commonly-mcp --silent chown -R node:node /work - su node -s /bin/bash -c "cd /work/cli && node --experimental-vm-modules node_modules/.bin/jest __tests__/opencode.bwrap-egress.test.mjs --runInBand" + su node -s /bin/bash -c "cd /work/cli && COMMONLY_REQUIRE_BWRAP_SMOKE=1 node --experimental-vm-modules node_modules/.bin/jest __tests__/opencode.bwrap-egress.test.mjs --runInBand" su node -s /bin/bash -c "node /work/scripts/verify-opencode-bwrap-mcp.mjs" ' diff --git a/cli/__tests__/opencode.bwrap-egress.test.mjs b/cli/__tests__/opencode.bwrap-egress.test.mjs index cfde9a7d80..2f83adbb26 100644 --- a/cli/__tests__/opencode.bwrap-egress.test.mjs +++ b/cli/__tests__/opencode.bwrap-egress.test.mjs @@ -26,8 +26,14 @@ const listen = (server) => new Promise((resolve) => { const close = (server) => new Promise((resolve) => server.close(resolve)); test('public bwrap reaches its provider proxy but refuses other host and internet ports', async () => { - if (process.platform !== 'linux' || !detectBwrap().available - || spawnSync('ip', ['-V']).status !== 0) return; + const available = process.platform === 'linux' && detectBwrap().available + && spawnSync('ip', ['-V']).status === 0; + if (!available) { + if (process.env.COMMONLY_REQUIRE_BWRAP_SMOKE === '1') { + throw new Error('dedicated bwrap smoke requires a working Linux namespace and iproute2'); + } + return; + } const root = await mkdtemp(join(tmpdir(), 'commonly-egress-')); const privateRoot = await mkdtemp(join(tmpdir(), 'commonly-egress-key-')); From 0d6875cdebee69d60bde115155558262ebb3f3aa Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:37:20 -0700 Subject: [PATCH 4/7] Cancel in-flight Commonly relay requests on disconnect Co-authored-by: kai Agent-Model: codex/gpt-6-sol/medium Agent-Model: codex/gpt-6.1-sol/medium --- .github/workflows/opencode-bwrap-egress.yml | 3 +- cli/__tests__/instance-proxy.test.mjs | 51 +++++++++++++++++++++ cli/src/lib/instance-proxy.js | 10 +++- 3 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 cli/__tests__/instance-proxy.test.mjs diff --git a/.github/workflows/opencode-bwrap-egress.yml b/.github/workflows/opencode-bwrap-egress.yml index a5e79de51f..18933d93ad 100644 --- a/.github/workflows/opencode-bwrap-egress.yml +++ b/.github/workflows/opencode-bwrap-egress.yml @@ -8,6 +8,7 @@ on: - 'cli/src/lib/instance-proxy.js' - 'cli/src/lib/sandbox/opencode-loopback.mjs' - 'cli/__tests__/opencode.bwrap-egress.test.mjs' + - 'cli/__tests__/instance-proxy.test.mjs' - 'cli/__tests__/fixtures/opencode-mcp-probe.mjs' - 'scripts/verify-opencode-bwrap-mcp.mjs' - '.github/workflows/opencode-bwrap-egress.yml' @@ -33,6 +34,6 @@ jobs: npm ci --prefix /work/cli --silent npm ci --prefix /work/commonly-mcp --silent chown -R node:node /work - su node -s /bin/bash -c "cd /work/cli && COMMONLY_REQUIRE_BWRAP_SMOKE=1 node --experimental-vm-modules node_modules/.bin/jest __tests__/opencode.bwrap-egress.test.mjs --runInBand" + su node -s /bin/bash -c "cd /work/cli && COMMONLY_REQUIRE_BWRAP_SMOKE=1 node --experimental-vm-modules node_modules/.bin/jest __tests__/opencode.bwrap-egress.test.mjs __tests__/instance-proxy.test.mjs --runInBand" su node -s /bin/bash -c "node /work/scripts/verify-opencode-bwrap-mcp.mjs" ' diff --git a/cli/__tests__/instance-proxy.test.mjs b/cli/__tests__/instance-proxy.test.mjs new file mode 100644 index 0000000000..a99b796eac --- /dev/null +++ b/cli/__tests__/instance-proxy.test.mjs @@ -0,0 +1,51 @@ +import { createServer } from 'http'; +import { connect } from 'net'; +import { once } from 'events'; +import { mkdtemp, rm, stat } from 'fs/promises'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { startInstanceProxy } from '../src/lib/instance-proxy.js'; + +test.each(['client disconnect', 'proxy shutdown'])( + '%s cancels a pending instance request', async (ending) => { + const root = await mkdtemp(join(tmpdir(), 'commonly-instance-proxy-')); + const upstreamSockets = new Set(); + let received; + const hit = new Promise((resolve) => { received = resolve; }); + const target = createServer(() => { received(); }); + target.on('connection', (socket) => { + upstreamSockets.add(socket); + socket.on('close', () => upstreamSockets.delete(socket)); + }); + await new Promise((resolve) => target.listen(0, '127.0.0.1', resolve)); + const socketPath = join(root, 'instance.sock'); + let proxy; + try { + proxy = await startInstanceProxy({ + instanceUrl: `http://127.0.0.1:${target.address().port}`, + socketPath, + }); + expect((await stat(socketPath)).mode & 0o777).toBe(0o600); + const client = connect(socketPath); + await once(client, 'connect'); + client.write('GET /api/agents/runtime/pods/probe/context HTTP/1.1\r\nHost: proxy\r\n\r\n'); + await hit; + if (ending === 'client disconnect') { + const closed = once(client, 'close'); + client.destroy(); + await closed; + } else { + await proxy.close(); + proxy = null; + client.destroy(); + } + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(upstreamSockets.size).toBe(0); + } finally { + await proxy?.close(); + target.closeAllConnections(); + await new Promise((resolve) => target.close(resolve)); + await rm(root, { recursive: true, force: true }); + } + }, +); diff --git a/cli/src/lib/instance-proxy.js b/cli/src/lib/instance-proxy.js index ee944ace7e..66af87f2aa 100644 --- a/cli/src/lib/instance-proxy.js +++ b/cli/src/lib/instance-proxy.js @@ -11,6 +11,7 @@ export const startInstanceProxy = async ({ instanceUrl, socketPath }) => { throw new Error('Commonly instance proxy requires a bare http(s) origin'); } const transport = target.protocol === 'https:' ? httpsRequest : httpRequest; + const upstreams = new Set(); const server = createServer((incoming, outgoing) => { const path = incoming.url || ''; if (!path.startsWith('/') || path.startsWith('//') @@ -31,7 +32,11 @@ export const startInstanceProxy = async ({ instanceUrl, socketPath }) => { outgoing.writeHead(response.statusCode || 502, response.headers); response.pipe(outgoing); }); + upstreams.add(upstream); + upstream.on('close', () => upstreams.delete(upstream)); + outgoing.on('close', () => upstream.destroy()); upstream.on('error', () => { + if (outgoing.destroyed) return; if (!outgoing.headersSent) outgoing.writeHead(502); outgoing.end(); }); @@ -48,7 +53,10 @@ export const startInstanceProxy = async ({ instanceUrl, socketPath }) => { return { socketPath, close: async () => { - await new Promise((resolve) => server.close(resolve)); + const closed = new Promise((resolve) => server.close(resolve)); + for (const upstream of upstreams) upstream.destroy(); + server.closeAllConnections(); + await closed; await unlink(socketPath).catch((err) => { if (err.code !== 'ENOENT') throw err; }); }, }; From 17ded2c9b83ac2a7697fcbf74e4903b8d6e3ddb4 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sun, 18 Oct 2026 12:50:14 -0700 Subject: [PATCH 5/7] Keep OpenCode probe fixture outside Jest discovery Co-authored-by: kai Agent-Model: codex/gpt-6-sol/medium Agent-Model: codex/gpt-6.1-sol/medium --- .github/workflows/opencode-bwrap-egress.yml | 2 +- {cli/__tests__ => scripts}/fixtures/opencode-mcp-probe.mjs | 0 scripts/verify-opencode-bwrap-mcp.mjs | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename {cli/__tests__ => scripts}/fixtures/opencode-mcp-probe.mjs (100%) diff --git a/.github/workflows/opencode-bwrap-egress.yml b/.github/workflows/opencode-bwrap-egress.yml index 18933d93ad..35577ff12c 100644 --- a/.github/workflows/opencode-bwrap-egress.yml +++ b/.github/workflows/opencode-bwrap-egress.yml @@ -9,7 +9,7 @@ on: - 'cli/src/lib/sandbox/opencode-loopback.mjs' - 'cli/__tests__/opencode.bwrap-egress.test.mjs' - 'cli/__tests__/instance-proxy.test.mjs' - - 'cli/__tests__/fixtures/opencode-mcp-probe.mjs' + - 'scripts/fixtures/opencode-mcp-probe.mjs' - 'scripts/verify-opencode-bwrap-mcp.mjs' - '.github/workflows/opencode-bwrap-egress.yml' diff --git a/cli/__tests__/fixtures/opencode-mcp-probe.mjs b/scripts/fixtures/opencode-mcp-probe.mjs similarity index 100% rename from cli/__tests__/fixtures/opencode-mcp-probe.mjs rename to scripts/fixtures/opencode-mcp-probe.mjs diff --git a/scripts/verify-opencode-bwrap-mcp.mjs b/scripts/verify-opencode-bwrap-mcp.mjs index 16329e92f1..ed3f2b1b6a 100644 --- a/scripts/verify-opencode-bwrap-mcp.mjs +++ b/scripts/verify-opencode-bwrap-mcp.mjs @@ -20,7 +20,7 @@ await writeFile(join(mcpHome, 'current'), '0.3.13\n'); await writeFile(join(mcpHome, '.warm.lock'), 'probe'); process.env.COMMONLY_MCP_HOME = mcpHome; const binary = join(workspace, 'opencode'); -await cp(join(repo, 'cli/__tests__/fixtures/opencode-mcp-probe.mjs'), binary); +await cp(join(repo, 'scripts/fixtures/opencode-mcp-probe.mjs'), binary); await chmod(binary, 0o755); const keyFile = join(root, 'key'); await writeFile(keyFile, 'fake-provider-key', { mode: 0o600 }); From 71887815f7d1a03a19fe1ff1b405fe03911ee348 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:15:27 -0700 Subject: [PATCH 6/7] Harden OpenCode egress verifier and incomplete response cleanup Co-authored-by: kai Agent-Model: codex/gpt-6-sol/medium Agent-Model: codex/gpt-6.1-sol/medium --- .github/workflows/opencode-bwrap-egress.yml | 4 +++ cli/__tests__/adapters.opencode.test.mjs | 1 + cli/__tests__/instance-proxy.test.mjs | 33 +++++++++++++++++++++ cli/src/lib/instance-proxy.js | 3 ++ 4 files changed, 41 insertions(+) diff --git a/.github/workflows/opencode-bwrap-egress.yml b/.github/workflows/opencode-bwrap-egress.yml index 35577ff12c..06ed235d94 100644 --- a/.github/workflows/opencode-bwrap-egress.yml +++ b/.github/workflows/opencode-bwrap-egress.yml @@ -6,7 +6,9 @@ on: - 'cli/src/lib/adapters/opencode.js' - 'cli/src/lib/provider-proxy.js' - 'cli/src/lib/instance-proxy.js' + - 'cli/src/lib/sandbox/bwrap.js' - 'cli/src/lib/sandbox/opencode-loopback.mjs' + - 'commonly-mcp/**' - 'cli/__tests__/opencode.bwrap-egress.test.mjs' - 'cli/__tests__/instance-proxy.test.mjs' - 'scripts/fixtures/opencode-mcp-probe.mjs' @@ -21,6 +23,8 @@ jobs: runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - name: Exercise provider and Commonly MCP in a disposable namespace run: | docker run --rm --privileged --security-opt apparmor=unconfined \ diff --git a/cli/__tests__/adapters.opencode.test.mjs b/cli/__tests__/adapters.opencode.test.mjs index f5c60ea692..ac8b33c951 100644 --- a/cli/__tests__/adapters.opencode.test.mjs +++ b/cli/__tests__/adapters.opencode.test.mjs @@ -969,6 +969,7 @@ describe('opencode external provider config', () => { expect(opts.enableLoopback).toBe(true); expect(argv.slice(0, 2)).toEqual([process.execPath, expect.stringMatching(/opencode-loopback\.mjs$/)]); expect(argv[2]).toMatch(/provider-proxy\.sock$/); + expect(argv[4]).toBe('-'); expect(opts.readOnlyPaths).not.toContain(realKey); expect(opts.readOnlyPaths).not.toContain(realAuthPath); return ['/usr/bin/bwrap', '--test', ...argv]; diff --git a/cli/__tests__/instance-proxy.test.mjs b/cli/__tests__/instance-proxy.test.mjs index a99b796eac..9d9867f404 100644 --- a/cli/__tests__/instance-proxy.test.mjs +++ b/cli/__tests__/instance-proxy.test.mjs @@ -49,3 +49,36 @@ test.each(['client disconnect', 'proxy shutdown'])( } }, ); + +test('closes the downstream client when an upstream response ends incomplete', async () => { + const root = await mkdtemp(join(tmpdir(), 'commonly-instance-proxy-')); + const target = createServer((_request, response) => { + response.writeHead(200, { 'content-length': '100' }); + response.write('partial'); + setTimeout(() => response.destroy(), 25); + }); + await new Promise((resolve) => target.listen(0, '127.0.0.1', resolve)); + let proxy; + let client; + let timeout; + try { + proxy = await startInstanceProxy({ + instanceUrl: `http://127.0.0.1:${target.address().port}`, + socketPath: join(root, 'instance.sock'), + }); + client = connect(proxy.socketPath); + await once(client, 'connect'); + const closed = new Promise((resolve) => client.once('close', () => resolve(true))); + client.write('GET /v1/models HTTP/1.1\r\nHost: proxy\r\n\r\n'); + await once(client, 'data'); + const timedOut = new Promise((resolve) => { timeout = setTimeout(() => resolve(false), 500); }); + expect(await Promise.race([closed, timedOut])).toBe(true); + } finally { + clearTimeout(timeout); + client?.destroy(); + await proxy?.close(); + target.closeAllConnections(); + await new Promise((resolve) => target.close(resolve)); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/cli/src/lib/instance-proxy.js b/cli/src/lib/instance-proxy.js index 66af87f2aa..394374f2f6 100644 --- a/cli/src/lib/instance-proxy.js +++ b/cli/src/lib/instance-proxy.js @@ -31,6 +31,9 @@ export const startInstanceProxy = async ({ instanceUrl, socketPath }) => { }, (response) => { outgoing.writeHead(response.statusCode || 502, response.headers); response.pipe(outgoing); + response.on('close', () => { + if (!response.complete) outgoing.destroy(); + }); }); upstreams.add(upstream); upstream.on('close', () => upstreams.delete(upstream)); From d790f534d70cfed982d8ea00f47fec7432c8bf60 Mon Sep 17 00:00:00 2001 From: Lily Shen <115414357+lilyshen0722@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:47:48 -0700 Subject: [PATCH 7/7] Handle errors from instance proxy responses Co-authored-by: kai Agent-Model: codex/gpt-6.1-sol/medium --- cli/src/lib/instance-proxy.js | 1 + 1 file changed, 1 insertion(+) diff --git a/cli/src/lib/instance-proxy.js b/cli/src/lib/instance-proxy.js index 394374f2f6..e93362b4a9 100644 --- a/cli/src/lib/instance-proxy.js +++ b/cli/src/lib/instance-proxy.js @@ -31,6 +31,7 @@ export const startInstanceProxy = async ({ instanceUrl, socketPath }) => { }, (response) => { outgoing.writeHead(response.statusCode || 502, response.headers); response.pipe(outgoing); + response.on('error', () => outgoing.destroy()); response.on('close', () => { if (!response.complete) outgoing.destroy(); });