diff --git a/docs/awf-config-spec.md b/docs/awf-config-spec.md index 19a4ff795..876e79c31 100644 --- a/docs/awf-config-spec.md +++ b/docs/awf-config-spec.md @@ -161,6 +161,7 @@ the corresponding CLI flag. - `container.tty` → `--tty` - `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)* - `environment.envFile` → `--env-file` - `environment.envAll` → `--env-all` - `environment.excludeEnv[]` → `--exclude-env` *(repeatable)* diff --git a/docs/awf-config.schema.json b/docs/awf-config.schema.json index 18f4f2ab3..b15b20b9a 100644 --- a/docs/awf-config.schema.json +++ b/docs/awf-config.schema.json @@ -49,7 +49,7 @@ "properties": { "enabled": { "type": "boolean", - "description": "Enable the API proxy sidecar container. When enabled, source credentials (OPENAI_API_KEY, ANTHROPIC_API_KEY, COPILOT_GITHUB_TOKEN, COPILOT_PROVIDER_API_KEY, GEMINI_API_KEY) are held exclusively in the sidecar and excluded from the agent environment. The agent receives proxy-routing base URLs instead. See docs/awf-config-spec.md \u00a79 for credential isolation semantics." + "description": "Enable the API proxy sidecar container. When enabled, source credentials (OPENAI_API_KEY, ANTHROPIC_API_KEY, COPILOT_GITHUB_TOKEN, COPILOT_PROVIDER_API_KEY, GEMINI_API_KEY) are held exclusively in the sidecar and excluded from the agent environment. The agent receives proxy-routing base URLs instead. See docs/awf-config-spec.md §9 for credential isolation semantics." }, "enableTokenSteering": { "type": "boolean", @@ -70,11 +70,11 @@ "maxEffectiveTokens": { "type": "integer", "minimum": 1, - "description": "Maximum cumulative effective tokens allowed for a run. When reached, the API proxy rejects subsequent requests with HTTP 429 and error type 'effective_tokens_limit_exceeded'. Tokens are weighted: input \u00d71, cache-read \u00d70.1, output \u00d74, reasoning \u00d74. See spec \u00a710." + "description": "Maximum cumulative effective tokens allowed for a run. When reached, the API proxy rejects subsequent requests with HTTP 429 and error type 'effective_tokens_limit_exceeded'. Tokens are weighted: input ×1, cache-read ×0.1, output ×4, reasoning ×4. See spec §10." }, "modelMultipliers": { "type": "object", - "description": "Per-model multipliers for effective token accounting. Each model's weighted tokens are multiplied by this value before accumulation. Unlisted models use defaultModelMultiplier when set, otherwise the highest configured multiplier. See spec \u00a710.2.", + "description": "Per-model multipliers for effective token accounting. Each model's weighted tokens are multiplied by this value before accumulation. Unlisted models use defaultModelMultiplier when set, otherwise the highest configured multiplier. See spec §10.2.", "additionalProperties": { "type": "number", "exclusiveMinimum": 0 @@ -93,7 +93,7 @@ "maxRuns": { "type": "integer", "minimum": 1, - "description": "Maximum number of LLM invocations allowed for a run. When reached, the API proxy rejects subsequent requests with HTTP 429 and error type 'max_runs_exceeded'. See spec \u00a711." + "description": "Maximum number of LLM invocations allowed for a run. When reached, the API proxy rejects subsequent requests with HTTP 429 and error type 'max_runs_exceeded'. See spec §11." }, "maxPermissionDenied": { "type": "integer", @@ -183,7 +183,7 @@ }, "auth": { "type": "object", - "description": "Authentication configuration for the API proxy sidecar. Enables OIDC-based credential exchange (e.g., GitHub OIDC \u2192 Azure AD, AWS STS, GCP Workload Identity, or Anthropic Workload Identity Federation). See docs/awf-config-spec.md \u00a79.5.", + "description": "Authentication configuration for the API proxy sidecar. Enables OIDC-based credential exchange (e.g., GitHub OIDC → Azure AD, AWS STS, GCP Workload Identity, or Anthropic Workload Identity Federation). See docs/awf-config-spec.md §9.5.", "additionalProperties": false, "properties": { "type": { @@ -480,12 +480,16 @@ "dockerHostPathPrefix": { "type": "string", "description": "Prefix bind-mount source paths so the Docker daemon can resolve runner filesystem paths. Required for ARC DinD sidecar runners where the runner and daemon have separate filesystems. Example: \"/host\". Kernel virtual filesystems (/dev, /sys, /proc) are automatically excluded from prefixing. When this points at a daemon-visible shared /tmp path, AWF also stages the invoking CLI binary plus /etc/passwd, /etc/group, and the generated chroot /etc/hosts there." + }, + "runnerToolCachePath": { + "type": "string", + "description": "Host runner tool cache directory to mount read-only into chroot mode. When set, AWF checks this path first before environment-based auto-detection." } } }, "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 \u00a78\u20139 for credential isolation rules.", + "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.", "additionalProperties": false, "properties": { "envFile": { @@ -494,7 +498,7 @@ }, "envAll": { "type": "boolean", - "description": "Forward all host environment variables into the agent container. Use with caution \u2014 may expose secrets." + "description": "Forward all host environment variables into the agent container. Use with caution — may expose secrets." }, "excludeEnv": { "type": "array", diff --git a/src/awf-config-schema.json b/src/awf-config-schema.json index 18f4f2ab3..b15b20b9a 100644 --- a/src/awf-config-schema.json +++ b/src/awf-config-schema.json @@ -49,7 +49,7 @@ "properties": { "enabled": { "type": "boolean", - "description": "Enable the API proxy sidecar container. When enabled, source credentials (OPENAI_API_KEY, ANTHROPIC_API_KEY, COPILOT_GITHUB_TOKEN, COPILOT_PROVIDER_API_KEY, GEMINI_API_KEY) are held exclusively in the sidecar and excluded from the agent environment. The agent receives proxy-routing base URLs instead. See docs/awf-config-spec.md \u00a79 for credential isolation semantics." + "description": "Enable the API proxy sidecar container. When enabled, source credentials (OPENAI_API_KEY, ANTHROPIC_API_KEY, COPILOT_GITHUB_TOKEN, COPILOT_PROVIDER_API_KEY, GEMINI_API_KEY) are held exclusively in the sidecar and excluded from the agent environment. The agent receives proxy-routing base URLs instead. See docs/awf-config-spec.md §9 for credential isolation semantics." }, "enableTokenSteering": { "type": "boolean", @@ -70,11 +70,11 @@ "maxEffectiveTokens": { "type": "integer", "minimum": 1, - "description": "Maximum cumulative effective tokens allowed for a run. When reached, the API proxy rejects subsequent requests with HTTP 429 and error type 'effective_tokens_limit_exceeded'. Tokens are weighted: input \u00d71, cache-read \u00d70.1, output \u00d74, reasoning \u00d74. See spec \u00a710." + "description": "Maximum cumulative effective tokens allowed for a run. When reached, the API proxy rejects subsequent requests with HTTP 429 and error type 'effective_tokens_limit_exceeded'. Tokens are weighted: input ×1, cache-read ×0.1, output ×4, reasoning ×4. See spec §10." }, "modelMultipliers": { "type": "object", - "description": "Per-model multipliers for effective token accounting. Each model's weighted tokens are multiplied by this value before accumulation. Unlisted models use defaultModelMultiplier when set, otherwise the highest configured multiplier. See spec \u00a710.2.", + "description": "Per-model multipliers for effective token accounting. Each model's weighted tokens are multiplied by this value before accumulation. Unlisted models use defaultModelMultiplier when set, otherwise the highest configured multiplier. See spec §10.2.", "additionalProperties": { "type": "number", "exclusiveMinimum": 0 @@ -93,7 +93,7 @@ "maxRuns": { "type": "integer", "minimum": 1, - "description": "Maximum number of LLM invocations allowed for a run. When reached, the API proxy rejects subsequent requests with HTTP 429 and error type 'max_runs_exceeded'. See spec \u00a711." + "description": "Maximum number of LLM invocations allowed for a run. When reached, the API proxy rejects subsequent requests with HTTP 429 and error type 'max_runs_exceeded'. See spec §11." }, "maxPermissionDenied": { "type": "integer", @@ -183,7 +183,7 @@ }, "auth": { "type": "object", - "description": "Authentication configuration for the API proxy sidecar. Enables OIDC-based credential exchange (e.g., GitHub OIDC \u2192 Azure AD, AWS STS, GCP Workload Identity, or Anthropic Workload Identity Federation). See docs/awf-config-spec.md \u00a79.5.", + "description": "Authentication configuration for the API proxy sidecar. Enables OIDC-based credential exchange (e.g., GitHub OIDC → Azure AD, AWS STS, GCP Workload Identity, or Anthropic Workload Identity Federation). See docs/awf-config-spec.md §9.5.", "additionalProperties": false, "properties": { "type": { @@ -480,12 +480,16 @@ "dockerHostPathPrefix": { "type": "string", "description": "Prefix bind-mount source paths so the Docker daemon can resolve runner filesystem paths. Required for ARC DinD sidecar runners where the runner and daemon have separate filesystems. Example: \"/host\". Kernel virtual filesystems (/dev, /sys, /proc) are automatically excluded from prefixing. When this points at a daemon-visible shared /tmp path, AWF also stages the invoking CLI binary plus /etc/passwd, /etc/group, and the generated chroot /etc/hosts there." + }, + "runnerToolCachePath": { + "type": "string", + "description": "Host runner tool cache directory to mount read-only into chroot mode. When set, AWF checks this path first before environment-based auto-detection." } } }, "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 \u00a78\u20139 for credential isolation rules.", + "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.", "additionalProperties": false, "properties": { "envFile": { @@ -494,7 +498,7 @@ }, "envAll": { "type": "boolean", - "description": "Forward all host environment variables into the agent container. Use with caution \u2014 may expose secrets." + "description": "Forward all host environment variables into the agent container. Use with caution — may expose secrets." }, "excludeEnv": { "type": "array", diff --git a/src/commands/build-config.test.ts b/src/commands/build-config.test.ts index 1641ba99b..bff6807ea 100644 --- a/src/commands/build-config.test.ts +++ b/src/commands/build-config.test.ts @@ -293,6 +293,13 @@ describe('buildConfig', () => { expect(config.dockerHostPathPrefix).toBe('/host'); }); + it('should pass through runnerToolCachePath', () => { + const config = buildConfig(makeInputs({ + options: { ...makeInputs().options, runnerToolCachePath: '/opt/hostedtoolcache' }, + })); + expect(config.runnerToolCachePath).toBe('/opt/hostedtoolcache'); + }); + 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 8eb8c3ffd..2afb42cda 100644 --- a/src/commands/build-config.ts +++ b/src/commands/build-config.ts @@ -96,6 +96,7 @@ export function buildConfig(inputs: BuildConfigInputs): WrapperConfig { auditDir: (options.auditDir as string | undefined) || process.env.AWF_AUDIT_DIR, sessionStateDir: (options.sessionStateDir as string | undefined) || process.env.AWF_SESSION_STATE_DIR, + runnerToolCachePath: options.runnerToolCachePath as string | undefined, enableHostAccess: options.enableHostAccess as boolean, localhostDetected, allowHostPorts: options.allowHostPorts as string | undefined, diff --git a/src/config-file-mapping.test.ts b/src/config-file-mapping.test.ts index 5fc32cf02..098cf2be1 100644 --- a/src/config-file-mapping.test.ts +++ b/src/config-file-mapping.test.ts @@ -256,6 +256,7 @@ describe('mapAwfFileConfigToCliOptions', () => { tty: true, dockerHost: 'unix:///var/run/docker.sock', dockerHostPathPrefix: '/host', + runnerToolCachePath: '/opt/hostedtoolcache', }, }); @@ -270,6 +271,7 @@ describe('mapAwfFileConfigToCliOptions', () => { expect(result.tty).toBe(true); expect(result.dockerHost).toBe('unix:///var/run/docker.sock'); expect(result.dockerHostPathPrefix).toBe('/host'); + expect(result.runnerToolCachePath).toBe('/opt/hostedtoolcache'); }); it('maps environment fields', () => { diff --git a/src/config-file-validation.test.ts b/src/config-file-validation.test.ts index 8fa99a1dc..7a1281ab5 100644 --- a/src/config-file-validation.test.ts +++ b/src/config-file-validation.test.ts @@ -386,6 +386,11 @@ describe('validateAwfFileConfig', () => { expect(errors).toContain('config.container.dockerHostPathPrefix must be a string'); }); + it('rejects non-string container.runnerToolCachePath', () => { + const errors = validateAwfFileConfig({ container: { runnerToolCachePath: 123 } }); + expect(errors).toContain('config.container.runnerToolCachePath must be a string'); + }); + it('rejects unknown container keys', () => { const errors = validateAwfFileConfig({ container: { unknown: true } }); expect(errors).toContain('config.container.unknown is not supported'); diff --git a/src/config-file.ts b/src/config-file.ts index 4db043174..58b972911 100644 --- a/src/config-file.ts +++ b/src/config-file.ts @@ -79,6 +79,7 @@ interface AwfFileConfig { tty?: boolean; dockerHost?: string; dockerHostPathPrefix?: string; + runnerToolCachePath?: string; }; environment?: { envFile?: string; @@ -242,6 +243,7 @@ export function mapAwfFileConfigToCliOptions(config: AwfFileConfig): Record { expect(validate({ container: { agentTimeout: 1 } })).toBe(true); }); + it('accepts container.runnerToolCachePath as a string', () => { + expect(validate({ container: { runnerToolCachePath: '/opt/hostedtoolcache' } })).toBe(true); + expect(validate({ container: { runnerToolCachePath: 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-volumes-mounts.test.ts b/src/services/agent-volumes-mounts.test.ts index ecb407837..4ba4befc9 100644 --- a/src/services/agent-volumes-mounts.test.ts +++ b/src/services/agent-volumes-mounts.test.ts @@ -479,42 +479,85 @@ describe('agent service', () => { expect(volumes).toContain(`${homeDir}/.gemini:/host${homeDir}/.gemini:rw`); }); - it('should mount self-hosted runner toolcache when present under HOME/work/_tool', () => { - const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-home-')); + it('should mount container.runnerToolCachePath when it points to a real directory', () => { + const toolcacheDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-toolcache-')); try { - withEnv({ HOME: fakeHome, SUDO_USER: undefined }, () => { - const toolcacheDir = path.join(fakeHome, 'work', '_tool'); - fs.mkdirSync(toolcacheDir, { recursive: true }); + withEnv({ RUNNER_TOOL_CACHE: undefined }, () => { + const result = generateDockerCompose({ + ...getConfig(), + runnerToolCachePath: toolcacheDir, + }, mockNetworkConfig); + const volumes = result.services.agent.volumes as string[]; + expect(volumes).toContain(`${toolcacheDir}:/host${toolcacheDir}:ro`); + }); + } finally { + fs.rmSync(toolcacheDir, { recursive: true, force: true }); + } + }); + + it('should mount RUNNER_TOOL_CACHE when it is set to a real directory', () => { + const runnerToolCacheDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-runner-toolcache-')); + + try { + withEnv({ RUNNER_TOOL_CACHE: runnerToolCacheDir }, () => { const result = generateDockerCompose(getConfig(), mockNetworkConfig); const volumes = result.services.agent.volumes as string[]; - expect(volumes).toContain(`${toolcacheDir}:/host${toolcacheDir}:ro`); + expect(volumes).toContain(`${runnerToolCacheDir}:/host${runnerToolCacheDir}:ro`); }); } finally { - fs.rmSync(fakeHome, { recursive: true, force: true }); + fs.rmSync(runnerToolCacheDir, { recursive: true, force: true }); } }); - it('should not mount HOME/work/_tool when it is a symlink', () => { - const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-home-')); + it('should prefer container.runnerToolCachePath over RUNNER_TOOL_CACHE', () => { + const configuredDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-configured-toolcache-')); + const runnerToolCacheDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-runner-toolcache-')); + + try { + withEnv({ RUNNER_TOOL_CACHE: runnerToolCacheDir }, () => { + const result = generateDockerCompose(getConfig(), mockNetworkConfig); + const volumes = result.services.agent.volumes as string[]; + + expect(volumes).toContain(`${runnerToolCacheDir}:/host${runnerToolCacheDir}:ro`); + }); + + withEnv({ RUNNER_TOOL_CACHE: runnerToolCacheDir }, () => { + const result = generateDockerCompose({ + ...getConfig(), + runnerToolCachePath: configuredDir, + }, mockNetworkConfig); + const volumes = result.services.agent.volumes as string[]; + + expect(volumes).toContain(`${configuredDir}:/host${configuredDir}:ro`); + expect(volumes).not.toContain(`${runnerToolCacheDir}:/host${runnerToolCacheDir}:ro`); + }); + } finally { + fs.rmSync(configuredDir, { recursive: true, force: true }); + fs.rmSync(runnerToolCacheDir, { recursive: true, force: true }); + } + }); + + it('should not mount container.runnerToolCachePath when it is a symlink', () => { const symlinkTarget = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-tool-target-')); + const symlinkPath = path.join(os.tmpdir(), `awf-toolcache-link-${Date.now()}-${Math.random().toString(36).slice(2)}`); try { - withEnv({ HOME: fakeHome, SUDO_USER: undefined }, () => { - const workDir = path.join(fakeHome, 'work'); - fs.mkdirSync(workDir, { recursive: true }); - const toolcacheDir = path.join(workDir, '_tool'); - fs.symlinkSync(symlinkTarget, toolcacheDir); + fs.symlinkSync(symlinkTarget, symlinkPath); - const result = generateDockerCompose(getConfig(), mockNetworkConfig); + withEnv({ RUNNER_TOOL_CACHE: undefined }, () => { + const result = generateDockerCompose({ + ...getConfig(), + runnerToolCachePath: symlinkPath, + }, mockNetworkConfig); const volumes = result.services.agent.volumes as string[]; - expect(volumes).not.toContain(`${toolcacheDir}:/host${toolcacheDir}:ro`); + expect(volumes).not.toContain(`${symlinkPath}:/host${symlinkPath}:ro`); }); } finally { - fs.rmSync(fakeHome, { recursive: true, force: true }); + fs.rmSync(symlinkPath, { force: true }); fs.rmSync(symlinkTarget, { recursive: true, force: true }); } }); diff --git a/src/services/agent-volumes/home-strategy.ts b/src/services/agent-volumes/home-strategy.ts index c0b908a83..a05e21bde 100644 --- a/src/services/agent-volumes/home-strategy.ts +++ b/src/services/agent-volumes/home-strategy.ts @@ -57,15 +57,33 @@ function buildToolDirectoryMounts(params: HomeMountsParams): string[] { mounts.push(`${effectiveHome}/.npm:/host${effectiveHome}/.npm:rw`); mounts.push(`${effectiveHome}/.nvm:/host${effectiveHome}/.nvm:rw`); - const runnerToolCacheDir = path.join(effectiveHome, 'work', '_tool'); - try { - const stat = fs.lstatSync(runnerToolCacheDir); - if (stat.isDirectory()) { - mounts.push(`${runnerToolCacheDir}:/host${runnerToolCacheDir}:ro`); - } - } catch { - // Directory does not exist or is not accessible — skip the optional mount + const runnerToolCacheDir = resolveRunnerToolCachePath(config, effectiveHome); + if (runnerToolCacheDir) { + mounts.push(`${runnerToolCacheDir}:/host${runnerToolCacheDir}:ro`); } return mounts; } + +function resolveRunnerToolCachePath(config: WrapperConfig, effectiveHome: string): string | undefined { + const candidates = [ + config.runnerToolCachePath, + process.env.RUNNER_TOOL_CACHE, + path.join(effectiveHome, 'work', '_tool'), + ]; + + for (const candidate of candidates) { + if (!candidate) continue; + + try { + const stat = fs.lstatSync(candidate); + if (stat.isDirectory()) { + return candidate; + } + } catch { + // Path does not exist or is not accessible — try next candidate + } + } + + return undefined; +} diff --git a/src/types/volume-options.ts b/src/types/volume-options.ts index 91b6c66bf..1ccc2fb61 100644 --- a/src/types/volume-options.ts +++ b/src/types/volume-options.ts @@ -105,6 +105,19 @@ export interface VolumeOptions { */ sessionStateDir?: string; + /** + * Host runner tool cache directory to mount read-only into chroot mode. + * + * When specified, AWF prefers this path over auto-detection and mounts it as: + * `:/host:ro` if the host path exists and is a real directory. + * + * Primarily intended for stdin config usage where shell interpolation of + * `RUNNER_TOOL_CACHE` is unavailable. + * + * @example '/opt/hostedtoolcache' + */ + runnerToolCachePath?: string; + /** * Enable diagnostic log collection on non-zero exit *