Skip to content

Commit b55345e

Browse files
authored
Merge branch 'main' into copilot/fix-code-for-review-comments
2 parents 63eadf9 + 37e4dce commit b55345e

File tree

10 files changed

+662
-3
lines changed

10 files changed

+662
-3
lines changed

.github/workflows/firewall-issue-dispatcher.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,11 +80,12 @@ For each **unprocessed** issue:
8080
- Reference specific source files. See `AGENTS.md` for component descriptions.
8181

8282
2. **Comment on the original `github/gh-aw` issue** linking to the newly created tracking issue. Use this exact format:
83-
8483
> 🔗 AWF tracking issue: https://github.com/github/gh-aw-firewall/issues/{NUMBER}
8584
8685
where `{NUMBER}` is replaced with **only the numeric issue number** (e.g., `1896`). Do NOT include the repository name, hash symbols, or any other text — just the number in the URL path. Use the `add_comment` safe output tool with `repo: "github/gh-aw"` and the original issue number.
8786

87+
where `{NUMBER}` is replaced with **only the numeric issue number** (e.g., `1896`). Do NOT include the repository name, hash symbols, or any other text — just the number in the URL path. Use the `add_comment` safe output tool with `repo: "github/gh-aw"` and the original issue number.
88+
8889
### 4. Report Results
8990

9091
Report: issues found, skipped (already audited), tracking issues created.

containers/agent/entrypoint.sh

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -783,6 +783,21 @@ AWFEOF
783783
fi
784784
fi
785785

786+
# Ensure ~/.gemini exists and is owned by the chroot user.
787+
# If Docker created this directory as root:root (because it did not exist on the
788+
# host at container start time), the Gemini CLI cannot write its project registry
789+
# (atomic rename of projects.json.tmp → projects.json fails with ENOENT).
790+
# AWF pre-creates this directory host-side in writeConfigs(), but on first run or
791+
# after a previous failed run the directory may still be root-owned.
792+
# We fix ownership here (as root, before privilege drop) as a defense-in-depth measure.
793+
GEMINI_DIR="/host${HOME}/.gemini"
794+
mkdir -p "${GEMINI_DIR}" 2>/dev/null || true
795+
if chown "${HOST_UID}:${HOST_GID}" "${GEMINI_DIR}" 2>/dev/null; then
796+
echo "[entrypoint] Ensured ~/.gemini ownership for chroot user (${HOST_UID}:${HOST_GID})"
797+
else
798+
echo "[entrypoint][WARN] Could not set ~/.gemini ownership to chroot user (${HOST_UID}:${HOST_GID})"
799+
fi
800+
786801
# Build LD_PRELOAD command for one-shot token protection
787802
LD_PRELOAD_CMD=""
788803
if [ -n "${ONE_SHOT_TOKEN_LIB}" ]; then

docs/environment.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,32 @@ sudo -E awf --allow-domains github.com 'copilot --prompt "..."'
8888
- Working with untrusted code
8989
- In production/CI environments
9090

91+
## `COPILOT_GITHUB_TOKEN` and Classic PAT Compatibility
92+
93+
When `COPILOT_GITHUB_TOKEN` is set in the host environment, AWF injects it into the agent container so the Copilot CLI can authenticate against the GitHub Copilot API.
94+
95+
### ⚠️ Classic PAT + `COPILOT_MODEL` Incompatibility (Copilot CLI 1.0.21+)
96+
97+
Copilot CLI 1.0.21 introduced a startup model validation step: when `COPILOT_MODEL` is set, the CLI calls `GET /models` before executing any task. **This endpoint does not accept classic PATs** (`ghp_*` tokens), causing the agent to fail at startup with exit code 1 — before any useful work begins.
98+
99+
**Affected combination:**
100+
- `COPILOT_GITHUB_TOKEN` is a classic PAT (prefixed with `ghp_`)
101+
- `COPILOT_MODEL` is set in the agent environment (e.g., via `--env COPILOT_MODEL=...`, `--env-file`, or `--env-all`)
102+
103+
**Unaffected:** Workflows that do not set `COPILOT_MODEL` are not affected — the `/models` validation is only triggered when `COPILOT_MODEL` is set.
104+
105+
**AWF detects this combination at startup** and emits a `[WARN]` message:
106+
```
107+
⚠️ COPILOT_MODEL is set with a classic PAT (ghp_* token)
108+
Copilot CLI 1.0.21+ validates COPILOT_MODEL via GET /models at startup.
109+
Classic PATs are rejected by this endpoint — the agent will likely fail with exit code 1.
110+
Use a fine-grained PAT or OAuth token, or unset COPILOT_MODEL to skip model validation.
111+
```
112+
113+
**Remediation options:**
114+
1. Replace the classic PAT with a **fine-grained PAT** or **OAuth token** (these are accepted by the `/models` endpoint).
115+
2. Remove `COPILOT_MODEL` from the agent environment to skip model validation entirely.
116+
91117
## Internal Environment Variables
92118

