diff --git a/containers/agent/entrypoint.sh b/containers/agent/entrypoint.sh index 00094a497..fd00a2086 100644 --- a/containers/agent/entrypoint.sh +++ b/containers/agent/entrypoint.sh @@ -708,11 +708,12 @@ if [ "${AWF_CHROOT_ENABLED}" = "true" ]; then # Find the user name on the host system by UID # This allows us to run as the same user inside the chroot - HOST_USER_UID="${AWF_USER_UID:-1000}" - HOST_USER_GID="${AWF_USER_GID:-${HOST_USER_UID}}" + HOST_USER_UID="${AWF_CHROOT_IDENTITY_UID:-${AWF_USER_UID:-1000}}" + HOST_USER_GID="${AWF_CHROOT_IDENTITY_GID:-${AWF_USER_GID:-${HOST_USER_UID}}}" HOST_USER=$(chroot /host getent passwd "${HOST_USER_UID}" 2>/dev/null | cut -d: -f1 || echo "") CAPSH_IDENTITY_ARGS="" - CHROOT_HOME_OVERRIDE="" + CHROOT_HOME_OVERRIDE="${AWF_CHROOT_IDENTITY_HOME:-}" + CHROOT_USER_OVERRIDE="${AWF_CHROOT_IDENTITY_USER:-}" if [ -z "${HOST_USER}" ]; then # User not found in chroot's /etc/passwd (common on ARC-DinD Alpine daemons). # Synthesize minimal identity files so the agent can resolve its own UID/GID. @@ -793,7 +794,9 @@ if [ "${AWF_CHROOT_ENABLED}" = "true" ]; then echo "[entrypoint] Running as synthesized host user: ${HOST_USER} (UID: ${HOST_USER_UID})" else CAPSH_IDENTITY_ARGS="--gid=${HOST_USER_GID} --uid=${HOST_USER_UID} --groups=${HOST_USER_GID}" - CHROOT_HOME_OVERRIDE="${SYNTH_HOME}" + if [ -z "${CHROOT_HOME_OVERRIDE}" ]; then + CHROOT_HOME_OVERRIDE="${SYNTH_HOME}" + fi echo "[entrypoint][WARN] Proceeding with numeric UID/GID fallback (${HOST_USER_UID}:${HOST_USER_GID})" fi else @@ -801,6 +804,10 @@ if [ "${AWF_CHROOT_ENABLED}" = "true" ]; then echo "[entrypoint] Running as host user: ${HOST_USER} (UID: ${HOST_USER_UID})" fi + if [ -z "${CHROOT_USER_OVERRIDE}" ] && [ -n "${HOST_USER}" ]; then + CHROOT_USER_OVERRIDE="${HOST_USER}" + fi + # Write the command to a temporary script file in the chroot # This avoids complex quoting issues with nested shells SCRIPT_FILE="/tmp/awf-cmd-$$.sh" @@ -1055,11 +1062,14 @@ AWFEOF LD_PRELOAD_CMD="export LD_PRELOAD=${ONE_SHOT_TOKEN_LIB};" fi + AWF_CHROOT_EFFECTIVE_HOME="${CHROOT_HOME_OVERRIDE}" \ + AWF_CHROOT_EFFECTIVE_USER="${CHROOT_USER_OVERRIDE}" \ run_agent_with_token_protection chroot /host /bin/bash -c " cd '${CHROOT_WORKDIR}' 2>/dev/null || cd / trap '${CLEANUP_CMD}' EXIT ${LD_PRELOAD_CMD} - if [ -n '${CHROOT_HOME_OVERRIDE}' ]; then export HOME='${CHROOT_HOME_OVERRIDE}'; fi + if [ -n \"\${AWF_CHROOT_EFFECTIVE_HOME:-}\" ]; then export HOME=\"\${AWF_CHROOT_EFFECTIVE_HOME}\"; fi + if [ -n \"\${AWF_CHROOT_EFFECTIVE_USER:-}\" ]; then export USER=\"\${AWF_CHROOT_EFFECTIVE_USER}\"; export LOGNAME=\"\${AWF_CHROOT_EFFECTIVE_USER}\"; fi exec capsh --drop=${CAPS_TO_DROP} ${CAPSH_IDENTITY_ARGS} -- -c 'exec ${SCRIPT_FILE}' " else diff --git a/docs/arc-dind.md b/docs/arc-dind.md index ccd1a0daf..869ea297f 100644 --- a/docs/arc-dind.md +++ b/docs/arc-dind.md @@ -1,29 +1,60 @@ -# ARC + DinD notes - -When using ARC runners with a split runner/daemon filesystem (`DOCKER_HOST` sidecar) and `--docker-host-path-prefix`, AWF now stages required chroot files automatically: - -- invoking CLI binary (for example `copilot`, `claude`, `codex`) -- `/etc/passwd` -- `/etc/group` -- chroot `/etc/hosts` - -AWF validates the staged runner binary name before using it in chroot bootstrap paths. Per-run staged chroot-host directories remain unique and AWF prunes stale ones automatically from the shared staging root. +# ARC + DinD Configuration + +AWF supports ARC runners where the runner filesystem and Docker daemon filesystem are split (DinD sidecar patterns). + +## What AWF now handles automatically + +- Split-filesystem probing for `--docker-host-path-prefix` +- Chroot staging for: + - invoking CLI binary (`copilot`, `claude`, `codex`, etc.) + - `/etc/passwd` + - `/etc/group` + - generated chroot `/etc/hosts` +- DinD `DOCKER_HOST` propagation into agent/MCP environments when DinD is detected + +## ARC/DinD stdin config surface + +```json +{ + "container": { + "enableDind": true, + "dockerHostPathPrefix": "/tmp/gh-aw" + }, + "chroot": { + "identity": { + "home": "/tmp/gh-aw/home", + "user": "runner", + "uid": 1001, + "gid": 1001 + } + }, + "dind": { + "preStageDirs": true, + "workDir": "/tmp/gh-aw", + "stagingImage": "ghcr.io/github/gh-aw-firewall/agent:latest", + "stageEngineBinary": { + "path": "/usr/local/bin/copilot", + "targetPath": "/usr/local/bin/copilot" + } + } +} +``` + +## Field behavior + +- `chroot.identity.*`: applied inside entrypoint **after** `chroot /host` to override HOME/USER/LOGNAME and identity mapping hints. +- `dind.preStageDirs`: runs a short-lived staging container in DinD mode to create required workdir tree with open permissions. +- `dind.stageEngineBinary`: copies an engine binary from the runner path into daemon-visible filesystem before compose startup. +- `dind.stagingImage`: image used for short-lived staging containers. +- `dind.workDir`: target root for DinD pre-staged directory tree (`/tmp/gh-aw` default). ## Auto-detection of split filesystem setups AWF detects likely ARC/DinD environments at startup and warns when `--docker-host-path-prefix` is missing: -- **Non-standard `DOCKER_HOST` unix socket**: any `unix://` socket outside `/var/run/docker.sock` and `/run/docker.sock` is treated as a sibling-daemon pod indicator. -- **`AWF_DIND=1`**: operators can set this environment variable to explicitly declare a DinD setup. - -When either signal is present and no explicit prefix is supplied, AWF emits a warning suggesting `--docker-host-path-prefix` (for example, `--docker-host-path-prefix /tmp/gh-aw` for typical ARC layouts). The DinD probe also considers `/tmp/gh-aw` as a candidate prefix when discovering the split-filesystem layout. - -## Remaining requirement: Node.js in the DinD-visible host filesystem - -Copilot CLI still requires `node` to be available inside the chrooted runtime PATH. Ensure your DinD image (or staged host toolcache) includes Node.js. - -Recommended base image for ARC DinD sidecars: +- non-default unix `DOCKER_HOST` socket paths (outside `/var/run/docker.sock` and `/run/docker.sock`) +- `AWF_DIND=1` -- `node:20-bookworm` +## Runtime prerequisite -This provides a glibc userspace compatible with AWF chroot mode plus a current Node.js runtime. +Copilot CLI still requires `node` to be available inside the chrooted runtime PATH. diff --git a/docs/awf-config-spec.md b/docs/awf-config-spec.md index 2d12cc908..4cd13463d 100644 --- a/docs/awf-config-spec.md +++ b/docs/awf-config-spec.md @@ -162,6 +162,15 @@ the corresponding CLI flag. - `container.dockerHost` → `--docker-host` - `container.dockerHostPathPrefix` → `--docker-host-path-prefix` - `container.runnerToolCachePath` → *(config-only; checked first for optional read-only runner tool cache mount, before `RUNNER_TOOL_CACHE` and `/home/runner/work/_tool` auto-detection)* +- `chroot.identity.home` → *(config-only; forwarded as `AWF_CHROOT_IDENTITY_HOME` and applied after chroot pivot)* +- `chroot.identity.user` → *(config-only; forwarded as `AWF_CHROOT_IDENTITY_USER` and applied to `USER`/`LOGNAME` after chroot pivot)* +- `chroot.identity.uid` → *(config-only; forwarded as `AWF_CHROOT_IDENTITY_UID` for chroot user mapping)* +- `chroot.identity.gid` → *(config-only; forwarded as `AWF_CHROOT_IDENTITY_GID` for chroot user mapping)* +- `dind.preStageDirs` → *(config-only; enables daemon-side pre-staging of the DinD work directory tree before compose startup)* +- `dind.workDir` → *(config-only; daemon-visible staging root, default `/tmp/gh-aw`)* +- `dind.stagingImage` → *(config-only; image used for short-lived DinD staging containers)* +- `dind.stageEngineBinary.path` → *(config-only; runner-side engine binary source path for DinD staging)* +- `dind.stageEngineBinary.targetPath` → *(config-only; daemon-side destination path for staged engine binary)* - `environment.envFile` → `--env-file` - `environment.envAll` → `--env-all` - `environment.excludeEnv[]` → `--exclude-env` *(repeatable)* @@ -177,6 +186,8 @@ the corresponding CLI flag. When `container.dockerHostPathPrefix` points at a daemon-visible shared `/tmp` path, the implementation stages the invoking CLI binary together with `/etc/passwd`, `/etc/group`, and the generated chroot `/etc/hosts` under that shared path so chroot mode can bootstrap on split-filesystem ARC/DinD hosts. +When DinD is detected, AWF preserves the detected `DOCKER_HOST` value for the agent environment (including MCP servers) so DinD-aware tooling can reach the correct daemon without manual workflow env overrides. + The following CLI flag has no config-file equivalent by design: - `-e, --env ` — inject a single environment variable into diff --git a/docs/awf-config.schema.json b/docs/awf-config.schema.json index 6e3cbe2d1..ed38b5da0 100644 --- a/docs/awf-config.schema.json +++ b/docs/awf-config.schema.json @@ -492,6 +492,72 @@ } } }, + "chroot": { + "type": "object", + "description": "Chroot execution overrides for split-filesystem ARC/DinD runners.", + "additionalProperties": false, + "properties": { + "identity": { + "type": "object", + "description": "Identity values applied after chroot pivot to override HOME/USER defaults inside chroot mode.", + "additionalProperties": false, + "properties": { + "home": { + "type": "string", + "description": "Home directory path to export inside chroot mode (for example: \"/tmp/gh-aw/home\")." + }, + "user": { + "type": "string", + "description": "User/LOGNAME string to export inside chroot mode (for example: \"runner\")." + }, + "uid": { + "type": "integer", + "minimum": 1, + "description": "UID hint used for chroot identity synthesis and user switching." + }, + "gid": { + "type": "integer", + "minimum": 1, + "description": "GID hint used for chroot identity synthesis and user switching." + } + } + } + } + }, + "dind": { + "type": "object", + "description": "Bootstrap helpers for ARC/DinD split runner/daemon filesystems.", + "additionalProperties": false, + "properties": { + "preStageDirs": { + "type": "boolean", + "description": "When true and DinD is detected, AWF pre-creates the required /tmp/gh-aw directory tree inside the daemon-visible filesystem before compose startup." + }, + "workDir": { + "type": "string", + "description": "Daemon-visible working directory for DinD pre-staging (default: \"/tmp/gh-aw\")." + }, + "stagingImage": { + "type": "string", + "description": "Container image used for short-lived DinD staging operations." + }, + "stageEngineBinary": { + "type": "object", + "description": "Engine binary staging settings for split-filesystem DinD setups.", + "additionalProperties": false, + "properties": { + "path": { + "type": "string", + "description": "Runner-side path to the engine binary to stage (for example: \"/usr/local/bin/copilot\")." + }, + "targetPath": { + "type": "string", + "description": "Daemon filesystem destination path for the staged engine binary." + } + } + } + } + }, "environment": { "type": "object", "description": "Environment variable propagation into the agent container. Merge behavior is: AWF-reserved variables are set by AWF and are not overridden by envAll or envFile; if envAll is true, host environment variables are forwarded next; envFile is then applied only for variables not already present, so it does not override envAll; CLI -e/--env has highest precedence and may override any variable, including AWF-reserved ones. When apiProxy.enabled is true, source credentials (OPENAI_API_KEY, ANTHROPIC_API_KEY, etc.) are excluded from the agent and held in the API proxy sidecar. See docs/awf-config-spec.md §8–9 for credential isolation rules.", diff --git a/docs/chroot-mode.md b/docs/chroot-mode.md index 1b489d001..78e270f8a 100644 --- a/docs/chroot-mode.md +++ b/docs/chroot-mode.md @@ -351,6 +351,25 @@ AWF handles this automatically at two layers: No configuration is required — synthesis is triggered automatically when user lookup fails. +### Chroot Identity Override (ARC/DinD) + +On split-filesystem ARC/DinD runners, you can explicitly override chroot identity values via stdin config: + +```json +{ + "chroot": { + "identity": { + "home": "/tmp/gh-aw/home", + "user": "runner", + "uid": 1001, + "gid": 1001 + } + } +} +``` + +AWF forwards these values to the agent entrypoint and applies them **after** `chroot /host`, overriding default `HOME`, `USER`, and `LOGNAME` values for the chrooted command runtime. + ### Error: Working directory does not exist ``` diff --git a/src/awf-config-schema.json b/src/awf-config-schema.json index 6e3cbe2d1..ed38b5da0 100644 --- a/src/awf-config-schema.json +++ b/src/awf-config-schema.json @@ -492,6 +492,72 @@ } } }, + "chroot": { + "type": "object", + "description": "Chroot execution overrides for split-filesystem ARC/DinD runners.", + "additionalProperties": false, + "properties": { + "identity": { + "type": "object", + "description": "Identity values applied after chroot pivot to override HOME/USER defaults inside chroot mode.", + "additionalProperties": false, + "properties": { + "home": { + "type": "string", + "description": "Home directory path to export inside chroot mode (for example: \"/tmp/gh-aw/home\")." + }, + "user": { + "type": "string", + "description": "User/LOGNAME string to export inside chroot mode (for example: \"runner\")." + }, + "uid": { + "type": "integer", + "minimum": 1, + "description": "UID hint used for chroot identity synthesis and user switching." + }, + "gid": { + "type": "integer", + "minimum": 1, + "description": "GID hint used for chroot identity synthesis and user switching." + } + } + } + } + }, + "dind": { + "type": "object", + "description": "Bootstrap helpers for ARC/DinD split runner/daemon filesystems.", + "additionalProperties": false, + "properties": { + "preStageDirs": { + "type": "boolean", + "description": "When true and DinD is detected, AWF pre-creates the required /tmp/gh-aw directory tree inside the daemon-visible filesystem before compose startup." + }, + "workDir": { + "type": "string", + "description": "Daemon-visible working directory for DinD pre-staging (default: \"/tmp/gh-aw\")." + }, + "stagingImage": { + "type": "string", + "description": "Container image used for short-lived DinD staging operations." + }, + "stageEngineBinary": { + "type": "object", + "description": "Engine binary staging settings for split-filesystem DinD setups.", + "additionalProperties": false, + "properties": { + "path": { + "type": "string", + "description": "Runner-side path to the engine binary to stage (for example: \"/usr/local/bin/copilot\")." + }, + "targetPath": { + "type": "string", + "description": "Daemon filesystem destination path for the staged engine binary." + } + } + } + } + }, "environment": { "type": "object", "description": "Environment variable propagation into the agent container. Merge behavior is: AWF-reserved variables are set by AWF and are not overridden by envAll or envFile; if envAll is true, host environment variables are forwarded next; envFile is then applied only for variables not already present, so it does not override envAll; CLI -e/--env has highest precedence and may override any variable, including AWF-reserved ones. When apiProxy.enabled is true, source credentials (OPENAI_API_KEY, ANTHROPIC_API_KEY, etc.) are excluded from the agent and held in the API proxy sidecar. See docs/awf-config-spec.md §8–9 for credential isolation rules.", diff --git a/src/commands/build-config.test.ts b/src/commands/build-config.test.ts index 5fd5e9799..9b0ebe008 100644 --- a/src/commands/build-config.test.ts +++ b/src/commands/build-config.test.ts @@ -301,6 +301,57 @@ describe('buildConfig', () => { expect(config.runnerToolCachePath).toBe('/opt/hostedtoolcache'); }); + it('should pass through chroot identity fields', () => { + const config = buildConfig(makeInputs({ + options: { + ...makeInputs().options, + chrootIdentityHome: '/tmp/gh-aw/home', + chrootIdentityUser: 'runner', + chrootIdentityUid: '1001', + chrootIdentityGid: '1001', + }, + })); + expect(config.chrootIdentity).toEqual({ + home: '/tmp/gh-aw/home', + user: 'runner', + uid: 1001, + gid: 1001, + }); + }); + + it('should ignore non-positive chroot identity uid/gid values', () => { + const config = buildConfig(makeInputs({ + options: { + ...makeInputs().options, + chrootIdentityUid: '0', + chrootIdentityGid: '-1', + }, + })); + expect(config.chrootIdentity).toBeUndefined(); + }); + + it('should pass through dind bootstrap fields', () => { + const config = buildConfig(makeInputs({ + options: { + ...makeInputs().options, + dindPreStageDirs: true, + dindWorkDir: '/tmp/gh-aw', + dindStagingImage: 'ghcr.io/github/gh-aw-firewall/agent:latest', + dindStageEngineBinaryPath: '/usr/local/bin/copilot', + dindStageEngineBinaryTargetPath: '/usr/local/bin/copilot', + }, + })); + expect(config.dind).toEqual({ + preStageDirs: true, + workDir: '/tmp/gh-aw', + stagingImage: 'ghcr.io/github/gh-aw-firewall/agent:latest', + stageEngineBinary: { + path: '/usr/local/bin/copilot', + targetPath: '/usr/local/bin/copilot', + }, + }); + }); + it('should pass through modelAliases', () => { const aliases = { 'gpt-4': ['gpt-4-turbo'] }; const config = buildConfig(makeInputs({ modelAliases: aliases })); diff --git a/src/commands/build-config.ts b/src/commands/build-config.ts index 9bd29c07d..18be048ff 100644 --- a/src/commands/build-config.ts +++ b/src/commands/build-config.ts @@ -69,6 +69,44 @@ export function buildConfig(inputs: BuildConfigInputs): WrapperConfig { dockerHostPathPrefix, } = inputs; + const chrootIdentityUid = parseOptionalIntegerOption(options.chrootIdentityUid); + const chrootIdentityGid = parseOptionalIntegerOption(options.chrootIdentityGid); + const chrootIdentity = ( + options.chrootIdentityHome !== undefined || + options.chrootIdentityUser !== undefined || + chrootIdentityUid !== undefined || + chrootIdentityGid !== undefined + ) + ? { + home: options.chrootIdentityHome as string | undefined, + user: options.chrootIdentityUser as string | undefined, + uid: chrootIdentityUid, + gid: chrootIdentityGid, + } + : undefined; + const dind = ( + options.dindPreStageDirs !== undefined || + options.dindWorkDir !== undefined || + options.dindStagingImage !== undefined || + options.dindStageEngineBinaryPath !== undefined || + options.dindStageEngineBinaryTargetPath !== undefined + ) + ? { + preStageDirs: options.dindPreStageDirs as boolean | undefined, + workDir: options.dindWorkDir as string | undefined, + stagingImage: options.dindStagingImage as string | undefined, + stageEngineBinary: ( + options.dindStageEngineBinaryPath !== undefined || + options.dindStageEngineBinaryTargetPath !== undefined + ) + ? { + path: options.dindStageEngineBinaryPath as string | undefined, + targetPath: options.dindStageEngineBinaryTargetPath as string | undefined, + } + : undefined, + } + : undefined; + return { allowedDomains, blockedDomains: blockedDomains.length > 0 ? blockedDomains : undefined, @@ -162,5 +200,20 @@ export function buildConfig(inputs: BuildConfigInputs): WrapperConfig { awfDockerHost: options.dockerHost as string | undefined, upstreamProxy, dockerHostPathPrefix, + chrootIdentity, + dind, }; } + +function parseOptionalIntegerOption(value: unknown): number | undefined { + if (typeof value === 'number' && Number.isInteger(value) && value > 0) { + return value; + } + if (typeof value === 'string' && value.trim() !== '') { + const parsed = Number(value); + if (Number.isInteger(parsed) && parsed > 0) { + return parsed; + } + } + return undefined; +} diff --git a/src/commands/main-action.test.ts b/src/commands/main-action.test.ts index 66586b4fa..df0691601 100644 --- a/src/commands/main-action.test.ts +++ b/src/commands/main-action.test.ts @@ -8,6 +8,7 @@ jest.mock('../cli-workflow'); jest.mock('../redact-secrets'); jest.mock('../option-parsers'); jest.mock('../dind-probe'); +jest.mock('../dind-bootstrap'); jest.mock('./preflight'); jest.mock('./signal-handler'); jest.mock('./validate-options'); @@ -19,6 +20,7 @@ import * as cliWorkflow from '../cli-workflow'; import * as redactSecrets from '../redact-secrets'; import * as optionParsers from '../option-parsers'; import * as dindProbe from '../dind-probe'; +import * as dindBootstrap from '../dind-bootstrap'; import * as preflight from './preflight'; import * as signalHandler from './signal-handler'; import * as validateOptions from './validate-options'; @@ -30,6 +32,7 @@ const mockedCliWorkflow = cliWorkflow as jest.Mocked; const mockedRedactSecrets = redactSecrets as jest.Mocked; const mockedOptionParsers = optionParsers as jest.Mocked; const mockedDindProbe = dindProbe as jest.Mocked; +const mockedDindBootstrap = dindBootstrap as jest.Mocked; const mockedPreflight = preflight as jest.Mocked; const mockedSignalHandler = signalHandler as jest.Mocked; const mockedValidateOptions = validateOptions as jest.Mocked; @@ -79,6 +82,7 @@ describe('createMainAction', () => { splitDetected: false, inconclusive: false, }); + mockedDindBootstrap.runDindBootstrap.mockResolvedValue(undefined); mockedSignalHandler.registerSignalHandlers.mockImplementation(() => {}); mockedCliWorkflow.runMainWorkflow.mockResolvedValue(0); }); @@ -164,6 +168,12 @@ describe('createMainAction', () => { expect(mockedSignalHandler.registerSignalHandlers).toHaveBeenCalled(); }); + it('runs DinD bootstrap before workflow execution', async () => { + const action = createMainAction(getOptionValueSource); + await action(['echo hi'], {}); + expect(mockedDindBootstrap.runDindBootstrap).toHaveBeenCalledWith(STUB_CONFIG); + }); + it('logs allowed domains', async () => { const action = createMainAction(getOptionValueSource); await action(['echo hi'], {}); diff --git a/src/commands/main-action.ts b/src/commands/main-action.ts index 50c78c016..797a42643 100644 --- a/src/commands/main-action.ts +++ b/src/commands/main-action.ts @@ -22,6 +22,7 @@ import { applyConfigFilePrecedence } from './preflight'; import { registerSignalHandlers } from './signal-handler'; import { validateOptions } from './validate-options'; import { probeSplitFilesystem } from '../dind-probe'; +import { runDindBootstrap } from '../dind-bootstrap'; /** * Resolves the Commander option-value source for a given option name. @@ -96,6 +97,8 @@ export function createMainAction(getOptionValueSource: OptionSourceResolver) { 'Set --docker-host-path-prefix manually if bind mounts fail.', ); } + + await runDindBootstrap(config); } // Log config with redacted secrets - remove API keys entirely diff --git a/src/config-file-mapping.test.ts b/src/config-file-mapping.test.ts index 1caddf97a..9271509a1 100644 --- a/src/config-file-mapping.test.ts +++ b/src/config-file-mapping.test.ts @@ -310,6 +310,38 @@ describe('mapAwfFileConfigToCliOptions', () => { expect(result.sessionStateDir).toBe('/tmp/state'); }); + it('maps chroot and dind config-only fields', () => { + const result = mapAwfFileConfigToCliOptions({ + chroot: { + identity: { + home: '/tmp/gh-aw/home', + user: 'runner', + uid: 1001, + gid: 1001, + }, + }, + dind: { + preStageDirs: true, + workDir: '/tmp/gh-aw', + stagingImage: 'ghcr.io/github/gh-aw-firewall/agent:latest', + stageEngineBinary: { + path: '/usr/local/bin/copilot', + targetPath: '/usr/local/bin/copilot', + }, + }, + }); + + expect(result.chrootIdentityHome).toBe('/tmp/gh-aw/home'); + expect(result.chrootIdentityUser).toBe('runner'); + expect(result.chrootIdentityUid).toBe('1001'); + expect(result.chrootIdentityGid).toBe('1001'); + expect(result.dindPreStageDirs).toBe(true); + expect(result.dindWorkDir).toBe('/tmp/gh-aw'); + expect(result.dindStagingImage).toBe('ghcr.io/github/gh-aw-firewall/agent:latest'); + expect(result.dindStageEngineBinaryPath).toBe('/usr/local/bin/copilot'); + expect(result.dindStageEngineBinaryTargetPath).toBe('/usr/local/bin/copilot'); + }); + it('maps apiProxy.auth.anthropicTokenUrl', () => { const result = mapAwfFileConfigToCliOptions({ apiProxy: { diff --git a/src/config-file-validation.test.ts b/src/config-file-validation.test.ts index b7565b478..1a9b64b44 100644 --- a/src/config-file-validation.test.ts +++ b/src/config-file-validation.test.ts @@ -399,6 +399,53 @@ describe('validateAwfFileConfig', () => { expect(errors).toContain('config.container.unknown is not supported'); }); + it('rejects non-object chroot', () => { + const errors = validateAwfFileConfig({ chroot: 'invalid' }); + expect(errors).toContain('config.chroot must be an object'); + }); + + it('rejects non-object chroot.identity', () => { + const errors = validateAwfFileConfig({ chroot: { identity: 'invalid' } }); + expect(errors).toContain('config.chroot.identity must be an object'); + }); + + it('rejects invalid chroot.identity field types', () => { + expect(validateAwfFileConfig({ chroot: { identity: { home: 1 } } })).toContain('config.chroot.identity.home must be a string'); + expect(validateAwfFileConfig({ chroot: { identity: { user: 1 } } })).toContain('config.chroot.identity.user must be a string'); + expect(validateAwfFileConfig({ chroot: { identity: { uid: 0 } } })).toContain('config.chroot.identity.uid must be a positive integer'); + expect(validateAwfFileConfig({ chroot: { identity: { uid: -1 } } })).toContain('config.chroot.identity.uid must be a positive integer'); + expect(validateAwfFileConfig({ chroot: { identity: { uid: 1.5 } } })).toContain('config.chroot.identity.uid must be a positive integer'); + expect(validateAwfFileConfig({ chroot: { identity: { gid: 0 } } })).toContain('config.chroot.identity.gid must be a positive integer'); + expect(validateAwfFileConfig({ chroot: { identity: { gid: -1 } } })).toContain('config.chroot.identity.gid must be a positive integer'); + expect(validateAwfFileConfig({ chroot: { identity: { gid: 1.5 } } })).toContain('config.chroot.identity.gid must be a positive integer'); + }); + + it('rejects unknown chroot.identity keys', () => { + const errors = validateAwfFileConfig({ chroot: { identity: { home: '/tmp', extra: true } } }); + expect(errors).toContain('config.chroot.identity.extra is not supported'); + }); + + it('rejects non-object dind', () => { + const errors = validateAwfFileConfig({ dind: 'invalid' }); + expect(errors).toContain('config.dind must be an object'); + }); + + it('rejects invalid dind field types', () => { + expect(validateAwfFileConfig({ dind: { preStageDirs: 'true' } })).toContain('config.dind.preStageDirs must be a boolean'); + expect(validateAwfFileConfig({ dind: { workDir: 1 } })).toContain('config.dind.workDir must be a string'); + expect(validateAwfFileConfig({ dind: { stagingImage: 1 } })).toContain('config.dind.stagingImage must be a string'); + }); + + it('rejects non-object dind.stageEngineBinary', () => { + const errors = validateAwfFileConfig({ dind: { stageEngineBinary: 'invalid' } }); + expect(errors).toContain('config.dind.stageEngineBinary must be an object'); + }); + + it('rejects invalid dind.stageEngineBinary field types', () => { + expect(validateAwfFileConfig({ dind: { stageEngineBinary: { path: 1 } } })).toContain('config.dind.stageEngineBinary.path must be a string'); + expect(validateAwfFileConfig({ dind: { stageEngineBinary: { targetPath: 1 } } })).toContain('config.dind.stageEngineBinary.targetPath must be a string'); + }); + it('rejects non-object environment', () => { const errors = validateAwfFileConfig({ environment: 'invalid' }); expect(errors).toContain('config.environment must be an object'); diff --git a/src/config-file.ts b/src/config-file.ts index 1b99fb6b7..e3926707d 100644 --- a/src/config-file.ts +++ b/src/config-file.ts @@ -83,6 +83,23 @@ interface AwfFileConfig { dockerHostPathPrefix?: string; runnerToolCachePath?: string; }; + chroot?: { + identity?: { + home?: string; + user?: string; + uid?: number; + gid?: number; + }; + }; + dind?: { + preStageDirs?: boolean; + workDir?: string; + stagingImage?: string; + stageEngineBinary?: { + path?: string; + targetPath?: string; + }; + }; environment?: { envFile?: string; envAll?: boolean; @@ -248,6 +265,15 @@ export function mapAwfFileConfigToCliOptions(config: AwfFileConfig): Record require('./test-helpers/mock-execa.test-utils').execaMockFactory()); + +function makeConfig(overrides: Partial = {}): WrapperConfig { + return { + allowedDomains: ['github.com'], + agentCommand: 'echo ok', + logLevel: 'info', + keepContainers: false, + buildLocal: false, + imageRegistry: 'ghcr.io/github/gh-aw-firewall', + imageTag: 'latest', + workDir: '/tmp/awf-test', + ...overrides, + }; +} + +describe('runDindBootstrap', () => { + const originalDockerHost = process.env.DOCKER_HOST; + + beforeEach(() => { + jest.clearAllMocks(); + process.env.DOCKER_HOST = 'tcp://localhost:2375'; + mockExecaFn.mockResolvedValue({ stdout: '', stderr: '', exitCode: 0 }); + }); + + afterEach(() => { + if (originalDockerHost !== undefined) { + process.env.DOCKER_HOST = originalDockerHost; + } else { + delete process.env.DOCKER_HOST; + } + }); + + it('pre-stages DinD directories when enabled', async () => { + await runDindBootstrap(makeConfig({ + dind: { + preStageDirs: true, + workDir: '/tmp/gh-aw', + stagingImage: 'busybox:latest', + }, + })); + + expect(mockExecaFn).toHaveBeenCalledWith( + 'docker', + expect.arrayContaining(['run', '--rm', '-v', '/tmp/gh-aw:/awf-work:rw', 'busybox:latest']), + expect.objectContaining({ env: expect.any(Object) }), + ); + const preStageCommand = mockExecaFn.mock.calls[0]?.[1]?.[7]; + expect(preStageCommand).toContain('chmod 0777 /awf-work'); + expect(preStageCommand).not.toContain('chmod -R'); + }); + + it('stages engine binary when configured', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-dind-bootstrap-')); + const sourcePath = path.join(tempDir, 'copilot'); + fs.writeFileSync(sourcePath, 'binary-data'); + fs.chmodSync(sourcePath, 0o755); + + try { + await runDindBootstrap(makeConfig({ + dind: { + stageEngineBinary: { + path: sourcePath, + targetPath: '/usr/local/bin/copilot', + }, + stagingImage: 'busybox:latest', + }, + })); + + expect(mockExecaFn).toHaveBeenCalledWith( + 'docker', + expect.arrayContaining(['run', '--rm', '-i', '-v', '/usr/local/bin:/awf-target:rw', 'busybox:latest']), + expect.objectContaining({ input: expect.any(Buffer) }), + ); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('skips when DinD signals are absent', async () => { + delete process.env.DOCKER_HOST; + await runDindBootstrap(makeConfig({ + dind: { preStageDirs: true }, + enableDind: false, + dockerHostPathPrefix: undefined, + })); + + expect(mockExecaFn).not.toHaveBeenCalled(); + }); +}); diff --git a/src/dind-bootstrap.ts b/src/dind-bootstrap.ts new file mode 100644 index 000000000..0d1000c1a --- /dev/null +++ b/src/dind-bootstrap.ts @@ -0,0 +1,127 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import execa from 'execa'; +import { getLocalDockerEnv } from './docker-host'; +import { logger } from './logger'; +import { WrapperConfig } from './types'; + +const DEFAULT_STAGING_IMAGE = 'ghcr.io/github/gh-aw-firewall/agent:latest'; +const DEFAULT_DIND_WORKDIR = '/tmp/gh-aw'; +const SAFE_BINARY_NAME_REGEX = /^[a-zA-Z0-9_][a-zA-Z0-9_.-]*$/; +const DEFAULT_PRE_STAGE_DIRS = [ + '.cache', + '.config', + '.local', + '.local/state', + 'home', + 'mcp-logs', + 'sandbox', +]; + +function isLikelyDindEnvironment(config: WrapperConfig): boolean { + if (config.enableDind || !!config.dockerHostPathPrefix || process.env.AWF_DIND === '1') { + return true; + } + const dockerHost = process.env.DOCKER_HOST; + if (!dockerHost) return false; + if (!dockerHost.startsWith('unix://')) return true; + return dockerHost !== 'unix:///var/run/docker.sock' && dockerHost !== 'unix:///run/docker.sock'; +} + +function assertAbsolutePath(value: string, fieldName: string): void { + if (!path.posix.isAbsolute(value)) { + throw new Error(`${fieldName} must be an absolute path, got: ${value}`); + } +} + +async function preStageDindDirs(workDir: string, stagingImage: string): Promise { + assertAbsolutePath(workDir, 'dind.workDir'); + + const stageDirs = DEFAULT_PRE_STAGE_DIRS + .map((dirName) => `/awf-work/${dirName}`) + .join(' '); + await execa( + 'docker', + [ + 'run', + '--rm', + '-v', + `${workDir}:/awf-work:rw`, + stagingImage, + '/bin/sh', + '-c', + `set -eu; mkdir -p ${stageDirs}; chmod 0777 /awf-work ${stageDirs}`, + ], + { env: getLocalDockerEnv() }, + ); +} + +async function stageEngineBinary( + sourcePath: string, + targetPath: string, + stagingImage: string, +): Promise { + assertAbsolutePath(sourcePath, 'dind.stageEngineBinary.path'); + assertAbsolutePath(targetPath, 'dind.stageEngineBinary.targetPath'); + + const targetDir = path.posix.dirname(targetPath); + const targetBaseName = path.posix.basename(targetPath); + if (!SAFE_BINARY_NAME_REGEX.test(targetBaseName)) { + throw new Error(`dind.stageEngineBinary.targetPath has unsafe file name: ${targetPath}`); + } + + const sourceFd = fs.openSync(sourcePath, 'r'); + let binaryBytes: Buffer; + try { + const sourceStat = fs.fstatSync(sourceFd); + if (!sourceStat.isFile()) { + throw new Error(`dind.stageEngineBinary.path is not a file: ${sourcePath}`); + } + binaryBytes = fs.readFileSync(sourceFd); + } finally { + fs.closeSync(sourceFd); + } + await execa( + 'docker', + [ + 'run', + '--rm', + '-i', + '-v', + `${targetDir}:/awf-target:rw`, + stagingImage, + '/bin/sh', + '-c', + `set -eu; cat > /awf-target/${targetBaseName}; chmod 0755 /awf-target/${targetBaseName}`, + ], + { + env: getLocalDockerEnv(), + input: binaryBytes, + }, + ); +} + +export async function runDindBootstrap(config: WrapperConfig): Promise { + const dindConfig = config.dind; + if (!dindConfig?.preStageDirs && !dindConfig?.stageEngineBinary) { + return; + } + if (!isLikelyDindEnvironment(config)) { + logger.debug('Skipping DinD bootstrap because no DinD signals were detected'); + return; + } + + const stagingImage = dindConfig.stagingImage || DEFAULT_STAGING_IMAGE; + if (dindConfig.preStageDirs) { + const workDir = dindConfig.workDir || DEFAULT_DIND_WORKDIR; + logger.info(`Pre-staging DinD work directory tree at ${workDir}`); + await preStageDindDirs(workDir, stagingImage); + } + + const stageEngineBinaryConfig = dindConfig.stageEngineBinary; + if (stageEngineBinaryConfig?.path) { + const targetPath = stageEngineBinaryConfig.targetPath || stageEngineBinaryConfig.path; + logger.info(`Staging engine binary into DinD daemon filesystem: ${targetPath}`); + await stageEngineBinary(stageEngineBinaryConfig.path, targetPath, stagingImage); + } +} diff --git a/src/schema.test.ts b/src/schema.test.ts index 278967e07..536f271e4 100644 --- a/src/schema.test.ts +++ b/src/schema.test.ts @@ -36,6 +36,8 @@ describe('awf-config.schema.json', () => { 'apiProxy', 'security', 'container', + 'chroot', + 'dind', 'environment', 'logging', 'rateLimiting', @@ -101,6 +103,23 @@ describe('awf-config.schema.json', () => { dockerHost: 'unix:///var/run/docker.sock', dockerHostPathPrefix: '/host', }, + chroot: { + identity: { + home: '/tmp/gh-aw/home', + user: 'runner', + uid: 1001, + gid: 1001, + }, + }, + dind: { + preStageDirs: true, + workDir: '/tmp/gh-aw', + stagingImage: 'ghcr.io/github/gh-aw-firewall/agent:latest', + stageEngineBinary: { + path: '/usr/local/bin/copilot', + targetPath: '/usr/local/bin/copilot', + }, + }, environment: { envFile: '.env', envAll: false, @@ -198,6 +217,28 @@ describe('awf-config.schema.json', () => { expect(validate({ container: { runnerToolCachePath: 123 } })).toBe(false); }); + it('validates chroot.identity fields', () => { + expect(validate({ chroot: { identity: { home: '/tmp/gh-aw/home', user: 'runner', uid: 1001, gid: 1001 } } })).toBe(true); + expect(validate({ chroot: { identity: { uid: 1.2 } } })).toBe(false); + expect(validate({ chroot: { identity: { gid: 1.2 } } })).toBe(false); + }); + + it('validates dind bootstrap fields', () => { + expect(validate({ + dind: { + preStageDirs: true, + workDir: '/tmp/gh-aw', + stagingImage: 'ghcr.io/github/gh-aw-firewall/agent:latest', + stageEngineBinary: { + path: '/usr/local/bin/copilot', + targetPath: '/usr/local/bin/copilot', + }, + }, + })).toBe(true); + expect(validate({ dind: { preStageDirs: 'true' } })).toBe(false); + expect(validate({ dind: { stageEngineBinary: { path: 123 } } })).toBe(false); + }); + it('rejects non-positive-integer rateLimiting values', () => { expect(validate({ rateLimiting: { requestsPerMinute: 0 } })).toBe(false); expect(validate({ rateLimiting: { requestsPerMinute: 1 } })).toBe(true); diff --git a/src/services/agent-environment-runtime.test.ts b/src/services/agent-environment-runtime.test.ts index d7be2fc18..2352db37b 100644 --- a/src/services/agent-environment-runtime.test.ts +++ b/src/services/agent-environment-runtime.test.ts @@ -214,4 +214,25 @@ describe('agent environment: runtime', () => { expect(environment.AWF_WORKDIR).toBe('/workspace/project'); }); + + it('should set chroot identity override environment variables when configured', () => { + const result = generateDockerCompose( + { + ...mockConfig, + chrootIdentity: { + home: '/tmp/gh-aw/home', + user: 'runner', + uid: 1001, + gid: 1001, + }, + }, + mockNetworkConfig, + ); + const environment = result.services.agent.environment as Record; + + expect(environment.AWF_CHROOT_IDENTITY_HOME).toBe('/tmp/gh-aw/home'); + expect(environment.AWF_CHROOT_IDENTITY_USER).toBe('runner'); + expect(environment.AWF_CHROOT_IDENTITY_UID).toBe('1001'); + expect(environment.AWF_CHROOT_IDENTITY_GID).toBe('1001'); + }); }); diff --git a/src/services/agent-environment/env-passthrough.ts b/src/services/agent-environment/env-passthrough.ts index 2c1cfc22b..d12153816 100644 --- a/src/services/agent-environment/env-passthrough.ts +++ b/src/services/agent-environment/env-passthrough.ts @@ -79,7 +79,7 @@ export function passthroughHostEnvironment(params: EnvPassthroughParams): void { environment.TERM = process.env.TERM; } - if (config.enableDind && config.awfDockerHost?.startsWith('unix://')) { + if (config.enableDind && !environment.DOCKER_HOST && config.awfDockerHost?.startsWith('unix://')) { environment.DOCKER_HOST = config.awfDockerHost; } } diff --git a/src/services/agent-environment/tool-specific-environment.ts b/src/services/agent-environment/tool-specific-environment.ts index 90984a5b8..e7ad75f65 100644 --- a/src/services/agent-environment/tool-specific-environment.ts +++ b/src/services/agent-environment/tool-specific-environment.ts @@ -18,6 +18,19 @@ export function buildToolEnvironment(params: ToolEnvironmentParams): void { const stagedBinaryName = extractCommandBinaryName(config.agentCommand); const hasCopilotProviderApiKey = !!config.copilotProviderApiKey; + if (config.chrootIdentity?.home) { + environment.AWF_CHROOT_IDENTITY_HOME = config.chrootIdentity.home; + } + if (config.chrootIdentity?.user) { + environment.AWF_CHROOT_IDENTITY_USER = config.chrootIdentity.user; + } + if (config.chrootIdentity?.uid !== undefined) { + environment.AWF_CHROOT_IDENTITY_UID = String(config.chrootIdentity.uid); + } + if (config.chrootIdentity?.gid !== undefined) { + environment.AWF_CHROOT_IDENTITY_GID = String(config.chrootIdentity.gid); + } + // Any Copilot signal (named command, GitHub token, or direct-BYOK key) means // the user is going to invoke Copilot CLI, which requires Node.js. Set // AWF_REQUIRE_NODE so the entrypoint emits a friendly preflight error when diff --git a/src/services/agent-volumes-mounts.test.ts b/src/services/agent-volumes-mounts.test.ts index 09b21737b..2c92a7ac4 100644 --- a/src/services/agent-volumes-mounts.test.ts +++ b/src/services/agent-volumes-mounts.test.ts @@ -389,7 +389,7 @@ describe('agent service', () => { }); }); - it('should prefer awfDockerHost over DOCKER_HOST when enableDind is true', () => { + it('should preserve host DOCKER_HOST for agent env when enableDind is true', () => { withEnv({ DOCKER_HOST: 'unix:///tmp/arc/docker.sock' }, () => { const dindConfig = { ...getConfig(), @@ -402,7 +402,7 @@ describe('agent service', () => { expect(volumes).toContain('/run/user/1000/docker.sock:/host/run/user/1000/docker.sock:rw'); expect(volumes).not.toContain('/tmp/arc/docker.sock:/host/tmp/arc/docker.sock:rw'); - expect(env.DOCKER_HOST).toBe('unix:///run/user/1000/docker.sock'); + expect(env.DOCKER_HOST).toBe('unix:///tmp/arc/docker.sock'); }); }); diff --git a/src/types/runtime-options.ts b/src/types/runtime-options.ts index e4100805c..322696a29 100644 --- a/src/types/runtime-options.ts +++ b/src/types/runtime-options.ts @@ -125,4 +125,31 @@ export interface RuntimeOptions { * @example 45 */ agentTimeout?: number; + + /** + * Chroot identity override applied inside the agent entrypoint. + * + * These values are forwarded to the entrypoint and applied after `chroot /host` + * so tools that rely on HOME/USER identity (for example Copilot CLI state under + * `~/.copilot`) can run against DinD-staged writable paths. + */ + chrootIdentity?: { + home?: string; + user?: string; + uid?: number; + gid?: number; + }; + + /** + * ARC/DinD bootstrap configuration for split runner/daemon filesystems. + */ + dind?: { + preStageDirs?: boolean; + workDir?: string; + stagingImage?: string; + stageEngineBinary?: { + path?: string; + targetPath?: string; + }; + }; }