-
Notifications
You must be signed in to change notification settings - Fork 21
Expand file tree
/
Copy pathdocker-manager.ts
More file actions
3106 lines (2807 loc) · 137 KB
/
docker-manager.ts
File metadata and controls
3106 lines (2807 loc) · 137 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import * as yaml from 'js-yaml';
import execa from 'execa';
import { DockerComposeConfig, WrapperConfig, BlockedTarget, API_PROXY_PORTS, API_PROXY_HEALTH_PORT, CLI_PROXY_PORT } from './types';
import { logger } from './logger';
import { generateSquidConfig, generatePolicyManifest } from './squid-config';
import { generateSessionCa, initSslDb, CaFiles, parseUrlPatterns, cleanupSslKeyMaterial, unmountSslTmpfs } from './ssl-bump';
import { DEFAULT_DNS_SERVERS } from './dns-resolver';
import { PROXY_ENV_VARS } from './upstream-proxy';
import { parseImageTag, buildRuntimeImageRef } from './image-tag';
const SQUID_PORT = 3128;
/**
* Container names used in Docker Compose and referenced by docker CLI commands.
* Extracted as constants so that generateDockerCompose() and helpers like
* fastKillAgentContainer() stay in sync.
*/
export const AGENT_CONTAINER_NAME = 'awf-agent';
const SQUID_CONTAINER_NAME = 'awf-squid';
const IPTABLES_INIT_CONTAINER_NAME = 'awf-iptables-init';
const API_PROXY_CONTAINER_NAME = 'awf-api-proxy';
const DOH_PROXY_CONTAINER_NAME = 'awf-doh-proxy';
const CLI_PROXY_CONTAINER_NAME = 'awf-cli-proxy';
/**
* Maximum size (bytes) of a single environment variable value allowed through
* --env-all passthrough. Variables exceeding this are skipped with a warning
* to prevent E2BIG errors from ARG_MAX exhaustion.
*/
const MAX_ENV_VALUE_SIZE = 64 * 1024; // 64 KB
/**
* Total environment size (bytes) threshold for issuing an ARG_MAX warning.
* Linux ARG_MAX is ~2 MB for argv + envp combined; warn well before that.
*/
const ENV_SIZE_WARNING_THRESHOLD = 1_500_000; // ~1.5 MB
/**
* 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;
/**
* Optional override for the Docker host used by AWF's own container operations.
* Set via setAwfDockerHost() from the CLI --docker-host flag.
* When undefined, AWF auto-selects the local socket (see getLocalDockerEnv).
*/
let awfDockerHostOverride: string | undefined;
/**
* Sets the Docker host to use for AWF's own container operations.
*
* When set, overrides DOCKER_HOST for all docker CLI calls made by AWF
* (compose up/down, docker wait, docker logs, etc.).
*
* When not set, AWF auto-detects:
* - unix:// DOCKER_HOST values are kept as-is (local socket).
* - TCP DOCKER_HOST values (e.g. DinD) are cleared so docker falls back
* to the system default socket.
*
* @internal Called from cli.ts when --docker-host flag is provided.
*/
export function setAwfDockerHost(host: string | undefined): void {
awfDockerHostOverride = host;
}
/**
* Returns an environment object suitable for AWF's own docker CLI calls.
*
* When DOCKER_HOST is set to an external TCP daemon (e.g. a workflow-scope
* DinD sidecar), it is removed so docker/docker-compose use the local Unix
* socket instead. When --docker-host was provided via the CLI, that value
* is used regardless of the environment.
*
* The original DOCKER_HOST value is NOT removed from the agent container's
* environment — see generateDockerCompose for the passthrough logic.
*/
export function getLocalDockerEnv(): NodeJS.ProcessEnv {
const env = { ...process.env };
if (awfDockerHostOverride !== undefined) {
// Explicit CLI override — always use this socket for AWF operations
env.DOCKER_HOST = awfDockerHostOverride;
} else {
const dockerHost = env.DOCKER_HOST;
if (dockerHost && !dockerHost.startsWith('unix://')) {
// Non-unix DOCKER_HOST (e.g. tcp://localhost:2375 from a DinD sidecar).
// Clear it so AWF's docker commands target the local daemon, not the DinD one.
delete env.DOCKER_HOST;
}
}
return env;
}
// When bundled with esbuild, this global is replaced at build time with the
// JSON content of containers/agent/seccomp-profile.json. In normal (tsc)
// builds the identifier remains undeclared, so the typeof check below is safe.
declare const __AWF_SECCOMP_PROFILE__: string | undefined;
/**
* Base image for the 'act' preset when building locally.
* Uses catthehacker's GitHub Actions parity image.
*/
export const ACT_PRESET_BASE_IMAGE = 'ghcr.io/catthehacker/ubuntu:act-24.04';
/**
* Minimum UID/GID value for regular users.
* UIDs 0-999 are reserved for system users on most Linux distributions.
*/
export const MIN_REGULAR_UID = 1000;
/**
* Validates that a UID/GID value is safe for use (not in system range).
* Returns the value if valid, or the default (1000) if in system range.
* @internal Exported for testing
*/
export function validateIdNotInSystemRange(id: number): string {
// Reject system UIDs/GIDs (0-999) - use default unprivileged user instead
if (id < MIN_REGULAR_UID) {
return MIN_REGULAR_UID.toString();
}
return id.toString();
}
/**
* Gets the host user's UID, with fallback to 1000 if unavailable, root (0),
* or in the system UID range (0-999).
* When running with sudo, uses SUDO_UID to get the actual user's UID.
* @internal Exported for testing
*/
export function getSafeHostUid(): string {
const uid = process.getuid?.();
// When running as root (sudo), try to get the original user's UID
if (!uid || uid === 0) {
const sudoUid = process.env.SUDO_UID;
if (sudoUid) {
const parsedUid = parseInt(sudoUid, 10);
if (!isNaN(parsedUid)) {
return validateIdNotInSystemRange(parsedUid);
}
}
return MIN_REGULAR_UID.toString();
}
return validateIdNotInSystemRange(uid);
}
/**
* Gets the host user's GID, with fallback to 1000 if unavailable, root (0),
* or in the system GID range (0-999).
* When running with sudo, uses SUDO_GID to get the actual user's GID.
* @internal Exported for testing
*/
export function getSafeHostGid(): string {
const gid = process.getgid?.();
// When running as root (sudo), try to get the original user's GID
if (!gid || gid === 0) {
const sudoGid = process.env.SUDO_GID;
if (sudoGid) {
const parsedGid = parseInt(sudoGid, 10);
if (!isNaN(parsedGid)) {
return validateIdNotInSystemRange(parsedGid);
}
}
return MIN_REGULAR_UID.toString();
}
return validateIdNotInSystemRange(gid);
}
/**
* Gets the real user's home directory, accounting for sudo.
* When running with sudo, uses SUDO_USER to find the actual user's home.
* @internal Exported for testing
*/
export function getRealUserHome(): string {
const uid = process.getuid?.();
// When running as root (sudo), try to get the original user's home
if (!uid || uid === 0) {
// Try SUDO_USER first - look up their home directory from passwd
const sudoUser = process.env.SUDO_USER;
if (sudoUser) {
try {
// Look up user's home directory from /etc/passwd
const passwd = fs.readFileSync('/etc/passwd', 'utf-8');
const userLine = passwd.split('\n').find(line => line.startsWith(`${sudoUser}:`));
if (userLine) {
const parts = userLine.split(':');
if (parts.length >= 6 && parts[5]) {
return parts[5]; // Home directory is the 6th field
}
}
} catch {
// Fall through to use HOME
}
}
}
// Use HOME environment variable as fallback
return process.env.HOME || '/root';
}
/**
* Extracts the hostname from GITHUB_SERVER_URL to set GH_HOST for gh CLI.
* Returns the hostname if GITHUB_SERVER_URL points to a non-github.com instance,
* or null if it points to github.com (no GH_HOST needed).
* @param serverUrl - The GITHUB_SERVER_URL environment variable value
* @returns The hostname to use for GH_HOST, or null if not needed
* @internal Exported for testing
*/
export function extractGhHostFromServerUrl(serverUrl: string | undefined): string | null {
if (!serverUrl) {
return null;
}
try {
const url = new URL(serverUrl);
const hostname = url.hostname;
// If pointing to public GitHub, no GH_HOST needed
if (hostname === 'github.com') {
return null;
}
// For GHES/GHEC instances, return the hostname
return hostname;
} catch {
// Invalid URL, return null
return null;
}
}
/**
* Reads path entries from the $GITHUB_PATH file used by GitHub Actions.
*
* When setup-* actions (e.g., setup-ruby, setup-dart, setup-python) run before AWF,
* they add tool paths to the $GITHUB_PATH file. The Actions runner prepends these
* to $PATH for subsequent steps, but if `sudo` resets PATH (depending on sudoers
* configuration), those entries may be lost by the time AWF reads process.env.PATH.
*
* This function reads the $GITHUB_PATH file directly and returns any path entries
* found, so they can be merged into AWF_HOST_PATH regardless of sudo behavior.
*
* @returns Array of path entries from the $GITHUB_PATH file, or empty array if unavailable
* @internal Exported for testing
*/
export function readGitHubPathEntries(): string[] {
const githubPathFile = process.env.GITHUB_PATH;
if (!githubPathFile) {
logger.debug('GITHUB_PATH env var is not set; skipping $GITHUB_PATH file merge (tools installed by setup-* actions may be missing from PATH if sudo reset it)');
return [];
}
try {
const content = fs.readFileSync(githubPathFile, 'utf-8');
return content
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0);
} catch {
// File doesn't exist or isn't readable — expected outside GitHub Actions
logger.debug(`GITHUB_PATH file at '${githubPathFile}' could not be read; skipping file merge`);
return [];
}
}
/**
* Reads key-value environment entries from the $GITHUB_ENV file.
*
* The Actions runner writes to this file when steps call `core.exportVariable()`.
* When AWF runs via `sudo`, non-standard env vars may be stripped. This function
* reads the file directly to recover them.
*
* Supports both formats used by the Actions runner:
* - Simple: `KEY=VALUE` (value may contain `=`)
* - Heredoc: `KEY<<DELIMITER\nVALUE_LINES\nDELIMITER`
*
* @returns Map of environment variable names to values
* @internal Exported for testing
*/
export function readGitHubEnvEntries(): Record<string, string> {
const githubEnvFile = process.env.GITHUB_ENV;
if (!githubEnvFile) {
logger.debug('GITHUB_ENV env var is not set; skipping $GITHUB_ENV file read');
return {};
}
try {
const content = fs.readFileSync(githubEnvFile, 'utf-8');
return parseGitHubEnvFile(content);
} catch {
logger.debug(`GITHUB_ENV file at '${githubEnvFile}' could not be read; skipping`);
return {};
}
}
/**
* Parses the content of a $GITHUB_ENV file into key-value pairs.
* @internal Exported for testing
*/
export function parseGitHubEnvFile(content: string): Record<string, string> {
const result: Record<string, string> = {};
// Normalize CRLF to LF
const lines = content.replace(/\r\n/g, '\n').split('\n');
let i = 0;
while (i < lines.length) {
const line = lines[i];
// Skip empty lines
if (line.trim() === '') {
i++;
continue;
}
// Check for heredoc format: KEY<<DELIMITER
const heredocMatch = line.match(/^([^=]+)<<(.+)$/);
if (heredocMatch) {
const key = heredocMatch[1];
const delimiter = heredocMatch[2];
const valueLines: string[] = [];
i++;
// Collect lines until we find the delimiter
while (i < lines.length && lines[i] !== delimiter) {
valueLines.push(lines[i]);
i++;
}
// Skip the closing delimiter line
if (i < lines.length) i++;
result[key] = valueLines.join('\n');
continue;
}
// Simple format: KEY=VALUE (split on first = only)
const eqIdx = line.indexOf('=');
if (eqIdx > 0) {
const key = line.slice(0, eqIdx);
const value = line.slice(eqIdx + 1);
result[key] = value;
}
i++;
}
return result;
}
/**
* Toolchain environment variables that should be recovered from $GITHUB_ENV
* when sudo strips them from process.env. These are set by setup-* actions
* (setup-go, setup-java, setup-dotnet, etc.) and are needed for correct
* tool resolution inside the agent container.
*/
const TOOLCHAIN_ENV_VARS = [
'GOROOT',
'CARGO_HOME',
'RUSTUP_HOME',
'JAVA_HOME',
'DOTNET_ROOT',
'BUN_INSTALL',
] as const;
/**
* Merges path entries from the $GITHUB_PATH file into a PATH string.
* Entries from $GITHUB_PATH are prepended (they have higher priority, matching
* how the Actions runner processes them). Duplicate entries are removed.
*
* @param currentPath - The current PATH string (e.g., from process.env.PATH)
* @param githubPathEntries - Path entries read from the $GITHUB_PATH file
* @returns Merged PATH string with $GITHUB_PATH entries prepended
* @internal Exported for testing
*/
export function mergeGitHubPathEntries(currentPath: string, githubPathEntries: string[]): string {
if (githubPathEntries.length === 0) {
return currentPath;
}
const currentEntries = currentPath ? currentPath.split(':') : [];
const currentSet = new Set(currentEntries);
// Only add entries that aren't already in the current PATH
const newEntries = githubPathEntries.filter(entry => !currentSet.has(entry));
if (newEntries.length === 0) {
return currentPath;
}
// Prepend new entries (setup-* actions expect their paths to have priority)
return [...newEntries, ...currentEntries].join(':');
}
/**
* Reads environment variables from a KEY=VALUE file (like Docker's --env-file).
*
* Rules:
* - Lines starting with '#' are comments and are ignored.
* - Empty/whitespace-only lines are ignored.
* - Each non-comment line must match the pattern KEY=VALUE where KEY starts with a
* letter or underscore and contains only letters, digits, or underscores.
* - Values may be empty (KEY=).
* - Values are taken literally; no quote-stripping or variable expansion is done.
*
* @param filePath - Absolute or relative path to the env file
* @returns An object mapping variable names to their values
* @throws {Error} If the file cannot be read
*/
export function readEnvFile(filePath: string): Record<string, string> {
const content = fs.readFileSync(filePath, 'utf-8');
const result: Record<string, string> = {};
for (const raw of content.split('\n')) {
const line = raw.trim();
// Skip comments and blank lines
if (line === '' || line.startsWith('#')) continue;
const match = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
if (match) {
result[match[1]] = match[2];
}
}
return result;
}
/**
* Gets existing Docker network subnets to avoid conflicts
*/
async function getExistingDockerSubnets(): Promise<string[]> {
try {
// Get all network IDs
const { stdout: networkIds } = await execa('docker', ['network', 'ls', '-q'], { env: getLocalDockerEnv() });
if (!networkIds.trim()) {
return [];
}
// Get subnet information for each network
const { stdout } = await execa('docker', [
'network',
'inspect',
'--format={{range .IPAM.Config}}{{.Subnet}} {{end}}',
...networkIds.trim().split('\n'),
], { env: getLocalDockerEnv() });
// Parse subnets from output (format: "172.17.0.0/16 172.18.0.0/16 ")
const subnets = stdout
.split(/\s+/)
.filter((s) => s.includes('/'))
.map((s) => s.trim());
logger.debug(`Found existing Docker subnets: ${subnets.join(', ')}`);
return subnets;
} catch {
logger.debug('Failed to query Docker networks, proceeding with random subnet');
return [];
}
}
/**
* Checks if two subnets overlap
* Returns true if the new subnet conflicts with an existing subnet
*/
export function subnetsOverlap(subnet1: string, subnet2: string): boolean {
// Parse CIDR notation: "172.17.0.0/16" -> ["172.17.0.0", "16"]
const [ip1, cidr1] = subnet1.split('/');
const [ip2, cidr2] = subnet2.split('/');
// Convert IP to number
const ipToNumber = (ip: string): number => {
return ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>> 0;
};
// Calculate network address and broadcast address for a subnet
const getNetworkRange = (ip: string, cidr: string): [number, number] => {
const ipNum = ipToNumber(ip);
const maskBits = parseInt(cidr, 10);
const mask = (0xffffffff << (32 - maskBits)) >>> 0;
const networkAddr = (ipNum & mask) >>> 0;
const broadcastAddr = (networkAddr | ~mask) >>> 0;
return [networkAddr, broadcastAddr];
};
const [start1, end1] = getNetworkRange(ip1, cidr1);
const [start2, end2] = getNetworkRange(ip2, cidr2);
// Check if ranges overlap
return (start1 <= end2 && end1 >= start2);
}
/**
* Generates a random subnet in Docker's private IP range that doesn't conflict with existing networks
* Uses 172.16-31.x.0/24 range (Docker's default bridge network range)
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
async function _generateRandomSubnet(): Promise<{ subnet: string; squidIp: string; agentIp: string }> {
const existingSubnets = await getExistingDockerSubnets();
const MAX_RETRIES = 50;
for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
// Use 172.16-31.x.0/24 range
const secondOctet = Math.floor(Math.random() * 16) + 16; // 16-31
const thirdOctet = Math.floor(Math.random() * 256); // 0-255
const subnet = `172.${secondOctet}.${thirdOctet}.0/24`;
// Check for conflicts with existing subnets
const hasConflict = existingSubnets.some((existingSubnet) =>
subnetsOverlap(subnet, existingSubnet)
);
if (!hasConflict) {
const squidIp = `172.${secondOctet}.${thirdOctet}.10`;
const agentIp = `172.${secondOctet}.${thirdOctet}.20`;
return { subnet, squidIp, agentIp };
}
logger.debug(`Subnet ${subnet} conflicts with existing network, retrying... (attempt ${attempt + 1}/${MAX_RETRIES})`);
}
throw new Error(
`Failed to generate non-conflicting subnet after ${MAX_RETRIES} attempts. ` +
`Existing subnets: ${existingSubnets.join(', ')}`
);
}
/**
* SSL configuration for Docker Compose (when SSL Bump is enabled)
*/
export interface SslConfig {
caFiles: CaFiles;
sslDbPath: string;
}
/**
* Normalizes an API target value to a bare hostname.
* API target values should be bare hostnames (e.g., "api.openai.com"), but
* may arrive with a scheme or path when set via GitHub Actions expressions
* that are resolved at runtime (see github/gh-aw#25137).
* Discards any scheme, path, query, fragment, credentials, or port —
* path prefixes must use the separate *_API_BASE_PATH settings.
*/
export function stripScheme(value: string): string {
const trimmed = value.trim();
if (!trimmed) return trimmed;
const candidate = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(trimmed)
? trimmed
: `https://${trimmed}`;
try {
return new URL(candidate).hostname || trimmed;
} catch {
return trimmed;
}
}
/**
* Parses a host:port string into separate host and port components.
* Supports IPv6 bracketed notation (e.g., [::1]:18443), plain host:port,
* and optional scheme prefixes.
* Defaults to host.docker.internal:18443 for empty/missing values.
*/
export function parseDifcProxyHost(value: string): { host: string; port: string } {
const trimmed = value.trim();
if (!trimmed) {
return { host: 'host.docker.internal', port: '18443' };
}
// Use URL to parse host:port correctly (handles IPv6 brackets)
const hasScheme = /^[a-zA-Z][a-zA-Z\d+\-.]*:\/\//.test(trimmed);
const candidate = hasScheme ? trimmed : `tcp://${trimmed}`;
let parsed: URL;
try {
parsed = new URL(candidate);
} catch {
throw new Error(`Invalid --difc-proxy-host value: "${value}". Expected host:port format.`);
}
const host = parsed.hostname || 'host.docker.internal';
const port = parsed.port || '18443';
if (!/^\d+$/.test(port)) {
throw new Error(`Invalid --difc-proxy-host port: "${port}". Must be a number.`);
}
const portNum = Number(port);
if (portNum < 1 || portNum > 65535) {
throw new Error(`Invalid --difc-proxy-host port: ${portNum}. Must be between 1 and 65535.`);
}
return { host, port: String(portNum) };
}
/**
* Generates Docker Compose configuration
* Note: Uses external network 'awf-net' created by host-iptables setup
*/
export function generateDockerCompose(
config: WrapperConfig,
networkConfig: { subnet: string; squidIp: string; agentIp: string; proxyIp?: string; dohProxyIp?: string; cliProxyIp?: string },
sslConfig?: SslConfig,
squidConfigContent?: string
): DockerComposeConfig {
const projectRoot = path.join(__dirname, '..');
// Guard: --build-local requires full repo checkout (not available in standalone bundle)
if (config.buildLocal) {
const containersDir = path.join(projectRoot, 'containers');
if (!fs.existsSync(containersDir)) {
throw new Error(
'The --build-local flag requires a full repository checkout. ' +
'It is not supported with the standalone bundle. ' +
'Use the npm package or clone the repository instead.'
);
}
}
// Default to GHCR images unless buildLocal is explicitly set
const useGHCR = !config.buildLocal;
const registry = config.imageRegistry || 'ghcr.io/github/gh-aw-firewall';
const parsedImageTag = parseImageTag(config.imageTag || 'latest');
// Squid logs path: use proxyLogsDir if specified (direct write), otherwise workDir/squid-logs
const squidLogsPath = config.proxyLogsDir || `${config.workDir}/squid-logs`;
// Session state path: use sessionStateDir if specified (timeout-safe, predictable path),
// otherwise workDir/agent-session-state (will be moved to /tmp after cleanup)
const sessionStatePath = config.sessionStateDir || `${config.workDir}/agent-session-state`;
// Agent logs path: always workDir/agent-logs (moved to /tmp after cleanup)
const agentLogsPath = `${config.workDir}/agent-logs`;
// API proxy logs path: if proxyLogsDir is specified, write inside it as a subdirectory
// so that token-usage.jsonl is included in the firewall-audit-logs artifact automatically.
// Otherwise, write to workDir/api-proxy-logs (will be moved to /tmp after cleanup)
const apiProxyLogsPath = config.proxyLogsDir
? path.join(config.proxyLogsDir, 'api-proxy-logs')
: path.join(config.workDir, 'api-proxy-logs');
// CLI proxy logs path: write to workDir/cli-proxy-logs (will be moved to /tmp after cleanup)
const cliProxyLogsPath = config.proxyLogsDir
? path.join(config.proxyLogsDir, 'cli-proxy-logs')
: path.join(config.workDir, 'cli-proxy-logs');
// Build Squid volumes list
// Note: squid.conf is NOT bind-mounted. Instead, it's passed as a base64-encoded
// environment variable (AWF_SQUID_CONFIG_B64) and decoded by the entrypoint override.
// This supports Docker-in-Docker (DinD) environments where the Docker daemon runs
// in a separate container and cannot access files on the host filesystem.
// See: https://github.com/github/gh-aw/issues/18385
const squidVolumes = [
`${squidLogsPath}:/var/log/squid:rw`,
];
// Add SSL-related volumes if SSL Bump is enabled
if (sslConfig) {
squidVolumes.push(`${sslConfig.caFiles.certPath}:${sslConfig.caFiles.certPath}:ro`);
squidVolumes.push(`${sslConfig.caFiles.keyPath}:${sslConfig.caFiles.keyPath}:ro`);
// Mount SSL database at /var/spool/squid_ssl_db (Squid's expected location)
squidVolumes.push(`${sslConfig.sslDbPath}:/var/spool/squid_ssl_db:rw`);
}
// Squid service configuration
const squidService: any = {
container_name: SQUID_CONTAINER_NAME,
networks: {
'awf-net': {
ipv4_address: networkConfig.squidIp,
},
},
volumes: squidVolumes,
healthcheck: {
test: ['CMD', 'nc', '-z', 'localhost', '3128'],
interval: '1s',
timeout: '1s',
retries: 5,
start_period: '2s',
},
ports: [`${SQUID_PORT}:${SQUID_PORT}`],
// Security hardening: Drop unnecessary capabilities
// Squid only needs network capabilities, not system administration capabilities
cap_drop: [
'NET_RAW', // No raw socket access needed
'SYS_ADMIN', // No system administration needed
'SYS_PTRACE', // No process tracing needed
'SYS_MODULE', // No kernel module loading
'MKNOD', // No device node creation
'AUDIT_WRITE', // No audit log writing
'SETFCAP', // No setting file capabilities
],
stop_grace_period: '2s',
};
// Inject squid.conf via environment variable instead of bind mount.
// In Docker-in-Docker (DinD) environments, the Docker daemon runs in a separate
// container and cannot access files on the host filesystem. Bind-mounting
// squid.conf fails because the daemon creates a directory at the missing path.
// Passing the config as a base64-encoded env var works universally because
// env vars are part of the container spec sent via the Docker API.
if (squidConfigContent) {
const configB64 = Buffer.from(squidConfigContent).toString('base64');
squidService.environment = {
...squidService.environment,
AWF_SQUID_CONFIG_B64: configB64,
};
// Override entrypoint to decode the config before starting squid.
// The original entrypoint (/usr/local/bin/entrypoint.sh) is called after decoding.
// Use $$ to escape $ for Docker Compose variable interpolation.
// Docker Compose interprets $VAR as variable substitution in YAML values;
// $$ produces a literal $ that the shell inside the container will expand.
squidService.entrypoint = [
'/bin/bash', '-c',
'echo "$$AWF_SQUID_CONFIG_B64" | base64 -d > /etc/squid/squid.conf && exec /usr/local/bin/entrypoint.sh',
];
}
// Only enable host.docker.internal when explicitly requested via --enable-host-access
// This allows containers to reach services on the host machine (e.g., MCP gateways)
// Security note: When combined with allowing host.docker.internal domain,
// containers can access any port on the host
if (config.enableHostAccess) {
squidService.extra_hosts = ['host.docker.internal:host-gateway'];
logger.debug('Host access enabled: host.docker.internal will resolve to host gateway');
}
// Use GHCR image or build locally
// For SSL Bump, we always build locally to include OpenSSL tools
if (useGHCR && !config.sslBump) {
squidService.image = buildRuntimeImageRef(registry, 'squid', parsedImageTag);
} else {
squidService.build = {
context: path.join(projectRoot, 'containers/squid'),
dockerfile: 'Dockerfile',
};
}
// Build environment variables for agent execution container
// System variables that must be overridden or excluded (would break container operation)
const EXCLUDED_ENV_VARS = new Set([
'PATH', // Must use container's PATH
'PWD', // Container's working directory
'OLDPWD', // Not relevant in container
'SHLVL', // Shell level not relevant
'_', // Last command executed
'SUDO_COMMAND', // Sudo metadata
'SUDO_USER', // Sudo metadata
'SUDO_UID', // Sudo metadata
'SUDO_GID', // Sudo metadata
// GitHub Actions artifact service tokens — excluded from inherited environment
// propagation to prevent agents from uploading arbitrary data as workflow artifacts
// (potential data exfiltration vector). These tokens are only needed by the
// Actions runner itself, not by the agent.
'ACTIONS_RUNTIME_TOKEN',
'ACTIONS_RESULTS_URL',
// Proxy environment variables — excluded to prevent host proxy settings from
// conflicting with AWF's internal routing (agent → Squid → internet).
// AWF sets its own HTTP_PROXY/HTTPS_PROXY pointing to Squid.
...PROXY_ENV_VARS,
]);
// When api-proxy is enabled, exclude API keys from agent environment
// (they are held securely in the api-proxy sidecar instead)
if (config.enableApiProxy) {
EXCLUDED_ENV_VARS.add('OPENAI_API_KEY');
EXCLUDED_ENV_VARS.add('OPENAI_KEY');
EXCLUDED_ENV_VARS.add('CODEX_API_KEY');
EXCLUDED_ENV_VARS.add('ANTHROPIC_API_KEY');
EXCLUDED_ENV_VARS.add('CLAUDE_API_KEY');
EXCLUDED_ENV_VARS.add('GEMINI_API_KEY');
// COPILOT_GITHUB_TOKEN and COPILOT_API_KEY get placeholders (not excluded), protected by one-shot-token
// GITHUB_API_URL is intentionally NOT excluded: the Copilot CLI needs it to know the
// GitHub API base URL. Copilot-specific API calls (inference and token exchange) go
// through COPILOT_API_URL → api-proxy regardless of GITHUB_API_URL being set.
// See: github/gh-aw#20875
}
// When cli-proxy is enabled (external DIFC proxy), exclude GitHub tokens
// from agent environment. Tokens are held securely by the external DIFC proxy.
if (config.difcProxyHost) {
EXCLUDED_ENV_VARS.add('GITHUB_TOKEN');
EXCLUDED_ENV_VARS.add('GH_TOKEN');
}
// Start with required/overridden environment variables
// Use the real user's home (not /root when running with sudo)
const homeDir = getRealUserHome();
const environment: Record<string, string> = {
HTTP_PROXY: `http://${networkConfig.squidIp}:${SQUID_PORT}`,
HTTPS_PROXY: `http://${networkConfig.squidIp}:${SQUID_PORT}`,
// Lowercase https_proxy for tools that only check lowercase (e.g., Yarn 4/undici, Corepack).
// NOTE: We intentionally do NOT set lowercase http_proxy. Some curl builds (Ubuntu 22.04)
// ignore uppercase HTTP_PROXY for HTTP URLs (httpoxy mitigation), which means HTTP traffic
// falls through to iptables DNAT interception — the correct behavior for connection-level
// blocking. Setting http_proxy would route HTTP through the forward proxy where Squid's
// 403 error page returns exit code 0, breaking security expectations.
https_proxy: `http://${networkConfig.squidIp}:${SQUID_PORT}`,
SQUID_PROXY_HOST: 'squid-proxy',
SQUID_PROXY_PORT: SQUID_PORT.toString(),
HOME: homeDir,
PATH: '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
// Color output control: when --tty is set, enable color output for tools that support it.
// When tty is off (default), disable colors to avoid ANSI escape codes in log output.
// NO_COLOR is a standard convention (https://no-color.org/) supported by many libraries.
// FORCE_COLOR is used by Chalk, Rich, and other tools to enable color output.
...(config.tty ? {
FORCE_COLOR: '1',
TERM: 'xterm-256color',
COLUMNS: '120',
} : {
NO_COLOR: '1',
}),
// Configure one-shot-token library with sensitive tokens to protect
// These tokens are cached on first access and unset from /proc/self/environ
AWF_ONE_SHOT_TOKENS: 'COPILOT_GITHUB_TOKEN,GITHUB_TOKEN,GH_TOKEN,GITHUB_API_TOKEN,GITHUB_PAT,GH_ACCESS_TOKEN,OPENAI_API_KEY,OPENAI_KEY,ANTHROPIC_API_KEY,CLAUDE_API_KEY,CODEX_API_KEY,COPILOT_API_KEY,COPILOT_PROVIDER_API_KEY',
};
// Copilot CLI requires Node.js. Ask the agent entrypoint to fail fast with a
// clear diagnostic if node is not reachable inside the chroot before startup.
const commandExecutable = config.agentCommand.trim().split(/\s+/, 1)[0] || '';
const commandExecutableBase = path.posix.basename(commandExecutable.replace(/\\/g, '/'));
const isCopilotCommand = commandExecutableBase.toLowerCase() === 'copilot';
if (config.copilotGithubToken || config.copilotApiKey || isCopilotCommand) {
environment.AWF_REQUIRE_NODE = '1';
}
// When api-proxy is enabled with Copilot, set placeholder tokens early
// so --env-all won't override them with real values from host environment
if (config.enableApiProxy && config.copilotGithubToken) {
environment.COPILOT_GITHUB_TOKEN = 'placeholder-token-for-credential-isolation';
logger.debug('COPILOT_GITHUB_TOKEN set to placeholder value (early) to prevent --env-all override');
}
if (config.enableApiProxy && config.copilotApiKey) {
environment.COPILOT_API_KEY = 'placeholder-token-for-credential-isolation';
logger.debug('COPILOT_API_KEY set to placeholder value (early) to prevent --env-all override');
environment.COPILOT_PROVIDER_API_KEY = 'placeholder-token-for-credential-isolation';
logger.debug('COPILOT_PROVIDER_API_KEY set to placeholder value (early) to prevent --env-all override');
}
// Always set NO_PROXY to prevent HTTP clients from proxying localhost traffic through Squid.
// Without this, test frameworks that start local servers (e.g., go/echo, python/uvicorn,
// deno/fresh) get 403 errors because Squid rejects requests to localhost (not in allowed domains).
// Include the agent's own container IP because test frameworks often bind to 0.0.0.0 and
// test clients may connect via the container's non-loopback IP (e.g., 172.30.0.20).
environment.NO_PROXY = `localhost,127.0.0.1,::1,0.0.0.0,${networkConfig.squidIp},${networkConfig.agentIp}`;
environment.no_proxy = environment.NO_PROXY;
// When host access is enabled, also bypass the proxy for the host gateway IPs.
// MCP Streamable HTTP (SSE) traffic through Squid crashes it (comm.cc:1583),
// so MCP gateway traffic must go directly to the host, not through Squid.
if (config.enableHostAccess) {
// Compute the network gateway IP (first usable IP in the subnet)
const subnetBase = networkConfig.subnet.split('/')[0]; // e.g. "172.30.0.0"
const parts = subnetBase.split('.');
const networkGatewayIp = `${parts[0]}.${parts[1]}.${parts[2]}.1`;
environment.NO_PROXY += `,host.docker.internal,${networkGatewayIp}`;
environment.no_proxy = environment.NO_PROXY;
}
// When API proxy is enabled, bypass HTTP_PROXY for the api-proxy IP
// so the agent can reach the sidecar directly without going through Squid
if (config.enableApiProxy && networkConfig.proxyIp) {
environment.NO_PROXY += `,${networkConfig.proxyIp}`;
environment.no_proxy = environment.NO_PROXY;
}
// Pass the host's actual PATH and tool directories so the entrypoint can use them
// This ensures toolcache paths (Python, Node, Go, Rust, Java, Ruby, Dart, etc.) are correctly resolved
//
// Also merge paths from $GITHUB_PATH file. When setup-* actions (setup-ruby, setup-dart,
// setup-python, etc.) run before AWF, they write tool paths to this file. The Actions
// runner normally prepends these to $PATH, but sudo may reset PATH, losing them.
// Reading the file directly ensures these paths are always included.
if (process.env.PATH) {
const githubPathEntries = readGitHubPathEntries();
environment.AWF_HOST_PATH = mergeGitHubPathEntries(process.env.PATH, githubPathEntries);
if (githubPathEntries.length > 0) {
logger.debug(`Merged ${githubPathEntries.length} path(s) from $GITHUB_PATH into AWF_HOST_PATH`);
}
}
// Toolchain variables (GOROOT, CARGO_HOME, JAVA_HOME, etc.) set by setup-* actions.
// When AWF runs via sudo, these may be stripped from process.env. Fall back to
// reading $GITHUB_ENV file directly (analogous to readGitHubPathEntries for $GITHUB_PATH).
const runningUnderSudo =
process.getuid?.() === 0 && (Boolean(process.env.SUDO_UID) || Boolean(process.env.SUDO_USER));
const githubEnvEntries = runningUnderSudo ? readGitHubEnvEntries() : {};
for (const varName of TOOLCHAIN_ENV_VARS) {
const value = process.env[varName] || (runningUnderSudo ? githubEnvEntries[varName] : undefined);
if (value) {
environment[`AWF_${varName}`] = value;
if (!process.env[varName] && runningUnderSudo && githubEnvEntries[varName]) {
logger.debug(`Recovered ${varName} from $GITHUB_ENV (sudo likely stripped it from process.env)`);
}
}
}
// If --exclude-env names were specified, add them to the excluded set
if (config.excludeEnv && config.excludeEnv.length > 0) {
for (const name of config.excludeEnv) {
EXCLUDED_ENV_VARS.add(name);
}
}
// If --env-all is specified, pass through all host environment variables (except excluded ones)
if (config.envAll) {
const skippedLargeVars: string[] = [];
for (const [key, value] of Object.entries(process.env)) {
if (value !== undefined && !EXCLUDED_ENV_VARS.has(key) && !Object.prototype.hasOwnProperty.call(environment, key)) {
// Skip oversized values to prevent E2BIG (Argument list too long) errors.
// The Linux kernel enforces ARG_MAX (~2MB) on argv+envp combined; large env
// vars can exhaust this budget, especially when combined with large prompts.
const valueSizeBytes = Buffer.byteLength(value, 'utf8');
if (valueSizeBytes > MAX_ENV_VALUE_SIZE) {
skippedLargeVars.push(`${key} (${(valueSizeBytes / 1024).toFixed(0)} KB)`);
continue;
}
environment[key] = value;
}
}
if (skippedLargeVars.length > 0) {
logger.warn(`Skipped ${skippedLargeVars.length} oversized env var(s) from --env-all passthrough (>${(MAX_ENV_VALUE_SIZE / 1024).toFixed(0)} KB each):`);
for (const entry of skippedLargeVars) {
logger.warn(` - ${entry}`);
}
logger.warn('Use --env VAR="$VAR" to explicitly pass large values if needed.');
}
} else {
// Default behavior: selectively pass through specific variables
if (process.env.GITHUB_TOKEN) environment.GITHUB_TOKEN = process.env.GITHUB_TOKEN;
if (process.env.GH_TOKEN) environment.GH_TOKEN = process.env.GH_TOKEN;
if (process.env.GITHUB_PERSONAL_ACCESS_TOKEN) environment.GITHUB_PERSONAL_ACCESS_TOKEN = process.env.GITHUB_PERSONAL_ACCESS_TOKEN;
// API keys for LLM providers — skip when api-proxy is enabled
// (the sidecar holds the keys; the agent uses *_BASE_URL instead)
if (process.env.OPENAI_API_KEY && !config.enableApiProxy) environment.OPENAI_API_KEY = process.env.OPENAI_API_KEY;
if (process.env.CODEX_API_KEY && !config.enableApiProxy) environment.CODEX_API_KEY = process.env.CODEX_API_KEY;
if (process.env.ANTHROPIC_API_KEY && !config.enableApiProxy) environment.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY;
// COPILOT_GITHUB_TOKEN — forward when api-proxy is NOT enabled; when api-proxy IS enabled,
// it gets a placeholder value set earlier (line ~362) for credential isolation
if (process.env.COPILOT_GITHUB_TOKEN && !config.enableApiProxy) environment.COPILOT_GITHUB_TOKEN = process.env.COPILOT_GITHUB_TOKEN;
// COPILOT_API_KEY (BYOK) — forward when api-proxy is NOT enabled; when api-proxy IS enabled,
// the real key is not forwarded to the agent env and a placeholder may still be present
// for credential isolation while the real key is held securely in the api-proxy sidecar
if (process.env.COPILOT_API_KEY && !config.enableApiProxy) environment.COPILOT_API_KEY = process.env.COPILOT_API_KEY;
if (process.env.USER) environment.USER = process.env.USER;
// When --tty is set, we use TERM=xterm-256color (set above); otherwise inherit host TERM
if (process.env.TERM && !config.tty) environment.TERM = process.env.TERM;
if (process.env.XDG_CONFIG_HOME) environment.XDG_CONFIG_HOME = process.env.XDG_CONFIG_HOME;
// Enterprise environment variables — needed for GHEC/GHES Copilot authentication
if (process.env.GITHUB_SERVER_URL) environment.GITHUB_SERVER_URL = process.env.GITHUB_SERVER_URL;
// GITHUB_API_URL — always pass when set. The Copilot CLI needs it to locate the GitHub API
// (especially on GHES/GHEC where the URL differs from api.github.com).
// Copilot-specific API calls (inference and token exchange) always route through
// COPILOT_API_URL → api-proxy when api-proxy is enabled, so GITHUB_API_URL does not
// interfere with credential isolation.
if (process.env.GITHUB_API_URL) environment.GITHUB_API_URL = process.env.GITHUB_API_URL;
// GitHub Actions OIDC — required for MCP servers with auth.type: 'github-oidc'
if (process.env.ACTIONS_ID_TOKEN_REQUEST_URL) environment.ACTIONS_ID_TOKEN_REQUEST_URL = process.env.ACTIONS_ID_TOKEN_REQUEST_URL;
if (process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN) environment.ACTIONS_ID_TOKEN_REQUEST_TOKEN = process.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN;
// Forward Docker client environment so the agent workload can reach the same DinD daemon,
// custom Docker socket, or TCP endpoint as the parent process. DOCKER_HOST alone is not
// sufficient for TLS/authenticated daemons; the companion Docker client variables must also
// be preserved so docker commands inside the agent work as expected.
const dockerClientEnvVars = [
'DOCKER_HOST',
'DOCKER_TLS',
'DOCKER_TLS_VERIFY',
'DOCKER_CERT_PATH',
'DOCKER_CONTEXT',
'DOCKER_CONFIG',
'DOCKER_API_VERSION',
'DOCKER_DEFAULT_PLATFORM',
] as const;
for (const dockerEnvVar of dockerClientEnvVars) {
if (process.env[dockerEnvVar]) environment[dockerEnvVar] = process.env[dockerEnvVar]!;
}
}
// Always derive GH_HOST from GITHUB_SERVER_URL to prevent proxy-rewritten values
// (e.g. GH_HOST=localhost:18443 from DIFC proxy) from breaking gh CLI remote matching.
// When running inside GitHub Actions, GITHUB_SERVER_URL is injected by the Actions
// runner and points to the real GitHub instance for the workflow run, so within that
// context it is the canonical source of truth. Outside Actions it may be unset.
// Must run AFTER the env-all block so it overrides any leaked proxy values.
const ghHost = extractGhHostFromServerUrl(process.env.GITHUB_SERVER_URL);
if (ghHost) {
environment.GH_HOST = ghHost;
logger.debug(`Set GH_HOST=${ghHost} from GITHUB_SERVER_URL`);
} else if (environment.GH_HOST) {
// When GITHUB_SERVER_URL does not yield a custom host (e.g. github.com, unset, or invalid),
// GH_HOST should not be set. If --env-all passed through a proxy-rewritten value, remove it
// so gh CLI uses its default behavior (github.com). See: gh-aw-firewall#1492
delete environment.GH_HOST;
logger.debug('Removed GH_HOST from environment; falling back to gh CLI default since GITHUB_SERVER_URL did not yield a custom host override');
}
// Forward one-shot-token debug flag if set (used for testing/debugging)
if (process.env.AWF_ONE_SHOT_TOKEN_DEBUG) {