93119
The following environment variables are set internally by the firewall and used by container scripts:

src/cli-workflow.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,4 +224,90 @@ describe('runMainWorkflow', () => {
224224
expect(logger.warn).toHaveBeenCalledWith('Command completed with exit code: 42');
225225
expect(logger.success).not.toHaveBeenCalled();
226226
});
227+
228+
it('calls collectDiagnosticLogs before cleanup on non-zero exit when diagnosticLogs is enabled', async () => {
229+
const callOrder: string[] = [];
230+
const configWithDiagnostics: WrapperConfig = {
231+
...baseConfig,
232+
diagnosticLogs: true,
233+
};
234+
const dependencies: WorkflowDependencies = {
235+
ensureFirewallNetwork: jest.fn().mockResolvedValue({ squidIp: '172.30.0.10' }),
236+
setupHostIptables: jest.fn().mockResolvedValue(undefined),
237+
writeConfigs: jest.fn().mockResolvedValue(undefined),
238+
startContainers: jest.fn().mockResolvedValue(undefined),
239+
runAgentCommand: jest.fn().mockImplementation(async () => {
240+
callOrder.push('runAgentCommand');
241+
return { exitCode: 1 };
242+
}),
243+
collectDiagnosticLogs: jest.fn().mockImplementation(async () => {
244+
callOrder.push('collectDiagnosticLogs');
245+
}),
246+
};
247+
const performCleanup = jest.fn().mockImplementation(async () => {
248+
callOrder.push('performCleanup');
249+
});
250+
const logger = createLogger();
251+
252+
await runMainWorkflow(configWithDiagnostics, dependencies, { logger, performCleanup });
253+
254+
expect(callOrder).toEqual(['runAgentCommand', 'collectDiagnosticLogs', 'performCleanup']);
255+
expect(dependencies.collectDiagnosticLogs).toHaveBeenCalledWith(configWithDiagnostics.workDir);
256+
});
257+
258+
it('does not call collectDiagnosticLogs when diagnosticLogs is disabled', async () => {
259+
const collectDiagnosticLogs = jest.fn().mockResolvedValue(undefined);
260+
const dependencies: WorkflowDependencies = {
261+
ensureFirewallNetwork: jest.fn().mockResolvedValue({ squidIp: '172.30.0.10' }),
262+
setupHostIptables: jest.fn().mockResolvedValue(undefined),
263+
writeConfigs: jest.fn().mockResolvedValue(undefined),
264+
startContainers: jest.fn().mockResolvedValue(undefined),
265+
runAgentCommand: jest.fn().mockResolvedValue({ exitCode: 1 }),
266+
collectDiagnosticLogs,
267+
};
268+
const logger = createLogger();
269+
270+
await runMainWorkflow(baseConfig, dependencies, { logger, performCleanup: jest.fn() });
271+
272+
expect(collectDiagnosticLogs).not.toHaveBeenCalled();
273+
});
274+
275+
it('does not call collectDiagnosticLogs on zero exit even when diagnosticLogs is enabled', async () => {
276+
const collectDiagnosticLogs = jest.fn().mockResolvedValue(undefined);
277+
const configWithDiagnostics: WrapperConfig = {
278+
...baseConfig,
279+
diagnosticLogs: true,
280+
};
281+
const dependencies: WorkflowDependencies = {
282+
ensureFirewallNetwork: jest.fn().mockResolvedValue({ squidIp: '172.30.0.10' }),
283+
setupHostIptables: jest.fn().mockResolvedValue(undefined),
284+
writeConfigs: jest.fn().mockResolvedValue(undefined),
285+
startContainers: jest.fn().mockResolvedValue(undefined),
286+
runAgentCommand: jest.fn().mockResolvedValue({ exitCode: 0 }),
287+
collectDiagnosticLogs,
288+
};
289+
const logger = createLogger();
290+
291+
await runMainWorkflow(configWithDiagnostics, dependencies, { logger, performCleanup: jest.fn() });
292+
293+
expect(collectDiagnosticLogs).not.toHaveBeenCalled();
294+
});
295+
296+
it('does not call collectDiagnosticLogs when dependency is not provided', async () => {
297+
const configWithDiagnostics: WrapperConfig = {
298+
...baseConfig,
299+
diagnosticLogs: true,
300+
};
301+
const dependencies: WorkflowDependencies = {
302+
ensureFirewallNetwork: jest.fn().mockResolvedValue({ squidIp: '172.30.0.10' }),
303+
setupHostIptables: jest.fn().mockResolvedValue(undefined),
304+
writeConfigs: jest.fn().mockResolvedValue(undefined),
305+
startContainers: jest.fn().mockResolvedValue(undefined),
306+
runAgentCommand: jest.fn().mockResolvedValue({ exitCode: 1 }),
307+
// collectDiagnosticLogs not provided
308+
};
309+
const logger = createLogger();
310+
311+
await expect(runMainWorkflow(configWithDiagnostics, dependencies, { logger, performCleanup: jest.fn() })).resolves.toBe(1);
312+
});
227313
});

