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
43 changes: 43 additions & 0 deletions .github/workflows/opencode-bwrap-egress.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
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/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'
- 'scripts/verify-opencode-bwrap-mcp.mjs'
- '.github/workflows/opencode-bwrap-egress.yml'

permissions:
contents: read

jobs:
isolated-egress:
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 \
--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 && 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"
'
29 changes: 23 additions & 6 deletions cli/__tests__/adapters.opencode.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -957,12 +957,19 @@ 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(argv[4]).toBe('-');
expect(opts.readOnlyPaths).not.toContain(realKey);
expect(opts.readOnlyPaths).not.toContain(realAuthPath);
return ['/usr/bin/bwrap', '--test', ...argv];
Expand All @@ -971,17 +978,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' },
},
Expand All @@ -991,8 +1000,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);
});
});

Expand Down Expand Up @@ -1554,7 +1569,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');
Expand Down
17 changes: 17 additions & 0 deletions cli/__tests__/bwrap.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down
84 changes: 84 additions & 0 deletions cli/__tests__/instance-proxy.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
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 });
}
},
);

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 });
}
});
127 changes: 127 additions & 0 deletions cli/__tests__/opencode.bwrap-egress.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
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';
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 = '';
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 () => {
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-'));
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 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 });
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;
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';
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 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, 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,
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, context: 200, otherHostPort: 0, internet: true,
netAdminDropped: true, capabilitiesDropped: true,
});
} finally {
await proxy?.close();
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 });
}
}, 25_000);
Loading
Loading