Skip to content
Closed
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
7 changes: 4 additions & 3 deletions src/cli/tui/screens/agent/AddAgentScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ import {
} from './types';
import { Box, Text, useInput } from 'ink';
import Spinner from 'ink-spinner';
import { basename, resolve } from 'path';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';

// Helper to get provider display name and env var name from ModelProvider
Expand Down Expand Up @@ -1086,12 +1085,14 @@ export function AddAgentScreen({ existingAgentNames, onComplete, onExit }: AddAg
{byoStep === 'dockerfile' && (
<PathInput
placeholder="Select a Dockerfile"
basePath={resolve(project?.projectRoot ?? process.cwd(), byoConfig.codeLocation)}
basePath={process.cwd()}
pathType="file"
allowEmpty
emptyHelpText="Press Enter to use the default Dockerfile"
onSubmit={value => {
setByoConfig(c => ({ ...c, dockerfile: value ? basename(value) : '' }));
// Preserve the user-entered path; resolution + copy into the
// agent code directory happens later in useAddAgent.handleByoPath.
setByoConfig(c => ({ ...c, dockerfile: value || '' }));
goToNextByoStep('dockerfile');
}}
onCancel={handleByoBack}
Expand Down
107 changes: 107 additions & 0 deletions src/cli/tui/screens/agent/__tests__/useAddAgent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { resolveUserDockerfile } from '../useAddAgent';
import { resolve } from 'path';
import { describe, expect, it, vi } from 'vitest';

// ---------------------------------------------------------------------------
// resolveUserDockerfile — regression tests for issue #1128
//
// The CLI must resolve a user-supplied Dockerfile path relative to the
// directory the user invoked the CLI from (cwd), NOT relative to the
// project root or codeLocation. Bare filenames are passed through
// unchanged so the rendered template default keeps working.
// ---------------------------------------------------------------------------

describe('resolveUserDockerfile', () => {
it('returns copy:false for undefined', () => {
const result = resolveUserDockerfile(undefined, '/some/cwd');
expect(result).toEqual({ ok: true, copy: false, filename: undefined });
});

it('returns copy:false for empty string', () => {
const result = resolveUserDockerfile('', '/some/cwd');
expect(result).toEqual({ ok: true, copy: false, filename: undefined });
});

it('returns copy:false for a bare filename and preserves the name', () => {
// A bare filename is the schema-valid value persisted in the project spec.
// We must not try to copy it; we must keep it as-is.
const fileExists = vi.fn(() => false);
const result = resolveUserDockerfile('Dockerfile', '/some/cwd', fileExists);
expect(result).toEqual({ ok: true, copy: false, filename: 'Dockerfile' });
// Importantly: existsSync is NOT consulted for a bare filename, so a
// user-supplied "Dockerfile" never gets a misleading "not found" error.
expect(fileExists).not.toHaveBeenCalled();
});

it('resolves a relative path against cwd (not project root)', () => {
const fileExists = vi.fn(() => true);
const cwd = '/home/user/.github';
const result = resolveUserDockerfile('./Dockerfile.dev', cwd, fileExists);

expect(result).toEqual({
ok: true,
copy: true,
sourcePath: resolve(cwd, './Dockerfile.dev'),
filename: 'Dockerfile.dev',
});
expect(fileExists).toHaveBeenCalledWith(resolve(cwd, './Dockerfile.dev'));
});

it('resolves a nested relative path against cwd', () => {
const fileExists = vi.fn(() => true);
const cwd = '/home/user/.github';
const result = resolveUserDockerfile('subdir/Dockerfile.gpu', cwd, fileExists);

expect(result).toEqual({
ok: true,
copy: true,
sourcePath: resolve(cwd, 'subdir/Dockerfile.gpu'),
filename: 'Dockerfile.gpu',
});
});

it('honors absolute paths regardless of cwd', () => {
const fileExists = vi.fn(() => true);
const result = resolveUserDockerfile('/tmp/MyDockerfile', '/home/user', fileExists);

expect(result).toEqual({
ok: true,
copy: true,
sourcePath: resolve('/tmp/MyDockerfile'),
filename: 'MyDockerfile',
});
});

it('returns ok:false with a cwd-resolved path when source file is missing', () => {
const fileExists = vi.fn(() => false);
const cwd = '/home/user/.github';
const result = resolveUserDockerfile('./missing.Dockerfile', cwd, fileExists);

expect(result.ok).toBe(false);
if (!result.ok) {
// Error message must point at the cwd-resolved path so users can see
// the actual filesystem location that was checked.
expect(result.error).toContain(resolve(cwd, './missing.Dockerfile'));
expect(result.error).toMatch(/Dockerfile not found at/);
}
});

it('does not resolve relative to projectRoot/codeLocation (regression for #1128)', () => {
// The reporter's scenario: cwd is a sub-directory like ".github", but
// the project root and BYO codeLocation are different. The resolver
// must use cwd, not <projectRoot>/<codeLocation>.
const cwd = '/workspace/proj/.github';
const projectRoot = '/workspace/proj';
const codeLocation = 'harnesses/';

const fileExists = vi.fn((p: string) => p === resolve(cwd, './my.Dockerfile'));

const result = resolveUserDockerfile('./my.Dockerfile', cwd, fileExists);

expect(result.ok).toBe(true);
if (result.ok && result.copy) {
expect(result.sourcePath).toBe(resolve(cwd, './my.Dockerfile'));
expect(result.sourcePath).not.toBe(resolve(projectRoot, codeLocation, './my.Dockerfile'));
}
});
});
94 changes: 77 additions & 17 deletions src/cli/tui/screens/agent/useAddAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,62 @@ function mapAddAgentConfigToGenerateConfig(config: AddAgentConfig): GenerateConf
};
}

// ─────────────────────────────────────────────────────────────────────────────
// Dockerfile path helpers
// ─────────────────────────────────────────────────────────────────────────────

/**
* Result of resolving a user-supplied Dockerfile path.
*
* - `ok: true, copy: true` → the user supplied a path; caller must copy
* `sourcePath` into the destination dir under name `filename`.
* - `ok: true, copy: false` → user supplied a bare filename or nothing;
* no copy is needed.
* - `ok: false` → file does not exist at the resolved path.
*/
export type ResolveDockerfileResult =
| { ok: true; copy: false; filename: string | undefined }
| { ok: true; copy: true; sourcePath: string; filename: string }
| { ok: false; error: string };

/**
* Resolve a user-supplied Dockerfile value relative to `cwd` (the directory
* the user invoked the CLI from).
*
* Behaviour:
* - undefined / empty → no-op (`copy: false`, filename stays undefined).
* - bare filename (e.g. `Dockerfile`) → no-op; the filename is preserved
* as-is for the project spec.
* - anything else (e.g. `./Dockerfile.dev`, `subdir/X`, `/abs/X`, `C:\X`) →
* instruct the caller to copy from `resolve(cwd, value)` into the
* destination directory, persisting only the basename.
*
* Validates that the resolved source file exists; returns `ok: false` with
* a descriptive error otherwise.
*
* Exported for unit testing — see issue #1128.
*/
export function resolveUserDockerfile(
value: string | undefined,
cwd: string,
fileExists: (p: string) => boolean = existsSync
): ResolveDockerfileResult {
if (!value) {
return { ok: true, copy: false, filename: undefined };
}

// Bare filename → preserve as-is, no copy.
if (value === basename(value)) {
return { ok: true, copy: false, filename: value };
}

const sourcePath = resolve(cwd, value);
if (!fileExists(sourcePath)) {
return { ok: false, error: `Dockerfile not found at ${sourcePath}` };
}
return { ok: true, copy: true, sourcePath, filename: basename(sourcePath) };
}

// ─────────────────────────────────────────────────────────────────────────────
// Hook
// ─────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -260,15 +316,17 @@ async function handleCreatePath(
const renderer = createRenderer(renderConfig);
await renderer.render({ outputDir: projectRoot });

// If dockerfile is a path (contains /), copy it into the agent directory (overwriting template default)
if (generateConfig.dockerfile?.includes('/')) {
const sourcePath = resolve(projectRoot, generateConfig.dockerfile);
if (!existsSync(sourcePath)) {
return { ok: false, error: `Dockerfile not found at ${sourcePath}` };
}
const filename = basename(sourcePath);
copyFileSync(sourcePath, join(agentPath, filename));
generateConfig.dockerfile = filename;
// If dockerfile is a path (not a bare filename), resolve it relative to the
// directory the user invoked the CLI from (process.cwd()), copy it into the
// agent directory, and persist only the basename in the project spec
// (agent-env schema requires a filename, not a path).
const dockerfileResolution = resolveUserDockerfile(generateConfig.dockerfile, process.cwd());
if (!dockerfileResolution.ok) {
return { ok: false, error: dockerfileResolution.error };
}
if (dockerfileResolution.copy) {
copyFileSync(dockerfileResolution.sourcePath, join(agentPath, dockerfileResolution.filename));
generateConfig.dockerfile = dockerfileResolution.filename;
}

// Write agent to project config
Expand Down Expand Up @@ -369,15 +427,17 @@ async function handleByoPath(
const codeDir = join(projectRoot, config.codeLocation.replace(/\/$/, ''));
mkdirSync(codeDir, { recursive: true });

// If dockerfile is a path (contains /), copy it into the code directory and use the filename
// If dockerfile is a path (not a bare filename), resolve it relative to the
// directory the user invoked the CLI from (process.cwd()), copy it into the
// BYO code directory, and use only the basename when persisting.
let dockerfileName = config.dockerfile;
if (dockerfileName?.includes('/')) {
const sourcePath = resolve(projectRoot, dockerfileName);
if (!existsSync(sourcePath)) {
return { ok: false, error: `Dockerfile not found at ${sourcePath}` };
}
dockerfileName = basename(sourcePath);
copyFileSync(sourcePath, join(codeDir, dockerfileName));
const byoDockerfileResolution = resolveUserDockerfile(dockerfileName, process.cwd());
if (!byoDockerfileResolution.ok) {
return { ok: false, error: byoDockerfileResolution.error };
}
if (byoDockerfileResolution.copy) {
copyFileSync(byoDockerfileResolution.sourcePath, join(codeDir, byoDockerfileResolution.filename));
dockerfileName = byoDockerfileResolution.filename;
}

const project = await configIO.readProjectSpec();
Expand Down
7 changes: 5 additions & 2 deletions src/cli/tui/screens/generate/GenerateWizardUI.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@ import {
} from './types';
import type { useGenerateWizard } from './useGenerateWizard';
import { Box, Text, useInput } from 'ink';
import { basename } from 'path';

// Helper to get provider display name and env var name from ModelProvider
function getProviderInfo(provider: ModelProvider): { name: string; envVarName: string } {
Expand Down Expand Up @@ -234,7 +233,11 @@ export function GenerateWizardUI({
allowEmpty
emptyHelpText="Press Enter to use the default Dockerfile"
onSubmit={value => {
wizard.setDockerfile(value ? basename(value) : undefined);
// Pass the user-entered path through unchanged. Final basename
// resolution + copy into the agent directory happens in
// useAddAgent.handleCreatePath, so the directory component must
// survive this step.
wizard.setDockerfile(value || undefined);
}}
onCancel={onBack}
/>
Expand Down
22 changes: 22 additions & 0 deletions src/cli/tui/screens/generate/__tests__/useGenerateWizard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,28 @@ describe('useGenerateWizard — advanced config gate', () => {
expect(ref.current!.wizard.step).toBe('confirm');
vi.useRealTimers();
});

it('setDockerfile preserves directory components (no basename stripping)', () => {
// Regression test for #1128: the wizard must keep the user-supplied
// path intact so useAddAgent.handleCreatePath can resolve it relative
// to process.cwd() and copy the file.
vi.useFakeTimers();
const { ref } = setup();
walkToAdvancedWithContainer(ref);

act(() => ref.current!.wizard.setAdvanced(['dockerfile']));
act(() => {
vi.runAllTimers();
});

act(() => ref.current!.wizard.setDockerfile('./subdir/Dockerfile.dev'));
act(() => {
vi.runAllTimers();
});

expect(ref.current!.wizard.config.dockerfile).toBe('./subdir/Dockerfile.dev');
vi.useRealTimers();
});
});

describe('filesystem advanced setting', () => {
Expand Down
Loading