src/cli-workflow.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export interface WorkflowDependencies {
1414
proxyLogsDir?: string,
1515
agentTimeoutMinutes?: number
1616
) => Promise<{ exitCode: number }>;
17+
collectDiagnosticLogs?: (workDir: string) => Promise<void>;
1718
}
1819

1920
export interface WorkflowCallbacks {
@@ -76,6 +77,16 @@ export async function runMainWorkflow(
7677
// Step 3: Wait for agent to complete
7778
const result = await dependencies.runAgentCommand(config.workDir, config.allowedDomains, config.proxyLogsDir, config.agentTimeout);
7879

80+
// Step 3.5: Collect diagnostic logs before containers are stopped
81+
// Must run BEFORE performCleanup() which calls docker compose down -v.
82+
if (config.diagnosticLogs && result.exitCode !== 0 && dependencies.collectDiagnosticLogs) {
83+
try {
84+
await dependencies.collectDiagnosticLogs(config.workDir);
85+
} catch (error) {
86+
logger.warn('Failed to collect diagnostic logs; continuing with cleanup.', error);
87+
}
88+
}
89+
7990
// Step 4: Cleanup (logs will be preserved automatically if they exist)
8091
await performCleanup();
8192

src/cli.test.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Command } from 'commander';
2-
import { parseEnvironmentVariables, parseDomains, parseDomainsFile, escapeShellArg, joinShellArgs, parseVolumeMounts, isValidIPv4, isValidIPv6, parseDnsServers, parseDnsOverHttps, validateAgentImage, isAgentImagePreset, AGENT_IMAGE_PRESETS, processAgentImageOption, processLocalhostKeyword, validateSkipPullWithBuildLocal, validateAllowHostPorts, validateAllowHostServicePorts, applyHostServicePortsConfig, parseMemoryLimit, validateFormat, validateApiProxyConfig, buildRateLimitConfig, validateRateLimitFlags, hasRateLimitOptions, collectRulesetFile, validateApiTargetInAllowedDomains, DEFAULT_OPENAI_API_TARGET, DEFAULT_ANTHROPIC_API_TARGET, DEFAULT_COPILOT_API_TARGET, DEFAULT_GEMINI_API_TARGET, emitApiProxyTargetWarnings, emitCliProxyStatusLogs, formatItem, program, parseAgentTimeout, applyAgentTimeout, handlePredownloadAction, resolveApiTargetsToAllowedDomains, extractGhesDomainsFromEngineApiTarget, extractGhecDomainsFromServerUrl } from './cli';
2+
import { parseEnvironmentVariables, parseDomains, parseDomainsFile, escapeShellArg, joinShellArgs, parseVolumeMounts, isValidIPv4, isValidIPv6, parseDnsServers, parseDnsOverHttps, validateAgentImage, isAgentImagePreset, AGENT_IMAGE_PRESETS, processAgentImageOption, processLocalhostKeyword, validateSkipPullWithBuildLocal, validateAllowHostPorts, validateAllowHostServicePorts, applyHostServicePortsConfig, parseMemoryLimit, validateFormat, validateApiProxyConfig, buildRateLimitConfig, validateRateLimitFlags, hasRateLimitOptions, collectRulesetFile, validateApiTargetInAllowedDomains, DEFAULT_OPENAI_API_TARGET, DEFAULT_ANTHROPIC_API_TARGET, DEFAULT_COPILOT_API_TARGET, DEFAULT_GEMINI_API_TARGET, emitApiProxyTargetWarnings, emitCliProxyStatusLogs, warnClassicPATWithCopilotModel, formatItem, program, parseAgentTimeout, applyAgentTimeout, handlePredownloadAction, resolveApiTargetsToAllowedDomains, extractGhesDomainsFromEngineApiTarget, extractGhecDomainsFromServerUrl } from './cli';
33
import { redactSecrets } from './redact-secrets';
44
import * as fs from 'fs';
55
import * as path from 'path';
@@ -2112,6 +2112,46 @@ describe('cli', () => {
21122112
});
21132113
});
21142114

