Skip to content

Commit 41df40e

Browse files
authored
Merge branch 'main' into copilot/fix-dind-container-networking
2 parents e4fc1cf + 335adfa commit 41df40e

File tree

10 files changed

+652
-11
lines changed

10 files changed

+652
-11
lines changed

.github/workflows/firewall-issue-dispatcher.lock.yml

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -65,23 +65,21 @@ gh api graphql -f query='
6565

6666
## Step 2: Filter Locally
6767

68-
From the response, filter out issues where **any comment** contains `github.com/github/gh-aw-firewall/issues/`. These are already audited. Do this filtering in your analysis — do NOT make additional API calls.
68+
For each issue found, read its comments and check whether any comment contains a reference to a `github/gh-aw-firewall` issue (i.e., a URL matching `https://github.com/github/gh-aw-firewall/issues/` or a GitHub cross-repo reference matching `github/gh-aw-firewall#`). If such a comment exists, **skip** that issue — it has already been audited.
6969

7070
If no unprocessed issues remain, call `noop` and stop.
7171

7272
## Step 3: Create Tracking Issues
7373

7474
For each **unprocessed** issue:
7575

76-
1. **Create a tracking issue in `github/gh-aw-firewall`** with:
77-
- Title: `[awf] <component>: <summary>`
78-
- Body: **Problem**, **Context** (link to original), **Root Cause**, **Proposed Solution**
79-
- Reference specific source files. See `AGENTS.md` for component descriptions.
76+
4. **Comment on the original `github/gh-aw` issue** linking to the newly created tracking issue. Use this exact format:
8077

81-
2. **Comment on the original `github/gh-aw` issue**:
82-
> 🔗 AWF tracking issue: https://github.com/github/gh-aw-firewall/issues/NUMBER
78+
> 🔗 AWF tracking issue: https://github.com/github/gh-aw-firewall/issues/{NUMBER}
8379
84-
## Step 4: Summarize
80+
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.
81+
82+
### 4. Report Results
8583

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

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, checkDockerHost } 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, checkDockerHost } 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,
@@ -1532,6 +1557,13 @@ program
15321557
'--session-state-dir <path>',
15331558
'Directory to save Copilot CLI session state (events.jsonl, session data)'
15341559
)
1560+
.option(
1561+
'--diagnostic-logs',
1562+
'Collect container logs, exit state, and sanitized config on non-zero exit.\n' +
1563+
' Useful for debugging container startup failures (e.g. Squid crashes in DinD).\n' +
1564+
' Written to <workDir>/diagnostics/ (or <audit-dir>/diagnostics/ when set).',
1565+
false
1566+
)
15351567
.argument('[args...]', 'Command and arguments to execute (use -- to separate from options)')
15361568
.action(async (args: string[], options) => {
15371569
// Require -- separator for passing command arguments
@@ -1882,6 +1914,7 @@ program
18821914
difcProxyHost: options.difcProxyHost,
18831915
difcProxyCaCert: options.difcProxyCaCert,
18841916
githubToken: process.env.GITHUB_TOKEN || process.env.GH_TOKEN,
1917+
diagnosticLogs: options.diagnosticLogs || false,
18851918
};
18861919

18871920
// Parse and validate --agent-timeout
@@ -1982,6 +2015,39 @@ program
19822015
// Log CLI proxy status
19832016
emitCliProxyStatusLogs(config, logger.info.bind(logger), logger.warn.bind(logger));
19842017

2018+
// Warn if a classic PAT is combined with COPILOT_MODEL (Copilot CLI 1.0.21+ incompatibility)
2019+
const hasCopilotModelInEnvFiles = (envFile: unknown): boolean => {
2020+
const envFiles = Array.isArray(envFile) ? envFile : envFile ? [envFile] : [];
2021+
for (const candidate of envFiles) {
2022+
if (typeof candidate !== 'string' || candidate.trim() === '') continue;
2023+
try {
2024+
const envFilePath = path.isAbsolute(candidate) ? candidate : path.resolve(process.cwd(), candidate);
2025+
const envFileContents = fs.readFileSync(envFilePath, 'utf8');
2026+
for (const line of envFileContents.split(/\r?\n/)) {
2027+
const trimmedLine = line.trim();
2028+
if (!trimmedLine || trimmedLine.startsWith('#')) continue;
2029+
if (/^(?:export\s+)?COPILOT_MODEL\s*=/.test(trimmedLine)) {
2030+
return true;
2031+
}
2032+
}
2033+
} catch {
2034+
// Ignore unreadable env files here; this check is only for a pre-flight warning.
2035+
}
2036+
}
2037+
return false;
2038+
};
2039+
2040+
// Warn if a classic PAT is combined with COPILOT_MODEL (Copilot CLI 1.0.21+ incompatibility)
2041+
// Check if COPILOT_MODEL is set via --env/-e flags, host env (when --env-all is active), or --env-file
2042+
const copilotModelFromFlags = !!(additionalEnv['COPILOT_MODEL']);
2043+
const copilotModelInHostEnv = !!(config.envAll && process.env.COPILOT_MODEL);
2044+
const copilotModelInEnvFile = hasCopilotModelInEnvFiles((config as { envFile?: unknown }).envFile);
2045+
warnClassicPATWithCopilotModel(
2046+
config.copilotGithubToken?.startsWith('ghp_') ?? false,
2047+
copilotModelFromFlags || copilotModelInHostEnv || copilotModelInEnvFile,
2048+
logger.warn.bind(logger)
2049+
);
2050+
19852051
// Log config with redacted secrets - remove API keys entirely
19862052
// to prevent sensitive data from flowing to logger (CodeQL sensitive data logging)
19872053
const redactedConfig: Record<string, unknown> = {};
@@ -2062,6 +2128,7 @@ program
20622128
writeConfigs,
20632129
startContainers,
20642130
runAgentCommand,
2131+
collectDiagnosticLogs,
20652132
},
20662133
{
20672134
logger,

0 commit comments

Comments
 (0)