|
| 1 | +import { execFileSync, spawnSync } from 'node:child_process'; |
| 2 | +import { randomUUID } from 'node:crypto'; |
| 3 | +import type { InvocationInput } from '../contract.ts'; |
| 4 | +import { assertBuiltAgentImage } from '../container/image.ts'; |
| 5 | + |
| 6 | +export const VENDOR_HOSTS = Object.freeze({ |
| 7 | + claude: Object.freeze(['api.anthropic.com']), |
| 8 | + codex: Object.freeze(['api.openai.com', 'chatgpt.com']), |
| 9 | +} satisfies Record<InvocationInput['vendor'], readonly string[]>); |
| 10 | + |
| 11 | +export interface VendorNetwork { |
| 12 | + readonly name: string; |
| 13 | + readonly proxyContainer: string; |
| 14 | + readonly proxyUrl: string; |
| 15 | + readonly vendor: InvocationInput['vendor']; |
| 16 | +} |
| 17 | +interface NetworkIdentity { readonly allocationId: string; readonly imageId: string } |
| 18 | +const identities = new WeakMap<VendorNetwork, NetworkIdentity>(); |
| 19 | +const environment = () => ({ PATH: process.env.PATH, DOCKER_HOST: process.env.DOCKER_HOST }); |
| 20 | +const deadline = (timeoutMs: number) => { |
| 21 | + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) throw new Error('Network deadline must be a positive integer.'); |
| 22 | + const end = performance.now() + timeoutMs; |
| 23 | + return () => { |
| 24 | + const value = Math.ceil(end - performance.now()); |
| 25 | + if (value <= 0) throw new Error('Vendor network operation exceeded its overall deadline.'); |
| 26 | + return value; |
| 27 | + }; |
| 28 | +}; |
| 29 | +const docker = (args: readonly string[], timeout: number) => execFileSync('docker', [...args], { |
| 30 | + encoding: 'utf8', timeout, killSignal: 'SIGKILL', env: environment(), stdio: ['ignore', 'pipe', 'pipe'], |
| 31 | +}).trim(); |
| 32 | +const absent = (result: ReturnType<typeof spawnSync>) => result.status !== 0 && !result.error |
| 33 | + && /No such (?:object|container|network)/i.test(`${result.stdout ?? ''}\n${result.stderr ?? ''}`); |
| 34 | +const remove = (args: readonly string[], inspect: readonly string[], remaining: () => number, kind: string, |
| 35 | + allocationId: string) => { |
| 36 | + const before = spawnSync('docker', [...inspect], { encoding: 'utf8', timeout: remaining(), killSignal: 'SIGKILL', |
| 37 | + env: environment(), stdio: ['ignore', 'pipe', 'pipe'] }); |
| 38 | + if (before.status !== 0) { |
| 39 | + if (absent(before)) return; |
| 40 | + throw new Error(`Failed to establish ownership of ${kind}.`); |
| 41 | + } |
| 42 | + const inspected = JSON.parse(before.stdout || '[]')[0] as |
| 43 | + { Labels?: Record<string, string>; Config?: { Labels?: Record<string, string> } } | undefined; |
| 44 | + const labels = inspected?.Labels ?? inspected?.Config?.Labels; |
| 45 | + if (labels?.['io.codeboost.egress'] !== allocationId) throw new Error(`Refused to remove unowned ${kind}.`); |
| 46 | + const result = spawnSync('docker', [...args], { encoding: 'utf8', timeout: remaining(), killSignal: 'SIGKILL', |
| 47 | + env: environment(), stdio: ['ignore', 'pipe', 'pipe'] }); |
| 48 | + if (result.status === 0) return; |
| 49 | + const check = spawnSync('docker', [...inspect], { encoding: 'utf8', timeout: remaining(), killSignal: 'SIGKILL', |
| 50 | + env: environment(), stdio: ['ignore', 'pipe', 'pipe'] }); |
| 51 | + if (!absent(check)) throw new Error(`Failed to confirm removal of ${kind}.`); |
| 52 | +}; |
| 53 | + |
| 54 | +export function assertVendorNetwork(network: VendorNetwork, vendor?: InvocationInput['vendor']): void { |
| 55 | + const identity = identities.get(network); |
| 56 | + if (!identity) throw new Error('Vendor network was not created by the trusted network builder.'); |
| 57 | + if (vendor && network.vendor !== vendor) throw new Error('Vendor network does not match the invocation vendor.'); |
| 58 | + assertBuiltAgentImage(identity.imageId); |
| 59 | +} |
| 60 | + |
| 61 | +export function createVendorNetwork(vendor: InvocationInput['vendor'], imageId: string, |
| 62 | + timeoutMs = 60_000): VendorNetwork { |
| 63 | + assertBuiltAgentImage(imageId); |
| 64 | + const remaining = deadline(timeoutMs), allocationId = randomUUID(); |
| 65 | + const name = `codeboost-egress-${vendor}-${randomUUID()}`; |
| 66 | + const proxyContainer = `codeboost-proxy-${vendor}-${randomUUID()}`; |
| 67 | + let networkPlanned = false, proxyPlanned = false; |
| 68 | + try { |
| 69 | + networkPlanned = true; |
| 70 | + docker(['network', 'create', '--internal', '--driver', 'bridge', |
| 71 | + '--label', `io.codeboost.egress=${allocationId}`, name], remaining()); |
| 72 | + proxyPlanned = true; |
| 73 | + docker(['run', '--detach', '--name', proxyContainer, '--read-only', '--user', '10001:10001', |
| 74 | + '--cap-drop=ALL', '--security-opt=no-new-privileges', '--pids-limit=64', '--memory=64m', '--memory-swap=64m', |
| 75 | + '--cpus=.25', '--network', name, '--network-alias', 'codeboost-proxy', |
| 76 | + '--label', `io.codeboost.egress=${allocationId}`, '--env', `CODEBOOST_ALLOWED_HOSTS=${VENDOR_HOSTS[vendor].join(',')}`, |
| 77 | + '--entrypoint', 'node', imageId, '/usr/local/lib/codeboost-egress-proxy.mjs'], remaining()); |
| 78 | + docker(['network', 'connect', 'bridge', proxyContainer], remaining()); |
| 79 | + docker(['exec', proxyContainer, 'node', '-e', [ |
| 80 | + "const net=require('node:net');let attempts=0;", |
| 81 | + "const check=()=>{const socket=net.connect(3128,'127.0.0.1');", |
| 82 | + "socket.once('connect',()=>{socket.destroy();process.exit(0)});", |
| 83 | + "socket.once('error',()=>{socket.destroy();if(++attempts===50)process.exit(1);setTimeout(check,20)})};check();", |
| 84 | + ].join('')], remaining()); |
| 85 | + const inspect = JSON.parse(docker(['container', 'inspect', proxyContainer], remaining()))[0] as |
| 86 | + { State?: { Running?: boolean }; Config?: { Image?: string; User?: string; Labels?: Record<string, string>; Env?: string[] }; |
| 87 | + HostConfig?: { ReadonlyRootfs?: boolean; Privileged?: boolean; CapDrop?: string[]; CapAdd?: string[] | null; |
| 88 | + SecurityOpt?: string[]; Memory?: number; MemorySwap?: number; NanoCpus?: number }; |
| 89 | + NetworkSettings?: { Networks?: Record<string, unknown> }; Mounts?: unknown[] } | undefined; |
| 90 | + const inspectedNetwork = JSON.parse(docker(['network', 'inspect', name], remaining()))[0] as |
| 91 | + { Internal?: boolean; Driver?: string; Labels?: Record<string, string> } | undefined; |
| 92 | + const networks = Object.keys(inspect?.NetworkSettings?.Networks ?? {}).sort(); |
| 93 | + if (!inspect?.State?.Running || inspect.Config?.Image !== imageId || inspect.Config?.User !== '10001:10001' |
| 94 | + || inspect.Config?.Labels?.['io.codeboost.egress'] !== allocationId || !inspect.HostConfig?.ReadonlyRootfs |
| 95 | + || inspect.HostConfig.Privileged || !inspect.HostConfig.CapDrop?.map(value => value.toUpperCase()).includes('ALL') |
| 96 | + || (inspect.HostConfig.CapAdd?.length ?? 0) || inspect.HostConfig.SecurityOpt?.length !== 1 |
| 97 | + || !['no-new-privileges', 'no-new-privileges:true'].includes(inspect.HostConfig.SecurityOpt[0] ?? '') |
| 98 | + || inspect.HostConfig.Memory !== 64 * 1024 * 1024 |
| 99 | + || inspect.HostConfig.MemorySwap !== 64 * 1024 * 1024 || inspect.HostConfig.NanoCpus !== 250_000_000 |
| 100 | + || JSON.stringify(networks) !== JSON.stringify(['bridge', name].sort()) |
| 101 | + || inspect.Mounts?.length || !inspect.Config.Env?.includes(`CODEBOOST_ALLOWED_HOSTS=${VENDOR_HOSTS[vendor].join(',')}`) |
| 102 | + || !inspectedNetwork?.Internal || inspectedNetwork.Driver !== 'bridge' |
| 103 | + || inspectedNetwork.Labels?.['io.codeboost.egress'] !== allocationId) |
| 104 | + throw new Error('Vendor proxy does not match its pinned isolation profile.'); |
| 105 | + remaining(); |
| 106 | + const network = Object.freeze({ name, proxyContainer, proxyUrl: 'http://codeboost-proxy:3128', vendor }); |
| 107 | + identities.set(network, Object.freeze({ allocationId, imageId })); |
| 108 | + return network; |
| 109 | + } catch (error) { |
| 110 | + const failures: unknown[] = []; |
| 111 | + if (proxyPlanned) try { remove(['rm', '--force', proxyContainer], ['container', 'inspect', proxyContainer], |
| 112 | + deadline(30_000), 'vendor proxy', allocationId); } catch (cleanupError) { failures.push(cleanupError); } |
| 113 | + if (networkPlanned) try { remove(['network', 'rm', name], ['network', 'inspect', name], |
| 114 | + deadline(30_000), 'vendor network', allocationId); } catch (cleanupError) { failures.push(cleanupError); } |
| 115 | + if (failures.length) throw new AggregateError([error, ...failures], 'Vendor network creation and cleanup failed.'); |
| 116 | + throw error; |
| 117 | + } |
| 118 | +} |
| 119 | + |
| 120 | +export function removeVendorNetwork(network: VendorNetwork): void { |
| 121 | + assertVendorNetwork(network); |
| 122 | + const allocationId = identities.get(network)!.allocationId; |
| 123 | + const remaining = deadline(30_000), failures: unknown[] = []; |
| 124 | + try { remove(['rm', '--force', network.proxyContainer], ['container', 'inspect', network.proxyContainer], |
| 125 | + remaining, 'vendor proxy', allocationId); } catch (error) { failures.push(error); } |
| 126 | + try { remove(['network', 'rm', network.name], ['network', 'inspect', network.name], |
| 127 | + remaining, 'vendor network', allocationId); } catch (error) { failures.push(error); } |
| 128 | + if (failures.length) throw new AggregateError(failures, 'Vendor network cleanup did not settle.'); |
| 129 | + identities.delete(network); |
| 130 | +} |
0 commit comments