2115+
describe('warnClassicPATWithCopilotModel', () => {
2116+
it('should emit warnings when classic PAT and COPILOT_MODEL are both set', () => {
2117+
const warns: string[] = [];
2118+
warnClassicPATWithCopilotModel(true, true, (msg) => warns.push(msg));
2119+
expect(warns.length).toBeGreaterThan(0);
2120+
expect(warns[0]).toContain('COPILOT_MODEL');
2121+
expect(warns.some(w => w.includes('classic PAT'))).toBe(true);
2122+
});
2123+
2124+
it('should not warn when token is not a classic PAT', () => {
2125+
const warns: string[] = [];
2126+
warnClassicPATWithCopilotModel(false, true, (msg) => warns.push(msg));
2127+
expect(warns).toHaveLength(0);
2128+
});
2129+
2130+
it('should not warn when COPILOT_MODEL is not set', () => {
2131+
const warns: string[] = [];
2132+
warnClassicPATWithCopilotModel(true, false, (msg) => warns.push(msg));
2133+
expect(warns).toHaveLength(0);
2134+
});
2135+
2136+
it('should not warn when neither condition holds', () => {
2137+
const warns: string[] = [];
2138+
warnClassicPATWithCopilotModel(false, false, (msg) => warns.push(msg));
2139+
expect(warns).toHaveLength(0);
2140+
});
2141+
2142+
it('should mention /models endpoint in warning', () => {
2143+
const warns: string[] = [];
2144+
warnClassicPATWithCopilotModel(true, true, (msg) => warns.push(msg));
2145+
expect(warns.some(w => w.includes('/models'))).toBe(true);
2146+
});
2147+
2148+
it('should mention exit code 1 in warning', () => {
2149+
const warns: string[] = [];
2150+
warnClassicPATWithCopilotModel(true, true, (msg) => warns.push(msg));
2151+
expect(warns.some(w => w.includes('exit code 1'))).toBe(true);
2152+
});
2153+
});
2154+
21152155
describe('resolveApiTargetsToAllowedDomains', () => {
21162156
it('should add copilot-api-target option to allowed domains', () => {
21172157
const domains: string[] = ['github.com'];

src/cli.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
cleanup,
1616
preserveIptablesAudit,
1717
fastKillAgentContainer,
18+
collectDiagnosticLogs,
1819
} from './docker-manager';
1920
import {
2021
ensureFirewallNetwork,
@@ -419,6 +420,30 @@ export function emitCliProxyStatusLogs(
419420
}
420421
}
421422

423+
/**
424+
* Warns when a classic GitHub PAT (ghp_* prefix) is used alongside COPILOT_MODEL.
425+
* Copilot CLI 1.0.21+ performs a GET /models validation at startup when COPILOT_MODEL
426+
* is set. This endpoint rejects classic PATs, causing the agent to fail with exit code 1
427+
* before any useful work begins.
428+
* Accepts booleans (not actual tokens/values) to prevent sensitive data from flowing
429+
* through to log output (CodeQL: clear-text logging of sensitive information).
430+
* @param isClassicPAT - Whether COPILOT_GITHUB_TOKEN starts with 'ghp_' (classic PAT)
431+
* @param hasCopilotModel - Whether COPILOT_MODEL is set in the agent environment
432+
* @param warn - Function to emit a warning message
433+
*/
434+
export function warnClassicPATWithCopilotModel(
435+
isClassicPAT: boolean,
436+
hasCopilotModel: boolean,
437+
warn: (msg: string) => void,
438+
): void {
439+
if (!isClassicPAT || !hasCopilotModel) return;
440+
441+
warn('⚠️ COPILOT_MODEL is set with a classic PAT (ghp_* token)');
442+
warn(' Copilot CLI 1.0.21+ validates COPILOT_MODEL via GET /models at startup.');
443+
warn(' Classic PATs are rejected by this endpoint — the agent will likely fail with exit code 1.');
444+
warn(' Use a fine-grained PAT or OAuth token, or unset COPILOT_MODEL to skip model validation.');
445+
}
446+
422447
/**
423448
* Extracts GHEC domains from GITHUB_SERVER_URL and GITHUB_API_URL environment variables.
424449
* When GITHUB_SERVER_URL points to a GHEC tenant (*.ghe.com), returns the tenant hostname,
@@ -1484,6 +1509,13 @@ program
14841509
'--session-state-dir <path>',
14851510
'Directory to save Copilot CLI session state (events.jsonl, session data)'
14861511
)
1512+
.option(
1513+
'--diagnostic-logs',
1514+
'Collect container logs, exit state, and sanitized config on non-zero exit.\n' +
1515+
' Useful for debugging container startup failures (e.g. Squid crashes in DinD).\n' +
1516+
' Written to <workDir>/diagnostics/ (or <audit-dir>/diagnostics/ when set).',
1517+
false
1518+
)
14871519
.argument('[args...]', 'Command and arguments to execute (use -- to separate from options)')
14881520
.action(async (args: string[], options) => {
14891521
// Require -- separator for passing command arguments
@@ -1826,6 +1858,7 @@ program
18261858
difcProxyHost: options.difcProxyHost,
18271859
difcProxyCaCert: options.difcProxyCaCert,
18281860
githubToken: process.env.GITHUB_TOKEN || process.env.GH_TOKEN,
1861+
diagnosticLogs: options.diagnosticLogs || false,
18291862
};
18301863

18311864
// Parse and validate --agent-timeout
@@ -1926,6 +1959,39 @@ program
19261959
// Log CLI proxy status
19271960
emitCliProxyStatusLogs(config, logger.info.bind(logger), logger.warn.bind(logger));
19281961

1962+
// Warn if a classic PAT is combined with COPILOT_MODEL (Copilot CLI 1.0.21+ incompatibility)
1963+
const hasCopilotModelInEnvFiles = (envFile: unknown): boolean => {
1964+
const envFiles = Array.isArray(envFile) ? envFile : envFile ? [envFile] : [];
1965+
for (const candidate of envFiles) {
1966+
if (typeof candidate !== 'string' || candidate.trim() === '') continue;
1967+
try {
1968+
const envFilePath = path.isAbsolute(candidate) ? candidate : path.resolve(process.cwd(), candidate);
1969+
const envFileContents = fs.readFileSync(envFilePath, 'utf8');
1970+
for (const line of envFileContents.split(/\r?\n/)) {
1971+
const trimmedLine = line.trim();
1972+
if (!trimmedLine || trimmedLine.startsWith('#')) continue;
1973+
if (/^(?:export\s+)?COPILOT_MODEL\s*=/.test(trimmedLine)) {
1974+
return true;
1975+
}
1976+
}
1977+
} catch {
1978+
// Ignore unreadable env files here; this check is only for a pre-flight warning.
1979+
}
1980+
}
1981+
return false;
1982+
};
1983+
1984+
// Warn if a classic PAT is combined with COPILOT_MODEL (Copilot CLI 1.0.21+ incompatibility)
1985+
// Check if COPILOT_MODEL is set via --env/-e flags, host env (when --env-all is active), or --env-file
1986+
const copilotModelFromFlags = !!(additionalEnv['COPILOT_MODEL']);
1987+
const copilotModelInHostEnv = !!(config.envAll && process.env.COPILOT_MODEL);
1988+
const copilotModelInEnvFile = hasCopilotModelInEnvFiles((config as { envFile?: unknown }).envFile);
1989+
warnClassicPATWithCopilotModel(
1990+
config.copilotGithubToken?.startsWith('ghp_') ?? false,
1991+
copilotModelFromFlags || copilotModelInHostEnv || copilotModelInEnvFile,
1992+
logger.warn.bind(logger)
1993+
);
1994+
19291995
// Log config with redacted secrets - remove API keys entirely
19301996
// to prevent sensitive data from flowing to logger (CodeQL sensitive data logging)
19311997
const redactedConfig: Record<string, unknown> = {};
@@ -2006,6 +2072,7 @@ program
20062072
writeConfigs,
20072073
startContainers,
20082074
runAgentCommand,
2075+
collectDiagnosticLogs,
20092076
},
20102077
{
20112078
logger,

0 commit comments

Comments
 (0)