From 8aa91d6cfdc6c6c3bccf273e09a80f2818599230 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Fri, 29 May 2026 14:36:49 -0700 Subject: [PATCH 1/4] fix: synthesize identity files for ARC-DinD environments When running in ARC DinD mode, the Docker daemon's filesystem often lacks the runner's UID in /etc/passwd and /etc/group. This causes 'getent passwd' to fail and the agent to run as 'nobody'. Two-layer fix: - etc-mounts.ts: synthesize minimal passwd/group when staging fails (source files don't exist on the DinD daemon's filesystem) - entrypoint.sh: runtime fallback synthesizes identity entries when getent passwd fails inside the chroot The synthesis creates entries for root and the runner user (matching the host UID/GID) so the agent process has a valid username and home. Closes #4022 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- containers/agent/entrypoint.sh | 59 +++++++++++++- docs/chroot-mode.md | 14 +++- src/services/agent-volumes/etc-mounts.test.ts | 80 +++++++++++++++++++ src/services/agent-volumes/etc-mounts.ts | 52 ++++++++++-- 4 files changed, 196 insertions(+), 9 deletions(-) create mode 100644 src/services/agent-volumes/etc-mounts.test.ts diff --git a/containers/agent/entrypoint.sh b/containers/agent/entrypoint.sh index 1690ec524..43dcaf874 100644 --- a/containers/agent/entrypoint.sh +++ b/containers/agent/entrypoint.sh @@ -707,11 +707,64 @@ 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=$(chroot /host getent passwd "${HOST_USER_UID}" 2>/dev/null | cut -d: -f1 || echo "") if [ -z "${HOST_USER}" ]; then - # Fall back to 'nobody' if user not found by UID - HOST_USER="nobody" - echo "[entrypoint][WARN] Could not find user with UID ${HOST_USER_UID} on host, using ${HOST_USER}" + # 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. + HOST_USER="runner" + echo "[entrypoint] User with UID ${HOST_USER_UID} not found in chroot — synthesizing identity files" + + # Determine the user's home directory (default to /home/runner) + SYNTH_HOME="${AWF_HOST_HOME:-/home/${HOST_USER}}" + + # Synthesize /etc/passwd entry if missing + if ! grep -q "^[^:]*:[^:]*:${HOST_USER_UID}:" /host/etc/passwd 2>/dev/null; then + PASSWD_ENTRY="${HOST_USER}:x:${HOST_USER_UID}:${HOST_USER_GID}:GitHub Actions Runner:${SYNTH_HOME}:/bin/bash" + # Append to existing file or create new one + if [ -f /host/etc/passwd ]; then + if echo "${PASSWD_ENTRY}" >> /host/etc/passwd 2>/dev/null; then + echo "[entrypoint] Appended ${HOST_USER} (UID ${HOST_USER_UID}) to /host/etc/passwd" + else + echo "[entrypoint][WARN] Could not write to /host/etc/passwd — identity resolution may fail" + fi + else + # Create minimal passwd with root and the runner user + if printf '%s\n' "root:x:0:0:root:/root:/bin/bash" "nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin" "${PASSWD_ENTRY}" > /host/etc/passwd 2>/dev/null; then + chmod 644 /host/etc/passwd 2>/dev/null + echo "[entrypoint] Created /host/etc/passwd with ${HOST_USER} (UID ${HOST_USER_UID})" + else + echo "[entrypoint][WARN] Could not create /host/etc/passwd — identity resolution may fail" + fi + fi + fi + + # Synthesize /etc/group entry if missing + if ! grep -q "^[^:]*:[^:]*:${HOST_USER_GID}:" /host/etc/group 2>/dev/null; then + GROUP_ENTRY="${HOST_USER}:x:${HOST_USER_GID}:" + if [ -f /host/etc/group ]; then + if echo "${GROUP_ENTRY}" >> /host/etc/group 2>/dev/null; then + echo "[entrypoint] Appended group ${HOST_USER} (GID ${HOST_USER_GID}) to /host/etc/group" + else + echo "[entrypoint][WARN] Could not write to /host/etc/group" + fi + else + if printf '%s\n' "root:x:0:" "nobody:x:65534:" "${GROUP_ENTRY}" > /host/etc/group 2>/dev/null; then + chmod 644 /host/etc/group 2>/dev/null + echo "[entrypoint] Created /host/etc/group with group ${HOST_USER} (GID ${HOST_USER_GID})" + else + echo "[entrypoint][WARN] Could not create /host/etc/group" + fi + fi + fi + + # Synthesize /etc/hosts if it doesn't exist (DinD Alpine daemon may not have one) + if [ ! -f /host/etc/hosts ]; then + if printf '%s\n' "127.0.0.1 localhost" "::1 localhost ip6-localhost ip6-loopback" > /host/etc/hosts 2>/dev/null; then + chmod 644 /host/etc/hosts 2>/dev/null + echo "[entrypoint] Created minimal /host/etc/hosts" + fi + fi else echo "[entrypoint] Running as host user: ${HOST_USER} (UID: ${HOST_USER_UID})" fi diff --git a/docs/chroot-mode.md b/docs/chroot-mode.md index 9e351081e..54dd5d881 100644 --- a/docs/chroot-mode.md +++ b/docs/chroot-mode.md @@ -302,7 +302,7 @@ sudo mv /etc/resolv.conf.awf-backup-* /etc/resolv.conf | glibc-based host userspace | Required for chroot execution chain (`capsh` + `bash`) | | `capsh` | Must be installed on host (usually in `libcap2-bin` package) | | `/bin/bash` | Must exist and be executable on host | -| User by UID | Host user must exist in `/etc/passwd` | +| User by UID | Host user should exist in `/etc/passwd` (auto-synthesized in DinD mode if missing) | | Docker | Standard Docker requirement | | sudo | Required for iptables manipulation | @@ -339,6 +339,18 @@ sudo dnf install libcap **Fix**: Run AWF on a glibc-based daemon host (for example Ubuntu/Debian/RHEL-family). +### DinD Identity Synthesis + +In ARC (Actions Runner Controller) environments using the DinD (Docker-in-Docker) sidecar pattern, the Docker daemon's filesystem is separate from the runner's. This means `/etc/passwd` and `/etc/group` may not exist or may not contain the runner's UID/GID. + +AWF handles this automatically at two layers: + +1. **Mount staging** (`etc-mounts.ts`): When `--docker-host-path-prefix` is set and `/etc/passwd` or `/etc/group` cannot be staged from the runner, AWF synthesizes minimal identity files containing `root` and a `runner` entry matching the host UID/GID. + +2. **Runtime fallback** (`entrypoint.sh`): If `getent passwd $UID` fails inside the chroot (user not found), the entrypoint synthesizes `/etc/passwd` and `/etc/group` entries directly, ensuring the agent process has a valid username and home directory. + +No configuration is required — synthesis is triggered automatically when user lookup fails. + ### Error: Working directory does not exist ``` diff --git a/src/services/agent-volumes/etc-mounts.test.ts b/src/services/agent-volumes/etc-mounts.test.ts new file mode 100644 index 000000000..a02d850cd --- /dev/null +++ b/src/services/agent-volumes/etc-mounts.test.ts @@ -0,0 +1,80 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { buildEtcMounts } from './etc-mounts'; +import { WrapperConfig } from '../../types'; + +function createMinimalConfig(overrides: Partial = {}): WrapperConfig { + return { + allowDomains: 'example.com', + agentCommand: 'echo test', + workDir: '/tmp/awf-test', + ...overrides, + } as WrapperConfig; +} + +describe('buildEtcMounts', () => { + describe('non-DinD mode', () => { + it('mounts /etc/passwd and /etc/group directly', () => { + const config = createMinimalConfig({ dockerHostPathPrefix: undefined }); + const mounts = buildEtcMounts(config); + expect(mounts).toContain('/etc/passwd:/host/etc/passwd:ro'); + expect(mounts).toContain('/etc/group:/host/etc/group:ro'); + }); + + it('includes standard /etc mounts', () => { + const config = createMinimalConfig({ dockerHostPathPrefix: undefined }); + const mounts = buildEtcMounts(config); + expect(mounts).toContain('/etc/ssl:/host/etc/ssl:ro'); + expect(mounts).toContain('/etc/ca-certificates:/host/etc/ca-certificates:ro'); + expect(mounts).toContain('/etc/nsswitch.conf:/host/etc/nsswitch.conf:ro'); + }); + }); + + describe('DinD mode with dockerHostPathPrefix', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-etc-mounts-')); + }); + + afterEach(() => { + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('stages /etc/passwd when it exists on the runner', () => { + const config = createMinimalConfig({ + dockerHostPathPrefix: '/host', + workDir: tmpDir, + }); + const mounts = buildEtcMounts(config); + // Should have passwd and group mounts (either staged or synthesized) + const passwdMount = mounts.find(m => m.includes('/host/etc/passwd')); + expect(passwdMount).toBeDefined(); + expect(passwdMount).toContain(':ro'); + }); + + it('produces passwd and group mounts in DinD mode', () => { + const workDir = path.join(tmpDir, 'work'); + fs.mkdirSync(workDir, { recursive: true }); + const config = createMinimalConfig({ + dockerHostPathPrefix: '/host', + workDir, + }); + + const mounts = buildEtcMounts(config); + + const passwdMount = mounts.find(m => m.includes('/host/etc/passwd')); + const groupMount = mounts.find(m => m.includes('/host/etc/group')); + expect(passwdMount).toBeDefined(); + expect(groupMount).toBeDefined(); + + // In DinD mode, the mount source is a staged file path (not bare /etc/passwd) + const passwdPath = passwdMount!.split(':')[0]; + expect(fs.existsSync(passwdPath)).toBe(true); + + const groupPath = groupMount!.split(':')[0]; + expect(fs.existsSync(groupPath)).toBe(true); + }); + }); +}); diff --git a/src/services/agent-volumes/etc-mounts.ts b/src/services/agent-volumes/etc-mounts.ts index fcf32ee16..efc6b3640 100644 --- a/src/services/agent-volumes/etc-mounts.ts +++ b/src/services/agent-volumes/etc-mounts.ts @@ -1,5 +1,24 @@ +import * as fs from 'fs'; +import * as path from 'path'; import { WrapperConfig } from '../../types'; -import { shouldUseDockerHostStaging, stageHostFile } from './docker-host-staging'; +import { shouldUseDockerHostStaging, stageHostFile, getDockerHostStageRoot } from './docker-host-staging'; +import { getSafeHostUid, getSafeHostGid } from '../../host-identity'; + +/** + * Synthesize a minimal /etc/passwd or /etc/group file in the staging directory. + * Used when the runner doesn't have these files (e.g., minimal ARC-DinD containers). + */ +function synthesizeIdentityFile(config: WrapperConfig, relPath: string, content: string): string | undefined { + try { + const stageRoot = getDockerHostStageRoot(config); + const targetPath = path.resolve(stageRoot, relPath); + fs.mkdirSync(path.dirname(targetPath), { recursive: true }); + fs.writeFileSync(targetPath, content, { mode: 0o644 }); + return targetPath; + } catch { + return undefined; + } +} export function buildEtcMounts(config: WrapperConfig): string[] { const mounts: string[] = [ @@ -16,10 +35,33 @@ export function buildEtcMounts(config: WrapperConfig): string[] { return mounts; } - const stagedPasswdPath = stageHostFile(config, '/etc/passwd', 'etc/passwd'); - const stagedGroupPath = stageHostFile(config, '/etc/group', 'etc/group'); - mounts.push(`${stagedPasswdPath || '/etc/passwd'}:/host/etc/passwd:ro`); - mounts.push(`${stagedGroupPath || '/etc/group'}:/host/etc/group:ro`); + // In DinD mode, stage /etc/passwd and /etc/group from the runner. + // If the runner doesn't have these files (minimal ARC containers), synthesize minimal ones. + const uid = getSafeHostUid(); + const gid = getSafeHostGid(); + + let passwdPath = stageHostFile(config, '/etc/passwd', 'etc/passwd'); + if (!passwdPath) { + const minimalPasswd = [ + 'root:x:0:0:root:/root:/bin/bash', + 'nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin', + `runner:x:${uid}:${gid}:GitHub Actions Runner:/home/runner:/bin/bash`, + ].join('\n') + '\n'; + passwdPath = synthesizeIdentityFile(config, 'etc/passwd', minimalPasswd); + } + + let groupPath = stageHostFile(config, '/etc/group', 'etc/group'); + if (!groupPath) { + const minimalGroup = [ + 'root:x:0:', + 'nobody:x:65534:', + `runner:x:${gid}:`, + ].join('\n') + '\n'; + groupPath = synthesizeIdentityFile(config, 'etc/group', minimalGroup); + } + + mounts.push(`${passwdPath || '/etc/passwd'}:/host/etc/passwd:ro`); + mounts.push(`${groupPath || '/etc/group'}:/host/etc/group:ro`); return mounts; } From a64e5cd18e74290202beb405040feee6f46814cd Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Sat, 30 May 2026 07:57:25 -0700 Subject: [PATCH 2/4] fix: add excludeEngines to modelFallback TypeScript types The modelFallback.excludeEngines field was present in the JSON schema and spec (added in PR #4015) but missing from the TypeScript interfaces in src/types/api-proxy-options.ts and src/config-file.ts. The runtime was not affected (JSON.stringify passthrough), but type-safety was incomplete. - Add excludeEngines?: string[] to modelFallback in api-proxy-options.ts - Add excludeEngines?: string[] to modelFallback in config-file.ts - Add config-file-mapping test for excludeEngines passthrough Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- containers/agent/entrypoint.sh | 16 +- .../api-proxy/guards/effective-token-guard.js | 53 +++- .../guards/effective-token-guard.test.js | 86 ++++++ .../api-proxy/server.anthropic-beta.test.js | 137 +-------- .../api-proxy/server.error-handling.test.js | 16 +- containers/api-proxy/server.js | 64 ++++ .../server.model-not-supported.test.js | 76 +---- .../api-proxy/server.proxy-headers.test.js | 16 +- .../server.startup-model-validation.test.js | 158 ++++++++++ .../api-proxy/server.token-guards.test.js | 21 +- .../api-proxy/server.token-steering.test.js | 23 +- containers/api-proxy/server.websocket.test.js | 16 +- .../test-helpers/server-mock-factories.js | 31 +- docs/api-proxy-sidecar.md | 3 +- docs/awf-config-spec.md | 50 ++- docs/awf-config.schema.json | 11 +- docs/chroot-mode.md | 4 +- src/awf-config-schema.json | 11 +- src/commands/build-config.test.ts | 1 + src/commands/build-config.ts | 4 + src/commands/validate-options.test.ts | 1 + .../validators/config-assembly.test.ts | 1 + src/commands/validators/config-assembly.ts | 1 + src/commands/validators/log-and-limits.ts | 16 + src/config-file-mapping.test.ts | 20 ++ src/config-file-validation.test.ts | 3 + src/config-file.ts | 5 + src/config-writer.test.ts | 285 +++++------------- src/container-lifecycle-state.ts | 17 ++ src/container-lifecycle.test-utils.ts | 11 +- src/container-lifecycle.ts | 30 +- src/copilot-api-resolver.internal.ts | 43 +++ src/copilot-api-resolver.test-utils.ts | 12 +- src/copilot-api-resolver.test.ts | 5 + src/copilot-api-resolver.ts | 56 +--- src/schema-validator.test.ts | 2 + src/schema.test.ts | 8 + src/services/agent-volumes-mounts.test.ts | 201 +++++------- src/services/agent-volumes/etc-mounts.test.ts | 26 +- src/services/agent-volumes/etc-mounts.ts | 42 ++- .../api-proxy-service-rate-limit.test.ts | 2 + src/services/api-proxy-service.ts | 6 + src/services/host-path-prefix.ts | 2 +- src/types/api-proxy-options.ts | 16 + src/types/docker.ts | 150 +-------- src/types/index.ts | 3 + src/types/rate-limit-options.ts | 11 +- src/types/squid.ts | 151 ++++++++++ 48 files changed, 1094 insertions(+), 829 deletions(-) create mode 100644 containers/api-proxy/server.startup-model-validation.test.js create mode 100644 src/container-lifecycle-state.ts create mode 100644 src/copilot-api-resolver.internal.ts create mode 100644 src/types/squid.ts diff --git a/containers/agent/entrypoint.sh b/containers/agent/entrypoint.sh index 43dcaf874..7213ebce0 100644 --- a/containers/agent/entrypoint.sh +++ b/containers/agent/entrypoint.sh @@ -709,6 +709,8 @@ if [ "${AWF_CHROOT_ENABLED}" = "true" ]; then HOST_USER_UID="${AWF_USER_UID:-1000}" HOST_USER_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="" 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. @@ -765,7 +767,18 @@ if [ "${AWF_CHROOT_ENABLED}" = "true" ]; then echo "[entrypoint] Created minimal /host/etc/hosts" fi fi + + HOST_USER=$(chroot /host getent passwd "${HOST_USER_UID}" 2>/dev/null | cut -d: -f1 || echo "") + if [ -n "${HOST_USER}" ]; then + CAPSH_IDENTITY_ARGS="--user=${HOST_USER}" + 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}" + echo "[entrypoint][WARN] Proceeding with numeric UID/GID fallback (${HOST_USER_UID}:${HOST_USER_GID})" + fi else + CAPSH_IDENTITY_ARGS="--user=${HOST_USER}" echo "[entrypoint] Running as host user: ${HOST_USER} (UID: ${HOST_USER_UID})" fi @@ -1027,7 +1040,8 @@ AWFEOF cd '${CHROOT_WORKDIR}' 2>/dev/null || cd / trap '${CLEANUP_CMD}' EXIT ${LD_PRELOAD_CMD} - exec capsh --drop=${CAPS_TO_DROP} --user=${HOST_USER} -- -c 'exec ${SCRIPT_FILE}' + if [ -n '${CHROOT_HOME_OVERRIDE}' ]; then export HOME='${CHROOT_HOME_OVERRIDE}'; fi + exec capsh --drop=${CAPS_TO_DROP} ${CAPSH_IDENTITY_ARGS} -- -c 'exec ${SCRIPT_FILE}' " else # Original behavior - run in container filesystem diff --git a/containers/api-proxy/guards/effective-token-guard.js b/containers/api-proxy/guards/effective-token-guard.js index f7fa65752..159b2b035 100644 --- a/containers/api-proxy/guards/effective-token-guard.js +++ b/containers/api-proxy/guards/effective-token-guard.js @@ -1,6 +1,7 @@ 'use strict'; const { parsePositiveInteger } = require('./guard-utils'); +const { logRequest, sanitizeForLog } = require('../logging'); const ET_WARNING_THRESHOLDS = [80, 90, 95, 99]; @@ -32,7 +33,8 @@ let etGuardState = createEffectiveTokenState(); const effectiveTokenConfigCache = { rawMax: undefined, rawMultipliers: undefined, - parsed: { max: null, multipliers: {} }, + rawDefaultMultiplier: undefined, + parsed: { max: null, multipliers: {}, defaultMultiplier: 1 }, }; function parseModelMultipliers(raw) { @@ -53,34 +55,74 @@ function parseModelMultipliers(raw) { } } +function parsePositiveNumber(raw) { + const value = Number(raw); + return Number.isFinite(value) && value > 0 ? value : null; +} + function getEffectiveTokenConfig() { const rawMax = process.env.AWF_MAX_EFFECTIVE_TOKENS; const rawMultipliers = process.env.AWF_EFFECTIVE_TOKEN_MODEL_MULTIPLIERS; - if (effectiveTokenConfigCache.rawMax === rawMax && effectiveTokenConfigCache.rawMultipliers === rawMultipliers) { + const rawDefaultMultiplier = process.env.AWF_EFFECTIVE_TOKEN_DEFAULT_MODEL_MULTIPLIER; + if ( + effectiveTokenConfigCache.rawMax === rawMax && + effectiveTokenConfigCache.rawMultipliers === rawMultipliers && + effectiveTokenConfigCache.rawDefaultMultiplier === rawDefaultMultiplier + ) { return effectiveTokenConfigCache.parsed; } effectiveTokenConfigCache.rawMax = rawMax; effectiveTokenConfigCache.rawMultipliers = rawMultipliers; + effectiveTokenConfigCache.rawDefaultMultiplier = rawDefaultMultiplier; const parsedMultipliers = Object.freeze(parseModelMultipliers(rawMultipliers)); + const configuredDefaultMultiplier = parsePositiveNumber(rawDefaultMultiplier); + const maxConfiguredMultiplier = Math.max(1, ...Object.values(parsedMultipliers)); effectiveTokenConfigCache.parsed = { max: parsePositiveInteger(rawMax), multipliers: parsedMultipliers, + defaultMultiplier: configuredDefaultMultiplier ?? maxConfiguredMultiplier, }; return effectiveTokenConfigCache.parsed; } function getEffectiveTokenState(config) { if (!config.max) return null; - const configKey = `${config.max}|${JSON.stringify(config.multipliers)}`; + const configKey = `${config.max}|${JSON.stringify(config.multipliers)}|${config.defaultMultiplier}`; if (etGuardState.configKey !== configKey) { etGuardState = createEffectiveTokenState(configKey); } return etGuardState; } +function resolveModelMultiplier(model, config) { + if (Object.hasOwn(config.multipliers, model)) { + return { multiplier: config.multipliers[model], source: 'exact' }; + } + + let prefixMatch = null; + for (const [configuredModel, multiplier] of Object.entries(config.multipliers)) { + if (model.startsWith(`${configuredModel}-`)) { + if (!prefixMatch || configuredModel.length > prefixMatch.matchedModel.length) { + prefixMatch = { multiplier, source: 'prefix', matchedModel: configuredModel }; + } + } + } + + if (prefixMatch) return prefixMatch; + + logRequest('warn', 'unknown_model_multiplier', { + model: sanitizeForLog(model), + applied_multiplier: config.defaultMultiplier, + default_model_multiplier: config.defaultMultiplier, + }); + + return { multiplier: config.defaultMultiplier, source: 'default' }; +} + function calculateEffectiveTokens(normalizedUsage, model, config) { - const multiplier = config.multipliers[model] ?? 1; + const multiplierResolution = resolveModelMultiplier(model, config); + const multiplier = multiplierResolution.multiplier; const baseWeightedTokens = (ET_DEFAULT_WEIGHTS.input * (normalizedUsage.input_tokens || 0)) + (ET_DEFAULT_WEIGHTS.cacheRead * (normalizedUsage.cache_read_tokens || 0)) + @@ -162,7 +204,8 @@ function resetEffectiveTokenGuardForTests() { etGuardState = createEffectiveTokenState(); effectiveTokenConfigCache.rawMax = undefined; effectiveTokenConfigCache.rawMultipliers = undefined; - effectiveTokenConfigCache.parsed = { max: null, multipliers: {} }; + effectiveTokenConfigCache.rawDefaultMultiplier = undefined; + effectiveTokenConfigCache.parsed = { max: null, multipliers: {}, defaultMultiplier: 1 }; } function buildEffectiveTokenLimitError(etState) { diff --git a/containers/api-proxy/guards/effective-token-guard.test.js b/containers/api-proxy/guards/effective-token-guard.test.js index 8518c0fe9..6bcdefa51 100644 --- a/containers/api-proxy/guards/effective-token-guard.test.js +++ b/containers/api-proxy/guards/effective-token-guard.test.js @@ -5,15 +5,31 @@ const { resetEffectiveTokenGuardForTests, } = require('./effective-token-guard'); +function collectLogOutput() { + const lines = []; + const spy = jest.spyOn(process.stdout, 'write').mockImplementation((data) => { + try { + lines.push(JSON.parse(data.toString())); + } catch { + // ignore non-JSON writes + } + return true; + }); + return { lines, spy }; +} + describe('effective-token-guard reflect state', () => { beforeEach(() => { delete process.env.AWF_EFFECTIVE_TOKEN_MODEL_MULTIPLIERS; + delete process.env.AWF_EFFECTIVE_TOKEN_DEFAULT_MODEL_MULTIPLIER; resetEffectiveTokenGuardForTests(); }); afterEach(() => { delete process.env.AWF_MAX_EFFECTIVE_TOKENS; delete process.env.AWF_EFFECTIVE_TOKEN_MODEL_MULTIPLIERS; + delete process.env.AWF_EFFECTIVE_TOKEN_DEFAULT_MODEL_MULTIPLIER; + jest.restoreAllMocks(); resetEffectiveTokenGuardForTests(); }); @@ -59,4 +75,74 @@ describe('effective-token-guard reflect state', () => { percent_used: 100, }); }); + + it('uses the highest configured multiplier for unknown models by default and warns', () => { + const { lines } = collectLogOutput(); + process.env.AWF_MAX_EFFECTIVE_TOKENS = '1000'; + process.env.AWF_EFFECTIVE_TOKEN_MODEL_MULTIPLIERS = JSON.stringify({ + 'claude-opus-4.7': 27, + 'gpt-5-pro': 54, + }); + + const usage = applyEffectiveTokenUsage({ output_tokens: 1 }, 'unmapped-expensive-model'); + + expect(usage.modelMultiplier).toBe(54); + expect(usage.effectiveTokensThisResponse).toBe(216); + expect(lines).toContainEqual(expect.objectContaining({ + event: 'unknown_model_multiplier', + level: 'warn', + model: 'unmapped-expensive-model', + applied_multiplier: 54, + })); + }); + + it('supports explicit default multipliers for unknown models', () => { + const { lines } = collectLogOutput(); + process.env.AWF_MAX_EFFECTIVE_TOKENS = '1000'; + process.env.AWF_EFFECTIVE_TOKEN_MODEL_MULTIPLIERS = JSON.stringify({ + 'gpt-4o': 2, + }); + process.env.AWF_EFFECTIVE_TOKEN_DEFAULT_MODEL_MULTIPLIER = '27'; + + const usage = applyEffectiveTokenUsage({ output_tokens: 1 }, 'unknown-model'); + + expect(usage.modelMultiplier).toBe(27); + expect(usage.effectiveTokensThisResponse).toBe(108); + expect(lines).toContainEqual(expect.objectContaining({ + event: 'unknown_model_multiplier', + level: 'warn', + model: 'unknown-model', + applied_multiplier: 27, + })); + }); + + it('warns when explicit default multiplier is used with no configured model map', () => { + const { lines } = collectLogOutput(); + process.env.AWF_MAX_EFFECTIVE_TOKENS = '1000'; + process.env.AWF_EFFECTIVE_TOKEN_DEFAULT_MODEL_MULTIPLIER = '27'; + + const usage = applyEffectiveTokenUsage({ output_tokens: 1 }, 'unknown-model'); + + expect(usage.modelMultiplier).toBe(27); + expect(lines).toContainEqual(expect.objectContaining({ + event: 'unknown_model_multiplier', + level: 'warn', + model: 'unknown-model', + applied_multiplier: 27, + default_model_multiplier: 27, + })); + }); + + it('matches configured multipliers by concrete model prefix', () => { + const { lines } = collectLogOutput(); + process.env.AWF_MAX_EFFECTIVE_TOKENS = '1000'; + process.env.AWF_EFFECTIVE_TOKEN_MODEL_MULTIPLIERS = JSON.stringify({ + 'claude-opus-4.7': 27, + }); + + const usage = applyEffectiveTokenUsage({ output_tokens: 1 }, 'claude-opus-4.7-20260501'); + + expect(usage.modelMultiplier).toBe(27); + expect(lines.find((line) => line.event === 'unknown_model_multiplier')).toBeUndefined(); + }); }); diff --git a/containers/api-proxy/server.anthropic-beta.test.js b/containers/api-proxy/server.anthropic-beta.test.js index e536082cb..ee3198c4f 100644 --- a/containers/api-proxy/server.anthropic-beta.test.js +++ b/containers/api-proxy/server.anthropic-beta.test.js @@ -12,52 +12,47 @@ const { makeProxyReq, makeProxyRes, getStructuredLogs, + setupServerTestEnv, } = require('./test-helpers/server-mock-factories'); -const originalHttpsProxy = process.env.HTTPS_PROXY; let proxyRequest; let resetAnthropicDeprecatedBetaHeadersForTests; -beforeAll(() => { - delete process.env.HTTPS_PROXY; - jest.resetModules(); +setupServerTestEnv(() => { ({ proxyRequest } = require('./server')); ({ resetAnthropicDeprecatedBetaHeadersForTests } = require('./proxy-request')); + return { proxyRequest, resetAnthropicDeprecatedBetaHeadersForTests }; }); beforeEach(() => { resetAnthropicDeprecatedBetaHeadersForTests(); }); -afterAll(() => { - if (originalHttpsProxy === undefined) { - delete process.env.HTTPS_PROXY; - } else { - process.env.HTTPS_PROXY = originalHttpsProxy; - } - jest.resetModules(); -}); - describe('proxyRequest anthropic deprecated beta handling', () => { + let stdoutWriteSpy; + let responseHandlers; + let capturedOptions; function makeReq(headers = {}) { return makeReqFactory('/v1/messages', headers); } - afterEach(() => { - jest.restoreAllMocks(); - }); - - it('retries once after Anthropic rejects a deprecated anthropic-beta value', () => { - const stdoutWriteSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); - const responseHandlers = []; - const capturedOptions = []; + beforeEach(() => { + stdoutWriteSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + responseHandlers = []; + capturedOptions = []; jest.spyOn(https, 'request').mockImplementation((options, cb) => { capturedOptions.push(options); responseHandlers.push(cb); return makeProxyReq(); }); + }); + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('retries once after Anthropic rejects a deprecated anthropic-beta value', () => { const req = makeReq({ 'anthropic-beta': 'context-1m-2025-08-07,other-beta' }); const res = makeRes(); proxyRequest(req, res, 'api.anthropic.com', { 'x-api-key': 'sk-ant-test' }, 'anthropic'); @@ -96,16 +91,6 @@ describe('proxyRequest anthropic deprecated beta handling', () => { }); it('proactively strips learned deprecated anthropic-beta values on later requests', () => { - const stdoutWriteSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); - const responseHandlers = []; - const capturedOptions = []; - - jest.spyOn(https, 'request').mockImplementation((options, cb) => { - capturedOptions.push(options); - responseHandlers.push(cb); - return makeProxyReq(); - }); - const learnReq = makeReq({ 'anthropic-beta': 'context-1m-2025-08-07' }); const learnRes = makeRes(); proxyRequest(learnReq, learnRes, 'api.anthropic.com', { 'x-api-key': 'sk-ant-test' }, 'anthropic'); @@ -149,16 +134,6 @@ describe('proxyRequest anthropic deprecated beta handling', () => { }); it('retries after deprecated anthropic-beta rejection via copilot provider', () => { - const stdoutWriteSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); - const responseHandlers = []; - const capturedOptions = []; - - jest.spyOn(https, 'request').mockImplementation((options, cb) => { - capturedOptions.push(options); - responseHandlers.push(cb); - return makeProxyReq(); - }); - const req = makeReq({ 'anthropic-beta': 'context-1m-2025-08-07,prompt-caching-2024-07-31' }); const res = makeRes(); proxyRequest(req, res, 'api.githubcopilot.com', { authorization: 'Bearer ghu_test' }, 'copilot'); @@ -199,16 +174,6 @@ describe('proxyRequest anthropic deprecated beta handling', () => { }); it('proactively strips learned deprecated values for copilot provider requests', () => { - const stdoutWriteSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); - const responseHandlers = []; - const capturedOptions = []; - - jest.spyOn(https, 'request').mockImplementation((options, cb) => { - capturedOptions.push(options); - responseHandlers.push(cb); - return makeProxyReq(); - }); - // First: learn via anthropic provider const learnReq = makeReq({ 'anthropic-beta': 'context-1m-2025-08-07' }); const learnRes = makeRes(); @@ -247,16 +212,6 @@ describe('proxyRequest anthropic deprecated beta handling', () => { }); it('handles deprecated values in arbitrary headers (not just anthropic-beta)', () => { - const stdoutWriteSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); - const responseHandlers = []; - const capturedOptions = []; - - jest.spyOn(https, 'request').mockImplementation((options, cb) => { - capturedOptions.push(options); - responseHandlers.push(cb); - return makeProxyReq(); - }); - const req = makeReq({ 'x-custom-feature': 'old-feature-2024,new-feature-2025' }); const res = makeRes(); proxyRequest(req, res, 'api.anthropic.com', { 'x-api-key': 'sk-ant-test' }, 'anthropic'); @@ -305,16 +260,6 @@ describe('proxyRequest anthropic deprecated beta handling', () => { }); it('does not retry when 400 body does not match the deprecated header pattern', () => { - jest.spyOn(process.stdout, 'write').mockImplementation(() => true); - const responseHandlers = []; - const capturedOptions = []; - - jest.spyOn(https, 'request').mockImplementation((options, cb) => { - capturedOptions.push(options); - responseHandlers.push(cb); - return makeProxyReq(); - }); - const req = makeReq({ 'anthropic-beta': 'context-1m-2025-08-07' }); const res = makeRes(); proxyRequest(req, res, 'api.anthropic.com', { 'x-api-key': 'sk-ant-test' }, 'anthropic'); @@ -335,16 +280,6 @@ describe('proxyRequest anthropic deprecated beta handling', () => { }); it('does not retry more than once (retry itself returns 400)', () => { - jest.spyOn(process.stdout, 'write').mockImplementation(() => true); - const responseHandlers = []; - const capturedOptions = []; - - jest.spyOn(https, 'request').mockImplementation((options, cb) => { - capturedOptions.push(options); - responseHandlers.push(cb); - return makeProxyReq(); - }); - const req = makeReq({ 'anthropic-beta': 'bad-value-1,bad-value-2' }); const res = makeRes(); proxyRequest(req, res, 'api.anthropic.com', { 'x-api-key': 'sk-ant-test' }, 'anthropic'); @@ -379,16 +314,6 @@ describe('proxyRequest anthropic deprecated beta handling', () => { }); it('removes header entirely when all values are deprecated', () => { - jest.spyOn(process.stdout, 'write').mockImplementation(() => true); - const responseHandlers = []; - const capturedOptions = []; - - jest.spyOn(https, 'request').mockImplementation((options, cb) => { - capturedOptions.push(options); - responseHandlers.push(cb); - return makeProxyReq(); - }); - const req = makeReq({ 'anthropic-beta': 'context-1m-2025-08-07' }); const res = makeRes(); proxyRequest(req, res, 'api.anthropic.com', { 'x-api-key': 'sk-ant-test' }, 'anthropic'); @@ -414,16 +339,6 @@ describe('proxyRequest anthropic deprecated beta handling', () => { }); it('does not buffer 400 responses for non-anthropic/non-copilot providers', () => { - jest.spyOn(process.stdout, 'write').mockImplementation(() => true); - const responseHandlers = []; - const capturedOptions = []; - - jest.spyOn(https, 'request').mockImplementation((options, cb) => { - capturedOptions.push(options); - responseHandlers.push(cb); - return makeProxyReq(); - }); - const req = makeReq({ 'anthropic-beta': 'context-1m-2025-08-07' }); req.url = '/v1/chat/completions'; const res = makeRes(); @@ -443,16 +358,6 @@ describe('proxyRequest anthropic deprecated beta handling', () => { }); it('learns multiple deprecated values across separate requests', () => { - jest.spyOn(process.stdout, 'write').mockImplementation(() => true); - const responseHandlers = []; - const capturedOptions = []; - - jest.spyOn(https, 'request').mockImplementation((options, cb) => { - capturedOptions.push(options); - responseHandlers.push(cb); - return makeProxyReq(); - }); - // First request: learn that value-a is deprecated const req1 = makeReq({ 'anthropic-beta': 'value-a,value-b,value-c' }); const res1 = makeRes(); @@ -502,16 +407,6 @@ describe('proxyRequest anthropic deprecated beta handling', () => { }); it('handles whitespace in comma-separated header values', () => { - jest.spyOn(process.stdout, 'write').mockImplementation(() => true); - const responseHandlers = []; - const capturedOptions = []; - - jest.spyOn(https, 'request').mockImplementation((options, cb) => { - capturedOptions.push(options); - responseHandlers.push(cb); - return makeProxyReq(); - }); - // Header with spaces around commas const req = makeReq({ 'anthropic-beta': ' context-1m-2025-08-07 , prompt-caching-2024-07-31 ' }); const res = makeRes(); diff --git a/containers/api-proxy/server.error-handling.test.js b/containers/api-proxy/server.error-handling.test.js index 3676ea96b..bcc62b8d4 100644 --- a/containers/api-proxy/server.error-handling.test.js +++ b/containers/api-proxy/server.error-handling.test.js @@ -11,25 +11,15 @@ const { makeReq: makeReqFactory, makeRes, getStructuredLogs, + setupServerTestEnv, } = require('./test-helpers/server-mock-factories'); -const originalHttpsProxy = process.env.HTTPS_PROXY; let proxyRequest; let healthResponse; -beforeAll(() => { - delete process.env.HTTPS_PROXY; - jest.resetModules(); +setupServerTestEnv(() => { ({ proxyRequest, healthResponse } = require('./server')); -}); - -afterAll(() => { - if (originalHttpsProxy === undefined) { - delete process.env.HTTPS_PROXY; - } else { - process.env.HTTPS_PROXY = originalHttpsProxy; - } - jest.resetModules(); + return { proxyRequest, healthResponse }; }); describe('proxyRequest error handling', () => { diff --git a/containers/api-proxy/server.js b/containers/api-proxy/server.js index fcbd705fa..867a51832 100644 --- a/containers/api-proxy/server.js +++ b/containers/api-proxy/server.js @@ -484,6 +484,68 @@ async function fetchStartupModels(adapters = []) { modelFetchComplete = true; } +/** + * After model fetch, validate that the requested model (if specified via + * AWF_REQUESTED_MODEL) is available in the cached model list. + * Emits a clear diagnostic log if the model is not found. + */ +function validateRequestedModel() { + const requestedModel = (process.env.AWF_REQUESTED_MODEL || '').trim(); + if (!requestedModel) return; + + // Collect all known models across providers + const allModels = []; + for (const models of Object.values(cachedModels)) { + if (Array.isArray(models)) allModels.push(...models); + } + + if (allModels.length === 0) { + // No models fetched — cannot validate + logRequest('warn', 'model_validation_skipped', { + requested_model: requestedModel, + message: 'Cannot validate requested model — no model lists available from providers', + }); + return; + } + + // Check if the model or any alias of it resolves to an available model + const normalizedRequested = requestedModel.toLowerCase(); + const found = allModels.some(m => m.toLowerCase() === normalizedRequested); + + // Also check through model aliases — try resolution across all providers. + // Disable fallback so that middle-power fallback does not produce a non-null + // result for a model that does not actually exist in any provider catalogue. + let aliasResolved = false; + if (!found && MODEL_ALIASES) { + const { resolveModel } = require('./model-resolver'); + for (const provider of Object.keys(cachedModels)) { + const result = resolveModel(requestedModel, MODEL_ALIASES.models, cachedModels, provider, [], { enabled: false }); + if (result) { + aliasResolved = true; + break; + } + } + } + + if (!found && !aliasResolved) { + const availableModels = allModels.slice(0, 20).join(', '); + const truncated = allModels.length > 20 ? ` (and ${allModels.length - 20} more)` : ''; + logRequest('error', 'model_unavailable_at_startup', { + requested_model: requestedModel, + available_count: allModels.length, + message: `Requested model '${requestedModel}' is not available in any configured provider's model list. ` + + `This typically means the model is retired, restricted, or misspelled. ` + + `Available models: ${availableModels}${truncated}`, + }); + } else { + logRequest('info', 'model_validation', { + requested_model: requestedModel, + resolved_via: aliasResolved ? 'alias' : 'direct', + message: `Requested model '${requestedModel}' is available`, + }); + } +} + // ── Generic provider server factory ────────────────────────────────────────── /** * Create a health-check request handler for a provider adapter. @@ -689,6 +751,7 @@ if (require.main === module) { }); fetchStartupModels(adaptersToStart).then(() => { writeModelsJson(); + validateRequestedModel(); }).catch((err) => { logRequest('error', 'model_fetch_error', { message: 'Unexpected error fetching startup models', error: String(err) }); modelFetchComplete = true; @@ -744,6 +807,7 @@ module.exports = { probeProvider, httpProbe, fetchStartupModels, + validateRequestedModel, // State keyValidationResults, resetKeyValidationState, diff --git a/containers/api-proxy/server.model-not-supported.test.js b/containers/api-proxy/server.model-not-supported.test.js index 19f6b40c8..1ef5efba9 100644 --- a/containers/api-proxy/server.model-not-supported.test.js +++ b/containers/api-proxy/server.model-not-supported.test.js @@ -13,30 +13,23 @@ const { makeProxyReq, makeProxyRes, getStructuredLogs, + setupServerTestEnv, } = require('./test-helpers/server-mock-factories'); -const originalHttpsProxy = process.env.HTTPS_PROXY; let proxyRequest; let _setSleepForTests; let _resetSleepForTests; -beforeAll(() => { - delete process.env.HTTPS_PROXY; - jest.resetModules(); +setupServerTestEnv(() => { ({ proxyRequest } = require('./server')); ({ _setSleepForTests, _resetSleepForTests } = require('./proxy-request')); // Make retries instant — no real setTimeout delays in unit tests. _setSleepForTests(() => Promise.resolve()); + return { proxyRequest, _setSleepForTests, _resetSleepForTests }; }); afterAll(() => { _resetSleepForTests(); - if (originalHttpsProxy === undefined) { - delete process.env.HTTPS_PROXY; - } else { - process.env.HTTPS_PROXY = originalHttpsProxy; - } - jest.resetModules(); }); // ── helpers ─────────────────────────────────────────────────────────────────── @@ -53,21 +46,27 @@ function flushPromises() { // ── tests ───────────────────────────────────────────────────────────────────── describe('proxyRequest copilot model-not-supported retry', () => { - afterEach(() => { - jest.restoreAllMocks(); - }); + let stdoutWriteSpy; + let responseHandlers; + let capturedOptions; - it('retries once after Copilot returns 400 model not supported, then succeeds', async () => { - const stdoutWriteSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); - const responseHandlers = []; - const capturedOptions = []; + beforeEach(() => { + stdoutWriteSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + responseHandlers = []; + capturedOptions = []; jest.spyOn(https, 'request').mockImplementation((options, cb) => { capturedOptions.push(options); responseHandlers.push(cb); return makeProxyReq(); }); + }); + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('retries once after Copilot returns 400 model not supported, then succeeds', async () => { const req = makeReq(); const res = makeRes(); proxyRequest(req, res, 'api.githubcopilot.com', { Authorization: '******' }, 'copilot'); @@ -105,16 +104,6 @@ describe('proxyRequest copilot model-not-supported retry', () => { }); it('retries a second time when the first retry also returns 400 model not supported', async () => { - jest.spyOn(process.stdout, 'write').mockImplementation(() => true); - const responseHandlers = []; - const capturedOptions = []; - - jest.spyOn(https, 'request').mockImplementation((options, cb) => { - capturedOptions.push(options); - responseHandlers.push(cb); - return makeProxyReq(); - }); - const req = makeReq(); const res = makeRes(); proxyRequest(req, res, 'api.githubcopilot.com', { Authorization: '******' }, 'copilot'); @@ -145,16 +134,6 @@ describe('proxyRequest copilot model-not-supported retry', () => { }); it('surfaces the 400 to the client after exhausting all retries', async () => { - jest.spyOn(process.stdout, 'write').mockImplementation(() => true); - const responseHandlers = []; - const capturedOptions = []; - - jest.spyOn(https, 'request').mockImplementation((options, cb) => { - capturedOptions.push(options); - responseHandlers.push(cb); - return makeProxyReq(); - }); - const req = makeReq(); const res = makeRes(); proxyRequest(req, res, 'api.githubcopilot.com', { Authorization: '******' }, 'copilot'); @@ -180,16 +159,6 @@ describe('proxyRequest copilot model-not-supported retry', () => { }); it('does not retry a 400 that is not model-not-supported', async () => { - jest.spyOn(process.stdout, 'write').mockImplementation(() => true); - const responseHandlers = []; - const capturedOptions = []; - - jest.spyOn(https, 'request').mockImplementation((options, cb) => { - capturedOptions.push(options); - responseHandlers.push(cb); - return makeProxyReq(); - }); - const req = makeReq(); const res = makeRes(); proxyRequest(req, res, 'api.githubcopilot.com', { Authorization: '******' }, 'copilot'); @@ -207,16 +176,6 @@ describe('proxyRequest copilot model-not-supported retry', () => { }); it('does not retry model-not-supported for non-copilot providers', async () => { - jest.spyOn(process.stdout, 'write').mockImplementation(() => true); - const responseHandlers = []; - const capturedOptions = []; - - jest.spyOn(https, 'request').mockImplementation((options, cb) => { - capturedOptions.push(options); - responseHandlers.push(cb); - return makeProxyReq(); - }); - const req = makeReq(); const res = makeRes(); // Use openai provider — model-not-supported retry only applies to copilot @@ -234,9 +193,6 @@ describe('proxyRequest copilot model-not-supported retry', () => { }); it('sends an identical request body on retry', async () => { - jest.spyOn(process.stdout, 'write').mockImplementation(() => true); - const responseHandlers = []; - const capturedOptions = []; const capturedBodies = []; jest.spyOn(https, 'request').mockImplementation((options, cb) => { diff --git a/containers/api-proxy/server.proxy-headers.test.js b/containers/api-proxy/server.proxy-headers.test.js index 3397b2951..be29fe47a 100644 --- a/containers/api-proxy/server.proxy-headers.test.js +++ b/containers/api-proxy/server.proxy-headers.test.js @@ -9,24 +9,14 @@ const { makeReq: makeReqFactory, makeRes, makeProxyReq, + setupServerTestEnv, } = require('./test-helpers/server-mock-factories'); -const originalHttpsProxy = process.env.HTTPS_PROXY; let proxyRequest; -beforeAll(() => { - delete process.env.HTTPS_PROXY; - jest.resetModules(); +setupServerTestEnv(() => { ({ proxyRequest } = require('./server')); -}); - -afterAll(() => { - if (originalHttpsProxy === undefined) { - delete process.env.HTTPS_PROXY; - } else { - process.env.HTTPS_PROXY = originalHttpsProxy; - } - jest.resetModules(); + return { proxyRequest }; }); describe('proxyRequest X-Initiator injection', () => { diff --git a/containers/api-proxy/server.startup-model-validation.test.js b/containers/api-proxy/server.startup-model-validation.test.js new file mode 100644 index 000000000..a70c040a6 --- /dev/null +++ b/containers/api-proxy/server.startup-model-validation.test.js @@ -0,0 +1,158 @@ +/** + * Tests for pre-startup model validation (AWF_REQUESTED_MODEL). + */ + +const { validateRequestedModel, cachedModels, resetModelCacheState } = require('./server'); +const { logRequest } = require('./logging'); + +jest.mock('./logging', () => ({ + logRequest: jest.fn(), +})); + +beforeEach(() => { + jest.clearAllMocks(); + resetModelCacheState(); + delete process.env.AWF_REQUESTED_MODEL; +}); + +describe('validateRequestedModel', () => { + it('does nothing when AWF_REQUESTED_MODEL is not set', () => { + validateRequestedModel(); + expect(logRequest).not.toHaveBeenCalled(); + }); + + it('does nothing when AWF_REQUESTED_MODEL is empty', () => { + process.env.AWF_REQUESTED_MODEL = ' '; + validateRequestedModel(); + expect(logRequest).not.toHaveBeenCalled(); + }); + + it('emits model_validation_skipped when no models are cached', () => { + process.env.AWF_REQUESTED_MODEL = 'gpt-4o'; + validateRequestedModel(); + expect(logRequest).toHaveBeenCalledWith('warn', 'model_validation_skipped', expect.objectContaining({ + requested_model: 'gpt-4o', + })); + }); + + it('emits model_unavailable_at_startup when model is not found', () => { + process.env.AWF_REQUESTED_MODEL = 'gpt-5-codex'; + cachedModels.copilot = ['gpt-4o', 'gpt-4o-mini', 'claude-sonnet-4-5']; + validateRequestedModel(); + expect(logRequest).toHaveBeenCalledWith('error', 'model_unavailable_at_startup', expect.objectContaining({ + requested_model: 'gpt-5-codex', + available_count: 3, + })); + expect(logRequest.mock.calls[0][2].message).toContain("not available"); + expect(logRequest.mock.calls[0][2].message).toContain("gpt-4o"); + }); + + it('emits model_validation success when model is found directly', () => { + process.env.AWF_REQUESTED_MODEL = 'gpt-4o'; + cachedModels.copilot = ['gpt-4o', 'gpt-4o-mini']; + validateRequestedModel(); + expect(logRequest).toHaveBeenCalledWith('info', 'model_validation', expect.objectContaining({ + requested_model: 'gpt-4o', + resolved_via: 'direct', + })); + }); + + it('matches model case-insensitively', () => { + process.env.AWF_REQUESTED_MODEL = 'GPT-4o'; + cachedModels.copilot = ['gpt-4o', 'gpt-4o-mini']; + validateRequestedModel(); + expect(logRequest).toHaveBeenCalledWith('info', 'model_validation', expect.objectContaining({ + requested_model: 'GPT-4o', + resolved_via: 'direct', + })); + }); + + it('searches across multiple providers', () => { + process.env.AWF_REQUESTED_MODEL = 'claude-sonnet-4-5'; + cachedModels.copilot = ['gpt-4o']; + cachedModels.anthropic = ['claude-sonnet-4-5', 'claude-haiku-3']; + validateRequestedModel(); + expect(logRequest).toHaveBeenCalledWith('info', 'model_validation', expect.objectContaining({ + requested_model: 'claude-sonnet-4-5', + resolved_via: 'direct', + })); + }); + + it('skips providers with null model lists', () => { + process.env.AWF_REQUESTED_MODEL = 'gpt-4o'; + cachedModels.copilot = null; // fetch failed + cachedModels.openai = ['gpt-4o', 'gpt-4o-mini']; + validateRequestedModel(); + expect(logRequest).toHaveBeenCalledWith('info', 'model_validation', expect.objectContaining({ + requested_model: 'gpt-4o', + })); + }); + + it('lists available models in the error diagnostic', () => { + process.env.AWF_REQUESTED_MODEL = 'nonexistent-model'; + cachedModels.copilot = ['gpt-4o', 'gpt-4o-mini', 'o3']; + validateRequestedModel(); + const message = logRequest.mock.calls[0][2].message; + expect(message).toContain('gpt-4o'); + expect(message).toContain('retired, restricted, or misspelled'); + }); + + it('resolves AWF_REQUESTED_MODEL via model alias and logs resolved_via alias', () => { + const prevAliases = process.env.AWF_MODEL_ALIASES; + process.env.AWF_MODEL_ALIASES = JSON.stringify({ models: { sonnet: ['copilot/*sonnet*'] } }); + + let isolatedServer; + jest.isolateModules(() => { + jest.mock('./logging', () => ({ logRequest: jest.fn() })); + isolatedServer = require('./server'); + }); + + const { logRequest: isolatedLog } = require('./logging'); + + try { + isolatedServer.resetModelCacheState(); + isolatedServer.cachedModels.copilot = ['claude-sonnet-4-5', 'gpt-4o']; + process.env.AWF_REQUESTED_MODEL = 'sonnet'; + isolatedServer.validateRequestedModel(); + expect(isolatedLog).toHaveBeenCalledWith('info', 'model_validation', expect.objectContaining({ + requested_model: 'sonnet', + resolved_via: 'alias', + })); + } finally { + if (prevAliases === undefined) delete process.env.AWF_MODEL_ALIASES; + else process.env.AWF_MODEL_ALIASES = prevAliases; + } + }); + + it('does not emit model_validation via alias when fallback would fire but model is absent', () => { + const prevAliases = process.env.AWF_MODEL_ALIASES; + const prevFallback = process.env.AWF_MODEL_FALLBACK; + process.env.AWF_MODEL_ALIASES = JSON.stringify({ models: { sonnet: ['copilot/*sonnet*'] } }); + process.env.AWF_MODEL_FALLBACK = JSON.stringify({ enabled: true, strategy: 'middle_power' }); + + let isolatedServer; + jest.isolateModules(() => { + jest.mock('./logging', () => ({ logRequest: jest.fn() })); + isolatedServer = require('./server'); + }); + + const { logRequest: isolatedLog } = require('./logging'); + + try { + isolatedServer.resetModelCacheState(); + // No models matching the alias pattern — only a non-matching model is present + isolatedServer.cachedModels.copilot = ['gpt-4o']; + process.env.AWF_REQUESTED_MODEL = 'sonnet'; + isolatedServer.validateRequestedModel(); + // middle-power fallback is disabled during validation, so model_unavailable_at_startup is expected + expect(isolatedLog).toHaveBeenCalledWith('error', 'model_unavailable_at_startup', expect.objectContaining({ + requested_model: 'sonnet', + })); + } finally { + if (prevAliases === undefined) delete process.env.AWF_MODEL_ALIASES; + else process.env.AWF_MODEL_ALIASES = prevAliases; + if (prevFallback === undefined) delete process.env.AWF_MODEL_FALLBACK; + else process.env.AWF_MODEL_FALLBACK = prevFallback; + } + }); +}); diff --git a/containers/api-proxy/server.token-guards.test.js b/containers/api-proxy/server.token-guards.test.js index 58e7b45a9..46e40f434 100644 --- a/containers/api-proxy/server.token-guards.test.js +++ b/containers/api-proxy/server.token-guards.test.js @@ -7,30 +7,23 @@ const https = require('https'); const { EventEmitter } = require('events'); -const { makeReq: makeReqFactory, makeRes } = require('./test-helpers/server-mock-factories'); +const { + makeReq: makeReqFactory, + makeRes, + setupServerTestEnv, +} = require('./test-helpers/server-mock-factories'); -const originalHttpsProxy = process.env.HTTPS_PROXY; let proxyRequest; let resetEffectiveTokenGuardForTests; let resetMaxRunsGuardForTests; -beforeAll(() => { - delete process.env.HTTPS_PROXY; - jest.resetModules(); +setupServerTestEnv(() => { ({ proxyRequest } = require('./server')); ({ resetEffectiveTokenGuardForTests, resetMaxRunsGuardForTests, } = require('./proxy-request')); -}); - -afterAll(() => { - if (originalHttpsProxy === undefined) { - delete process.env.HTTPS_PROXY; - } else { - process.env.HTTPS_PROXY = originalHttpsProxy; - } - jest.resetModules(); + return { proxyRequest, resetEffectiveTokenGuardForTests, resetMaxRunsGuardForTests }; }); describe('proxyRequest effective token guard', () => { diff --git a/containers/api-proxy/server.token-steering.test.js b/containers/api-proxy/server.token-steering.test.js index b07745f81..082e07f61 100644 --- a/containers/api-proxy/server.token-steering.test.js +++ b/containers/api-proxy/server.token-steering.test.js @@ -7,8 +7,8 @@ const https = require('https'); const { EventEmitter } = require('events'); +const { setupServerTestEnv } = require('./test-helpers/server-mock-factories'); -const originalHttpsProxy = process.env.HTTPS_PROXY; let proxyRequest; let getAndClearPendingSteeringMessage; let getAndClearPendingTimeoutSteeringMessage; @@ -16,9 +16,7 @@ let injectSteeringMessage; let resetEffectiveTokenGuardForTests; let resetTimeoutSteeringForTests; -beforeAll(() => { - delete process.env.HTTPS_PROXY; - jest.resetModules(); +setupServerTestEnv(() => { ({ proxyRequest } = require('./server')); ({ getAndClearPendingSteeringMessage, @@ -27,15 +25,14 @@ beforeAll(() => { resetEffectiveTokenGuardForTests, resetTimeoutSteeringForTests, } = require('./proxy-request')); -}); - -afterAll(() => { - if (originalHttpsProxy === undefined) { - delete process.env.HTTPS_PROXY; - } else { - process.env.HTTPS_PROXY = originalHttpsProxy; - } - jest.resetModules(); + return { + proxyRequest, + getAndClearPendingSteeringMessage, + getAndClearPendingTimeoutSteeringMessage, + injectSteeringMessage, + resetEffectiveTokenGuardForTests, + resetTimeoutSteeringForTests, + }; }); describe('token steering — getAndClearPendingSteeringMessage and injectSteeringMessage', () => { diff --git a/containers/api-proxy/server.websocket.test.js b/containers/api-proxy/server.websocket.test.js index f330386e0..036038405 100644 --- a/containers/api-proxy/server.websocket.test.js +++ b/containers/api-proxy/server.websocket.test.js @@ -8,23 +8,13 @@ const http = require('http'); const tls = require('tls'); const { EventEmitter } = require('events'); +const { setupServerTestEnv } = require('./test-helpers/server-mock-factories'); -const originalHttpsProxy = process.env.HTTPS_PROXY; let proxyWebSocket; -beforeAll(() => { - delete process.env.HTTPS_PROXY; - jest.resetModules(); +setupServerTestEnv(() => { ({ proxyWebSocket } = require('./server')); -}); - -afterAll(() => { - if (originalHttpsProxy === undefined) { - delete process.env.HTTPS_PROXY; - } else { - process.env.HTTPS_PROXY = originalHttpsProxy; - } - jest.resetModules(); + return { proxyWebSocket }; }); diff --git a/containers/api-proxy/test-helpers/server-mock-factories.js b/containers/api-proxy/test-helpers/server-mock-factories.js index 4a050acb0..2dc4ece1e 100644 --- a/containers/api-proxy/test-helpers/server-mock-factories.js +++ b/containers/api-proxy/test-helpers/server-mock-factories.js @@ -51,4 +51,33 @@ function getStructuredLogs(writeSpy, eventName) { .filter(entry => entry && entry.event === eventName); } -module.exports = { makeReq, makeRes, makeProxyReq, makeProxyRes, getStructuredLogs }; +function setupServerTestEnv(importFn) { + const originalHttpsProxy = process.env.HTTPS_PROXY; + let imported = {}; + + beforeAll(() => { + delete process.env.HTTPS_PROXY; + jest.resetModules(); + imported = importFn() || {}; + }); + + afterAll(() => { + if (originalHttpsProxy === undefined) { + delete process.env.HTTPS_PROXY; + } else { + process.env.HTTPS_PROXY = originalHttpsProxy; + } + jest.resetModules(); + }); + + return { get: () => imported }; +} + +module.exports = { + makeReq, + makeRes, + makeProxyReq, + makeProxyRes, + getStructuredLogs, + setupServerTestEnv, +}; diff --git a/docs/api-proxy-sidecar.md b/docs/api-proxy-sidecar.md index 0eed861b2..c3970730d 100644 --- a/docs/api-proxy-sidecar.md +++ b/docs/api-proxy-sidecar.md @@ -838,7 +838,8 @@ Set in the AWF config file or via the `--max-model-multiplier` CLI flag: "o3": 4, "claude-sonnet-4-20250514": 1, "gpt-4.1-mini": 0.5 - } + }, + "defaultModelMultiplier": 15 } } ``` diff --git a/docs/awf-config-spec.md b/docs/awf-config-spec.md index 5383c1b87..b6fa56cb5 100644 --- a/docs/awf-config-spec.md +++ b/docs/awf-config-spec.md @@ -100,7 +100,9 @@ the corresponding CLI flag. - `apiProxy.anthropicCacheTailTtl` → `--anthropic-cache-tail-ttl <5m|1h>` - `apiProxy.maxEffectiveTokens` → *(config-only; no CLI equivalent)* - `apiProxy.modelMultipliers` → `--max-model-multiplier ` +- `apiProxy.defaultModelMultiplier` → *(config-only; maps to `AWF_EFFECTIVE_TOKEN_DEFAULT_MODEL_MULTIPLIER`)* - `apiProxy.maxRuns` → *(config-only; no CLI equivalent)* +- `apiProxy.requestedModel` → *(config-only; maps to `AWF_REQUESTED_MODEL` for pre-startup validation)* - `apiProxy.modelFallback` → *(config-only; model fallback strategy)* - `apiProxy.models` → *(config-only; model alias rewriting)* - `apiProxy.logging.debugTokens` → *(config-only; maps to `AWF_DEBUG_TOKENS`)* @@ -555,8 +557,20 @@ an associated positive multiplier. The effective tokens for a response are: effective_tokens = model_multiplier × base_weighted_tokens ``` -If no multiplier is configured for a given model, the multiplier defaults -to `1`. +If no exact multiplier is configured, AWF MUST attempt to match +`apiProxy.modelMultipliers` keys against the request model using a hyphen-suffix +prefix match so family keys like `claude-opus-4.7` apply to concrete model IDs +like `claude-opus-4.7-20260501`. + +If no exact or prefix match is found, and `apiProxy.defaultModelMultiplier` is +configured, that default multiplier MUST be used. + +Otherwise, if no exact or prefix match is found, the multiplier MUST default to +the highest configured model multiplier. If no model multipliers are configured +at all, the multiplier defaults to `1`. + +When AWF falls back to the default multiplier because no configured model key +matched, it MUST emit a warning log entry. ### 10.3 Enforcement Behavior @@ -883,6 +897,38 @@ response: The `/reflect` endpoint does not include fallback state by design (it is static per run). +### 12.6 Pre-Startup Model Validation + +When `apiProxy.requestedModel` is configured, the API proxy validates at startup +that the specified model is available in at least one provider's model catalogue. + +**Configuration:** + +```json +{ + "apiProxy": { + "requestedModel": "gpt-4o" + } +} +``` + +**Mapping:** `apiProxy.requestedModel` → `AWF_REQUESTED_MODEL` *(config-only; set by AWF CLI)* + +**Behavior:** + +1. After `fetchStartupModels()` completes, the proxy checks `AWF_REQUESTED_MODEL` + against all cached provider model lists. +2. If the model is found directly or resolves via model aliases, a confirmation + `model_validation` log is emitted. +3. If the model is NOT found, a `model_unavailable_at_startup` error log is + emitted listing available models as a diagnostic aid. +4. Validation is **non-blocking** — the proxy continues serving requests regardless + of the outcome, so agents that ignore the model hint are not affected. + +This enables workflow authors to get clear, early feedback when a retired or +misspelled model is specified, rather than waiting for the first API request to +fail with an opaque error. + ## 13. Model Alias Logging The API proxy emits structured logging events during model alias resolution. diff --git a/docs/awf-config.schema.json b/docs/awf-config.schema.json index 7c113e940..2892e4d1c 100644 --- a/docs/awf-config.schema.json +++ b/docs/awf-config.schema.json @@ -74,17 +74,26 @@ }, "modelMultipliers": { "type": "object", - "description": "Per-model multipliers for effective token accounting. Each model's weighted tokens are multiplied by this value before accumulation. Defaults to 1 for unlisted models. See spec §10.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 } }, + "defaultModelMultiplier": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Default multiplier applied when a model is not present in modelMultipliers. When omitted, AWF uses the highest configured model multiplier as a conservative fallback." + }, "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 §11." }, + "requestedModel": { + "type": "string", + "description": "Expected model name for pre-startup validation. When set, the API proxy validates at startup that this model is available in at least one provider's model catalogue. Emits a clear diagnostic if the model is retired, restricted, or misspelled. Does not block startup." + }, "modelFallback": { "type": "object", "description": "Model fallback policy for unresolved model selections. Enabled by default as a safety net.", diff --git a/docs/chroot-mode.md b/docs/chroot-mode.md index 54dd5d881..1b489d001 100644 --- a/docs/chroot-mode.md +++ b/docs/chroot-mode.md @@ -345,9 +345,9 @@ In ARC (Actions Runner Controller) environments using the DinD (Docker-in-Docker AWF handles this automatically at two layers: -1. **Mount staging** (`etc-mounts.ts`): When `--docker-host-path-prefix` is set and `/etc/passwd` or `/etc/group` cannot be staged from the runner, AWF synthesizes minimal identity files containing `root` and a `runner` entry matching the host UID/GID. +1. **Mount staging** (`etc-mounts.ts`): When `--docker-host-path-prefix` uses a `/tmp/...` prefix (the DinD staging path) and `/etc/passwd` or `/etc/group` cannot be staged from the runner, AWF synthesizes minimal identity files containing `root` and a `runner` entry matching the host UID/GID. If staging succeeds but the staged files are missing the runner UID/GID, AWF supplements them before mounting. -2. **Runtime fallback** (`entrypoint.sh`): If `getent passwd $UID` fails inside the chroot (user not found), the entrypoint synthesizes `/etc/passwd` and `/etc/group` entries directly, ensuring the agent process has a valid username and home directory. +2. **Runtime fallback** (`entrypoint.sh`): If `getent passwd $UID` fails inside the chroot (user not found), the entrypoint attempts to synthesize `/etc/passwd` and `/etc/group` entries. If those mounts are read-only, it falls back to running with numeric `UID:GID` directly. No configuration is required — synthesis is triggered automatically when user lookup fails. diff --git a/src/awf-config-schema.json b/src/awf-config-schema.json index 7c113e940..2892e4d1c 100644 --- a/src/awf-config-schema.json +++ b/src/awf-config-schema.json @@ -74,17 +74,26 @@ }, "modelMultipliers": { "type": "object", - "description": "Per-model multipliers for effective token accounting. Each model's weighted tokens are multiplied by this value before accumulation. Defaults to 1 for unlisted models. See spec §10.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 } }, + "defaultModelMultiplier": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Default multiplier applied when a model is not present in modelMultipliers. When omitted, AWF uses the highest configured model multiplier as a conservative fallback." + }, "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 §11." }, + "requestedModel": { + "type": "string", + "description": "Expected model name for pre-startup validation. When set, the API proxy validates at startup that this model is available in at least one provider's model catalogue. Emits a clear diagnostic if the model is retired, restricted, or misspelled. Does not block startup." + }, "modelFallback": { "type": "object", "description": "Model fallback policy for unresolved model selections. Enabled by default as a safety net.", diff --git a/src/commands/build-config.test.ts b/src/commands/build-config.test.ts index f058471e4..b044ff2d9 100644 --- a/src/commands/build-config.test.ts +++ b/src/commands/build-config.test.ts @@ -45,6 +45,7 @@ function makeInputs(overrides: Partial[0]> = {}): modelAliases: undefined, maxEffectiveTokens: undefined, effectiveTokenModelMultipliers: undefined, + effectiveTokenDefaultModelMultiplier: undefined, maxRuns: undefined, resolvedCopilotApiTarget: undefined, resolvedCopilotApiBasePath: undefined, diff --git a/src/commands/build-config.ts b/src/commands/build-config.ts index 3e8a0bfcd..a6b1a52fb 100644 --- a/src/commands/build-config.ts +++ b/src/commands/build-config.ts @@ -24,6 +24,7 @@ interface BuildConfigInputs { modelAliases: Record | undefined; maxEffectiveTokens: number | undefined; effectiveTokenModelMultipliers: Record | undefined; + effectiveTokenDefaultModelMultiplier: number | undefined; maxRuns: number | undefined; resolvedCopilotApiTarget: string | undefined; resolvedCopilotApiBasePath: string | undefined; @@ -56,6 +57,7 @@ export function buildConfig(inputs: BuildConfigInputs): WrapperConfig { modelAliases, maxEffectiveTokens, effectiveTokenModelMultipliers, + effectiveTokenDefaultModelMultiplier, maxRuns, resolvedCopilotApiTarget, resolvedCopilotApiBasePath, @@ -101,11 +103,13 @@ export function buildConfig(inputs: BuildConfigInputs): WrapperConfig { allowedUrls, enableApiProxy: options.enableApiProxy as boolean, modelFallback: options.modelFallback as { enabled?: boolean; strategy?: 'middle_power' } | undefined, + requestedModel: options.requestedModel as string | undefined, anthropicAutoCache: options.anthropicAutoCache as boolean, anthropicCacheTailTtl: options.anthropicCacheTailTtl as '5m' | '1h' | undefined, modelAliases, maxEffectiveTokens, effectiveTokenModelMultipliers, + effectiveTokenDefaultModelMultiplier, maxRuns, enableTokenSteering: options.enableTokenSteering as boolean, debugTokens: (options.debugTokens as boolean | undefined) ?? (process.env.AWF_DEBUG_TOKENS === '1' ? true : undefined), diff --git a/src/commands/validate-options.test.ts b/src/commands/validate-options.test.ts index 531f24187..a1f93b095 100644 --- a/src/commands/validate-options.test.ts +++ b/src/commands/validate-options.test.ts @@ -70,6 +70,7 @@ const STUB_CONFIG = { modelAliases: undefined, maxEffectiveTokens: undefined, effectiveTokenModelMultipliers: undefined, + effectiveTokenDefaultModelMultiplier: undefined, maxRuns: undefined, enableTokenSteering: false, openaiApiKey: undefined, diff --git a/src/commands/validators/config-assembly.test.ts b/src/commands/validators/config-assembly.test.ts index 1127a66ea..865e30399 100644 --- a/src/commands/validators/config-assembly.test.ts +++ b/src/commands/validators/config-assembly.test.ts @@ -88,6 +88,7 @@ describe('config-assembly', () => { modelAliases: {}, maxEffectiveTokens: undefined, effectiveTokenModelMultipliers: {}, + effectiveTokenDefaultModelMultiplier: undefined, maxRuns: undefined, }); diff --git a/src/commands/validators/config-assembly.ts b/src/commands/validators/config-assembly.ts index 39fb48424..c8c73ed58 100644 --- a/src/commands/validators/config-assembly.ts +++ b/src/commands/validators/config-assembly.ts @@ -63,6 +63,7 @@ export function assembleAndValidateConfig( modelAliases: logAndLimits.modelAliases, maxEffectiveTokens: logAndLimits.maxEffectiveTokens, effectiveTokenModelMultipliers: logAndLimits.effectiveTokenModelMultipliers, + effectiveTokenDefaultModelMultiplier: logAndLimits.effectiveTokenDefaultModelMultiplier, maxRuns: logAndLimits.maxRuns, resolvedCopilotApiTarget: networkOptions.resolvedCopilotApiTarget, resolvedCopilotApiBasePath: networkOptions.resolvedCopilotApiBasePath, diff --git a/src/commands/validators/log-and-limits.ts b/src/commands/validators/log-and-limits.ts index c9749dae5..b8be54426 100644 --- a/src/commands/validators/log-and-limits.ts +++ b/src/commands/validators/log-and-limits.ts @@ -17,6 +17,7 @@ export interface LogAndLimitsResult { modelAliases: Record | undefined; maxEffectiveTokens: number | undefined; effectiveTokenModelMultipliers: Record | undefined; + effectiveTokenDefaultModelMultiplier: number | undefined; maxRuns: number | undefined; memoryLimit: string | undefined; agentImage: string | undefined; @@ -57,6 +58,8 @@ export function validateLogAndLimits(options: Record): LogAndLi | string | number | undefined; + const effectiveTokenDefaultModelMultiplierOption = (options as Record) + .effectiveTokenDefaultModelMultiplier as string | number | undefined; // Config-file multipliers (already a Record) const configFileMultipliers = (options as Record) .effectiveTokenModelMultipliers as Record | undefined; @@ -80,6 +83,10 @@ export function validateLogAndLimits(options: Record): LogAndLi : undefined; const maxEffectiveTokens = maxEffectiveTokensOption !== undefined ? Number(maxEffectiveTokensOption) : undefined; + const effectiveTokenDefaultModelMultiplier = + effectiveTokenDefaultModelMultiplierOption !== undefined + ? Number(effectiveTokenDefaultModelMultiplierOption) + : undefined; if ( maxEffectiveTokens !== undefined && @@ -89,6 +96,14 @@ export function validateLogAndLimits(options: Record): LogAndLi process.exit(1); } + if ( + effectiveTokenDefaultModelMultiplier !== undefined && + (!Number.isFinite(effectiveTokenDefaultModelMultiplier) || effectiveTokenDefaultModelMultiplier <= 0) + ) { + console.error('Error: Invalid effectiveTokenDefaultModelMultiplier value (must be > 0)'); + process.exit(1); + } + const maxRunsOption = (options as Record).maxRuns as | string | number @@ -129,6 +144,7 @@ export function validateLogAndLimits(options: Record): LogAndLi modelAliases, maxEffectiveTokens, effectiveTokenModelMultipliers, + effectiveTokenDefaultModelMultiplier, maxRuns, memoryLimit: memoryLimit.value, agentImage: agentImageResult.agentImage, diff --git a/src/config-file-mapping.test.ts b/src/config-file-mapping.test.ts index 6cb01642b..2f6afae34 100644 --- a/src/config-file-mapping.test.ts +++ b/src/config-file-mapping.test.ts @@ -138,6 +138,7 @@ describe('mapAwfFileConfigToCliOptions', () => { 'gpt-4o': 2, 'claude-sonnet-4': 1.5, }, + defaultModelMultiplier: 27, enableTokenSteering: true, }, }); @@ -146,6 +147,7 @@ describe('mapAwfFileConfigToCliOptions', () => { 'gpt-4o': 2, 'claude-sonnet-4': 1.5, }); + expect(result.effectiveTokenDefaultModelMultiplier).toBe(27); expect(result.enableTokenSteering).toBe(true); }); @@ -154,6 +156,11 @@ describe('mapAwfFileConfigToCliOptions', () => { expect(result.maxRuns).toBe(42); }); + it('maps requestedModel field', () => { + const result = mapAwfFileConfigToCliOptions({ apiProxy: { requestedModel: 'gpt-4o' } }); + expect(result.requestedModel).toBe('gpt-4o'); + }); + it('maps modelFallback field', () => { const result = mapAwfFileConfigToCliOptions({ apiProxy: { modelFallback: { enabled: false, strategy: 'middle_power' } }, @@ -161,6 +168,19 @@ describe('mapAwfFileConfigToCliOptions', () => { expect(result.modelFallback).toEqual({ enabled: false, strategy: 'middle_power' }); }); + it('maps modelFallback.excludeEngines field', () => { + const result = mapAwfFileConfigToCliOptions({ + apiProxy: { + modelFallback: { enabled: true, strategy: 'middle_power', excludeEngines: ['openai', 'copilot'] }, + }, + }); + expect(result.modelFallback).toEqual({ + enabled: true, + strategy: 'middle_power', + excludeEngines: ['openai', 'copilot'], + }); + }); + it('leaves maxRuns undefined when not set', () => { const result = mapAwfFileConfigToCliOptions({}); expect(result.maxRuns).toBeUndefined(); diff --git a/src/config-file-validation.test.ts b/src/config-file-validation.test.ts index 06f22ebfc..2d113889f 100644 --- a/src/config-file-validation.test.ts +++ b/src/config-file-validation.test.ts @@ -111,6 +111,7 @@ describe('validateAwfFileConfig', () => { apiProxy: { maxEffectiveTokens: 5000, modelMultipliers: { 'gpt-4o': 2, 'claude-sonnet-4': 1.5 }, + defaultModelMultiplier: 27, }, })).toEqual([]); @@ -118,6 +119,8 @@ describe('validateAwfFileConfig', () => { .toContain('config.apiProxy.maxEffectiveTokens must be a positive integer'); expect(validateAwfFileConfig({ apiProxy: { modelMultipliers: { 'gpt-4o': 0 } } })) .toContain('config.apiProxy.modelMultipliers.gpt-4o must be > 0'); + expect(validateAwfFileConfig({ apiProxy: { defaultModelMultiplier: 0 } })) + .toContain('config.apiProxy.defaultModelMultiplier must be > 0'); }); it('validates maxRuns in apiProxy', () => { diff --git a/src/config-file.ts b/src/config-file.ts index 909b0f43d..bc7664b23 100644 --- a/src/config-file.ts +++ b/src/config-file.ts @@ -19,10 +19,13 @@ interface AwfFileConfig { anthropicCacheTailTtl?: string; maxEffectiveTokens?: number; modelMultipliers?: Record; + defaultModelMultiplier?: number; maxRuns?: number; + requestedModel?: string; modelFallback?: { enabled?: boolean; strategy?: 'middle_power'; + excludeEngines?: string[]; }; targets?: { openai?: { host?: string; basePath?: string; authHeader?: string }; @@ -178,7 +181,9 @@ export function mapAwfFileConfigToCliOptions(config: AwfFileConfig): Record { let tempDir: string; + const buildWriteConfig = ( + overrides: Partial[0]> = {} + ): Parameters[0] => ({ + workDir: tempDir, + sslBump: false, + allowedDomains: [], + agentCommand: 'echo test', + logLevel: 'info', + keepContainers: false, + buildLocal: false, + imageRegistry: 'ghcr.io/github/gh-aw-firewall', + imageTag: 'latest', + ...overrides, + }); beforeEach(() => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'config-writer-test-')); @@ -76,17 +90,11 @@ describe('writeConfigs', () => { (isOpenSslAvailable as jest.Mock).mockResolvedValue(false); await expect( - writeConfigs({ - workDir: tempDir, - sslBump: true, - allowedDomains: [], - agentCommand: 'echo test', - logLevel: 'info', - keepContainers: false, - buildLocal: false, - imageRegistry: 'ghcr.io/github/gh-aw-firewall', - imageTag: 'latest', - }) + writeConfigs( + buildWriteConfig({ + sslBump: true, + }) + ) ).rejects.toThrow('SSL Bump initialization failed: openssl is not available on this system'); }); @@ -95,17 +103,11 @@ describe('writeConfigs', () => { const { generateSessionCa } = jest.requireMock('./ssl-bump'); await expect( - writeConfigs({ - workDir: tempDir, - sslBump: true, - allowedDomains: [], - agentCommand: 'echo test', - logLevel: 'info', - keepContainers: false, - buildLocal: false, - imageRegistry: 'ghcr.io/github/gh-aw-firewall', - imageTag: 'latest', - }) + writeConfigs( + buildWriteConfig({ + sslBump: true, + }) + ) ).rejects.toThrow(); expect(isOpenSslAvailable).toHaveBeenCalledTimes(1); @@ -113,17 +115,7 @@ describe('writeConfigs', () => { }); it('should not check OpenSSL availability when sslBump is not enabled', async () => { - await writeConfigs({ - workDir: tempDir, - sslBump: false, - allowedDomains: [], - agentCommand: 'echo test', - logLevel: 'info', - keepContainers: false, - buildLocal: false, - imageRegistry: 'ghcr.io/github/gh-aw-firewall', - imageTag: 'latest', - }); + await writeConfigs(buildWriteConfig()); expect(isOpenSslAvailable).not.toHaveBeenCalled(); }); @@ -137,17 +129,11 @@ describe('writeConfigs', () => { fs.symlinkSync(realWorkDir, symlinkWorkDir); await expect( - writeConfigs({ - workDir: symlinkWorkDir, - sslBump: false, - allowedDomains: [], - agentCommand: 'echo test', - logLevel: 'info', - keepContainers: false, - buildLocal: false, - imageRegistry: 'ghcr.io/github/gh-aw-firewall', - imageTag: 'latest', - }) + writeConfigs( + buildWriteConfig({ + workDir: symlinkWorkDir, + }) + ) ).rejects.toThrow(`Refusing to use symlink as directory: ${symlinkWorkDir}`); }); @@ -159,18 +145,11 @@ describe('writeConfigs', () => { } }); - await writeConfigs({ - workDir: tempDir, - sslBump: false, - allowedDomains: [], - agentCommand: 'echo test', - logLevel: 'info', - keepContainers: false, - buildLocal: false, - imageRegistry: 'ghcr.io/github/gh-aw-firewall', - imageTag: 'latest', - proxyLogsDir, - }); + await writeConfigs( + buildWriteConfig({ + proxyLogsDir, + }) + ); const squidLogsDirMode = fs.statSync(proxyLogsDir).mode & 0o777; expect(squidLogsDirMode).toBe(0o777); @@ -181,17 +160,7 @@ describe('writeConfigs', () => { fs.mkdirSync(mcpLogsDir, { recursive: true, mode: 0o700 }); fs.chmodSync(mcpLogsDir, 0o700); - await writeConfigs({ - workDir: tempDir, - sslBump: false, - allowedDomains: [], - agentCommand: 'echo test', - logLevel: 'info', - keepContainers: false, - buildLocal: false, - imageRegistry: 'ghcr.io/github/gh-aw-firewall', - imageTag: 'latest', - }); + await writeConfigs(buildWriteConfig()); const mcpLogsDirMode = fs.statSync(mcpLogsDir).mode & 0o777; expect(mcpLogsDirMode).toBe(0o777); @@ -202,17 +171,11 @@ describe('writeConfigs', () => { fs.writeFileSync(filePath, 'content'); await expect( - writeConfigs({ - workDir: filePath, - sslBump: false, - allowedDomains: [], - agentCommand: 'echo test', - logLevel: 'info', - keepContainers: false, - buildLocal: false, - imageRegistry: 'ghcr.io/github/gh-aw-firewall', - imageTag: 'latest', - }) + writeConfigs( + buildWriteConfig({ + workDir: filePath, + }) + ) ).rejects.toThrow(/EEXIST|ENOTDIR/); }); @@ -220,17 +183,7 @@ describe('writeConfigs', () => { const emptyHomeDir = `${tempDir}-chroot-home`; expect(fs.existsSync(emptyHomeDir)).toBe(false); - await writeConfigs({ - workDir: tempDir, - sslBump: false, - allowedDomains: [], - agentCommand: 'echo test', - logLevel: 'info', - keepContainers: false, - buildLocal: false, - imageRegistry: 'ghcr.io/github/gh-aw-firewall', - imageTag: 'latest', - }); + await writeConfigs(buildWriteConfig()); expect(fs.existsSync(emptyHomeDir)).toBe(true); expect(fs.statSync(emptyHomeDir).isDirectory()).toBe(true); @@ -241,17 +194,7 @@ describe('writeConfigs', () => { fs.mkdirSync(emptyHomeDir, { recursive: true }); const statBefore = fs.statSync(emptyHomeDir); - await writeConfigs({ - workDir: tempDir, - sslBump: false, - allowedDomains: [], - agentCommand: 'echo test', - logLevel: 'info', - keepContainers: false, - buildLocal: false, - imageRegistry: 'ghcr.io/github/gh-aw-firewall', - imageTag: 'latest', - }); + await writeConfigs(buildWriteConfig()); const statAfter = fs.statSync(emptyHomeDir); expect(statAfter.ino).toBe(statBefore.ino); // Same directory @@ -267,17 +210,7 @@ describe('writeConfigs', () => { fs.rmSync(copilotDir, { recursive: true, force: true }); } - await writeConfigs({ - workDir: tempDir, - sslBump: false, - allowedDomains: [], - agentCommand: 'echo test', - logLevel: 'info', - keepContainers: false, - buildLocal: false, - imageRegistry: 'ghcr.io/github/gh-aw-firewall', - imageTag: 'latest', - }); + await writeConfigs(buildWriteConfig()); expect(fs.existsSync(copilotDir)).toBe(true); expect(fs.chownSync).toHaveBeenCalledWith(copilotDir, 1000, 1000); @@ -292,18 +225,11 @@ describe('writeConfigs', () => { fs.rmSync(geminiDir, { recursive: true, force: true }); } - await writeConfigs({ - workDir: tempDir, - sslBump: false, - allowedDomains: [], - agentCommand: 'echo test', - logLevel: 'info', - keepContainers: false, - buildLocal: false, - imageRegistry: 'ghcr.io/github/gh-aw-firewall', - imageTag: 'latest', - geminiApiKey: 'test-key', - }); + await writeConfigs( + buildWriteConfig({ + geminiApiKey: 'test-key', + }) + ); expect(fs.existsSync(geminiDir)).toBe(true); }); @@ -317,17 +243,7 @@ describe('writeConfigs', () => { fs.rmSync(geminiDir, { recursive: true, force: true }); } - await writeConfigs({ - workDir: tempDir, - sslBump: false, - allowedDomains: [], - agentCommand: 'echo test', - logLevel: 'info', - keepContainers: false, - buildLocal: false, - imageRegistry: 'ghcr.io/github/gh-aw-firewall', - imageTag: 'latest', - }); + await writeConfigs(buildWriteConfig()); expect(fs.existsSync(geminiDir)).toBe(false); }); @@ -335,18 +251,11 @@ describe('writeConfigs', () => { it('creates audit directory when it does not exist', async () => { const auditDir = path.join(tempDir, 'custom-audit'); - await writeConfigs({ - workDir: tempDir, - sslBump: false, - allowedDomains: [], - agentCommand: 'echo test', - logLevel: 'info', - keepContainers: false, - buildLocal: false, - imageRegistry: 'ghcr.io/github/gh-aw-firewall', - imageTag: 'latest', - auditDir, - }); + await writeConfigs( + buildWriteConfig({ + auditDir, + }) + ); expect(fs.existsSync(auditDir)).toBe(true); expect(fs.existsSync(path.join(auditDir, 'squid.conf'))).toBe(true); @@ -375,17 +284,7 @@ describe('writeConfigs', () => { try { await expect( - writeConfigs({ - workDir: tempDir, - sslBump: false, - allowedDomains: [], - agentCommand: 'echo test', - logLevel: 'info', - keepContainers: false, - buildLocal: false, - imageRegistry: 'ghcr.io/github/gh-aw-firewall', - imageTag: 'latest', - }) + writeConfigs(buildWriteConfig()) ).rejects.toThrow(/Seccomp profile not found/); } finally { existsSyncMock.mockImplementation(originalImpl); @@ -403,18 +302,12 @@ describe('writeConfigs', () => { const { parseUrlPatterns } = jest.requireMock('./ssl-bump'); const { generateSquidConfig } = jest.requireMock('./squid-config'); - await writeConfigs({ - workDir: tempDir, - sslBump: false, - allowedDomains: ['example.com'], - allowedUrls: ['https://example.com/api/*'], - agentCommand: 'echo test', - logLevel: 'info', - keepContainers: false, - buildLocal: false, - imageRegistry: 'ghcr.io/github/gh-aw-firewall', - imageTag: 'latest', - }); + await writeConfigs( + buildWriteConfig({ + allowedDomains: ['example.com'], + allowedUrls: ['https://example.com/api/*'], + }) + ); expect(parseUrlPatterns).toHaveBeenCalledWith(['https://example.com/api/*']); expect(generateSquidConfig).toHaveBeenCalledWith( @@ -428,18 +321,12 @@ describe('writeConfigs', () => { const { parseUrlPatterns } = jest.requireMock('./ssl-bump'); const { generateSquidConfig } = jest.requireMock('./squid-config'); - await writeConfigs({ - workDir: tempDir, - sslBump: false, - allowedDomains: ['example.com'], - allowedUrls: [], - agentCommand: 'echo test', - logLevel: 'info', - keepContainers: false, - buildLocal: false, - imageRegistry: 'ghcr.io/github/gh-aw-firewall', - imageTag: 'latest', - }); + await writeConfigs( + buildWriteConfig({ + allowedDomains: ['example.com'], + allowedUrls: [], + }) + ); expect(parseUrlPatterns).not.toHaveBeenCalled(); expect(generateSquidConfig).toHaveBeenCalledWith( @@ -453,18 +340,12 @@ describe('writeConfigs', () => { const { generateSquidConfig } = jest.requireMock('./squid-config'); const { generatePolicyManifest } = jest.requireMock('./squid-config'); - await writeConfigs({ - workDir: tempDir, - sslBump: false, - allowedDomains: ['example.com'], - agentCommand: 'echo test', - logLevel: 'info', - keepContainers: false, - buildLocal: false, - imageRegistry: 'ghcr.io/github/gh-aw-firewall', - imageTag: 'latest', - enableApiProxy: true, - }); + await writeConfigs( + buildWriteConfig({ + allowedDomains: ['example.com'], + enableApiProxy: true, + }) + ); expect(generateSquidConfig).toHaveBeenCalledWith( expect.objectContaining({ @@ -482,18 +363,12 @@ describe('writeConfigs', () => { it('does not include API proxy configuration when enableApiProxy is false', async () => { const { generateSquidConfig } = jest.requireMock('./squid-config'); - await writeConfigs({ - workDir: tempDir, - sslBump: false, - allowedDomains: ['example.com'], - agentCommand: 'echo test', - logLevel: 'info', - keepContainers: false, - buildLocal: false, - imageRegistry: 'ghcr.io/github/gh-aw-firewall', - imageTag: 'latest', - enableApiProxy: false, - }); + await writeConfigs( + buildWriteConfig({ + allowedDomains: ['example.com'], + enableApiProxy: false, + }) + ); expect(generateSquidConfig).toHaveBeenCalledWith( expect.not.objectContaining({ diff --git a/src/container-lifecycle-state.ts b/src/container-lifecycle-state.ts new file mode 100644 index 000000000..4f5c0a1ae --- /dev/null +++ b/src/container-lifecycle-state.ts @@ -0,0 +1,17 @@ +let agentExternallyKilled = false; + +export function markAgentExternallyKilled(): void { + agentExternallyKilled = true; +} + +export function isAgentExternallyKilled(): boolean { + return agentExternallyKilled; +} + +/** + * Internal test-only reset helper. + * Do not use in production flows. + */ +export function resetAgentExternallyKilled(): void { + agentExternallyKilled = false; +} diff --git a/src/container-lifecycle.test-utils.ts b/src/container-lifecycle.test-utils.ts index fad692e2e..12a69c157 100644 --- a/src/container-lifecycle.test-utils.ts +++ b/src/container-lifecycle.test-utils.ts @@ -1,5 +1,10 @@ /** - * Test-only re-export of internal state helpers from container-lifecycle. - * Tests should import from this file, not directly from the production module. + * Test-only access to container lifecycle state helpers. + * Tests should import from this file, not from production modules. */ -export { containerLifecycleTestHelpers } from './container-lifecycle'; +import { isAgentExternallyKilled, resetAgentExternallyKilled } from './container-lifecycle-state'; + +export const containerLifecycleTestHelpers = { + isAgentExternallyKilled, + resetAgentExternallyKilled, +}; diff --git a/src/container-lifecycle.ts b/src/container-lifecycle.ts index e04ce910e..486c2013a 100644 --- a/src/container-lifecycle.ts +++ b/src/container-lifecycle.ts @@ -13,13 +13,7 @@ import { CLI_PROXY_CONTAINER_NAME, } from './constants'; import { getLocalDockerEnv } from './docker-host'; - -/** - * Flag set by fastKillAgentContainer() to signal runAgentCommand() that - * the container was externally stopped. When true, runAgentCommand() skips - * its own docker wait / log collection to avoid racing with the signal handler. - */ -let agentExternallyKilled = false; +import { isAgentExternallyKilled, markAgentExternallyKilled } from './container-lifecycle-state'; /** * Checks Squid logs for access denials to provide better error context @@ -401,7 +395,7 @@ export async function runAgentCommand(workDir: string, allowedDomains: string[], // If the container was killed externally (e.g. by fastKillAgentContainer in a // signal handler), skip the remaining log analysis — the container state is // unreliable and the signal handler will drive the rest of the shutdown. - if (agentExternallyKilled) { + if (isAgentExternallyKilled()) { logger.debug('Agent was externally killed, skipping post-run analysis'); return { exitCode: exitCode || 143, blockedDomains: [] }; } @@ -437,7 +431,7 @@ export async function runAgentCommand(workDir: string, allowedDomains: string[], * @param stopTimeoutSeconds - Grace period before SIGKILL (default: 3) */ export async function fastKillAgentContainer(stopTimeoutSeconds = 3): Promise { - agentExternallyKilled = true; + markAgentExternallyKilled(); try { await execa('docker', ['stop', '-t', String(stopTimeoutSeconds), AGENT_CONTAINER_NAME], { reject: false, @@ -449,21 +443,3 @@ export async function fastKillAgentContainer(stopTimeoutSeconds = 3): Promise { + it('should not expose test helpers from the production module API', () => { + expect(copilotApiResolverModule).not.toHaveProperty('copilotApiResolverTestHelpers'); + }); + it('should return COPILOT_API_KEY when set', () => { const env = { COPILOT_API_KEY: 'key123' }; expect(resolveCopilotApiKey(env)).toBe('key123'); diff --git a/src/copilot-api-resolver.ts b/src/copilot-api-resolver.ts index d7c20f0f1..52ffba427 100644 --- a/src/copilot-api-resolver.ts +++ b/src/copilot-api-resolver.ts @@ -1,3 +1,8 @@ +import { + deriveCopilotApiBasePathFromProviderBaseUrl, + deriveCopilotApiTargetFromProviderBaseUrl, +} from './copilot-api-resolver.internal'; + /** * Resolve the Copilot BYOK key from supported environment variables. * COPILOT_API_KEY takes precedence over COPILOT_PROVIDER_API_KEY. @@ -8,50 +13,6 @@ export function resolveCopilotApiKey( return env.COPILOT_API_KEY || env.COPILOT_PROVIDER_API_KEY; } -/** - * Parse a provider base URL into a URL object, handling missing schemes. - * Returns undefined if the input is empty or unparseable. - */ -function parseProviderBaseUrl(providerBaseUrl: string | undefined): URL | undefined { - const trimmed = providerBaseUrl?.trim(); - if (!trimmed) return undefined; - - const candidate = trimmed.includes('://') - ? trimmed - : `https://${trimmed}`; - - try { - return new URL(candidate); - } catch { - return undefined; - } -} - -/** - * Derive a Copilot API target hostname from COPILOT_PROVIDER_BASE_URL. - * Returns undefined when the value is empty or not a valid URL/host. - */ -function deriveCopilotApiTargetFromProviderBaseUrl( - providerBaseUrl: string | undefined -): string | undefined { - return parseProviderBaseUrl(providerBaseUrl)?.hostname || undefined; -} - -/** - * Derive a Copilot API base-path prefix from COPILOT_PROVIDER_BASE_URL. - * Returns undefined when the value is empty, invalid, or has no path. - */ -function deriveCopilotApiBasePathFromProviderBaseUrl( - providerBaseUrl: string | undefined -): string | undefined { - const url = parseProviderBaseUrl(providerBaseUrl); - if (!url) return undefined; - - const pathname = url.pathname.replace(/\/+$/, ''); - if (!pathname || pathname === '/') return undefined; - return pathname.startsWith('/') ? pathname : `/${pathname}`; -} - /** * Resolve Copilot target/base-path routing for BYOK provider-style env vars. * @@ -82,10 +43,3 @@ export function resolveCopilotApiRouting( copilotApiBasePathFromProviderBaseUrl, }; } - -/** @internal Exposed only for unit tests — not part of the public API. */ -// ts-prune-ignore-next -export const copilotApiResolverTestHelpers = { - deriveCopilotApiTargetFromProviderBaseUrl, - deriveCopilotApiBasePathFromProviderBaseUrl, -}; diff --git a/src/schema-validator.test.ts b/src/schema-validator.test.ts index 6cc0759ae..29910d59d 100644 --- a/src/schema-validator.test.ts +++ b/src/schema-validator.test.ts @@ -64,6 +64,8 @@ describe('schema-validator', () => { .toContain('config.apiProxy.maxEffectiveTokens must be a positive integer'); expect(validateWithSchema({ apiProxy: { modelMultipliers: { 'gpt-4o': 0 } } })) .toContain('config.apiProxy.modelMultipliers.gpt-4o must be > 0'); + expect(validateWithSchema({ apiProxy: { defaultModelMultiplier: 0 } })) + .toContain('config.apiProxy.defaultModelMultiplier must be > 0'); }); it('formats logLevel enum correctly', () => { diff --git a/src/schema.test.ts b/src/schema.test.ts index 352d24a6e..9fa1f4527 100644 --- a/src/schema.test.ts +++ b/src/schema.test.ts @@ -66,6 +66,7 @@ describe('awf-config.schema.json', () => { 'gpt-4o': 2, 'claude-sonnet-4': 1.5, }, + defaultModelMultiplier: 2, targets: { openai: { host: 'api.openai.com', basePath: '/v1' }, anthropic: { host: 'api.anthropic.com', basePath: '/v1' }, @@ -150,6 +151,13 @@ describe('awf-config.schema.json', () => { expect(validate({ apiProxy: { maxEffectiveTokens: 0 } })).toBe(false); expect(validate({ apiProxy: { modelMultipliers: { 'gpt-4o': 2, 'claude': 1.5 } } })).toBe(true); expect(validate({ apiProxy: { modelMultipliers: { 'gpt-4o': 0 } } })).toBe(false); + expect(validate({ apiProxy: { defaultModelMultiplier: 27 } })).toBe(true); + expect(validate({ apiProxy: { defaultModelMultiplier: 0 } })).toBe(false); + }); + + it('accepts apiProxy.requestedModel as a string', () => { + expect(validate({ apiProxy: { requestedModel: 'gpt-4o' } })).toBe(true); + expect(validate({ apiProxy: { requestedModel: 123 } })).toBe(false); }); it('rejects invalid logging.logLevel values', () => { diff --git a/src/services/agent-volumes-mounts.test.ts b/src/services/agent-volumes-mounts.test.ts index f6cbdbce5..643b6d1e7 100644 --- a/src/services/agent-volumes-mounts.test.ts +++ b/src/services/agent-volumes-mounts.test.ts @@ -4,7 +4,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; import { stageHostFile } from './agent-volumes/docker-host-staging'; -import { translateBindMountHostPath } from './host-path-prefix'; +import { applyHostPathPrefixToVolumes } from './host-path-prefix'; // Create mock functions (must remain per-file — jest.mock() is hoisted before imports) @@ -15,6 +15,31 @@ jest.mock('execa', () => require('../test-helpers/mock-execa.test-utils').execaM let mockConfig: WrapperConfig; +function withEnv(envPatch: Record, fn: () => void): void { + const saved: Record = {}; + + for (const [key, value] of Object.entries(envPatch)) { + saved[key] = process.env[key]; + if (value !== undefined) { + process.env[key] = value; + } else { + delete process.env[key]; + } + } + + try { + fn(); + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value !== undefined) { + process.env[key] = value; + } else { + delete process.env[key]; + } + } + } +} + describe('agent service', () => { useTempWorkDir( baseConfig, @@ -218,10 +243,10 @@ describe('agent service', () => { }); it('should leave /etc/passwd and /etc/group unprefixed in shared /tmp staging fallback mode', () => { - expect(translateBindMountHostPath('/etc/passwd:/host/etc/passwd:ro', '/tmp/gh-aw')) - .toBe('/etc/passwd:/host/etc/passwd:ro'); - expect(translateBindMountHostPath('/etc/group:/host/etc/group:ro', '/tmp/gh-aw')) - .toBe('/etc/group:/host/etc/group:ro'); + expect(applyHostPathPrefixToVolumes(['/etc/passwd:/host/etc/passwd:ro'], '/tmp/gh-aw')) + .toEqual(['/etc/passwd:/host/etc/passwd:ro']); + expect(applyHostPathPrefixToVolumes(['/etc/group:/host/etc/group:ro'], '/tmp/gh-aw')) + .toEqual(['/etc/group:/host/etc/group:ro']); }); it('should prune stale staged chroot hosts directories under shared /tmp docker-host-path-prefix', () => { @@ -352,10 +377,7 @@ describe('agent service', () => { }); it('should expose the Unix DOCKER_HOST socket path when enableDind is true', () => { - const originalDockerHost = process.env.DOCKER_HOST; - process.env.DOCKER_HOST = 'unix:///tmp/arc/docker.sock'; - - try { + withEnv({ DOCKER_HOST: 'unix:///tmp/arc/docker.sock' }, () => { const dindConfig = { ...mockConfig, enableDind: true }; const result = generateDockerCompose(dindConfig, mockNetworkConfig); const volumes = result.services.agent.volumes as string[]; @@ -363,20 +385,11 @@ describe('agent service', () => { expect(volumes).toContain('/tmp/arc/docker.sock:/host/tmp/arc/docker.sock:rw'); expect(volumes).not.toContain('/var/run/docker.sock:/host/var/run/docker.sock:rw'); expect(volumes).not.toContain('/run/docker.sock:/host/run/docker.sock:rw'); - } finally { - if (originalDockerHost !== undefined) { - process.env.DOCKER_HOST = originalDockerHost; - } else { - delete process.env.DOCKER_HOST; - } - } + }); }); it('should prefer awfDockerHost over DOCKER_HOST when enableDind is true', () => { - const originalDockerHost = process.env.DOCKER_HOST; - process.env.DOCKER_HOST = 'unix:///tmp/arc/docker.sock'; - - try { + withEnv({ DOCKER_HOST: 'unix:///tmp/arc/docker.sock' }, () => { const dindConfig = { ...mockConfig, enableDind: true, @@ -389,20 +402,11 @@ 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'); - } finally { - if (originalDockerHost !== undefined) { - process.env.DOCKER_HOST = originalDockerHost; - } else { - delete process.env.DOCKER_HOST; - } - } + }); }); it('should set agent DOCKER_HOST from awfDockerHost when enableDind is true and host DOCKER_HOST is unset', () => { - const originalDockerHost = process.env.DOCKER_HOST; - delete process.env.DOCKER_HOST; - - try { + withEnv({ DOCKER_HOST: undefined }, () => { const dindConfig = { ...mockConfig, enableDind: true, @@ -416,36 +420,25 @@ describe('agent service', () => { expect(volumes).not.toContain('/var/run/docker.sock:/host/var/run/docker.sock:rw'); expect(volumes).not.toContain('/run/docker.sock:/host/run/docker.sock:rw'); expect(env.DOCKER_HOST).toBe('unix:///run/user/1000/docker.sock'); - } finally { - if (originalDockerHost !== undefined) { - process.env.DOCKER_HOST = originalDockerHost; - } else { - delete process.env.DOCKER_HOST; - } - } + }); }); it('should warn and fall back to the default socket for an invalid Unix DOCKER_HOST path', () => { - const originalDockerHost = process.env.DOCKER_HOST; const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined); - process.env.DOCKER_HOST = 'unix://relative/path'; try { - const dindConfig = { ...mockConfig, enableDind: true }; - const result = generateDockerCompose(dindConfig, mockNetworkConfig); - const volumes = result.services.agent.volumes as string[]; - - expect(volumes).toContain('/var/run/docker.sock:/host/var/run/docker.sock:rw'); - expect(volumes).toContain('/run/docker.sock:/host/run/docker.sock:rw'); - expect(volumes).not.toContain('relative/path:/hostrelative/path:rw'); - expect(warnSpy).toHaveBeenCalledWith('Ignoring invalid unix Docker host path: unix://relative/path'); + withEnv({ DOCKER_HOST: 'unix://relative/path' }, () => { + const dindConfig = { ...mockConfig, enableDind: true }; + const result = generateDockerCompose(dindConfig, mockNetworkConfig); + const volumes = result.services.agent.volumes as string[]; + + expect(volumes).toContain('/var/run/docker.sock:/host/var/run/docker.sock:rw'); + expect(volumes).toContain('/run/docker.sock:/host/run/docker.sock:rw'); + expect(volumes).not.toContain('relative/path:/hostrelative/path:rw'); + expect(warnSpy).toHaveBeenCalledWith('Ignoring invalid unix Docker host path: unix://relative/path'); + }); } finally { warnSpy.mockRestore(); - if (originalDockerHost !== undefined) { - process.env.DOCKER_HOST = originalDockerHost; - } else { - delete process.env.DOCKER_HOST; - } } }); @@ -487,30 +480,18 @@ describe('agent service', () => { it('should mount self-hosted runner toolcache when present under HOME/work/_tool', () => { const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-home-')); - const originalHome = process.env.HOME; - const originalSudoUser = process.env.SUDO_USER; - delete process.env.SUDO_USER; - process.env.HOME = fakeHome; try { - const toolcacheDir = path.join(fakeHome, 'work', '_tool'); - fs.mkdirSync(toolcacheDir, { recursive: true }); + withEnv({ HOME: fakeHome, SUDO_USER: undefined }, () => { + const toolcacheDir = path.join(fakeHome, 'work', '_tool'); + fs.mkdirSync(toolcacheDir, { recursive: true }); - const result = generateDockerCompose(mockConfig, mockNetworkConfig); - const volumes = result.services.agent.volumes as string[]; + const result = generateDockerCompose(mockConfig, mockNetworkConfig); + const volumes = result.services.agent.volumes as string[]; - expect(volumes).toContain(`${toolcacheDir}:/host${toolcacheDir}:ro`); + expect(volumes).toContain(`${toolcacheDir}:/host${toolcacheDir}:ro`); + }); } finally { - if (originalHome !== undefined) { - process.env.HOME = originalHome; - } else { - delete process.env.HOME; - } - if (originalSudoUser !== undefined) { - process.env.SUDO_USER = originalSudoUser; - } else { - delete process.env.SUDO_USER; - } fs.rmSync(fakeHome, { recursive: true, force: true }); } }); @@ -518,32 +499,20 @@ describe('agent service', () => { it('should not mount HOME/work/_tool when it is a symlink', () => { const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-home-')); const symlinkTarget = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-tool-target-')); - const originalHome = process.env.HOME; - const originalSudoUser = process.env.SUDO_USER; - delete process.env.SUDO_USER; - process.env.HOME = fakeHome; try { - const workDir = path.join(fakeHome, 'work'); - fs.mkdirSync(workDir, { recursive: true }); - const toolcacheDir = path.join(workDir, '_tool'); - fs.symlinkSync(symlinkTarget, toolcacheDir); + 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); - const result = generateDockerCompose(mockConfig, mockNetworkConfig); - const volumes = result.services.agent.volumes as string[]; + const result = generateDockerCompose(mockConfig, mockNetworkConfig); + const volumes = result.services.agent.volumes as string[]; - expect(volumes).not.toContain(`${toolcacheDir}:/host${toolcacheDir}:ro`); + expect(volumes).not.toContain(`${toolcacheDir}:/host${toolcacheDir}:ro`); + }); } finally { - if (originalHome !== undefined) { - process.env.HOME = originalHome; - } else { - delete process.env.HOME; - } - if (originalSudoUser !== undefined) { - process.env.SUDO_USER = originalSudoUser; - } else { - delete process.env.SUDO_USER; - } fs.rmSync(fakeHome, { recursive: true, force: true }); fs.rmSync(symlinkTarget, { recursive: true, force: true }); } @@ -551,38 +520,26 @@ describe('agent service', () => { it('should skip .copilot bind mount when directory does not exist at non-standard HOME path', () => { const fakeHome = fs.mkdtempSync(path.join(os.tmpdir(), 'awf-home-')); - const originalHome = process.env.HOME; - const originalSudoUser = process.env.SUDO_USER; - delete process.env.SUDO_USER; - process.env.HOME = fakeHome; try { - const copilotDir = path.join(fakeHome, '.copilot'); - expect(fs.existsSync(copilotDir)).toBe(false); - - const result = generateDockerCompose(mockConfig, mockNetworkConfig); - const volumes = result.services.agent.volumes as string[]; - - // Directory should NOT be auto-created (changed in #2114) - expect(fs.existsSync(copilotDir)).toBe(false); - // The blanket .copilot mount should be absent - expect(volumes).not.toContain(`${fakeHome}/.copilot:/host${fakeHome}/.copilot:rw`); - // Optional self-hosted runner toolcache mount should also be absent - expect(volumes).not.toContain(`${fakeHome}/work/_tool:/host${fakeHome}/work/_tool:ro`); - // But session-state and logs overlays are always present - expect(volumes).toContainEqual(expect.stringContaining(`${fakeHome}/.copilot/session-state:rw`)); - expect(volumes).toContainEqual(expect.stringContaining(`${fakeHome}/.copilot/logs:rw`)); + withEnv({ HOME: fakeHome, SUDO_USER: undefined }, () => { + const copilotDir = path.join(fakeHome, '.copilot'); + expect(fs.existsSync(copilotDir)).toBe(false); + + const result = generateDockerCompose(mockConfig, mockNetworkConfig); + const volumes = result.services.agent.volumes as string[]; + + // Directory should NOT be auto-created (changed in #2114) + expect(fs.existsSync(copilotDir)).toBe(false); + // The blanket .copilot mount should be absent + expect(volumes).not.toContain(`${fakeHome}/.copilot:/host${fakeHome}/.copilot:rw`); + // Optional self-hosted runner toolcache mount should also be absent + expect(volumes).not.toContain(`${fakeHome}/work/_tool:/host${fakeHome}/work/_tool:ro`); + // But session-state and logs overlays are always present + expect(volumes).toContainEqual(expect.stringContaining(`${fakeHome}/.copilot/session-state:rw`)); + expect(volumes).toContainEqual(expect.stringContaining(`${fakeHome}/.copilot/logs:rw`)); + }); } finally { - if (originalHome !== undefined) { - process.env.HOME = originalHome; - } else { - delete process.env.HOME; - } - if (originalSudoUser !== undefined) { - process.env.SUDO_USER = originalSudoUser; - } else { - delete process.env.SUDO_USER; - } fs.rmSync(fakeHome, { recursive: true, force: true }); } }); diff --git a/src/services/agent-volumes/etc-mounts.test.ts b/src/services/agent-volumes/etc-mounts.test.ts index a02d850cd..6b5c69e01 100644 --- a/src/services/agent-volumes/etc-mounts.test.ts +++ b/src/services/agent-volumes/etc-mounts.test.ts @@ -3,6 +3,7 @@ import * as os from 'os'; import * as path from 'path'; import { buildEtcMounts } from './etc-mounts'; import { WrapperConfig } from '../../types'; +import * as hostIdentity from '../../host-identity'; function createMinimalConfig(overrides: Partial = {}): WrapperConfig { return { @@ -39,18 +40,20 @@ describe('buildEtcMounts', () => { }); afterEach(() => { + jest.restoreAllMocks(); fs.rmSync(tmpDir, { recursive: true, force: true }); }); it('stages /etc/passwd when it exists on the runner', () => { const config = createMinimalConfig({ - dockerHostPathPrefix: '/host', + dockerHostPathPrefix: '/tmp/awf-dind-prefix', workDir: tmpDir, }); const mounts = buildEtcMounts(config); // Should have passwd and group mounts (either staged or synthesized) const passwdMount = mounts.find(m => m.includes('/host/etc/passwd')); expect(passwdMount).toBeDefined(); + expect(passwdMount!.startsWith('/etc/passwd:')).toBe(false); expect(passwdMount).toContain(':ro'); }); @@ -58,7 +61,7 @@ describe('buildEtcMounts', () => { const workDir = path.join(tmpDir, 'work'); fs.mkdirSync(workDir, { recursive: true }); const config = createMinimalConfig({ - dockerHostPathPrefix: '/host', + dockerHostPathPrefix: '/tmp/awf-dind-prefix', workDir, }); @@ -76,5 +79,24 @@ describe('buildEtcMounts', () => { const groupPath = groupMount!.split(':')[0]; expect(fs.existsSync(groupPath)).toBe(true); }); + + it('supplements staged passwd/group files when UID/GID are missing', () => { + const uid = '424242'; + const gid = '434343'; + jest.spyOn(hostIdentity, 'getSafeHostUid').mockReturnValue(uid); + jest.spyOn(hostIdentity, 'getSafeHostGid').mockReturnValue(gid); + + const config = createMinimalConfig({ + dockerHostPathPrefix: '/tmp/awf-dind-prefix', + workDir: tmpDir, + }); + + const mounts = buildEtcMounts(config); + const passwdPath = mounts.find(m => m.includes('/host/etc/passwd'))!.split(':')[0]; + const groupPath = mounts.find(m => m.includes('/host/etc/group'))!.split(':')[0]; + + expect(fs.readFileSync(passwdPath, 'utf8')).toContain(`runner:x:${uid}:${gid}:`); + expect(fs.readFileSync(groupPath, 'utf8')).toContain(`runner:x:${gid}:`); + }); }); }); diff --git a/src/services/agent-volumes/etc-mounts.ts b/src/services/agent-volumes/etc-mounts.ts index efc6b3640..7911b8fbe 100644 --- a/src/services/agent-volumes/etc-mounts.ts +++ b/src/services/agent-volumes/etc-mounts.ts @@ -11,15 +11,35 @@ import { getSafeHostUid, getSafeHostGid } from '../../host-identity'; function synthesizeIdentityFile(config: WrapperConfig, relPath: string, content: string): string | undefined { try { const stageRoot = getDockerHostStageRoot(config); - const targetPath = path.resolve(stageRoot, relPath); - fs.mkdirSync(path.dirname(targetPath), { recursive: true }); - fs.writeFileSync(targetPath, content, { mode: 0o644 }); + const tempDir = fs.mkdtempSync(path.join(stageRoot, 'identity-')); + const targetPath = path.join(tempDir, path.basename(relPath)); + fs.writeFileSync(targetPath, content, { mode: 0o644, flag: 'wx' }); return targetPath; } catch { return undefined; } } +function readFileContent(filePath: string): string | undefined { + try { + return fs.readFileSync(filePath, 'utf8'); + } catch { + return undefined; + } +} + +function fileHasPasswdUid(content: string, uid: string): boolean { + return new RegExp(`^[^:]*:[^:]*:${uid}:`, 'm').test(content); +} + +function fileHasGroupGid(content: string, gid: string): boolean { + return new RegExp(`^[^:]*:[^:]*:${gid}:`, 'm').test(content); +} + +function withTrailingNewline(content: string): string { + return content.endsWith('\n') ? content : `${content}\n`; +} + export function buildEtcMounts(config: WrapperConfig): string[] { const mounts: string[] = [ '/etc/ssl:/host/etc/ssl:ro', @@ -41,23 +61,35 @@ export function buildEtcMounts(config: WrapperConfig): string[] { const gid = getSafeHostGid(); let passwdPath = stageHostFile(config, '/etc/passwd', 'etc/passwd'); + const passwdEntry = `runner:x:${uid}:${gid}:GitHub Actions Runner:/home/runner:/bin/bash`; if (!passwdPath) { const minimalPasswd = [ 'root:x:0:0:root:/root:/bin/bash', 'nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin', - `runner:x:${uid}:${gid}:GitHub Actions Runner:/home/runner:/bin/bash`, + passwdEntry, ].join('\n') + '\n'; passwdPath = synthesizeIdentityFile(config, 'etc/passwd', minimalPasswd); + } else { + const stagedPasswdContent = readFileContent(passwdPath); + if (stagedPasswdContent && !fileHasPasswdUid(stagedPasswdContent, uid)) { + passwdPath = synthesizeIdentityFile(config, 'etc/passwd', `${withTrailingNewline(stagedPasswdContent)}${passwdEntry}\n`) || passwdPath; + } } let groupPath = stageHostFile(config, '/etc/group', 'etc/group'); + const groupEntry = `runner:x:${gid}:`; if (!groupPath) { const minimalGroup = [ 'root:x:0:', 'nobody:x:65534:', - `runner:x:${gid}:`, + groupEntry, ].join('\n') + '\n'; groupPath = synthesizeIdentityFile(config, 'etc/group', minimalGroup); + } else { + const stagedGroupContent = readFileContent(groupPath); + if (stagedGroupContent && !fileHasGroupGid(stagedGroupContent, gid)) { + groupPath = synthesizeIdentityFile(config, 'etc/group', `${withTrailingNewline(stagedGroupContent)}${groupEntry}\n`) || groupPath; + } } mounts.push(`${passwdPath || '/etc/passwd'}:/host/etc/passwd:ro`); diff --git a/src/services/api-proxy-service-rate-limit.test.ts b/src/services/api-proxy-service-rate-limit.test.ts index 564e08cbf..f9ab4d206 100644 --- a/src/services/api-proxy-service-rate-limit.test.ts +++ b/src/services/api-proxy-service-rate-limit.test.ts @@ -68,12 +68,14 @@ describe('API proxy sidecar: rate limiting and token guard', () => { 'gpt-4o': 2, 'claude-sonnet-4': 1.5, }, + effectiveTokenDefaultModelMultiplier: 27, }; const result = generateDockerCompose(configWithEtGuard, mockNetworkConfigWithProxy); const proxy = result.services['api-proxy']; const env = proxy.environment as Record; expect(env.AWF_MAX_EFFECTIVE_TOKENS).toBe('5000'); expect(env.AWF_EFFECTIVE_TOKEN_MODEL_MULTIPLIERS).toBe('{"gpt-4o":2,"claude-sonnet-4":1.5}'); + expect(env.AWF_EFFECTIVE_TOKEN_DEFAULT_MODEL_MULTIPLIER).toBe('27'); }); it('should set AWF_MAX_RUNS in api-proxy when maxRuns is configured', () => { diff --git a/src/services/api-proxy-service.ts b/src/services/api-proxy-service.ts index 5ebe41bf9..9b77cd000 100644 --- a/src/services/api-proxy-service.ts +++ b/src/services/api-proxy-service.ts @@ -60,6 +60,9 @@ function buildProviderTargetEnv(config: WrapperConfig): Record { if (copilotProviderBaseUrl) env.COPILOT_PROVIDER_BASE_URL = copilotProviderBaseUrl; if (copilotProviderApiKey) env.COPILOT_PROVIDER_API_KEY = copilotProviderApiKey; + // Pre-startup model validation (non-sensitive config value) + if (config.requestedModel) env.AWF_REQUESTED_MODEL = config.requestedModel; + return env; } @@ -163,6 +166,9 @@ export function buildApiProxyService(params: ApiProxyServiceParams): ApiProxyBui ...(config.effectiveTokenModelMultipliers && { AWF_EFFECTIVE_TOKEN_MODEL_MULTIPLIERS: JSON.stringify(config.effectiveTokenModelMultipliers), }), + ...(config.effectiveTokenDefaultModelMultiplier !== undefined && { + AWF_EFFECTIVE_TOKEN_DEFAULT_MODEL_MULTIPLIER: String(config.effectiveTokenDefaultModelMultiplier), + }), ...(config.maxRuns !== undefined && { AWF_MAX_RUNS: String(config.maxRuns), }), diff --git a/src/services/host-path-prefix.ts b/src/services/host-path-prefix.ts index 82b29bcb3..2d793acf2 100644 --- a/src/services/host-path-prefix.ts +++ b/src/services/host-path-prefix.ts @@ -27,7 +27,7 @@ function shouldPreserveUnprefixedEtcIdentityFile(hostPath: string, dockerHostPat ); } -export function translateBindMountHostPath(mount: string, dockerHostPathPrefix: string): string { +function translateBindMountHostPath(mount: string, dockerHostPathPrefix: string): string { const parts = mount.split(':'); if (parts.length < 2 || parts.length > 3) { return mount; diff --git a/src/types/api-proxy-options.ts b/src/types/api-proxy-options.ts index 042037d48..42fa03de9 100644 --- a/src/types/api-proxy-options.ts +++ b/src/types/api-proxy-options.ts @@ -15,6 +15,7 @@ export interface ApiProxyOptions { modelFallback?: { enabled?: boolean; strategy?: 'middle_power'; + excludeEngines?: string[]; }; /** @@ -337,6 +338,21 @@ export interface ApiProxyOptions { */ modelAliases?: Record; + /** + * Expected model name for pre-startup validation. + * + * When set, the API proxy validates at startup that this model is available + * in at least one configured provider's model catalogue. If the model is not + * found (retired, restricted, or misspelled), a clear `model_unavailable_at_startup` + * diagnostic is emitted. This does not block proxy startup. + * + * - Config: `apiProxy.requestedModel` + * - Environment variable: `AWF_REQUESTED_MODEL` (internal; set by AWF CLI) + * + * @example 'gpt-4o' + */ + requestedModel?: string; + /** * Enable detailed token and model-alias diagnostic logging. * diff --git a/src/types/docker.ts b/src/types/docker.ts index cf0ef6066..40d986b40 100644 --- a/src/types/docker.ts +++ b/src/types/docker.ts @@ -1,155 +1,7 @@ /** - * Docker and container configuration types for the agentic workflow firewall + * Docker Compose configuration types for the agentic workflow firewall */ -import type { UpstreamProxyConfig } from './upstream-proxy'; - -/** - * Configuration for the Squid proxy server - * - * Used to generate squid.conf with domain-based access control lists (ACLs). - * The generated configuration implements L7 (application layer) filtering for - * HTTP and HTTPS traffic using domain whitelisting and optional blocklisting. - */ -export interface SquidConfig { - /** - * List of allowed domains for proxy access - * - * These domains are converted to Squid ACL rules with subdomain matching. - * For example, 'github.com' becomes '.github.com' in Squid configuration, - * which matches both 'github.com' and all subdomains like 'api.github.com'. - */ - domains: string[]; - - /** - * List of blocked domains for proxy access - * - * These domains are explicitly denied. Blocked domains take precedence over - * allowed domains. This allows for fine-grained control like allowing - * '*.example.com' but blocking 'internal.example.com'. - * - * Supports the same wildcard patterns as domains. - */ - blockedDomains?: string[]; - - /** - * Port number for the Squid proxy to listen on - * - * The proxy listens on this port within the Docker network for HTTP - * and HTTPS (CONNECT method) requests. - * - * @default 3128 - */ - port: number; - - /** - * Whether to enable SSL Bump for HTTPS content inspection - * - * When true, Squid will intercept HTTPS connections and generate - * per-host certificates on-the-fly, allowing inspection of URL paths. - * - * @default false - */ - sslBump?: boolean; - - /** - * Paths to CA certificate files for SSL Bump - * - * Required when sslBump is true. - */ - caFiles?: { - certPath: string; - keyPath: string; - }; - - /** - * Path to SSL certificate database for dynamic certificate generation - * - * Required when sslBump is true. - */ - sslDbPath?: string; - - /** - * URL patterns for HTTPS traffic filtering (requires sslBump) - * - * When SSL Bump is enabled, these regex patterns are used to filter - * HTTPS traffic by URL path, not just domain. - */ - urlPatterns?: string[]; - - /** - * Whether to enable DLP (Data Loss Prevention) scanning - * - * When true, Squid will block requests containing credential patterns - * (API keys, tokens, secrets) in URLs via url_regex ACLs. - * - * @default false - */ - enableDlp?: boolean; - - /** - * Whether to enable host access (allows non-standard ports) - * - * When true, Squid will allow connections to any port, not just - * standard HTTP (80) and HTTPS (443) ports. This is required when - * --enable-host-access is used to allow access to host services - * running on non-standard ports. - * - * @default false - */ - enableHostAccess?: boolean; - - /** - * Additional ports to allow (comma-separated list) - * - * Ports or port ranges specified by the user via --allow-host-ports flag. - * These are added to the Safe_ports ACL in addition to 80 and 443. - * - * @example "3000,8080,9000" - * @example "3000-3010,8000-8090" - */ - allowHostPorts?: string; - - /** - * DNS servers for Squid to use for domain resolution - * - * In the simplified security model, Squid handles all DNS resolution - * for HTTP/HTTPS traffic. These servers are passed to Squid's - * dns_nameservers directive. - * - * @default ['8.8.8.8', '8.8.4.4'] - */ - dnsServers?: string[]; - - /** - * Upstream (corporate) proxy for Squid to chain outbound traffic through. - * - * When set, generates `cache_peer` / `never_direct` / `always_direct` - * directives so Squid forwards traffic through the parent proxy. - */ - upstreamProxy?: UpstreamProxyConfig; - - /** - * IP address of the AWF api-proxy sidecar container (e.g., "172.30.0.30"). - * - * When set, an explicit `http_access allow` rule is inserted for this IP - * *before* the `deny dst_ipv4` raw-IP block. This is required because some - * HTTP clients (e.g., Node.js fetch / undici ProxyAgent) route requests to - * the api-proxy through `HTTP_PROXY` without honouring `NO_PROXY` for raw IP - * addresses, causing Squid to deny them via the raw-IP rule. - */ - apiProxyIp?: string; - - /** - * Ports served by the AWF api-proxy sidecar (e.g., [10000, 10001, 10002, 10003]). - * - * When set, these ports are appended to Squid's `Safe_ports` ACL so that - * `http_access deny !Safe_ports` and `http_access deny CONNECT !Safe_ports` - * do not block connections to the api-proxy before the allow rule fires. - */ - apiProxyPorts?: number[]; -} - /** * Docker Compose configuration structure * diff --git a/src/types/index.ts b/src/types/index.ts index 2e1ec7ab2..353749936 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -17,6 +17,9 @@ export { type FlagValidationResult } from './validation'; export { type SquidConfig, +} from './squid'; + +export { type DockerComposeConfig, } from './docker'; diff --git a/src/types/rate-limit-options.ts b/src/types/rate-limit-options.ts index f81a72843..0821f25b4 100644 --- a/src/types/rate-limit-options.ts +++ b/src/types/rate-limit-options.ts @@ -27,10 +27,19 @@ export interface RateLimitOptions { * Model-specific multipliers used by effective token accounting. * * Keys are model names and values are positive numeric multipliers. - * Models not present in this map default to multiplier 1.0. + * Resolution uses exact match first, then a hyphen-suffix prefix match + * (for example `claude-opus-4.7` matches `claude-opus-4.7-20260501`). + * Models that still do not match use `effectiveTokenDefaultModelMultiplier` + * when set, otherwise the highest configured multiplier. */ effectiveTokenModelMultipliers?: Record; + /** + * Default multiplier used for models not present in + * `effectiveTokenModelMultipliers`. + */ + effectiveTokenDefaultModelMultiplier?: number; + /** * Maximum number of LLM invocations allowed for the current AWF run. * diff --git a/src/types/squid.ts b/src/types/squid.ts new file mode 100644 index 000000000..242983cb9 --- /dev/null +++ b/src/types/squid.ts @@ -0,0 +1,151 @@ +/** + * Squid proxy configuration types for the agentic workflow firewall + */ + +import type { UpstreamProxyConfig } from './upstream-proxy'; + +/** + * Configuration for the Squid proxy server + * + * Used to generate squid.conf with domain-based access control lists (ACLs). + * The generated configuration implements L7 (application layer) filtering for + * HTTP and HTTPS traffic using domain whitelisting and optional blocklisting. + */ +export interface SquidConfig { + /** + * List of allowed domains for proxy access + * + * These domains are converted to Squid ACL rules with subdomain matching. + * For example, 'github.com' becomes '.github.com' in Squid configuration, + * which matches both 'github.com' and all subdomains like 'api.github.com'. + */ + domains: string[]; + + /** + * List of blocked domains for proxy access + * + * These domains are explicitly denied. Blocked domains take precedence over + * allowed domains. This allows for fine-grained control like allowing + * '*.example.com' but blocking 'internal.example.com'. + * + * Supports the same wildcard patterns as domains. + */ + blockedDomains?: string[]; + + /** + * Port number for the Squid proxy to listen on + * + * The proxy listens on this port within the Docker network for HTTP + * and HTTPS (CONNECT method) requests. + * + * @default 3128 + */ + port: number; + + /** + * Whether to enable SSL Bump for HTTPS content inspection + * + * When true, Squid will intercept HTTPS connections and generate + * per-host certificates on-the-fly, allowing inspection of URL paths. + * + * @default false + */ + sslBump?: boolean; + + /** + * Paths to CA certificate files for SSL Bump + * + * Required when sslBump is true. + */ + caFiles?: { + certPath: string; + keyPath: string; + }; + + /** + * Path to SSL certificate database for dynamic certificate generation + * + * Required when sslBump is true. + */ + sslDbPath?: string; + + /** + * URL patterns for HTTPS traffic filtering (requires sslBump) + * + * When SSL Bump is enabled, these regex patterns are used to filter + * HTTPS traffic by URL path, not just domain. + */ + urlPatterns?: string[]; + + /** + * Whether to enable DLP (Data Loss Prevention) scanning + * + * When true, Squid will block requests containing credential patterns + * (API keys, tokens, secrets) in URLs via url_regex ACLs. + * + * @default false + */ + enableDlp?: boolean; + + /** + * Whether to enable host access (allows non-standard ports) + * + * When true, Squid will allow connections to any port, not just + * standard HTTP (80) and HTTPS (443) ports. This is required when + * --enable-host-access is used to allow access to host services + * running on non-standard ports. + * + * @default false + */ + enableHostAccess?: boolean; + + /** + * Additional ports to allow (comma-separated list) + * + * Ports or port ranges specified by the user via --allow-host-ports flag. + * These are added to the Safe_ports ACL in addition to 80 and 443. + * + * @example "3000,8080,9000" + * @example "3000-3010,8000-8090" + */ + allowHostPorts?: string; + + /** + * DNS servers for Squid to use for domain resolution + * + * In the simplified security model, Squid handles all DNS resolution + * for HTTP/HTTPS traffic. These servers are passed to Squid's + * dns_nameservers directive. + * + * @default ['8.8.8.8', '8.8.4.4'] + */ + dnsServers?: string[]; + + /** + * Upstream (corporate) proxy for Squid to chain outbound traffic through. + * + * When set, generates `cache_peer` / `never_direct` / `always_direct` + * directives so Squid forwards traffic through the parent proxy. + */ + upstreamProxy?: UpstreamProxyConfig; + + /** + * IP address of the AWF api-proxy sidecar container (e.g., "172.30.0.30"). + * + * When set, an explicit `http_access allow` rule is inserted for this IP + * *before* the `deny dst_ipv4` raw-IP block. This is required because some + * HTTP clients (e.g., Node.js fetch / undici ProxyAgent) route requests to + * the api-proxy through `HTTP_PROXY` without honouring `NO_PROXY` for raw IP + * addresses, causing Squid to deny them via the raw-IP rule. + */ + apiProxyIp?: string; + + /** + * Ports served by the AWF api-proxy sidecar (e.g., [10000, 10001, 10002, 10003]). + * + * When set, these ports are appended to Squid's `Safe_ports` ACL so that + * `http_access deny !Safe_ports` and `http_access deny CONNECT !Safe_ports` + * do not block connections to the api-proxy before the allow rule fires. + */ + apiProxyPorts?: number[]; +} From 18ffbb0f7c9eab3d8844c3ae2a9dedabc62083de Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Sat, 30 May 2026 08:00:39 -0700 Subject: [PATCH 3/4] feat: add daily config consistency auditor workflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an agentic workflow that runs daily on weekdays to audit recently merged PRs for configuration consistency gaps. When a new config field is introduced in one layer but missing from others (schema, spec, TypeScript types, or env var wiring), the workflow fixes the gaps and opens a PR. Checks: - JSON schema (src/ and docs/ copies must be identical) - Spec CLI mapping table (docs/awf-config-spec.md §5) - TypeScript types (src/types/*.ts, src/config-file.ts) - Env var wiring (src/services/api-proxy-service.ts) - Security classification (sensitive via env vars, non-sensitive via stdin) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../config-consistency-auditor.lock.yml | 1318 +++++++++++++++++ .../workflows/config-consistency-auditor.md | 193 +++ 2 files changed, 1511 insertions(+) create mode 100644 .github/workflows/config-consistency-auditor.lock.yml create mode 100644 .github/workflows/config-consistency-auditor.md diff --git a/.github/workflows/config-consistency-auditor.lock.yml b/.github/workflows/config-consistency-auditor.lock.yml new file mode 100644 index 000000000..66e095834 --- /dev/null +++ b/.github/workflows/config-consistency-auditor.lock.yml @@ -0,0 +1,1318 @@ +# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"189fcec8a8520d8f67f751257a4bae770b3f19dc3594aba48a5b50a5912653e1","compiler_version":"v0.76.1","strict":true,"agent_id":"copilot"} +# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/cache","sha":"27d5ce7f107fe9357f9df03efb73ab90386fccae","version":"v5.0.5"},{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"46d564922b082d0db93244972e8005ea6904ee5f","version":"v0.76.1"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.55"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.55"},{"image":"ghcr.io/github/gh-aw-firewall/cli-proxy:0.25.55"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.55"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.19"},{"image":"ghcr.io/github/github-mcp-server:v1.0.4","digest":"sha256:e3816a476a977cfb836e7d221510011436c654d11861db66ecfd826601aba6a4","pinned_image":"ghcr.io/github/github-mcp-server:v1.0.4@sha256:e3816a476a977cfb836e7d221510011436c654d11861db66ecfd826601aba6a4"},{"image":"node:lts-alpine","digest":"sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f","pinned_image":"node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f"}]} +# ___ _ _ +# / _ \ | | (_) +# | |_| | __ _ ___ _ __ | |_ _ ___ +# | _ |/ _` |/ _ \ '_ \| __| |/ __| +# | | | | (_| | __/ | | | |_| | (__ +# \_| |_/\__, |\___|_| |_|\__|_|\___| +# __/ | +# _ _ |___/ +# | | | | / _| | +# | | | | ___ _ __ _ __| |_| | _____ ____ +# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___| +# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \ +# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/ +# +# This file was automatically generated by gh-aw (v0.76.1). DO NOT EDIT. +# +# To update this file, edit the corresponding .md file and run: +# gh aw compile +# Not all edits will cause changes to this file. +# +# For more information: https://github.github.com/gh-aw/introduction/overview/ +# +# Daily audit of recently merged PRs to verify new configuration is consistently represented across JSON schema, spec, TypeScript types, and env var wiring — with security-sensitive values via env vars and non-sensitive via stdin config. +# +# Secrets used: +# - COPILOT_GITHUB_TOKEN +# - GH_AW_CI_TRIGGER_TOKEN +# - GH_AW_GITHUB_MCP_SERVER_TOKEN +# - GH_AW_GITHUB_TOKEN +# - GITHUB_TOKEN +# +# Custom actions used: +# - actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 +# - actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 +# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 +# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 +# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 (source v9) +# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 +# - github/gh-aw-actions/setup@46d564922b082d0db93244972e8005ea6904ee5f # v0.76.1 +# +# Container images used: +# - ghcr.io/github/gh-aw-firewall/agent:0.25.55 +# - ghcr.io/github/gh-aw-firewall/api-proxy:0.25.55 +# - ghcr.io/github/gh-aw-firewall/cli-proxy:0.25.55 +# - ghcr.io/github/gh-aw-firewall/squid:0.25.55 +# - ghcr.io/github/gh-aw-mcpg:v0.3.19 +# - ghcr.io/github/github-mcp-server:v1.0.4@sha256:e3816a476a977cfb836e7d221510011436c654d11861db66ecfd826601aba6a4 +# - node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f + +name: "Config Consistency Auditor" +on: + schedule: + - cron: "19 9 * * 1-5" + # Friendly format: daily on weekdays (scattered) + workflow_dispatch: + inputs: + aw_context: + default: "" + description: "Agent caller context (used internally by Agentic Workflows)." + required: false + type: string + +permissions: {} + +concurrency: + group: "gh-aw-${{ github.workflow }}" + +run-name: "Config Consistency Auditor" + +jobs: + activation: + runs-on: ubuntu-slim + permissions: + actions: read + contents: read + outputs: + comment_id: "" + comment_repo: "" + engine_id: ${{ steps.generate_aw_info.outputs.engine_id }} + lockdown_check_failed: ${{ steps.generate_aw_info.outputs.lockdown_check_failed == 'true' }} + model: ${{ steps.generate_aw_info.outputs.model }} + secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }} + setup-parent-span-id: ${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }} + setup-span-id: ${{ steps.setup.outputs.span-id }} + setup-trace-id: ${{ steps.setup.outputs.trace-id }} + stale_lock_file_failed: ${{ steps.check-lock-file.outputs.stale_lock_file_failed == 'true' }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@46d564922b082d0db93244972e8005ea6904ee5f # v0.76.1 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Config Consistency Auditor" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/config-consistency-auditor.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.52" + GH_AW_INFO_AWF_VERSION: "v0.25.55" + GH_AW_INFO_ENGINE_ID: "copilot" + - name: Generate agentic run info + id: generate_aw_info + env: + GH_AW_INFO_ENGINE_ID: "copilot" + GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI" + GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || 'claude-sonnet-4.6' }} + GH_AW_INFO_VERSION: "1.0.52" + GH_AW_INFO_AGENT_VERSION: "1.0.52" + GH_AW_INFO_CLI_VERSION: "v0.76.1" + GH_AW_INFO_WORKFLOW_NAME: "Config Consistency Auditor" + GH_AW_INFO_EXPERIMENTAL: "false" + GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true" + GH_AW_INFO_STAGED: "false" + GH_AW_INFO_ALLOWED_DOMAINS: '["defaults","node","github"]' + GH_AW_INFO_FIREWALL_ENABLED: "true" + GH_AW_INFO_AWF_VERSION: "v0.25.55" + GH_AW_INFO_AWMG_VERSION: "" + GH_AW_INFO_FIREWALL_TYPE: "squid" + GH_AW_COMPILED_STRICT: "true" + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_aw_info.cjs'); + await main(core, context); + - name: Validate COPILOT_GITHUB_TOKEN secret + id: validate-secret + run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_multi_secret.sh" COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default + env: + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + - name: Checkout .github and .agents folders + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + sparse-checkout-cone-mode: true + fetch-depth: 1 + - name: Save agent config folders for base branch restoration + env: + GH_AW_AGENT_FOLDERS: ".agents .antigravity .claude .codex .crush .gemini .github .opencode .pi" + GH_AW_AGENT_FILES: ".crush.json AGENTS.md ANTIGRAVITY.md CLAUDE.md GEMINI.md PI.md opencode.jsonc" + # poutine:ignore untrusted_checkout_exec + run: bash "${RUNNER_TEMP}/gh-aw/actions/save_base_github_folders.sh" + - name: Check workflow lock file + id: check-lock-file + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_WORKFLOW_FILE: "config-consistency-auditor.lock.yml" + GH_AW_CONTEXT_WORKFLOW_REF: "${{ github.workflow_ref }}" + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_workflow_timestamp_api.cjs'); + await main(); + - name: Check compile-agentic version + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_COMPILED_VERSION: "v0.76.1" + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/check_version_updates.cjs'); + await main(); + - name: Create prompt with built-in context + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_SAFE_OUTPUTS: ${{ runner.temp }}/gh-aw/safeoutputs/outputs.jsonl + GH_AW_EXPR_1A3A194A: ${{ github.event.discussion.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'discussion' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_463A214A: ${{ github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_802A9F6A: ${{ github.event.issue.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'issue' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_FF1D34CE: ${{ github.event.comment.id || fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').comment_id }} + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + # poutine:ignore untrusted_checkout_exec + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh" + { + cat << 'GH_AW_PROMPT_d1d04aa5227fe1df_EOF' + + GH_AW_PROMPT_d1d04aa5227fe1df_EOF + cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md" + cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md" + cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md" + cat "${RUNNER_TEMP}/gh-aw/prompts/cache_memory_prompt.md" + cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md" + cat << 'GH_AW_PROMPT_d1d04aa5227fe1df_EOF' + + Tools: create_pull_request, missing_tool, missing_data, noop + GH_AW_PROMPT_d1d04aa5227fe1df_EOF + cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_create_pull_request.md" + cat << 'GH_AW_PROMPT_d1d04aa5227fe1df_EOF' + + GH_AW_PROMPT_d1d04aa5227fe1df_EOF + cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md" + cat << 'GH_AW_PROMPT_d1d04aa5227fe1df_EOF' + + The following GitHub context information is available for this workflow: + {{#if github.actor}} + - **actor**: __GH_AW_GITHUB_ACTOR__ + {{/if}} + {{#if github.repository}} + - **repository**: __GH_AW_GITHUB_REPOSITORY__ + {{/if}} + {{#if github.workspace}} + - **workspace**: __GH_AW_GITHUB_WORKSPACE__ + {{/if}} + {{#if github.event.issue.number || (github.aw.context.item_type == 'issue' && github.aw.context.item_number)}} + - **issue-number**: #__GH_AW_EXPR_802A9F6A__ + {{/if}} + {{#if github.event.discussion.number || (github.aw.context.item_type == 'discussion' && github.aw.context.item_number)}} + - **discussion-number**: #__GH_AW_EXPR_1A3A194A__ + {{/if}} + {{#if github.event.pull_request.number || (github.aw.context.item_type == 'pull_request' && github.aw.context.item_number)}} + - **pull-request-number**: #__GH_AW_EXPR_463A214A__ + {{/if}} + {{#if github.event.comment.id || github.aw.context.comment_id}} + - **comment-id**: __GH_AW_EXPR_FF1D34CE__ + {{/if}} + {{#if github.run_id}} + - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__ + {{/if}} + + + GH_AW_PROMPT_d1d04aa5227fe1df_EOF + cat "${RUNNER_TEMP}/gh-aw/prompts/cli_proxy_with_safeoutputs_prompt.md" + cat << 'GH_AW_PROMPT_d1d04aa5227fe1df_EOF' + + {{#runtime-import .github/workflows/config-consistency-auditor.md}} + GH_AW_PROMPT_d1d04aa5227fe1df_EOF + } > "$GH_AW_PROMPT" + - name: Interpolate variables and render templates + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_ENGINE_ID: "copilot" + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/interpolate_prompt.cjs'); + await main(); + - name: Substitute placeholders + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_ALLOWED_EXTENSIONS: '' + GH_AW_CACHE_DESCRIPTION: '' + GH_AW_CACHE_DIR: '/tmp/gh-aw/cache-memory/' + GH_AW_EXPR_1A3A194A: ${{ github.event.discussion.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'discussion' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_463A214A: ${{ github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_802A9F6A: ${{ github.event.issue.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'issue' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }} + GH_AW_EXPR_FF1D34CE: ${{ github.event.comment.id || fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').comment_id }} + GH_AW_GITHUB_ACTOR: ${{ github.actor }} + GH_AW_GITHUB_REPOSITORY: ${{ github.repository }} + GH_AW_GITHUB_RUN_ID: ${{ github.run_id }} + GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }} + GH_AW_MCP_CLI_SERVERS_LIST: '- `safeoutputs` — run `safeoutputs --help` to see available tools' + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + + const substitutePlaceholders = require('${{ runner.temp }}/gh-aw/actions/substitute_placeholders.cjs'); + + // Call the substitution function + return await substitutePlaceholders({ + file: process.env.GH_AW_PROMPT, + substitutions: { + GH_AW_ALLOWED_EXTENSIONS: process.env.GH_AW_ALLOWED_EXTENSIONS, + GH_AW_CACHE_DESCRIPTION: process.env.GH_AW_CACHE_DESCRIPTION, + GH_AW_CACHE_DIR: process.env.GH_AW_CACHE_DIR, + GH_AW_EXPR_1A3A194A: process.env.GH_AW_EXPR_1A3A194A, + GH_AW_EXPR_463A214A: process.env.GH_AW_EXPR_463A214A, + GH_AW_EXPR_802A9F6A: process.env.GH_AW_EXPR_802A9F6A, + GH_AW_EXPR_FF1D34CE: process.env.GH_AW_EXPR_FF1D34CE, + GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR, + GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY, + GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID, + GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE, + GH_AW_MCP_CLI_SERVERS_LIST: process.env.GH_AW_MCP_CLI_SERVERS_LIST + } + }); + - name: Validate prompt placeholders + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + # poutine:ignore untrusted_checkout_exec + run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh" + - name: Print prompt + env: + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + # poutine:ignore untrusted_checkout_exec + run: bash "${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh" + - name: Upload activation artifact + if: success() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: activation + include-hidden-files: true + path: | + /tmp/gh-aw/aw_info.json + /tmp/gh-aw/aw-prompts/prompt.txt + /tmp/gh-aw/aw-prompts/prompt-template.txt + /tmp/gh-aw/aw-prompts/prompt-import-tree.json + /tmp/gh-aw/github_rate_limits.jsonl + /tmp/gh-aw/base + /tmp/gh-aw/.github/agents + /tmp/gh-aw/.github/skills + if-no-files-found: ignore + retention-days: 1 + + agent: + needs: activation + runs-on: ubuntu-latest + permissions: + contents: read + issues: read + pull-requests: read + concurrency: + group: "gh-aw-copilot-${{ github.workflow }}" + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + GH_AW_ASSETS_ALLOWED_EXTS: "" + GH_AW_ASSETS_BRANCH: "" + GH_AW_ASSETS_MAX_SIZE_KB: 0 + GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs + GH_AW_WORKFLOW_ID_SANITIZED: configconsistencyauditor + outputs: + agentic_engine_timeout: ${{ steps.detect-agent-errors.outputs.agentic_engine_timeout || 'false' }} + checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }} + effective_tokens: ${{ steps.parse-mcp-gateway.outputs.effective_tokens }} + effective_tokens_rate_limit_error: ${{ steps.parse-mcp-gateway.outputs.effective_tokens_rate_limit_error || 'false' }} + has_patch: ${{ steps.collect_output.outputs.has_patch }} + inference_access_error: ${{ steps.detect-agent-errors.outputs.inference_access_error || 'false' }} + mcp_policy_error: ${{ steps.detect-agent-errors.outputs.mcp_policy_error || 'false' }} + model: ${{ needs.activation.outputs.model }} + model_not_supported_error: ${{ steps.detect-agent-errors.outputs.model_not_supported_error || 'false' }} + output: ${{ steps.collect_output.outputs.output }} + output_types: ${{ steps.collect_output.outputs.output_types }} + setup-parent-span-id: ${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }} + setup-span-id: ${{ steps.setup.outputs.span-id }} + setup-trace-id: ${{ steps.setup.outputs.trace-id }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@46d564922b082d0db93244972e8005ea6904ee5f # v0.76.1 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} + parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Config Consistency Auditor" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/config-consistency-auditor.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.52" + GH_AW_INFO_AWF_VERSION: "v0.25.55" + GH_AW_INFO_ENGINE_ID: "copilot" + - name: Set runtime paths + id: set-runtime-paths + run: | + { + echo "GH_AW_SAFE_OUTPUTS=${RUNNER_TEMP}/gh-aw/safeoutputs/outputs.jsonl" + echo "GH_AW_SAFE_OUTPUTS_CONFIG_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" + echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json" + } >> "$GITHUB_OUTPUT" + - name: Checkout repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - name: Create gh-aw temp directory + run: bash "${RUNNER_TEMP}/gh-aw/actions/create_gh_aw_tmp_dir.sh" + - name: Configure gh CLI for GitHub Enterprise + run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_gh_for_ghe.sh" + env: + GH_TOKEN: ${{ github.token }} + # Cache memory file share configuration from frontmatter processed below + - name: Create cache-memory directory + run: bash "${RUNNER_TEMP}/gh-aw/actions/create_cache_memory_dir.sh" + - name: Compute cache-memory TTL date key + run: echo "CACHE_MEMORY_DATE=$(date -u +%Y%m%d)" >> "$GITHUB_ENV" + - name: Restore cache-memory file share data + uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 + with: + key: memory-none-nopolicy-${{ env.GH_AW_WORKFLOW_ID_SANITIZED }}-${{ env.CACHE_MEMORY_DATE }}-${{ github.run_id }} + path: /tmp/gh-aw/cache-memory + restore-keys: | + memory-none-nopolicy-${{ env.GH_AW_WORKFLOW_ID_SANITIZED }}-${{ env.CACHE_MEMORY_DATE }}- + - name: Setup cache-memory git repository + env: + GH_AW_CACHE_DIR: /tmp/gh-aw/cache-memory + GH_AW_MIN_INTEGRITY: none + run: bash "${RUNNER_TEMP}/gh-aw/actions/setup_cache_memory_git.sh" + - name: Strip execute bits from cache-memory files + if: always() + env: + GH_AW_CACHE_DIR: /tmp/gh-aw/cache-memory + run: | + CACHE_DIR="${GH_AW_CACHE_DIR:-/tmp/gh-aw/cache-memory}" + # Strip execute bits from all non-.git files to prevent execute-bit + # persistence of attacker-planted executables across cache restore cycles. + if [ -d "$CACHE_DIR" ]; then + find "$CACHE_DIR" -not -path '*/.git/*' -type f -exec chmod a-x {} + || true + echo "Execute bits stripped from cache-memory working tree" + else + echo "Skipping execute-bit stripping; cache-memory directory not present" + fi + - name: Configure Git credentials + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + GITHUB_TOKEN: ${{ github.token }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + git config --global am.keepcr true + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Checkout PR branch + id: checkout-pr + if: | + github.event.pull_request || github.event.issue.pull_request + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + with: + github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs'); + await main(); + - name: Install GitHub Copilot CLI + run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.52 + env: + GH_HOST: github.com + - name: Install awf dependencies + run: npm ci + - name: Build awf + run: npm run build + - name: Install awf binary (local) + run: | + WORKSPACE_PATH="${GITHUB_WORKSPACE:-$(pwd)}" + NODE_BIN="$(command -v node)" + if [ ! -d "$WORKSPACE_PATH" ]; then + echo "Workspace path not found: $WORKSPACE_PATH" + exit 1 + fi + if [ ! -x "$NODE_BIN" ]; then + echo "Node binary not found: $NODE_BIN" + exit 1 + fi + if [ ! -d "/usr/local/bin" ]; then + echo "/usr/local/bin is missing" + exit 1 + fi + sudo tee /usr/local/bin/awf > /dev/null < "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_3097c430c13b8ac6_EOF' + {"create_pull_request":{"labels":["automation","config-consistency"],"max":1,"max_patch_files":100,"max_patch_size":1024,"protect_top_level_dot_folders":true,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS","DESIGN.md","README.md","CONTRIBUTING.md","CHANGELOG.md","SECURITY.md","CODE_OF_CONDUCT.md","AGENTS.md","CLAUDE.md","GEMINI.md"],"protected_files_policy":"request_review","title_prefix":"fix: "},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}} + GH_AW_SAFE_OUTPUTS_CONFIG_3097c430c13b8ac6_EOF + - name: Generate Safe Outputs Tools + env: + GH_AW_TOOLS_META_JSON: | + { + "description_suffixes": { + "create_pull_request": " CONSTRAINTS: Maximum 1 pull request(s) can be created. Title will be prefixed with \"fix: \". Labels [\"automation\" \"config-consistency\"] will be automatically added." + }, + "repo_params": {}, + "dynamic_tools": [] + } + GH_AW_VALIDATION_JSON: | + { + "create_pull_request": { + "defaultMax": 1, + "fields": { + "base": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "body": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "branch": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "draft": { + "type": "boolean" + }, + "labels": { + "type": "array", + "itemType": "string", + "itemSanitize": true, + "itemMaxLength": 128 + }, + "repo": { + "type": "string", + "maxLength": 256 + }, + "title": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "missing_data": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "context": { + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "data_type": { + "type": "string", + "sanitize": true, + "maxLength": 128 + }, + "reason": { + "type": "string", + "sanitize": true, + "maxLength": 256 + } + } + }, + "missing_tool": { + "defaultMax": 20, + "fields": { + "alternatives": { + "type": "string", + "sanitize": true, + "maxLength": 512 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 256 + }, + "tool": { + "type": "string", + "sanitize": true, + "maxLength": 128 + } + } + }, + "noop": { + "defaultMax": 1, + "fields": { + "message": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 65000 + } + } + }, + "report_incomplete": { + "defaultMax": 5, + "fields": { + "details": { + "type": "string", + "sanitize": true, + "maxLength": 65000 + }, + "reason": { + "required": true, + "type": "string", + "sanitize": true, + "maxLength": 1024 + } + } + } + } + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_safe_outputs_tools.cjs'); + await main(); + - name: Generate Safe Outputs MCP Server Config + id: safe-outputs-config + run: | + # Generate a secure random API key (360 bits of entropy, 40+ chars) + # Mask immediately to prevent timing vulnerabilities + API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + echo "::add-mask::${API_KEY}" + + PORT=3001 + + # Set outputs for next steps + { + echo "safe_outputs_api_key=${API_KEY}" + echo "safe_outputs_port=${PORT}" + } >> "$GITHUB_OUTPUT" + + echo "Safe Outputs MCP server will run on port ${PORT}" + + - name: Start Safe Outputs MCP HTTP Server + id: safe-outputs-start + env: + DEBUG: '*' + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-config.outputs.safe_outputs_port }} + GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-config.outputs.safe_outputs_api_key }} + GH_AW_SAFE_OUTPUTS_TOOLS_PATH: ${{ runner.temp }}/gh-aw/safeoutputs/tools.json + GH_AW_SAFE_OUTPUTS_CONFIG_PATH: ${{ runner.temp }}/gh-aw/safeoutputs/config.json + GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs + run: | + # Environment variables are set above to prevent template injection + export DEBUG + export GH_AW_SAFE_OUTPUTS + export GH_AW_SAFE_OUTPUTS_PORT + export GH_AW_SAFE_OUTPUTS_API_KEY + export GH_AW_SAFE_OUTPUTS_TOOLS_PATH + export GH_AW_SAFE_OUTPUTS_CONFIG_PATH + export GH_AW_MCP_LOG_DIR + + bash "${RUNNER_TEMP}/gh-aw/actions/start_safe_outputs_server.sh" + + - name: Start MCP Gateway + id: start-mcp-gateway + env: + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-start.outputs.api_key }} + GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-start.outputs.port }} + run: | + set -eo pipefail + mkdir -p "${RUNNER_TEMP}/gh-aw/mcp-config" + + # Export gateway environment variables for MCP config and gateway script + export MCP_GATEWAY_PORT="8080" + export MCP_GATEWAY_DOMAIN="host.docker.internal" + export MCP_GATEWAY_HOST_DOMAIN="localhost" + MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=') + echo "::add-mask::${MCP_GATEWAY_API_KEY}" + export MCP_GATEWAY_API_KEY + export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads" + mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}" + export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288" + export DEBUG="*" + + export GH_AW_ENGINE="copilot" + MCP_GATEWAY_UID=$(id -u 2>/dev/null || echo '0') + MCP_GATEWAY_GID=$(id -g 2>/dev/null || echo '0') + case "${DOCKER_HOST:-}" in + unix://* ) DOCKER_SOCK_PATH="${DOCKER_HOST#unix://}" ;; + /* ) DOCKER_SOCK_PATH="$DOCKER_HOST" ;; + * ) DOCKER_SOCK_PATH=/var/run/docker.sock ;; + esac + DOCKER_SOCK_GID=$(stat -c '%g' "$DOCKER_SOCK_PATH" 2>/dev/null || echo '0') + export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host --add-host host.docker.internal:127.0.0.1 --user '"${MCP_GATEWAY_UID}"':'"${MCP_GATEWAY_GID}"' --group-add '"${DOCKER_SOCK_GID}"' -v '"${DOCKER_SOCK_PATH}"':/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DOCKER_HOST=unix:///var/run/docker.sock -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.3.19' + + mkdir -p /home/runner/.copilot + GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node) + cat << GH_AW_MCP_CONFIG_c10463bde3abb220_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs" + { + "mcpServers": { + "safeoutputs": { + "type": "http", + "url": "http://host.docker.internal:$GH_AW_SAFE_OUTPUTS_PORT", + "headers": { + "Authorization": "\${GH_AW_SAFE_OUTPUTS_API_KEY}" + }, + "guard-policies": { + "write-sink": { + "accept": [ + "*" + ] + } + } + } + }, + "gateway": { + "port": $MCP_GATEWAY_PORT, + "domain": "${MCP_GATEWAY_DOMAIN}", + "apiKey": "${MCP_GATEWAY_API_KEY}", + "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}" + } + } + GH_AW_MCP_CONFIG_c10463bde3abb220_EOF + - name: Mount MCP servers as CLIs + id: mount-mcp-clis + continue-on-error: true + env: + MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} + MCP_GATEWAY_DOMAIN: ${{ steps.start-mcp-gateway.outputs.gateway-domain }} + MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }} + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io); + const { main } = require('${{ runner.temp }}/gh-aw/actions/mount_mcp_as_cli.cjs'); + await main(); + - name: Clean credentials + continue-on-error: true + run: bash "${RUNNER_TEMP}/gh-aw/actions/clean_git_credentials.sh" + - name: Audit pre-agent workspace + id: pre_agent_audit + continue-on-error: true + run: bash "${RUNNER_TEMP}/gh-aw/actions/audit_pre_agent_workspace.sh" + - name: Start CLI Proxy + env: + GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + GITHUB_SERVER_URL: ${{ github.server_url }} + CLI_PROXY_POLICY: '{"allow-only":{"repos":"all","min-integrity":"none"}}' + CLI_PROXY_IMAGE: 'ghcr.io/github/gh-aw-mcpg:v0.3.19' + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/start_cli_proxy.sh" + - name: Execute GitHub Copilot CLI + id: agentic_execution + # Copilot CLI tool arguments (sorted): + timeout-minutes: 20 + run: | + set -o pipefail + printf '%s' "$(date +%s%3N)" > /tmp/gh-aw/agent_cli_start_ms.txt + touch /tmp/gh-aw/agent-step-summary.md + GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true) + export GH_AW_NODE_BIN + export COPILOT_API_KEY="$COPILOT_DUMMY_BYOK" + (umask 177 && touch /tmp/gh-aw/agent-stdio.log) + printf '%s\n' '{"$schema":"https://github.com/github/gh-aw-firewall/releases/download/v0.25.55/awf-config.schema.json","network":{"allowDomains":["*.githubusercontent.com","api.business.githubcopilot.com","api.enterprise.githubcopilot.com","api.github.com","api.githubcopilot.com","api.individual.githubcopilot.com","api.npms.io","api.snapcraft.io","archive.ubuntu.com","azure.archive.ubuntu.com","bun.sh","cdn.jsdelivr.net","codeload.github.com","crl.geotrust.com","crl.globalsign.com","crl.identrust.com","crl.sectigo.com","crl.thawte.com","crl.usertrust.com","crl.verisign.com","crl3.digicert.com","crl4.digicert.com","crls.ssl.com","deb.nodesource.com","deno.land","docs.github.com","esm.sh","get.pnpm.io","github-cloud.githubusercontent.com","github-cloud.s3.amazonaws.com","github.blog","github.com","github.githubassets.com","googleapis.deno.dev","googlechromelabs.github.io","host.docker.internal","json-schema.org","json.schemastore.org","jsr.io","keyserver.ubuntu.com","lfs.github.com","nodejs.org","npm.pkg.github.com","npmjs.com","npmjs.org","objects.githubusercontent.com","ocsp.digicert.com","ocsp.geotrust.com","ocsp.globalsign.com","ocsp.identrust.com","ocsp.sectigo.com","ocsp.ssl.com","ocsp.thawte.com","ocsp.usertrust.com","ocsp.verisign.com","packagecloud.io","packages.cloud.google.com","packages.microsoft.com","patch-diff.githubusercontent.com","ppa.launchpad.net","raw.githubusercontent.com","registry.bower.io","registry.npmjs.com","registry.npmjs.org","registry.yarnpkg.com","repo.yarnpkg.com","s.symcb.com","s.symcd.com","security.ubuntu.com","skimdb.npmjs.com","storage.googleapis.com","telemetry.enterprise.githubcopilot.com","telemetry.vercel.com","ts-crl.ws.symantec.com","ts-ocsp.ws.symantec.com","www.googleapis.com","www.npmjs.com","www.npmjs.org","yarnpkg.com"]},"apiProxy":{"enabled":true,"enableTokenSteering":true,"maxRuns":500,"maxEffectiveTokens":25000000,"models":{"agent":["sonnet-6x","gpt-5.4","gpt-5.3","gemini-pro","any"],"antigravity":["copilot/antigravity*","google/antigravity*","gemini/antigravity*"],"any":["copilot/*","anthropic/*","openai/*","google/*","gemini/*"],"claude":["agent"],"codex":["agent"],"coding":["copilot/gpt-5*codex*","openai/gpt-5*codex*","gpt-5-codex"],"computer-use":["copilot/*computer-use*","google/*computer-use*","gemini/*computer-use*","openai/*computer-use*"],"copilot":["agent"],"deep-research":["copilot/deep-research*","copilot/o3-deep-research*","copilot/o4-mini-deep-research*","google/deep-research*","gemini/deep-research*","openai/o3-deep-research*","openai/o4-mini-deep-research*"],"gemini":["agent"],"gemini-3-flash":["copilot/gemini-3*flash*","google/gemini-3*flash*","gemini/gemini-3*flash*"],"gemini-3-pro":["copilot/gemini-3*pro*","google/gemini-3*pro*","gemini/gemini-3*pro*"],"gemini-3.1-flash":["copilot/gemini-3.1*flash*","google/gemini-3.1*flash*","gemini/gemini-3.1*flash*"],"gemini-3.1-pro":["copilot/gemini-3.1*pro*","google/gemini-3.1*pro*","gemini/gemini-3.1*pro*"],"gemini-3.5-flash":["copilot/gemini-3.5*flash*","google/gemini-3.5*flash*","gemini/gemini-3.5*flash*"],"gemini-flash":["copilot/gemini-*flash*","google/gemini-*flash*","gemini/gemini-*flash*"],"gemini-flash-lite":["copilot/gemini-*flash*lite*","google/gemini-*flash*lite*","gemini/gemini-*flash*lite*"],"gemini-pro":["copilot/gemini-*pro*","google/gemini-*pro*","gemini/gemini-*pro*"],"gemma":["copilot/gemma*","google/gemma*","gemini/gemma*"],"gpt-4.1":["copilot/gpt-4.1*","openai/gpt-4.1*"],"gpt-5":["copilot/gpt-5*","openai/gpt-5*"],"gpt-5-codex":["copilot/gpt-5*codex*","openai/gpt-5*codex*"],"gpt-5-mini":["copilot/gpt-5*mini*","openai/gpt-5*mini*"],"gpt-5-nano":["copilot/gpt-5*nano*","openai/gpt-5*nano*"],"gpt-5-pro":["copilot/gpt-5*pro*","openai/gpt-5*pro*"],"gpt-5.2":["copilot/gpt-5.2*","openai/gpt-5.2*"],"gpt-5.3":["copilot/gpt-5.3*","openai/gpt-5.3*"],"gpt-5.4":["copilot/gpt-5.4*","openai/gpt-5.4*"],"gpt-5.5":["copilot/gpt-5.5*","openai/gpt-5.5*"],"haiku":["copilot/*haiku*","anthropic/*haiku*"],"large":["sonnet","gpt-5-pro","gpt-5","gemini-pro"],"mini":["haiku","gpt-5-mini","gpt-5-nano","gemini-flash-lite"],"opus":["copilot/*opus*","anthropic/*opus*"],"opusplan":["opus?effort=high"],"reasoning":["copilot/o1*","copilot/o3*","copilot/o4*","openai/o1*","openai/o3*","openai/o4*"],"robotics":["copilot/*robotics*","google/*robotics*","gemini/*robotics*"],"small":["mini"],"sonnet":["copilot/*sonnet*","anthropic/*sonnet*"],"sonnet-6x":["copilot/*sonnet-4-5-*","anthropic/*sonnet-4-5-*","copilot/*sonnet-4-6*","anthropic/*sonnet-4-6*"],"summarization":["haiku","gpt-5-mini","gemini-flash-lite","mini"],"vision":["copilot/gemini-*image*","gemini/gemini-*image*","copilot/gemini-*flash*","gemini/gemini-*flash*"]}},"container":{"imageTag":"0.25.55"}}' > "${RUNNER_TEMP}/gh-aw/awf-config.json" + cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json + GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS="" + if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then + GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS="--docker-host-path-prefix /tmp/gh-aw" + fi + # shellcheck disable=SC1003 + sudo -E awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS} --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GH_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --session-state-dir /tmp/gh-aw/sandbox/agent/session-state --enable-host-access --allow-host-ports 80,443,8080 --build-local --difc-proxy-host host.docker.internal:18443 --difc-proxy-ca-cert /tmp/gh-aw/difc-proxy-tls/ca.crt \ + -- /bin/bash -c 'export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && export PATH="$(find /opt/hostedtoolcache /home/runner/work/_tool -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner — check runtimes.node in workflow YAML" >&2; exit 127; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --add-dir /tmp/gh-aw/cache-memory/ --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + env: + AWF_REFLECT_ENABLED: 1 + COPILOT_AGENT_RUNNER_TYPE: STANDALONE + COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + COPILOT_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || 'claude-sonnet-4.6' }} + GH_AW_MCP_CONFIG: /home/runner/.copilot/mcp-config.json + GH_AW_PHASE: agent + GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_VERSION: v0.76.1 + GH_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || github.token }} + GITHUB_API_URL: ${{ github.api_url }} + GITHUB_AW: true + GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows + GITHUB_HEAD_REF: ${{ github.head_ref }} + GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + GITHUB_REF_NAME: ${{ github.ref_name }} + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md + GITHUB_WORKSPACE: ${{ github.workspace }} + GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_AUTHOR_NAME: github-actions[bot] + GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com + GIT_COMMITTER_NAME: github-actions[bot] + XDG_CONFIG_HOME: /home/runner + - name: Stop CLI Proxy + if: always() + continue-on-error: true + run: bash "${RUNNER_TEMP}/gh-aw/actions/stop_cli_proxy.sh" + - name: Detect agent errors + if: always() + id: detect-agent-errors + continue-on-error: true + run: node "${RUNNER_TEMP}/gh-aw/actions/detect_agent_errors.cjs" + - name: Configure Git credentials + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + GITHUB_TOKEN: ${{ github.token }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + git config --global am.keepcr true + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Copy Copilot session state files to logs + if: always() + continue-on-error: true + run: | + SESSION_STATE_SRC="/tmp/gh-aw/sandbox/agent/session-state" + LOGS_DIR="/tmp/gh-aw/sandbox/agent/logs" + if [ -d "$SESSION_STATE_SRC" ] && [ -n "$(ls -A "$SESSION_STATE_SRC" 2>/dev/null)" ]; then + mkdir -p "$LOGS_DIR/session-state" + cp -rp "$SESSION_STATE_SRC/." "$LOGS_DIR/session-state/" + echo "Copied session state to $LOGS_DIR/session-state" + else + echo "No session state found at $SESSION_STATE_SRC" + fi + - name: Stop MCP Gateway + if: always() + continue-on-error: true + env: + MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }} + MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }} + GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }} + run: | + bash "${RUNNER_TEMP}/gh-aw/actions/stop_mcp_gateway.sh" "$GATEWAY_PID" + - name: Redact secrets in logs + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/redact_secrets.cjs'); + await main(); + env: + GH_AW_SECRET_NAMES: 'COPILOT_GITHUB_TOKEN,GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN' + SECRET_COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} + SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }} + SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }} + SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Append agent step summary + if: always() + run: bash "${RUNNER_TEMP}/gh-aw/actions/append_agent_step_summary.sh" + - name: Copy Safe Outputs + if: always() + env: + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + run: | + mkdir -p /tmp/gh-aw + cp "$GH_AW_SAFE_OUTPUTS" /tmp/gh-aw/safeoutputs.jsonl 2>/dev/null || true + - name: Ingest agent output + id: collect_output + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }} + GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.npms.io,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,bun.sh,cdn.jsdelivr.net,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,deb.nodesource.com,deno.land,docs.github.com,esm.sh,get.pnpm.io,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,googleapis.deno.dev,googlechromelabs.github.io,host.docker.internal,json-schema.org,json.schemastore.org,jsr.io,keyserver.ubuntu.com,lfs.github.com,nodejs.org,npm.pkg.github.com,npmjs.com,npmjs.org,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,patch-diff.githubusercontent.com,ppa.launchpad.net,raw.githubusercontent.com,registry.bower.io,registry.npmjs.com,registry.npmjs.org,registry.yarnpkg.com,repo.yarnpkg.com,s.symcb.com,s.symcd.com,security.ubuntu.com,skimdb.npmjs.com,storage.googleapis.com,telemetry.enterprise.githubcopilot.com,telemetry.vercel.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com,www.npmjs.com,www.npmjs.org,yarnpkg.com" + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_API_URL: ${{ github.api_url }} + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/collect_ndjson_output.cjs'); + await main(); + - name: Parse agent logs for step summary + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: /tmp/gh-aw/sandbox/agent/logs/ + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_copilot_log.cjs'); + await main(); + - name: Parse MCP Gateway logs for step summary + if: always() + id: parse-mcp-gateway + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_mcp_gateway_log.cjs'); + await main(); + - name: Print firewall logs + if: always() + continue-on-error: true + env: + AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs + run: | + # Fix permissions on firewall logs/audit dirs so they can be uploaded as artifacts + # AWF runs with sudo, creating files owned by root + sudo chmod -R a+rX /tmp/gh-aw/sandbox/firewall 2>/dev/null || true + # Only run awf logs summary if awf command exists (it may not be installed if workflow failed before install step) + if command -v awf &> /dev/null; then + awf logs summary | tee -a "$GITHUB_STEP_SUMMARY" + else + echo 'AWF binary not installed, skipping firewall log summary' + fi + - name: Parse token usage for step summary + if: always() + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_token_usage.cjs'); + await main(); + - name: Print AWF reflect summary + if: always() + continue-on-error: true + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/awf_reflect_summary.cjs'); + await main(); + - name: Write agent output placeholder if missing + if: always() + run: | + if [ ! -f /tmp/gh-aw/agent_output.json ]; then + echo '{"items":[]}' > /tmp/gh-aw/agent_output.json + fi + - name: Scan cache-memory for instruction-injection content + if: always() + env: + GH_AW_CACHE_DIR: /tmp/gh-aw/cache-memory + run: | + CACHE_DIR="${GH_AW_CACHE_DIR:-/tmp/gh-aw/cache-memory}" + # Quarantine files containing instruction-shaped content to prevent + # cross-run agent-context instruction injection via cache-memory. + # Require a colon after the keyword to reduce false positives on + # legitimate files (e.g. '## System Requirements', 'Override: false'). + INJECTION_PATTERN='^(New instruction:|SYSTEM:|Ignore (all |previous |prior )instructions?:|)' + QUARANTINE_DIR="${GH_AW_CACHE_DIR:-/tmp/gh-aw/cache-memory}/.quarantine" + mapfile -t SUSPICIOUS_FILES < <( + find "$CACHE_DIR" -not -path '*/.git/*' -not -path '*/.quarantine/*' -type f \ + -exec grep -lEi "$INJECTION_PATTERN" {} \; 2>/dev/null || true + ) + if [ ${#SUSPICIOUS_FILES[@]} -gt 0 ]; then + mkdir -p "$QUARANTINE_DIR" + for f in "${SUSPICIOUS_FILES[@]}"; do + rel="${f#${CACHE_DIR}/}" + echo "::warning::Quarantining file with instruction-shaped content: $f" + echo "--- First 5 lines of quarantined file: $f ---" + head -5 "$f" | sed 's/^/| /' || true + mkdir -p "$QUARANTINE_DIR/$(dirname "$rel")" + mv -f "$f" "$QUARANTINE_DIR/$rel" + done + echo "Quarantined ${#SUSPICIOUS_FILES[@]} file(s) with instruction-shaped content to $QUARANTINE_DIR" + else + echo "No instruction-injection content found in cache-memory" + fi + - name: Commit cache-memory changes + if: always() + env: + GH_AW_CACHE_DIR: /tmp/gh-aw/cache-memory + run: bash "${RUNNER_TEMP}/gh-aw/actions/commit_cache_memory_git.sh" + - name: Upload agent artifacts + if: always() + continue-on-error: true + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: agent + path: | + /tmp/gh-aw/aw-prompts/prompt.txt + /tmp/gh-aw/sandbox/agent/logs/ + /tmp/gh-aw/redacted-urls.log + /tmp/gh-aw/mcp-logs/ + /tmp/gh-aw/agent_usage.json + /tmp/gh-aw/agent-stdio.log + /tmp/gh-aw/pre-agent-audit.txt + /tmp/gh-aw/agent/ + /tmp/gh-aw/github_rate_limits.jsonl + /tmp/gh-aw/safeoutputs.jsonl + /tmp/gh-aw/agent_output.json + /tmp/gh-aw/aw-*.patch + /tmp/gh-aw/aw-*.bundle + /tmp/gh-aw/awf-config.json + /tmp/gh-aw/sandbox/firewall/logs/ + /tmp/gh-aw/sandbox/firewall/audit/ + /tmp/gh-aw/sandbox/firewall/awf-reflect.json + if-no-files-found: ignore + + conclusion: + needs: + - activation + - agent + - safe_outputs + if: > + always() && (needs.agent.result != 'skipped' || needs.activation.outputs.lockdown_check_failed == 'true' || + needs.activation.outputs.stale_lock_file_failed == 'true') + runs-on: ubuntu-slim + permissions: + contents: write + issues: write + pull-requests: write + concurrency: + group: "gh-aw-conclusion-config-consistency-auditor" + cancel-in-progress: false + queue: max + outputs: + incomplete_count: ${{ steps.report_incomplete.outputs.incomplete_count }} + noop_message: ${{ steps.noop.outputs.noop_message }} + tools_reported: ${{ steps.missing_tool.outputs.tools_reported }} + total_count: ${{ steps.missing_tool.outputs.total_count }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@46d564922b082d0db93244972e8005ea6904ee5f # v0.76.1 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} + parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Config Consistency Auditor" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/config-consistency-auditor.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.52" + GH_AW_INFO_AWF_VERSION: "v0.25.55" + GH_AW_INFO_ENGINE_ID: "copilot" + - name: Download agent output artifact + id: download-agent-output + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent + path: /tmp/gh-aw/ + - name: Setup agent output environment variable + id: setup-agent-output-env + if: steps.download-agent-output.outcome == 'success' + run: | + mkdir -p /tmp/gh-aw/ + find "/tmp/gh-aw/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" + - name: Process no-op messages + id: noop + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_NOOP_MAX: "1" + GH_AW_WORKFLOW_NAME: "Config Consistency Auditor" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/config-consistency-auditor.md" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_NOOP_REPORT_AS_ISSUE: "true" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_noop_message.cjs'); + await main(); + - name: Record missing tool + id: missing_tool + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_MISSING_TOOL_CREATE_ISSUE: "true" + GH_AW_WORKFLOW_NAME: "Config Consistency Auditor" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/config-consistency-auditor.md" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/missing_tool.cjs'); + await main(); + - name: Record incomplete + id: report_incomplete + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_REPORT_INCOMPLETE_CREATE_ISSUE: "true" + GH_AW_WORKFLOW_NAME: "Config Consistency Auditor" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/config-consistency-auditor.md" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/report_incomplete_handler.cjs'); + await main(); + - name: Handle agent failure + id: handle_agent_failure + if: always() + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_WORKFLOW_NAME: "Config Consistency Auditor" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/config-consistency-auditor.md" + GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }} + GH_AW_WORKFLOW_ID: "config-consistency-auditor" + GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS: "168" + GH_AW_ENGINE_ID: "copilot" + GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.activation.outputs.secret_verification_result }} + GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }} + GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens || '' }} + GH_AW_EFFECTIVE_TOKENS_RATE_LIMIT_ERROR: ${{ needs.agent.outputs.effective_tokens_rate_limit_error || 'false' }} + GH_AW_INFERENCE_ACCESS_ERROR: ${{ needs.agent.outputs.inference_access_error }} + GH_AW_MCP_POLICY_ERROR: ${{ needs.agent.outputs.mcp_policy_error }} + GH_AW_AGENTIC_ENGINE_TIMEOUT: ${{ needs.agent.outputs.agentic_engine_timeout }} + GH_AW_MODEL_NOT_SUPPORTED_ERROR: ${{ needs.agent.outputs.model_not_supported_error }} + GH_AW_ENGINE_API_HOSTS: "api.enterprise.githubcopilot.com,api.githubcopilot.com,api.business.githubcopilot.com,api.individual.githubcopilot.com" + GH_AW_CODE_PUSH_FAILURE_ERRORS: ${{ needs.safe_outputs.outputs.code_push_failure_errors }} + GH_AW_CODE_PUSH_FAILURE_COUNT: ${{ needs.safe_outputs.outputs.code_push_failure_count }} + GH_AW_LOCKDOWN_CHECK_FAILED: ${{ needs.activation.outputs.lockdown_check_failed }} + GH_AW_STALE_LOCK_FILE_FAILED: ${{ needs.activation.outputs.stale_lock_file_failed }} + GH_AW_GROUP_REPORTS: "false" + GH_AW_FAILURE_REPORT_AS_ISSUE: "true" + GH_AW_MISSING_TOOL_REPORT_AS_FAILURE: "true" + GH_AW_MISSING_DATA_REPORT_AS_FAILURE: "true" + GH_AW_TIMEOUT_MINUTES: "20" + GH_AW_MAX_EFFECTIVE_TOKENS: "25000000" + GH_AW_CACHE_MEMORY_ENABLED: "true" + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_agent_failure.cjs'); + await main(); + + safe_outputs: + needs: + - activation + - agent + if: (!cancelled()) && needs.agent.result != 'skipped' + runs-on: ubuntu-slim + permissions: + contents: write + issues: write + pull-requests: write + timeout-minutes: 15 + env: + GH_AW_CALLER_WORKFLOW_ID: "${{ github.repository }}/config-consistency-auditor" + GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens }} + GH_AW_ENGINE_ID: "copilot" + GH_AW_ENGINE_MODEL: ${{ needs.agent.outputs.model }} + GH_AW_ENGINE_VERSION: "1.0.52" + GH_AW_WORKFLOW_ID: "config-consistency-auditor" + GH_AW_WORKFLOW_NAME: "Config Consistency Auditor" + GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/config-consistency-auditor.md" + outputs: + code_push_failure_count: ${{ steps.process_safe_outputs.outputs.code_push_failure_count }} + code_push_failure_errors: ${{ steps.process_safe_outputs.outputs.code_push_failure_errors }} + create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }} + create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }} + created_pr_number: ${{ steps.process_safe_outputs.outputs.created_pr_number }} + created_pr_url: ${{ steps.process_safe_outputs.outputs.created_pr_url }} + process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }} + process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }} + steps: + - name: Setup Scripts + id: setup + uses: github/gh-aw-actions/setup@46d564922b082d0db93244972e8005ea6904ee5f # v0.76.1 + with: + destination: ${{ runner.temp }}/gh-aw/actions + job-name: ${{ github.job }} + trace-id: ${{ needs.activation.outputs.setup-trace-id }} + parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }} + env: + GH_AW_SETUP_WORKFLOW_NAME: "Config Consistency Auditor" + GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/config-consistency-auditor.lock.yml@${{ github.ref }} + GH_AW_INFO_VERSION: "1.0.52" + GH_AW_INFO_AWF_VERSION: "v0.25.55" + GH_AW_INFO_ENGINE_ID: "copilot" + - name: Download agent output artifact + id: download-agent-output + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent + path: /tmp/gh-aw/ + - name: Setup agent output environment variable + id: setup-agent-output-env + if: steps.download-agent-output.outcome == 'success' + run: | + mkdir -p /tmp/gh-aw/ + find "/tmp/gh-aw/" -type f -print + echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT" + - name: Download patch artifact + continue-on-error: true + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: agent + path: /tmp/gh-aw/ + - name: Extract base branch from agent output + id: extract-base-branch + if: steps.download-agent-output.outcome == 'success' + shell: bash + run: | + if [ -f "/tmp/gh-aw/agent_output.json" ]; then + GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node) + BASE_BRANCH=$("$GH_AW_NODE" -e " + try { + const data = JSON.parse(require('fs').readFileSync('/tmp/gh-aw/agent_output.json', 'utf8')); + const item = (data.items || []).find(i => + (i.type === 'create_pull_request' || i.type === 'push_to_pull_request_branch') && + i.base_branch + ); + if (item) process.stdout.write(item.base_branch); + } catch(e) {} + " 2>/dev/null || true) + # Validate: only allow safe git branch name characters + if [[ "$BASE_BRANCH" =~ ^[a-zA-Z0-9/_.-]+$ ]] && [ ${#BASE_BRANCH} -le 255 ]; then + printf 'base-branch=%s\n' "$BASE_BRANCH" >> "$GITHUB_OUTPUT" + echo "Extracted base branch from safe output: $BASE_BRANCH" + fi + fi + - name: Checkout repository (trusted default branch for comment events) + if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && (github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment') + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.repository.default_branch }} + token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + persist-credentials: false + fetch-depth: 1 + - name: Checkout repository + if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && github.event_name != 'issue_comment' && github.event_name != 'pull_request_review_comment' + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ steps.extract-base-branch.outputs.base-branch || github.base_ref || github.event.pull_request.base.ref || github.ref_name || github.event.repository.default_branch }} + token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + persist-credentials: false + fetch-depth: 1 + - name: Configure Git credentials + if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') + env: + REPO_NAME: ${{ github.repository }} + SERVER_URL: ${{ github.server_url }} + GIT_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + run: | + git config --global user.email "github-actions[bot]@users.noreply.github.com" + git config --global user.name "github-actions[bot]" + git config --global am.keepcr true + # Re-authenticate git with GitHub token + SERVER_URL_STRIPPED="${SERVER_URL#https://}" + git remote set-url origin "https://x-access-token:${GIT_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git" + echo "Git configured with standard GitHub Actions identity" + - name: Configure GH_HOST for enterprise compatibility + id: ghes-host-config + shell: bash + run: | + # Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct + # GitHub instance (GHES/GHEC). On github.com this is a harmless no-op. + GH_HOST="${GITHUB_SERVER_URL#https://}" + GH_HOST="${GH_HOST#http://}" + echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV" + - name: Process Safe Outputs + id: process_safe_outputs + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + env: + GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }} + GH_AW_COMMENT_ID: ${{ needs.activation.outputs.comment_id }} + GH_AW_ALLOWED_DOMAINS: "*.githubusercontent.com,api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.npms.io,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,bun.sh,cdn.jsdelivr.net,codeload.github.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,deb.nodesource.com,deno.land,docs.github.com,esm.sh,get.pnpm.io,github-cloud.githubusercontent.com,github-cloud.s3.amazonaws.com,github.blog,github.com,github.githubassets.com,googleapis.deno.dev,googlechromelabs.github.io,host.docker.internal,json-schema.org,json.schemastore.org,jsr.io,keyserver.ubuntu.com,lfs.github.com,nodejs.org,npm.pkg.github.com,npmjs.com,npmjs.org,objects.githubusercontent.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,patch-diff.githubusercontent.com,ppa.launchpad.net,raw.githubusercontent.com,registry.bower.io,registry.npmjs.com,registry.npmjs.org,registry.yarnpkg.com,repo.yarnpkg.com,s.symcb.com,s.symcd.com,security.ubuntu.com,skimdb.npmjs.com,storage.googleapis.com,telemetry.enterprise.githubcopilot.com,telemetry.vercel.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com,www.npmjs.com,www.npmjs.org,yarnpkg.com" + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_API_URL: ${{ github.api_url }} + GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"create_pull_request\":{\"labels\":[\"automation\",\"config-consistency\"],\"max\":1,\"max_patch_files\":100,\"max_patch_size\":1024,\"protect_top_level_dot_folders\":true,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"DESIGN.md\",\"README.md\",\"CONTRIBUTING.md\",\"CHANGELOG.md\",\"SECURITY.md\",\"CODE_OF_CONDUCT.md\",\"AGENTS.md\",\"CLAUDE.md\",\"GEMINI.md\"],\"protected_files_policy\":\"request_review\",\"title_prefix\":\"fix: \"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"report_incomplete\":{}}" + GH_AW_CI_TRIGGER_TOKEN: ${{ secrets.GH_AW_CI_TRIGGER_TOKEN }} + with: + github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }} + script: | + const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs'); + setupGlobals(core, github, context, exec, io, getOctokit); + const { main } = require('${{ runner.temp }}/gh-aw/actions/safe_output_handler_manager.cjs'); + await main(); + - name: Upload Safe Outputs Items + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: safe-outputs-items + path: | + /tmp/gh-aw/safe-output-items.jsonl + /tmp/gh-aw/temporary-id-map.json + if-no-files-found: ignore + diff --git a/.github/workflows/config-consistency-auditor.md b/.github/workflows/config-consistency-auditor.md new file mode 100644 index 000000000..6a7506a31 --- /dev/null +++ b/.github/workflows/config-consistency-auditor.md @@ -0,0 +1,193 @@ +--- +name: Config Consistency Auditor +description: > + Daily audit of recently merged PRs to verify new configuration is consistently + represented across JSON schema, spec, TypeScript types, and env var wiring — + with security-sensitive values via env vars and non-sensitive via stdin config. +on: + schedule: daily on weekdays + workflow_dispatch: +permissions: + contents: read + pull-requests: read + issues: read +engine: copilot +strict: true +timeout-minutes: 20 +network: + allowed: + - defaults + - node + - github +tools: + github: + mode: gh-proxy + toolsets: [default, pull_requests] + cache-memory: true + bash: ["*"] + edit: +safe-outputs: + threat-detection: + enabled: false + create-pull-request: + max: 1 + labels: [automation, config-consistency] + title-prefix: "fix: " +--- + +# Config Consistency Auditor + +You are an AI agent that audits recently merged PRs for configuration consistency. +Your goal is to catch gaps where new configuration was added to one layer but not +propagated to all required layers. + +## Configuration Layers + +Every new AWF configuration field MUST be consistently represented across: + +1. **JSON Schema** (`src/awf-config-schema.json` and `docs/awf-config.schema.json`) + - Must be identical copies +2. **Spec** (`docs/awf-config-spec.md`) + - Section 5 CLI Mapping table must list the config path and its CLI flag or env var mapping +3. **TypeScript Types** (`src/types/*.ts` and `src/config-file.ts`) + - The config-file interface must include the field + - The options type must include the mapped CLI option +4. **Env Var Wiring** (`src/services/api-proxy-service.ts` or other service files) + - The field must be mapped to its corresponding `AWF_*` env var for the api-proxy + - OR mapped to a CLI flag that the runtime handles + +## Security Classification + +Configuration fields MUST follow these rules: + +- **Security-sensitive values** (API keys, tokens, credentials, OIDC client IDs/secrets): + - Passed via environment variables (`-e` flag or `--env-file`) + - MUST NOT appear in stdin config JSON (which may be logged) +- **Non-sensitive values** (domains, multipliers, model names, timeouts, strategies): + - Passed via stdin config (`--config -`) + - Mapped in `src/config-file.ts` + +## Procedure + +### 1. Load last-processed state + +Read `/tmp/gh-aw/cache-memory/config-audit-state.json`. It stores: +```json +{ "last_audit_date": "YYYY-MM-DD", "last_pr_number": 1234 } +``` + +- If the file exists, audit PRs merged since `last_audit_date`. +- If the file does NOT exist (first run), audit PRs merged in the **last 7 days**. + +### 2. Fetch recently merged PRs + +```bash +gh pr list --repo github/gh-aw-firewall --state merged --limit 20 \ + --json number,title,mergedAt,files --jq '.[] | select(.mergedAt > "CUTOFF_DATE")' +``` + +Filter to PRs that modify any of these paths (likely to introduce config): +- `src/config-file.ts` +- `src/types/*.ts` +- `src/awf-config-schema.json` +- `docs/awf-config-spec.md` +- `docs/awf-config.schema.json` +- `src/services/api-proxy-service.ts` +- `src/cli-options.ts` or `src/cli.ts` +- `containers/api-proxy/server.js` +- `containers/api-proxy/guards/*.js` + +If no relevant PRs are found, save state and exit with `noop`. + +### 3. For each relevant PR, check consistency + +For each PR, examine what new configuration was introduced by reading the diff: + +```bash +gh pr diff --repo github/gh-aw-firewall +``` + +Look for patterns indicating new config: +- New properties in schema JSON (`"propertyName": { "type":`) +- New rows in spec CLI mapping table +- New fields in TypeScript interfaces +- New `AWF_*` env var assignments +- New CLI `.option(` definitions + +### 4. Cross-reference all layers + +For each new configuration field found, verify it exists in ALL required layers: + +| Check | How to verify | +|-------|---------------| +| JSON Schema (src) | `grep "fieldName" src/awf-config-schema.json` | +| JSON Schema (docs) | Schemas must be identical: `diff src/awf-config-schema.json docs/awf-config.schema.json` | +| Spec CLI mapping | `grep "fieldName" docs/awf-config-spec.md` | +| TypeScript type | `grep "fieldName" src/types/*.ts src/config-file.ts` | +| Env var wiring | `grep "AWF_FIELD_NAME" src/services/api-proxy-service.ts` (for api-proxy config) | + +### 5. Check security classification + +For each new field, determine if it's security-sensitive: +- Contains "key", "secret", "token", "credential", "password" → security-sensitive +- Is an OIDC client ID or tenant ID → security-sensitive +- Is a domain, multiplier, timeout, strategy, model name → non-sensitive + +Verify: +- Security-sensitive fields are passed via env vars (not in config-file.ts stdin mapping) +- Non-sensitive fields are in config-file.ts (stdin config mapping) + +### 6. Fix gaps and create a PR + +If gaps are found, fix them directly: + +- **Missing TypeScript type field**: Add the field to the appropriate interface in + `src/types/*.ts` and/or `src/config-file.ts` +- **Missing spec CLI mapping row**: Add the row to Section 5 of `docs/awf-config-spec.md` +- **Missing schema field**: Add the property to `src/awf-config-schema.json` AND + `docs/awf-config.schema.json` (they must stay identical) +- **Missing env var wiring**: Add the mapping in `src/services/api-proxy-service.ts` +- **Schema drift**: Copy `src/awf-config-schema.json` to `docs/awf-config.schema.json` + +After making fixes, use the `create-pull-request` safe output with: +- Title: `"fix: propagate config fields to all layers"` +- Body: A summary table of what was fixed, organized by PR that introduced the gap + +Example PR body: +```markdown +## Config Consistency Fixes + +Automated fixes for configuration fields not fully propagated: + +### From PR #1234 — "feat: add fooBar config" + +| Field | Fix Applied | +|-------|-------------| +| `apiProxy.fooBar` | Added to TypeScript interface in `src/types/api-proxy-options.ts` | + +### Verification + +- [ ] TypeScript compiles (`tsc --noEmit`) +- [ ] Config-file-mapping tests pass +- [ ] Schema validation tests pass +``` + +If no gaps are found, use `noop` safe output. + +### 7. Save state + +Write the current date and highest PR number to +`/tmp/gh-aw/cache-memory/config-audit-state.json`: +```json +{ "last_audit_date": "YYYY-MM-DD", "last_pr_number": 4063 } +``` + +## Important Notes + +- Internal refactors (renaming files, moving code between modules) that don't add + new user-facing config should be ignored. +- Test-only changes (new test files, test helpers) should be ignored. +- The `docs/awf-config.schema.json` and `src/awf-config-schema.json` MUST always be + identical. If they differ, report that as a critical gap. +- Fields that are intentionally runtime-only (no config equivalent) should be noted + but not flagged as gaps if documented in the spec as "CLI-only". From 86a8b0a1cf391c641482160c53bc335ea8a7899f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 30 May 2026 15:11:08 +0000 Subject: [PATCH 4/4] fix: address review feedback on identity synthesis and token guard --- containers/agent/entrypoint.sh | 23 ++++++-- .../api-proxy/guards/effective-token-guard.js | 26 ++++++---- .../guards/effective-token-guard.test.js | 25 +++++++-- src/services/agent-volumes/etc-mounts.test.ts | 52 +++++++++++++++++++ src/services/agent-volumes/etc-mounts.ts | 34 ++++++++++-- 5 files changed, 138 insertions(+), 22 deletions(-) diff --git a/containers/agent/entrypoint.sh b/containers/agent/entrypoint.sh index 7213ebce0..8326ed674 100644 --- a/containers/agent/entrypoint.sh +++ b/containers/agent/entrypoint.sh @@ -715,10 +715,27 @@ if [ "${AWF_CHROOT_ENABLED}" = "true" ]; 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. HOST_USER="runner" + if [ -f /host/etc/passwd ] && grep -q "^${HOST_USER}:" /host/etc/passwd 2>/dev/null; then + HOST_USER="runner-${HOST_USER_UID}" + local_user_suffix=1 + while grep -q "^${HOST_USER}:" /host/etc/passwd 2>/dev/null; do + HOST_USER="runner-${HOST_USER_UID}-${local_user_suffix}" + local_user_suffix=$((local_user_suffix + 1)) + done + fi echo "[entrypoint] User with UID ${HOST_USER_UID} not found in chroot — synthesizing identity files" # Determine the user's home directory (default to /home/runner) SYNTH_HOME="${AWF_HOST_HOME:-/home/${HOST_USER}}" + SYNTH_GROUP_NAME="${HOST_USER}" + if [ -f /host/etc/group ] && grep -q "^${SYNTH_GROUP_NAME}:" /host/etc/group 2>/dev/null; then + SYNTH_GROUP_NAME="runner-${HOST_USER_GID}" + local_group_suffix=1 + while grep -q "^${SYNTH_GROUP_NAME}:" /host/etc/group 2>/dev/null; do + SYNTH_GROUP_NAME="runner-${HOST_USER_GID}-${local_group_suffix}" + local_group_suffix=$((local_group_suffix + 1)) + done + fi # Synthesize /etc/passwd entry if missing if ! grep -q "^[^:]*:[^:]*:${HOST_USER_UID}:" /host/etc/passwd 2>/dev/null; then @@ -743,17 +760,17 @@ if [ "${AWF_CHROOT_ENABLED}" = "true" ]; then # Synthesize /etc/group entry if missing if ! grep -q "^[^:]*:[^:]*:${HOST_USER_GID}:" /host/etc/group 2>/dev/null; then - GROUP_ENTRY="${HOST_USER}:x:${HOST_USER_GID}:" + GROUP_ENTRY="${SYNTH_GROUP_NAME}:x:${HOST_USER_GID}:" if [ -f /host/etc/group ]; then if echo "${GROUP_ENTRY}" >> /host/etc/group 2>/dev/null; then - echo "[entrypoint] Appended group ${HOST_USER} (GID ${HOST_USER_GID}) to /host/etc/group" + echo "[entrypoint] Appended group ${SYNTH_GROUP_NAME} (GID ${HOST_USER_GID}) to /host/etc/group" else echo "[entrypoint][WARN] Could not write to /host/etc/group" fi else if printf '%s\n' "root:x:0:" "nobody:x:65534:" "${GROUP_ENTRY}" > /host/etc/group 2>/dev/null; then chmod 644 /host/etc/group 2>/dev/null - echo "[entrypoint] Created /host/etc/group with group ${HOST_USER} (GID ${HOST_USER_GID})" + echo "[entrypoint] Created /host/etc/group with group ${SYNTH_GROUP_NAME} (GID ${HOST_USER_GID})" else echo "[entrypoint][WARN] Could not create /host/etc/group" fi diff --git a/containers/api-proxy/guards/effective-token-guard.js b/containers/api-proxy/guards/effective-token-guard.js index 159b2b035..0a5d90c1a 100644 --- a/containers/api-proxy/guards/effective-token-guard.js +++ b/containers/api-proxy/guards/effective-token-guard.js @@ -25,6 +25,7 @@ function createEffectiveTokenState(configKey = null) { totalEffectiveTokens: 0, emittedThresholds: new Set(), uninjectedThresholds: new Set(), + warnedUnknownModels: new Set(), }; } @@ -77,11 +78,10 @@ function getEffectiveTokenConfig() { effectiveTokenConfigCache.rawDefaultMultiplier = rawDefaultMultiplier; const parsedMultipliers = Object.freeze(parseModelMultipliers(rawMultipliers)); const configuredDefaultMultiplier = parsePositiveNumber(rawDefaultMultiplier); - const maxConfiguredMultiplier = Math.max(1, ...Object.values(parsedMultipliers)); effectiveTokenConfigCache.parsed = { max: parsePositiveInteger(rawMax), multipliers: parsedMultipliers, - defaultMultiplier: configuredDefaultMultiplier ?? maxConfiguredMultiplier, + defaultMultiplier: configuredDefaultMultiplier ?? 1, }; return effectiveTokenConfigCache.parsed; } @@ -95,7 +95,7 @@ function getEffectiveTokenState(config) { return etGuardState; } -function resolveModelMultiplier(model, config) { +function resolveModelMultiplier(model, config, state = null) { if (Object.hasOwn(config.multipliers, model)) { return { multiplier: config.multipliers[model], source: 'exact' }; } @@ -111,17 +111,21 @@ function resolveModelMultiplier(model, config) { if (prefixMatch) return prefixMatch; - logRequest('warn', 'unknown_model_multiplier', { - model: sanitizeForLog(model), - applied_multiplier: config.defaultMultiplier, - default_model_multiplier: config.defaultMultiplier, - }); + const shouldLog = !state || !state.warnedUnknownModels.has(model); + if (shouldLog) { + logRequest('warn', 'unknown_model_multiplier', { + model: sanitizeForLog(model), + applied_multiplier: config.defaultMultiplier, + default_model_multiplier: config.defaultMultiplier, + }); + state?.warnedUnknownModels.add(model); + } return { multiplier: config.defaultMultiplier, source: 'default' }; } -function calculateEffectiveTokens(normalizedUsage, model, config) { - const multiplierResolution = resolveModelMultiplier(model, config); +function calculateEffectiveTokens(normalizedUsage, model, config, state = null) { + const multiplierResolution = resolveModelMultiplier(model, config, state); const multiplier = multiplierResolution.multiplier; const baseWeightedTokens = (ET_DEFAULT_WEIGHTS.input * (normalizedUsage.input_tokens || 0)) + @@ -141,7 +145,7 @@ function applyEffectiveTokenUsage(normalizedUsage, model) { if (!state || !normalizedUsage) return null; const previousTotal = state.totalEffectiveTokens; - const calc = calculateEffectiveTokens(normalizedUsage, model || 'unknown', config); + const calc = calculateEffectiveTokens(normalizedUsage, model || 'unknown', config, state); state.totalEffectiveTokens += calc.effectiveTokens; const percentUsed = (state.totalEffectiveTokens / config.max) * 100; diff --git a/containers/api-proxy/guards/effective-token-guard.test.js b/containers/api-proxy/guards/effective-token-guard.test.js index 6bcdefa51..236cdbc46 100644 --- a/containers/api-proxy/guards/effective-token-guard.test.js +++ b/containers/api-proxy/guards/effective-token-guard.test.js @@ -76,7 +76,7 @@ describe('effective-token-guard reflect state', () => { }); }); - it('uses the highest configured multiplier for unknown models by default and warns', () => { + it('uses multiplier 1 for unknown models when explicit default is unset and warns', () => { const { lines } = collectLogOutput(); process.env.AWF_MAX_EFFECTIVE_TOKENS = '1000'; process.env.AWF_EFFECTIVE_TOKEN_MODEL_MULTIPLIERS = JSON.stringify({ @@ -86,13 +86,13 @@ describe('effective-token-guard reflect state', () => { const usage = applyEffectiveTokenUsage({ output_tokens: 1 }, 'unmapped-expensive-model'); - expect(usage.modelMultiplier).toBe(54); - expect(usage.effectiveTokensThisResponse).toBe(216); + expect(usage.modelMultiplier).toBe(1); + expect(usage.effectiveTokensThisResponse).toBe(4); expect(lines).toContainEqual(expect.objectContaining({ event: 'unknown_model_multiplier', level: 'warn', model: 'unmapped-expensive-model', - applied_multiplier: 54, + applied_multiplier: 1, })); }); @@ -145,4 +145,21 @@ describe('effective-token-guard reflect state', () => { expect(usage.modelMultiplier).toBe(27); expect(lines.find((line) => line.event === 'unknown_model_multiplier')).toBeUndefined(); }); + + it('logs unknown model multiplier once per model per config state', () => { + const { lines } = collectLogOutput(); + process.env.AWF_MAX_EFFECTIVE_TOKENS = '1000'; + process.env.AWF_EFFECTIVE_TOKEN_DEFAULT_MODEL_MULTIPLIER = '27'; + + applyEffectiveTokenUsage({ output_tokens: 1 }, 'unknown-model'); + applyEffectiveTokenUsage({ output_tokens: 2 }, 'unknown-model'); + applyEffectiveTokenUsage({ output_tokens: 1 }, 'other-unknown-model'); + + const unknownLogs = lines.filter((line) => line.event === 'unknown_model_multiplier'); + expect(unknownLogs).toHaveLength(2); + expect(unknownLogs).toEqual(expect.arrayContaining([ + expect.objectContaining({ model: 'unknown-model' }), + expect.objectContaining({ model: 'other-unknown-model' }), + ])); + }); }); diff --git a/src/services/agent-volumes/etc-mounts.test.ts b/src/services/agent-volumes/etc-mounts.test.ts index 6b5c69e01..2d13a0a8a 100644 --- a/src/services/agent-volumes/etc-mounts.test.ts +++ b/src/services/agent-volumes/etc-mounts.test.ts @@ -4,6 +4,7 @@ import * as path from 'path'; import { buildEtcMounts } from './etc-mounts'; import { WrapperConfig } from '../../types'; import * as hostIdentity from '../../host-identity'; +import * as dockerHostStaging from './docker-host-staging'; function createMinimalConfig(overrides: Partial = {}): WrapperConfig { return { @@ -98,5 +99,56 @@ describe('buildEtcMounts', () => { expect(fs.readFileSync(passwdPath, 'utf8')).toContain(`runner:x:${uid}:${gid}:`); expect(fs.readFileSync(groupPath, 'utf8')).toContain(`runner:x:${gid}:`); }); + + it('avoids runner name collisions and reuses deterministic identity staging paths', () => { + const uid = '424242'; + const gid = '434343'; + const stageRoot = path.join(tmpDir, 'staged-identities'); + const stagedPasswd = path.join(stageRoot, 'etc', 'passwd'); + const stagedGroup = path.join(stageRoot, 'etc', 'group'); + + fs.mkdirSync(path.dirname(stagedPasswd), { recursive: true }); + fs.writeFileSync( + stagedPasswd, + [ + 'root:x:0:0:root:/root:/bin/bash', + 'runner:x:1000:1000:Runner:/home/runner:/bin/bash', + ].join('\n') + '\n' + ); + fs.writeFileSync( + stagedGroup, + [ + 'root:x:0:', + 'runner:x:1000:', + ].join('\n') + '\n' + ); + + jest.spyOn(hostIdentity, 'getSafeHostUid').mockReturnValue(uid); + jest.spyOn(hostIdentity, 'getSafeHostGid').mockReturnValue(gid); + jest.spyOn(dockerHostStaging, 'shouldUseDockerHostStaging').mockReturnValue(true); + jest.spyOn(dockerHostStaging, 'getDockerHostStageRoot').mockReturnValue(stageRoot); + jest.spyOn(dockerHostStaging, 'stageHostFile').mockImplementation((_config, sourcePath) => { + if (sourcePath === '/etc/passwd') return stagedPasswd; + if (sourcePath === '/etc/group') return stagedGroup; + return undefined; + }); + + const config = createMinimalConfig({ + dockerHostPathPrefix: '/tmp/awf-dind-prefix', + workDir: tmpDir, + }); + const mounts = buildEtcMounts(config); + const passwdPath = mounts.find(m => m.includes('/host/etc/passwd'))!.split(':')[0]; + const groupPath = mounts.find(m => m.includes('/host/etc/group'))!.split(':')[0]; + const passwdContent = fs.readFileSync(passwdPath, 'utf8'); + const groupContent = fs.readFileSync(groupPath, 'utf8'); + + expect((passwdContent.match(/^runner:/gm) || []).length).toBe(1); + expect(passwdContent).toContain(`runner-${uid}:x:${uid}:${gid}:`); + expect((groupContent.match(/^runner:/gm) || []).length).toBe(1); + expect(groupContent).toContain(`runner-${gid}:x:${gid}:`); + expect(path.dirname(passwdPath)).toBe(path.join(stageRoot, 'identity')); + expect(path.dirname(groupPath)).toBe(path.join(stageRoot, 'identity')); + }); }); }); diff --git a/src/services/agent-volumes/etc-mounts.ts b/src/services/agent-volumes/etc-mounts.ts index 7911b8fbe..78ec8c184 100644 --- a/src/services/agent-volumes/etc-mounts.ts +++ b/src/services/agent-volumes/etc-mounts.ts @@ -11,9 +11,10 @@ import { getSafeHostUid, getSafeHostGid } from '../../host-identity'; function synthesizeIdentityFile(config: WrapperConfig, relPath: string, content: string): string | undefined { try { const stageRoot = getDockerHostStageRoot(config); - const tempDir = fs.mkdtempSync(path.join(stageRoot, 'identity-')); + const tempDir = path.join(stageRoot, 'identity'); + fs.mkdirSync(tempDir, { recursive: true }); const targetPath = path.join(tempDir, path.basename(relPath)); - fs.writeFileSync(targetPath, content, { mode: 0o644, flag: 'wx' }); + fs.writeFileSync(targetPath, content, { mode: 0o644 }); return targetPath; } catch { return undefined; @@ -40,6 +41,23 @@ function withTrailingNewline(content: string): string { return content.endsWith('\n') ? content : `${content}\n`; } +function hasEntryWithName(content: string, name: string): boolean { + return new RegExp(`^${name}:`, 'm').test(content); +} + +function resolveUniqueName(content: string, preferredName: string, id: string): string { + const baseName = hasEntryWithName(content, preferredName) ? `${preferredName}-${id}` : preferredName; + if (!hasEntryWithName(content, baseName)) { + return baseName; + } + + let counter = 1; + while (hasEntryWithName(content, `${baseName}-${counter}`)) { + counter += 1; + } + return `${baseName}-${counter}`; +} + export function buildEtcMounts(config: WrapperConfig): string[] { const mounts: string[] = [ '/etc/ssl:/host/etc/ssl:ro', @@ -72,7 +90,13 @@ export function buildEtcMounts(config: WrapperConfig): string[] { } else { const stagedPasswdContent = readFileContent(passwdPath); if (stagedPasswdContent && !fileHasPasswdUid(stagedPasswdContent, uid)) { - passwdPath = synthesizeIdentityFile(config, 'etc/passwd', `${withTrailingNewline(stagedPasswdContent)}${passwdEntry}\n`) || passwdPath; + const passwdUser = resolveUniqueName(stagedPasswdContent, 'runner', uid); + const userPasswdEntry = `${passwdUser}:x:${uid}:${gid}:GitHub Actions Runner:/home/${passwdUser}:/bin/bash`; + passwdPath = synthesizeIdentityFile( + config, + 'etc/passwd', + `${withTrailingNewline(stagedPasswdContent)}${userPasswdEntry}\n` + ) || passwdPath; } } @@ -88,7 +112,9 @@ export function buildEtcMounts(config: WrapperConfig): string[] { } else { const stagedGroupContent = readFileContent(groupPath); if (stagedGroupContent && !fileHasGroupGid(stagedGroupContent, gid)) { - groupPath = synthesizeIdentityFile(config, 'etc/group', `${withTrailingNewline(stagedGroupContent)}${groupEntry}\n`) || groupPath; + const groupName = resolveUniqueName(stagedGroupContent, 'runner', gid); + const userGroupEntry = `${groupName}:x:${gid}:`; + groupPath = synthesizeIdentityFile(config, 'etc/group', `${withTrailingNewline(stagedGroupContent)}${userGroupEntry}\n`) || groupPath; } }