From 9dacd087153bbea58e7d5d67ee94cb9ce138128c Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Sun, 19 Jul 2026 10:10:35 -0700 Subject: [PATCH 1/2] fix(gvisor): skip iptables and route MCP gateway via NO_PROXY gVisor's isolated userspace netstack is not governed by the host-netns iptables DNAT/RETURN rules that awf-iptables-init installs, so egress (including the MCP gateway bypass at 172.30.0.1) silently failed under gVisor: MCP requests were routed through Squid and rejected with 403, leaving the agent unable to emit safe outputs. Add a usesIptables runtime capability (false for gvisor/sbx) that: - skips the awf-iptables-init container - sets AWF_SKIP_IPTABLES_INIT=1 so the entrypoint skips the init handshake - adds the network gateway + host.docker.internal to NO_PROXY so proxy-aware MCP clients reach the gateway directly Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: be040aef-5830-42b1-a871-4cade09f4585 --- containers/agent/entrypoint.sh | 7 ++- docs/gvisor-integration.md | 53 ++++++++++++++----- src/container-runtime.test.ts | 25 +++++++-- src/container-runtime.ts | 36 +++++++++++++ .../proxy-environment.test.ts | 38 +++++++++++++ .../agent-environment/proxy-environment.ts | 9 +++- src/services/optional-services.test.ts | 16 ++++++ src/services/optional-services.ts | 19 +++++-- 8 files changed, 180 insertions(+), 23 deletions(-) create mode 100644 src/services/agent-environment/proxy-environment.test.ts diff --git a/containers/agent/entrypoint.sh b/containers/agent/entrypoint.sh index a4b755ff9..fc8361f10 100644 --- a/containers/agent/entrypoint.sh +++ b/containers/agent/entrypoint.sh @@ -139,8 +139,11 @@ wait_for_iptables() { # # In network-isolation (topology) mode there is no iptables-init container — # egress is enforced by Docker network topology — so skip the handshake. -if [ "${AWF_NETWORK_ISOLATION:-}" = "1" ]; then - echo "[entrypoint] Network-isolation mode: skipping iptables init container wait" +# Likewise for runtimes whose network stack can't be governed by host-netns +# iptables (e.g. gVisor's isolated netstack): AWF_SKIP_IPTABLES_INIT is set and +# egress relies on the HTTP_PROXY/HTTPS_PROXY env vars instead. +if [ "${AWF_NETWORK_ISOLATION:-}" = "1" ] || [ "${AWF_SKIP_IPTABLES_INIT:-}" = "1" ]; then + echo "[entrypoint] iptables-init skipped (proxy-based egress): skipping init container wait" else echo "[entrypoint] Waiting for iptables initialization from init container..." INIT_TIMEOUT=300 # 300 * 0.1s = 30 seconds diff --git a/docs/gvisor-integration.md b/docs/gvisor-integration.md index aa6f2526c..209c3e6ca 100644 --- a/docs/gvisor-integration.md +++ b/docs/gvisor-integration.md @@ -99,8 +99,8 @@ gVisor is registered with `executionModel: 'compose'`: ```ts const RUNTIME_REGISTRY = { - gvisor: { executionModel: 'compose', dockerRuntime: 'runsc', needsStaticDns: true }, - sbx: { executionModel: 'microvm', dockerRuntime: undefined, needsStaticDns: false }, + gvisor: { executionModel: 'compose', dockerRuntime: 'runsc', needsStaticDns: true, usesIptables: false }, + sbx: { executionModel: 'microvm', dockerRuntime: undefined, needsStaticDns: false, usesIptables: false }, }; ``` @@ -204,14 +204,35 @@ every static hostname into `/host/etc/hosts`. A new netstack-based runtime must account for both files. ::: -### iptables DNAT must work inside the sandbox +### gVisor skips iptables entirely (proxy-based egress) + +gVisor's userspace netstack is **isolated from the host network namespace**, so +the host-netns iptables DNAT/RETURN rules that `awf-iptables-init` installs +(port 80/443 → Squid, plus the NAT `RETURN` bypass for the MCP gateway) never +govern the sandbox's traffic. AWF therefore **does not run the iptables-init +container for gVisor at all** — the runtime is registered with +`usesIptables: false` in `container-runtime.ts`, which: + +- skips `assembleIptablesInitService()` (`optional-services.ts`), and +- sets `AWF_SKIP_IPTABLES_INIT=1` so `entrypoint.sh` doesn't wait for the (absent) + init-container ready-file handshake. + +Egress is enforced **solely** through the `HTTP_PROXY`/`HTTPS_PROXY` env vars +pointing at Squid. Because the iptables NAT bypass for the MCP gateway is gone, +`proxy-environment.ts` adds the network gateway IP (e.g. `172.30.0.1`) and +`host.docker.internal` to `NO_PROXY` for non-iptables runtimes, so proxy-aware +MCP clients (rmcp) connect to the gateway **directly** instead of being routed +through Squid and rejected with `403 ERR_ACCESS_DENIED`. + +:::caution Proxy-unaware tools under gVisor +With no iptables DNAT fallback, tools that ignore `HTTP_PROXY`/`HTTPS_PROXY` +(e.g. a raw `/dev/tcp` connection) have no route to external hosts and will fail +with "No route to host". Egress under gVisor requires proxy-aware clients. +::: -AWF's defense-in-depth relies on iptables DNAT (port 80/443 → Squid:3128) applied -inside the agent's network namespace. gVisor must support those rules for that -fallback to hold. `.github/workflows/test-gvisor-compat.yml` is a **manual, -non-gating diagnostic probe** that exercises iptables DNAT and proxy reachability -inside a `runsc` sandbox; it is useful evidence, but not an enforced guarantee in -CI. +`.github/workflows/test-gvisor-compat.yml` remains a **manual, non-gating +diagnostic probe** for iptables/proxy reachability inside a `runsc` sandbox; it +documents the historical behavior but is not part of the enforced egress path. ### Runtime-specific compatibility shims @@ -265,6 +286,7 @@ myruntime: { executionModel: 'compose', dockerRuntime: 'my-oci-runtime', // the name registered in daemon.json needsStaticDns: false, // true if its netstack can't reach 127.0.0.11 + usesIptables: true, // false if host-netns iptables can't govern its traffic }, ``` @@ -282,11 +304,14 @@ Set `needsStaticDns: true` only if the runtime cannot reach Docker's embedded DN `/etc/hosts` via `extra_hosts` *and* the chrooted `/host/etc/hosts`) and ensure topology peers are patched into both. -### 3. Confirm the network fallback works +### 3. Confirm the network egress model -AWF's iptables DNAT-to-Squid path must function inside the runtime's network -namespace. Add a compat check modeled on `test-gvisor-compat.yml` before relying -on it. +If the runtime shares the host network namespace (`usesIptables: true`), AWF's +iptables DNAT-to-Squid path must function inside it — add a compat check modeled +on `test-gvisor-compat.yml`. If the runtime has an isolated netstack +(`usesIptables: false`, like gVisor), AWF skips iptables entirely and relies on +the `HTTP_PROXY`/`HTTPS_PROXY` env vars plus a `NO_PROXY` entry for the MCP +gateway; ensure the agent's tools are all proxy-aware. ### 4. Ensure the runtime is installed @@ -302,7 +327,7 @@ viable and may be faster. | --- | --- | --- | | Host-kernel / syscall isolation | ✅ owns it (Sentry) | selects the runtime | | In-sandbox network stack | ✅ netstack | works around its DNS limits | -| Domain egress ACL | — | ✅ Squid + iptables DNAT | +| Domain egress ACL | — | ✅ Squid (via `HTTP_PROXY`/`HTTPS_PROXY`; no iptables under gVisor) | | Credential injection | — | ✅ api-proxy (`COPILOT_*`) | | Chroot + capability drop | — | ✅ entrypoint / capsh | | Lifecycle | OCI runtime under compose | ✅ `docker compose` + `docker wait` | diff --git a/src/container-runtime.test.ts b/src/container-runtime.test.ts index 595b19868..47b83bb8e 100644 --- a/src/container-runtime.test.ts +++ b/src/container-runtime.test.ts @@ -1,4 +1,4 @@ -import { resolveDockerRuntime, runtimeNeedsStaticDns, runtimeUsesComposeAgent } from './container-runtime'; +import { resolveDockerRuntime, runtimeNeedsStaticDns, runtimeUsesComposeAgent, runtimeUsesIptables } from './container-runtime'; import { sanitizeEnvForSbx } from './sbx-manager'; describe('container-runtime', () => { @@ -38,8 +38,27 @@ describe('container-runtime', () => { }); }); - describe('runtimeUsesComposeAgent', () => { - it('returns true when no runtime is configured', () => { + describe('runtimeUsesIptables', () => { + it('returns false for gvisor (isolated netstack)', () => { + expect(runtimeUsesIptables('gvisor')).toBe(false); + }); + + it('returns false for sbx (microVM manages own egress)', () => { + expect(runtimeUsesIptables('sbx')).toBe(false); + }); + + it('returns true for unknown runtimes (share host netns)', () => { + expect(runtimeUsesIptables('kata')).toBe(true); + expect(runtimeUsesIptables('runsc')).toBe(true); + }); + + it('returns true for undefined/empty (default runc)', () => { + expect(runtimeUsesIptables(undefined)).toBe(true); + expect(runtimeUsesIptables('')).toBe(true); + }); + }); + + describe('runtimeUsesComposeAgent', () => { it('returns true when no runtime is configured', () => { expect(runtimeUsesComposeAgent(undefined)).toBe(true); }); diff --git a/src/container-runtime.ts b/src/container-runtime.ts index 390b5d358..f39e6cb46 100644 --- a/src/container-runtime.ts +++ b/src/container-runtime.ts @@ -74,6 +74,21 @@ interface RuntimeCapabilities { * @see https://github.com/google/gvisor/issues/7469 */ readonly needsStaticDns: boolean; + + /** + * When `true`, AWF sets up egress control via the `awf-iptables-init` container, + * which applies host-netns iptables DNAT/RETURN rules inside the agent's network + * namespace (redirect port 80/443 → Squid, and bypass Squid for the MCP gateway). + * + * When `false`, those rules cannot govern the agent's traffic — e.g. gVisor's + * userspace netstack is isolated from the host network namespace, so the + * iptables-init container is skipped entirely and egress relies solely on the + * `HTTP_PROXY`/`HTTPS_PROXY` env vars plus `NO_PROXY` (the MCP gateway is reached + * directly instead of via an iptables bypass). + * + * @see https://github.com/google/gvisor/issues/7469 + */ + readonly usesIptables: boolean; } /** @@ -86,12 +101,16 @@ const RUNTIME_REGISTRY: Readonly> = { executionModel: 'compose', dockerRuntime: 'runsc', needsStaticDns: true, + // gVisor's isolated netstack can't be governed by host-netns iptables rules, + // so skip the iptables-init container and route egress via proxy env vars. + usesIptables: false, }, // Future: Docker sbx microVM backend sbx: { executionModel: 'microvm', dockerRuntime: undefined, needsStaticDns: false, // sbx manages its own DNS + usesIptables: false, // microVM manages its own network egress }, }; @@ -124,6 +143,23 @@ export function runtimeNeedsStaticDns(runtime: string | undefined): boolean { return RUNTIME_REGISTRY[runtime]?.needsStaticDns ?? false; } +/** + * Returns `true` when AWF should set up egress control for the runtime via the + * `awf-iptables-init` container (host-netns iptables DNAT/RETURN rules). + * + * Returns `false` for runtimes whose network stack cannot be governed by those + * rules (e.g. gVisor's isolated netstack, or microVM backends that manage their + * own egress). For these, AWF skips the iptables-init container and relies on + * proxy env vars + `NO_PROXY`. + * + * Defaults to `true` for unknown/undefined runtimes (raw Docker runtime names + * and the default runc), which share the host network namespace. + */ +export function runtimeUsesIptables(runtime: string | undefined): boolean { + if (!runtime) return true; + return RUNTIME_REGISTRY[runtime]?.usesIptables ?? true; +} + /** * Returns `true` when the agent should be included as a Docker Compose service * (the default for runc, gVisor, and other OCI runtimes). diff --git a/src/services/agent-environment/proxy-environment.test.ts b/src/services/agent-environment/proxy-environment.test.ts new file mode 100644 index 000000000..ffae83928 --- /dev/null +++ b/src/services/agent-environment/proxy-environment.test.ts @@ -0,0 +1,38 @@ +import { WrapperConfig } from '../../types'; +import { baseConfig, mockNetworkConfig } from '../../test-helpers/docker-test-fixtures.test-utils'; +import { buildProxyEnvironment } from './proxy-environment'; + +function run(config: Omit & { workDir?: string }): Record { + const environment: Record = {}; + buildProxyEnvironment({ + config: { ...config, workDir: config.workDir ?? '/tmp/awf-work' } as WrapperConfig, + networkConfig: mockNetworkConfig, + environment, + }); + return environment; +} + +describe('buildProxyEnvironment', () => { + it('does not add the network gateway to NO_PROXY for default (iptables) runtimes', () => { + const env = run({ ...baseConfig }); + expect(env.NO_PROXY.split(',')).not.toContain('172.30.0.1'); + expect(env.NO_PROXY.split(',')).not.toContain('host.docker.internal'); + // Keeps lowercase mirror in sync + expect(env.no_proxy).toBe(env.NO_PROXY); + }); + + it('adds the network gateway + host.docker.internal to NO_PROXY for gVisor', () => { + // gVisor has no iptables NAT bypass for the MCP gateway, so it must be in + // NO_PROXY for proxy-aware clients to reach it directly. + const env = run({ ...baseConfig, containerRuntime: 'gvisor' }); + expect(env.NO_PROXY.split(',')).toContain('172.30.0.1'); + expect(env.NO_PROXY.split(',')).toContain('host.docker.internal'); + expect(env.no_proxy).toBe(env.NO_PROXY); + }); + + it('adds the network gateway to NO_PROXY when host access is enabled (any runtime)', () => { + const env = run({ ...baseConfig, enableHostAccess: true }); + expect(env.NO_PROXY.split(',')).toContain('172.30.0.1'); + expect(env.NO_PROXY.split(',')).toContain('host.docker.internal'); + }); +}); diff --git a/src/services/agent-environment/proxy-environment.ts b/src/services/agent-environment/proxy-environment.ts index 530baba2c..959ca435a 100644 --- a/src/services/agent-environment/proxy-environment.ts +++ b/src/services/agent-environment/proxy-environment.ts @@ -1,6 +1,7 @@ import { WrapperConfig } from '../../types'; import { NetworkConfig } from '../squid-service'; import { buildNoProxyValue } from '../no-proxy-utils'; +import { runtimeUsesIptables } from '../../container-runtime'; interface ProxyEnvironmentParams { config: WrapperConfig; @@ -13,7 +14,13 @@ export function buildProxyEnvironment(params: ProxyEnvironmentParams): void { const noProxyHosts: string[] = ['0.0.0.0', networkConfig.squidIp, networkConfig.agentIp]; - if (config.enableHostAccess) { + // The MCP gateway is served on the network gateway (e.g. 172.30.0.1). In + // standard mode an iptables NAT RETURN rule bypasses Squid for it. Runtimes + // whose netstack can't use host-netns iptables (e.g. gVisor) have no such + // bypass, so add the gateway to NO_PROXY so proxy-aware clients (rmcp) connect + // to it directly instead of being routed through Squid and rejected. + const gatewayNeedsNoProxy = config.enableHostAccess || !runtimeUsesIptables(config.containerRuntime); + if (gatewayNeedsNoProxy) { const subnetBase = networkConfig.subnet.split('/')[0]; const parts = subnetBase.split('.'); const networkGatewayIp = `${parts[0]}.${parts[1]}.${parts[2]}.1`; diff --git a/src/services/optional-services.test.ts b/src/services/optional-services.test.ts index 2235bc251..3c55179ea 100644 --- a/src/services/optional-services.test.ts +++ b/src/services/optional-services.test.ts @@ -39,6 +39,22 @@ describe('optional-services helpers', () => { expect(environment.AWF_API_PROXY_IP).toBeUndefined(); expect(environment.AWF_CLI_PROXY_IP).toBeUndefined(); expect(environment.AWF_NETWORK_ISOLATION).toBeUndefined(); + expect(environment.AWF_SKIP_IPTABLES_INIT).toBeUndefined(); + }); + + it('sets AWF_SKIP_IPTABLES_INIT for gVisor without network isolation', () => { + const environment: Record = {}; + const config: WrapperConfig = { + ...baseConfig, + workDir: '/tmp/awf-work', + containerRuntime: 'gvisor', + }; + + testHelpers.presetSidecarIpEnvVars(environment, config, mockNetworkConfig); + + expect(environment.AWF_SKIP_IPTABLES_INIT).toBe('1'); + // gVisor is not network-isolation (topology) mode + expect(environment.AWF_NETWORK_ISOLATION).toBeUndefined(); }); }); diff --git a/src/services/optional-services.ts b/src/services/optional-services.ts index b922bcfc6..d0c0fc7bb 100644 --- a/src/services/optional-services.ts +++ b/src/services/optional-services.ts @@ -7,6 +7,7 @@ import { buildDohProxyService } from './doh-proxy-service'; import { buildCliProxyService } from './cli-proxy-service'; import { buildSysrootStageService, isSysrootEnabled } from './sysroot-service'; import { resolveDockerHostGateway } from './host-gateway'; +import { runtimeUsesIptables } from '../container-runtime'; import { NetworkConfig, ImageBuildConfig } from './squid-service'; interface AssembleOptionalServicesParams { @@ -44,6 +45,13 @@ function presetSidecarIpEnvVars( // Tell the agent entrypoint to skip the iptables-init handshake. environment.AWF_NETWORK_ISOLATION = '1'; } + + if (!runtimeUsesIptables(config.containerRuntime)) { + // Runtimes whose network stack can't be governed by host-netns iptables + // (e.g. gVisor's isolated netstack) have no iptables-init container, so the + // ready-file handshake would never complete. Tell the entrypoint to skip it. + environment.AWF_SKIP_IPTABLES_INIT = '1'; + } } function filterAgentVolumesForSysroot( @@ -144,9 +152,9 @@ function assembleSysrootService( function assembleIptablesInitService( params: AssembleOptionalServicesParams, - networkIsolation: boolean, + skipIptables: boolean, ): void { - if (networkIsolation) return; + if (skipIptables) return; const { services, agentService, environment, config, networkConfig, initSignalDir } = params; @@ -250,10 +258,15 @@ export function assembleOptionalServices( const includeComposeAgent = params.includeComposeAgent !== false; const sysrootActive = isSysrootEnabled(config); + // Skip the iptables-init container when egress isn't governed by host-netns + // iptables — either topology-based network isolation, or a runtime whose + // network stack can't use those rules (e.g. gVisor's isolated netstack). + const skipIptables = networkIsolation || !runtimeUsesIptables(config.containerRuntime); + presetSidecarIpEnvVars(environment, config, networkConfig); if (includeComposeAgent) { assembleSysrootService(params, imageConfig.registry, imageConfig.parsedTag, sysrootActive); - assembleIptablesInitService(params, networkIsolation); + assembleIptablesInitService(params, skipIptables); } assembleApiProxyService(params); assembleDohProxyService(params); From e9a4a6e23caa2549575eb89753d63d6d23e6053d Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Sun, 19 Jul 2026 10:29:57 -0700 Subject: [PATCH 2/2] fix(gvisor): alias runsc runtime and clarify egress docs - container-runtime: canonicalize raw runsc OCI name to gVisor capabilities via an alias table; add isGvisorRuntime() and use it for the Claude/Bun check. - compose-generator.test: assert gVisor (networkIsolation:false) omits iptables-init, keeps the agent, and sets AWF_SKIP_IPTABLES_INIT (gvisor+runsc). - docs/gvisor-integration: distinguish Squid-routed internet egress from the direct MCP/host-gateway NO_PROXY exception; clarify the enforced boundary is topology (strict) / host DOCKER-USER default-deny (legacy), not proxy env. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: be040aef-5830-42b1-a871-4cade09f4585 --- docs/gvisor-integration.md | 28 ++++++++--- src/compose-generator.test.ts | 39 ++++++++++++++++ src/container-runtime.test.ts | 34 ++++++++++++-- src/container-runtime.ts | 46 +++++++++++++++++-- .../tool-specific-environment.ts | 3 +- 5 files changed, 135 insertions(+), 15 deletions(-) diff --git a/docs/gvisor-integration.md b/docs/gvisor-integration.md index 209c3e6ca..590de536f 100644 --- a/docs/gvisor-integration.md +++ b/docs/gvisor-integration.md @@ -217,12 +217,28 @@ container for gVisor at all** — the runtime is registered with - sets `AWF_SKIP_IPTABLES_INIT=1` so `entrypoint.sh` doesn't wait for the (absent) init-container ready-file handshake. -Egress is enforced **solely** through the `HTTP_PROXY`/`HTTPS_PROXY` env vars -pointing at Squid. Because the iptables NAT bypass for the MCP gateway is gone, -`proxy-environment.ts` adds the network gateway IP (e.g. `172.30.0.1`) and -`host.docker.internal` to `NO_PROXY` for non-iptables runtimes, so proxy-aware -MCP clients (rmcp) connect to the gateway **directly** instead of being routed -through Squid and rejected with `403 ERR_ACCESS_DENIED`. +**Internet egress** for proxy-aware clients is routed through Squid via the +`HTTP_PROXY`/`HTTPS_PROXY` env vars. There is one deliberate exception: because +the iptables NAT bypass for the MCP gateway is gone, `proxy-environment.ts` adds +the network gateway IP (e.g. `172.30.0.1`) and `host.docker.internal` to +`NO_PROXY` for non-iptables runtimes, so proxy-aware MCP clients (rmcp) connect +to the gateway **directly** instead of being routed through Squid and rejected +with `403 ERR_ACCESS_DENIED`. + +Note that `NO_PROXY`/`HTTP_PROXY` are **client-side routing hints, not a security +boundary** — the agent controls its own environment and could reach the gateway +with or without them. The enforced egress boundary is external to the agent and +runtime-independent: + +- **Strict/default mode** (`networkIsolation: true`): the agent sits on an + internal Docker network with no route off-host except through the dual-homed + Squid; the network topology is the boundary. +- **Legacy mode** (`networkIsolation: false`): host-level iptables in the + `DOCKER-USER` (FORWARD) chain default-denies container→internet traffic + (`host-iptables-rules.ts`) — this is what produces "No route to host" and is + unaffected by which runtime the agent uses. The container-level iptables that + gVisor skips was only ever a defense-in-depth DNAT fallback, never the primary + boundary. :::caution Proxy-unaware tools under gVisor With no iptables DNAT fallback, tools that ignore `HTTP_PROXY`/`HTTPS_PROXY` diff --git a/src/compose-generator.test.ts b/src/compose-generator.test.ts index 42a921c6b..68f537792 100644 --- a/src/compose-generator.test.ts +++ b/src/compose-generator.test.ts @@ -321,6 +321,45 @@ describe('generateDockerCompose', () => { }); }); + describe('gVisor runtime (non-iptables compose agent)', () => { + it('omits iptables-init but keeps the compose agent when networkIsolation is false', () => { + const config = { + ...mockConfig, + containerRuntime: 'gvisor', + networkIsolation: false, + }; + const result = generateDockerCompose(config, mockNetworkConfig); + + expect(result.services.agent).toBeDefined(); + expect(result.services['iptables-init']).toBeUndefined(); + }); + + it('sets AWF_SKIP_IPTABLES_INIT (not AWF_NETWORK_ISOLATION) in the agent environment', () => { + const config = { + ...mockConfig, + containerRuntime: 'gvisor', + networkIsolation: false, + }; + const result = generateDockerCompose(config, mockNetworkConfig); + + expect(result.services.agent.environment?.AWF_SKIP_IPTABLES_INIT).toBe('1'); + expect(result.services.agent.environment?.AWF_NETWORK_ISOLATION).toBeUndefined(); + }); + + it('treats the raw runsc runtime name the same as gvisor', () => { + const config = { + ...mockConfig, + containerRuntime: 'runsc', + networkIsolation: false, + }; + const result = generateDockerCompose(config, mockNetworkConfig); + + expect(result.services.agent).toBeDefined(); + expect(result.services['iptables-init']).toBeUndefined(); + expect(result.services.agent.environment?.AWF_SKIP_IPTABLES_INIT).toBe('1'); + }); + }); + describe('microVM runtime (sbx)', () => { it('omits compose agent and agent-only helper services', () => { const config = { diff --git a/src/container-runtime.test.ts b/src/container-runtime.test.ts index 47b83bb8e..e7aded4e4 100644 --- a/src/container-runtime.test.ts +++ b/src/container-runtime.test.ts @@ -1,4 +1,4 @@ -import { resolveDockerRuntime, runtimeNeedsStaticDns, runtimeUsesComposeAgent, runtimeUsesIptables } from './container-runtime'; +import { resolveDockerRuntime, runtimeNeedsStaticDns, runtimeUsesComposeAgent, runtimeUsesIptables, isGvisorRuntime } from './container-runtime'; import { sanitizeEnvForSbx } from './sbx-manager'; describe('container-runtime', () => { @@ -13,9 +13,12 @@ describe('container-runtime', () => { it('passes through unknown runtime names unchanged', () => { expect(resolveDockerRuntime('kata')).toBe('kata'); - expect(resolveDockerRuntime('runsc')).toBe('runsc'); expect(resolveDockerRuntime('custom-runtime')).toBe('custom-runtime'); }); + + it('resolves the runsc alias to gVisor (docker runtime runsc)', () => { + expect(resolveDockerRuntime('runsc')).toBe('runsc'); + }); }); describe('runtimeNeedsStaticDns', () => { @@ -29,7 +32,10 @@ describe('container-runtime', () => { it('returns false for unknown runtimes', () => { expect(runtimeNeedsStaticDns('kata')).toBe(false); - expect(runtimeNeedsStaticDns('runsc')).toBe(false); + }); + + it('returns true for the runsc alias (same as gvisor)', () => { + expect(runtimeNeedsStaticDns('runsc')).toBe(true); }); it('returns false for undefined/empty', () => { @@ -49,7 +55,10 @@ describe('container-runtime', () => { it('returns true for unknown runtimes (share host netns)', () => { expect(runtimeUsesIptables('kata')).toBe(true); - expect(runtimeUsesIptables('runsc')).toBe(true); + }); + + it('returns false for the runsc alias (same as gvisor)', () => { + expect(runtimeUsesIptables('runsc')).toBe(false); }); it('returns true for undefined/empty (default runc)', () => { @@ -75,6 +84,23 @@ describe('container-runtime', () => { expect(runtimeUsesComposeAgent('runsc')).toBe(true); }); }); + + describe('isGvisorRuntime', () => { + it('returns true for the gvisor friendly name', () => { + expect(isGvisorRuntime('gvisor')).toBe(true); + }); + + it('returns true for the raw runsc runtime name', () => { + expect(isGvisorRuntime('runsc')).toBe(true); + }); + + it('returns false for other/undefined runtimes', () => { + expect(isGvisorRuntime('sbx')).toBe(false); + expect(isGvisorRuntime('kata')).toBe(false); + expect(isGvisorRuntime(undefined)).toBe(false); + expect(isGvisorRuntime('')).toBe(false); + }); + }); }); describe('sanitizeEnvForSbx', () => { diff --git a/src/container-runtime.ts b/src/container-runtime.ts index f39e6cb46..ff3f70485 100644 --- a/src/container-runtime.ts +++ b/src/container-runtime.ts @@ -114,6 +114,33 @@ const RUNTIME_REGISTRY: Readonly> = { }, }; +/** + * Aliases for runtime names that should resolve to the same capabilities as a + * canonical registry entry. This lets users pass either the friendly name + * (`gvisor`) or the raw Docker OCI runtime name (`runsc`) and get identical + * behavior — without an alias, `runsc` would fall through to the unknown-runtime + * defaults (iptables-capable), reintroducing the gVisor egress bug. + */ +const RUNTIME_ALIASES: Readonly> = { + runsc: 'gvisor', +}; + +/** + * Canonicalizes a user-facing runtime name to its registry key, following the + * alias table. Unknown names are returned unchanged. + */ +function canonicalRuntime(runtime: string): string { + return RUNTIME_ALIASES[runtime] ?? runtime; +} + +/** + * Looks up the capabilities for a runtime name, following aliases. Returns + * `undefined` for unknown names (raw Docker runtime identifiers / default runc). + */ +function lookupCapabilities(runtime: string): RuntimeCapabilities | undefined { + return RUNTIME_REGISTRY[canonicalRuntime(runtime)]; +} + // ─── Public API ────────────────────────────────────────────────────────────── /** @@ -124,7 +151,7 @@ const RUNTIME_REGISTRY: Readonly> = { * runtime field. */ export function resolveDockerRuntime(runtime: string): string | undefined { - const entry = RUNTIME_REGISTRY[runtime]; + const entry = lookupCapabilities(runtime); if (entry) return entry.dockerRuntime; // Unknown name — pass through as a raw Docker runtime identifier return runtime; @@ -140,7 +167,7 @@ export function resolveDockerRuntime(runtime: string): string | undefined { */ export function runtimeNeedsStaticDns(runtime: string | undefined): boolean { if (!runtime) return false; - return RUNTIME_REGISTRY[runtime]?.needsStaticDns ?? false; + return lookupCapabilities(runtime)?.needsStaticDns ?? false; } /** @@ -157,7 +184,7 @@ export function runtimeNeedsStaticDns(runtime: string | undefined): boolean { */ export function runtimeUsesIptables(runtime: string | undefined): boolean { if (!runtime) return true; - return RUNTIME_REGISTRY[runtime]?.usesIptables ?? true; + return lookupCapabilities(runtime)?.usesIptables ?? true; } /** @@ -172,7 +199,18 @@ export function runtimeUsesIptables(runtime: string | undefined): boolean { */ export function runtimeUsesComposeAgent(runtime: string | undefined): boolean { if (!runtime) return true; - const entry = RUNTIME_REGISTRY[runtime]; + const entry = lookupCapabilities(runtime); if (!entry) return true; // unknown runtime → assume compose return entry.executionModel === 'compose'; } + +/** + * Returns `true` when the configured runtime is gVisor, accepting either the + * friendly name (`gvisor`) or the raw Docker OCI runtime name (`runsc`). Use + * this instead of a direct `=== 'gvisor'` comparison so both spellings are + * handled consistently. + */ +export function isGvisorRuntime(runtime: string | undefined): boolean { + if (!runtime) return false; + return canonicalRuntime(runtime) === 'gvisor'; +} diff --git a/src/services/agent-environment/tool-specific-environment.ts b/src/services/agent-environment/tool-specific-environment.ts index c65122eff..2eaecf852 100644 --- a/src/services/agent-environment/tool-specific-environment.ts +++ b/src/services/agent-environment/tool-specific-environment.ts @@ -3,6 +3,7 @@ import { COPILOT_PLACEHOLDER_TOKEN } from '../../constants/placeholders'; import { logger } from '../../logger'; import { extractCommandBinaryName, shouldUseDockerHostStaging } from '../agent-volumes/docker-host-staging'; import { WrapperConfig } from '../../types'; +import { isGvisorRuntime } from '../../container-runtime'; interface ToolEnvironmentParams { config: WrapperConfig; @@ -50,7 +51,7 @@ export function buildToolEnvironment(params: ToolEnvironmentParams): void { // BUN_JSC_useJIT=0 forces Bun into interpreter-only mode, which is slower // but avoids the observed crashes. // Reference: https://github.com/oven-sh/bun/issues/22901 - if (isClaudeCommand && config.containerRuntime === 'gvisor') { + if (isClaudeCommand && isGvisorRuntime(config.containerRuntime)) { environment.BUN_JSC_useJIT = '0'; logger.info('gVisor runtime detected with Claude — disabled Bun JIT (BUN_JSC_useJIT=0)'); }