Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/commands/validate-options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ describe('validateOptions', () => {
mockedOptionParsers.resolveDockerHostPathPrefix.mockReturnValue({
dockerHostPathPrefix: undefined,
autoApplied: false,
dindHint: false,
});
mockedOptionParsers.parseEnvironmentVariables.mockReturnValue({ success: true, env: {} });
mockedOptionParsers.parseVolumeMounts.mockReturnValue({ success: true, mounts: [] });
Expand Down
2 changes: 1 addition & 1 deletion src/commands/validators/config-assembly.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ describe('config-assembly', () => {
dnsOverHttps: undefined,
resolvedCopilotApiTarget: undefined,
resolvedCopilotApiBasePath: undefined,
dockerHostPathPrefixResolution: { dockerHostPathPrefix: undefined, autoApplied: false },
dockerHostPathPrefixResolution: { dockerHostPathPrefix: undefined, autoApplied: false, dindHint: false },
});

const createMinimalAgentOptions = (): AgentOptionsResult => ({
Expand Down
14 changes: 14 additions & 0 deletions src/commands/validators/network-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,20 @@ export function validateNetworkOptions(options: Record<string, unknown>): Networ
'⚠️ If your Docker daemon uses a split runner/daemon filesystem, set --docker-host-path-prefix (for example: /host).',
);
}
if (dockerHostPathPrefixResolution.dindHint && !dockerHostPathPrefixResolution.dockerHostPathPrefix) {
logger.warn(
'⚠️ Non-standard DOCKER_HOST unix socket or AWF_DIND=1 detected — this typically indicates an ARC/DinD',
);
logger.warn(
' setup where the runner and Docker daemon have separate root filesystems.',
);
logger.warn(
' If bind mounts fail, set --docker-host-path-prefix to the path prefix where the runner filesystem',
);
logger.warn(
' is visible inside the daemon (e.g. --docker-host-path-prefix /tmp/gh-aw).',
);
}

// --- Domain resolution --------------------------------------------------

Expand Down
28 changes: 28 additions & 0 deletions src/dind-probe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,29 @@ describe('probeSplitFilesystem', () => {
expect(mockedExeca).toHaveBeenCalledTimes(4);
});

it('returns /tmp/gh-aw when /host and /runner fail but /tmp/gh-aw prefix works', async () => {
mockDockerReachable();
// Direct mount: file not found
mockedExeca.mockResolvedValueOnce({ exitCode: 1 } as any);
// /host prefix: file not found
mockedExeca.mockResolvedValueOnce({ exitCode: 1 } as any);
// /runner prefix: file not found
mockedExeca.mockResolvedValueOnce({ exitCode: 1 } as any);
// /tmp/gh-aw prefix succeeds
mockedExeca.mockResolvedValueOnce({ exitCode: 0 } as any);

const result = await probeSplitFilesystem(probeDir);

expect(result.prefix).toBe('/tmp/gh-aw');
expect(result.splitDetected).toBe(true);
expect(result.inconclusive).toBe(false);
// 1 docker info + 4 probes
expect(mockedExeca).toHaveBeenCalledTimes(5);
// Verify /tmp/gh-aw call uses prefixed path
const ghAwCallArgs = mockedExeca.mock.calls[4][1] as string[];
expect(ghAwCallArgs).toContain(`/tmp/gh-aw${probeDir}:/probe:ro`);
});

it('returns splitDetected=true when no candidate prefix works', async () => {
mockDockerReachable();
// Direct mount: file not found
Expand All @@ -92,12 +115,16 @@ describe('probeSplitFilesystem', () => {
mockedExeca.mockResolvedValueOnce({ exitCode: 1 } as any);
// /runner prefix: file not found
mockedExeca.mockResolvedValueOnce({ exitCode: 1 } as any);
// /tmp/gh-aw prefix: file not found
mockedExeca.mockResolvedValueOnce({ exitCode: 1 } as any);

const result = await probeSplitFilesystem(probeDir);

expect(result.prefix).toBeUndefined();
expect(result.splitDetected).toBe(true);
expect(result.inconclusive).toBe(false);
// 1 docker info + 4 probes
expect(mockedExeca).toHaveBeenCalledTimes(5);
});

it('returns inconclusive when Docker daemon is unreachable (fail-fast)', async () => {
Expand Down Expand Up @@ -181,6 +208,7 @@ describe('probeSplitFilesystem', () => {
mockedExeca.mockResolvedValueOnce({ exitCode: 1 } as any);
mockedExeca.mockResolvedValueOnce({ exitCode: 1 } as any);
mockedExeca.mockResolvedValueOnce({ exitCode: 1 } as any);
mockedExeca.mockResolvedValueOnce({ exitCode: 1 } as any);

await probeSplitFilesystem(probeDir);

Expand Down
2 changes: 1 addition & 1 deletion src/dind-probe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { getLocalDockerEnv } from './docker-host';
import { logger } from './logger';

/** Candidate prefixes to try when split filesystem is detected */
const CANDIDATE_PREFIXES = ['/host', '/runner'];
const CANDIDATE_PREFIXES = ['/host', '/runner', '/tmp/gh-aw'];

/** Timeout for each docker run probe (ms) */
const PROBE_TIMEOUT_MS = 10000;
Expand Down
78 changes: 75 additions & 3 deletions src/option-parsers-misc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -332,17 +332,89 @@ describe('checkDockerHost', () => {
describe('resolveDockerHostPathPrefix', () => {
it('returns explicit prefix when provided', () => {
const result = resolveDockerHostPathPrefix({ valid: false, error: 'external DOCKER_HOST' }, '/daemon-root');
expect(result).toEqual({ dockerHostPathPrefix: '/daemon-root', autoApplied: false });
expect(result).toEqual({ dockerHostPathPrefix: '/daemon-root', autoApplied: false, dindHint: false });
});

it('does not auto-apply a prefix for external DOCKER_HOST when none is provided', () => {
const result = resolveDockerHostPathPrefix({ valid: false, error: 'external DOCKER_HOST' }, undefined);
expect(result).toEqual({ dockerHostPathPrefix: undefined, autoApplied: false });
expect(result).toEqual({ dockerHostPathPrefix: undefined, autoApplied: false, dindHint: false });
});

it('returns undefined when DOCKER_HOST is local and no prefix is provided', () => {
const result = resolveDockerHostPathPrefix({ valid: true }, undefined);
expect(result).toEqual({ dockerHostPathPrefix: undefined, autoApplied: false });
expect(result).toEqual({ dockerHostPathPrefix: undefined, autoApplied: false, dindHint: false });
});

it('sets dindHint when DOCKER_HOST is a non-standard unix socket', () => {
const result = resolveDockerHostPathPrefix(
{ valid: true },
undefined,
{ DOCKER_HOST: 'unix:///tmp/docker-sibling.sock' },
);
expect(result).toEqual({ dockerHostPathPrefix: undefined, autoApplied: false, dindHint: true });
});

it('does not set dindHint for the default /var/run/docker.sock socket', () => {
const result = resolveDockerHostPathPrefix(
{ valid: true },
undefined,
{ DOCKER_HOST: 'unix:///var/run/docker.sock' },
);
expect(result).toEqual({ dockerHostPathPrefix: undefined, autoApplied: false, dindHint: false });
});

it('does not set dindHint for the /run/docker.sock socket', () => {
const result = resolveDockerHostPathPrefix(
{ valid: true },
undefined,
{ DOCKER_HOST: 'unix:///run/docker.sock' },
);
expect(result).toEqual({ dockerHostPathPrefix: undefined, autoApplied: false, dindHint: false });
});

it('sets dindHint when AWF_DIND=1 is set', () => {
const result = resolveDockerHostPathPrefix(
{ valid: true },
undefined,
{ AWF_DIND: '1' },
);
expect(result).toEqual({ dockerHostPathPrefix: undefined, autoApplied: false, dindHint: true });
});

it('does not set dindHint when AWF_DIND is not 1', () => {
const result = resolveDockerHostPathPrefix(
{ valid: true },
undefined,
{ AWF_DIND: '0' },
);
expect(result).toEqual({ dockerHostPathPrefix: undefined, autoApplied: false, dindHint: false });
});

it('does not set dindHint for TCP DOCKER_HOST (checked by checkDockerHost separately)', () => {
const result = resolveDockerHostPathPrefix(
{ valid: false, error: 'external' },
undefined,
{ DOCKER_HOST: 'tcp://localhost:2375' },
);
expect(result).toEqual({ dockerHostPathPrefix: undefined, autoApplied: false, dindHint: false });
});

it('explicit prefix wins and suppresses dindHint even when non-standard socket is set', () => {
const result = resolveDockerHostPathPrefix(
{ valid: true },
'/tmp/gh-aw',
{ DOCKER_HOST: 'unix:///tmp/docker-sibling.sock' },
);
expect(result).toEqual({ dockerHostPathPrefix: '/tmp/gh-aw', autoApplied: false, dindHint: false });
});

it('explicit prefix wins and suppresses dindHint when AWF_DIND=1', () => {
const result = resolveDockerHostPathPrefix(
{ valid: true },
'/host',
{ AWF_DIND: '1' },
);
expect(result).toEqual({ dockerHostPathPrefix: '/host', autoApplied: false, dindHint: false });
});
});

Expand Down
47 changes: 42 additions & 5 deletions src/option-parsers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,22 +139,59 @@ export function checkDockerHost(
};
}

/**
* Standard Docker socket paths that indicate a local daemon on the same
* filesystem as the runner. Any other unix:// path is treated as a potential
* sibling-daemon (ARC/DinD) socket that may use a split filesystem.
*/
const DEFAULT_DOCKER_SOCKET_URIS = [
'unix:///var/run/docker.sock',
'unix:///run/docker.sock',
];

/**
* Returns `true` when `DOCKER_HOST` is a unix socket on a non-default path,
* which typically indicates a sibling daemon pod in ARC/DinD deployments.
* These setups bind-mount the daemon's socket into the runner pod, meaning
* the runner and daemon may have separate root filesystems.
*/
function isSiblingDaemonSocket(env: Record<string, string | undefined>): boolean {
const dockerHost = env['DOCKER_HOST'];
if (!dockerHost || !dockerHost.startsWith('unix://')) return false;
return !DEFAULT_DOCKER_SOCKET_URIS.includes(dockerHost);
}

/**
* Resolves the effective Docker host path prefix for bind mount translation.
*
* If an explicit prefix is provided, it wins. Otherwise, no prefix is applied.
* If an explicit prefix is provided, it wins. Otherwise the function inspects
* the environment for DinD indicators:
* - `DOCKER_HOST` pointing at a non-standard unix socket (sibling daemon pod)
* - `AWF_DIND=1` set explicitly by the operator
*
* When a DinD indicator is found, `dindHint` is set to `true` so callers can
* emit actionable warnings. The actual prefix is NOT auto-applied here — the
* `probeSplitFilesystem` probe in `main-action.ts` discovers it at runtime.
*
* @param _dockerHostCheck - Result of {@link checkDockerHost} (unused; kept for
* interface symmetry with the caller in {@link validateNetworkOptions}).
* @param explicitPrefix - Value from the `--docker-host-path-prefix` flag.
* @param env - Environment variables to inspect (defaults to `process.env`).
*/
export function resolveDockerHostPathPrefix(
_dockerHostCheck: { valid: true } | { valid: false; error: string },
explicitPrefix: string | undefined
): { dockerHostPathPrefix?: string; autoApplied: boolean } {
explicitPrefix: string | undefined,
env: Record<string, string | undefined> = process.env
): { dockerHostPathPrefix?: string; autoApplied: boolean; dindHint: boolean } {
const trimmedExplicitPrefix = explicitPrefix?.trim();

if (trimmedExplicitPrefix) {
return { dockerHostPathPrefix: trimmedExplicitPrefix, autoApplied: false };
return { dockerHostPathPrefix: trimmedExplicitPrefix, autoApplied: false, dindHint: false };
}

return { dockerHostPathPrefix: undefined, autoApplied: false };
const dindHint = env['AWF_DIND'] === '1' || isSiblingDaemonSocket(env);

return { dockerHostPathPrefix: undefined, autoApplied: false, dindHint };
}

/**
Expand Down
Loading