From c1bf1f42ce1f434659913780a25773f246a64b4c Mon Sep 17 00:00:00 2001 From: Aidan Daly Date: Thu, 25 Jun 2026 06:11:29 +0000 Subject: [PATCH] fix(unit-only): resolve create harness Dockerfile paths from command cwd Relative Dockerfile paths in the agentcore create harness flow resolved against the freshly-created project subdir instead of the invocation cwd, causing a spurious 'Dockerfile not found' error. Add dockerfileBaseDir to AddHarnessOptions and resolve relative paths against options.dockerfileBaseDir ?? projectRoot. Thread the invocation cwd through the CLI create path, createProjectWithHarness, and the TUI flow. projectRoot remains the fallback so standalone add harness is unchanged; absolute paths still bypass via isAbsolute(). Fixes #1128 --- src/cli/commands/create/command.tsx | 6 +- src/cli/commands/create/harness-action.ts | 3 + src/cli/primitives/HarnessPrimitive.ts | 10 ++- .../HarnessPrimitive.add.dockerfile.test.ts | 86 +++++++++++++++++++ src/cli/tui/screens/create/useCreateFlow.ts | 1 + 5 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 src/cli/primitives/__tests__/HarnessPrimitive.add.dockerfile.test.ts diff --git a/src/cli/commands/create/command.tsx b/src/cli/commands/create/command.tsx index 88e37b117..1fb218915 100644 --- a/src/cli/commands/create/command.tsx +++ b/src/cli/commands/create/command.tsx @@ -156,7 +156,10 @@ function printCreateHarnessSummary(projectName: string, harnessName: string): vo /** Handle CLI mode for the harness path */ async function handleCreateHarnessCLI(options: CreateOptions): Promise { - const cwd = options.outputDir ?? getWorkingDirectory(); + // The invocation cwd is where the user typed a relative --container Dockerfile path. Capture it + // before --output-dir overrides `cwd`, so the Dockerfile resolves from where the command was run. + const invocationCwd = getWorkingDirectory(); + const cwd = options.outputDir ?? invocationCwd; const name = options.name ?? options.projectName; const projectName = options.projectName ?? name; @@ -274,6 +277,7 @@ async function handleCreateHarnessCLI(options: CreateOptions): Promise { additionalParams, containerUri: containerOption.containerUri, dockerfilePath: containerOption.dockerfilePath, + dockerfileBaseDir: invocationCwd, skipMemory: options.harnessMemory === false, maxIterations: options.maxIterations ? Number(options.maxIterations) : undefined, maxTokens: options.maxTokens ? Number(options.maxTokens) : undefined, diff --git a/src/cli/commands/create/harness-action.ts b/src/cli/commands/create/harness-action.ts index a6e9db258..40797bf46 100644 --- a/src/cli/commands/create/harness-action.ts +++ b/src/cli/commands/create/harness-action.ts @@ -19,6 +19,8 @@ export interface CreateHarnessProjectOptions { skipMemory?: boolean; containerUri?: string; dockerfilePath?: string; + /** Base dir a relative dockerfilePath resolves against (the invocation cwd). Defaults to `cwd`. */ + dockerfileBaseDir?: string; maxIterations?: number; maxTokens?: number; timeoutSeconds?: number; @@ -68,6 +70,7 @@ export async function createProjectWithHarness(options: CreateHarnessProjectOpti additionalParams: options.additionalParams, containerUri: options.containerUri, dockerfilePath: options.dockerfilePath, + dockerfileBaseDir: options.dockerfileBaseDir ?? cwd, skipMemory: options.skipMemory, maxIterations: options.maxIterations, maxTokens: options.maxTokens, diff --git a/src/cli/primitives/HarnessPrimitive.ts b/src/cli/primitives/HarnessPrimitive.ts index 1ff48fa1b..1fd6853d5 100644 --- a/src/cli/primitives/HarnessPrimitive.ts +++ b/src/cli/primitives/HarnessPrimitive.ts @@ -132,6 +132,13 @@ export interface AddHarnessOptions { memoryRelevanceScore?: number; containerUri?: string; dockerfilePath?: string; + /** + * Base directory a relative dockerfilePath is resolved against. During `create` this is the + * invocation cwd (where the user typed the relative path), since configBaseDir points at the + * freshly-created project subdir. Defaults to projectRoot (dirname(configBaseDir)) when omitted, + * preserving standalone `add harness`. + */ + dockerfileBaseDir?: string; maxIterations?: number; maxTokens?: number; timeoutSeconds?: number; @@ -222,9 +229,10 @@ export class HarnessPrimitive extends BasePrimitive/agentcore), so a relative --container path must resolve against +// the INVOCATION cwd (passed as dockerfileBaseDir), not dirname(configBaseDir). Without +// dockerfileBaseDir, resolution falls back to projectRoot so standalone `add harness` is unchanged. + +const mockReadProjectSpec = vi.fn(); + +vi.mock('../../../lib', () => ({ + APP_DIR: 'app', + ConfigIO: class { + readProjectSpec = mockReadProjectSpec; + writeProjectSpec = vi.fn(); + writeHarnessSpec = vi.fn(); + getPathResolver = () => ({ getHarnessDir: (name: string) => `/invocation/cwd/proj/agentcore/app/${name}` }); + }, + findConfigRoot: () => '/invocation/cwd/proj/agentcore', +})); + +vi.mock('fs/promises', () => ({ + access: vi.fn(), + copyFile: vi.fn(), + mkdir: vi.fn(), + readFile: vi.fn(), + writeFile: vi.fn(), +})); + +const INVOCATION_CWD = '/invocation/cwd'; +const CONFIG_BASE_DIR = '/invocation/cwd/proj/agentcore'; + +function baseProject() { + return { name: 'proj', harnesses: [], memories: [], runtimes: [] }; +} + +/** access() succeeds only for the Dockerfile sitting at the invocation cwd, and nowhere else. */ +function onlyExistsAtInvocationCwd() { + vi.mocked(access).mockImplementation(async (p: Parameters[0]) => { + if (String(p) === resolve(INVOCATION_CWD, './Dockerfile')) return; + throw new Error('ENOENT'); + }); +} + +describe('HarnessPrimitive.add — relative Dockerfile base dir', () => { + afterEach(() => vi.clearAllMocks()); + + it('resolves a relative dockerfilePath against dockerfileBaseDir (invocation cwd), not projectRoot', async () => { + mockReadProjectSpec.mockResolvedValue(baseProject()); + onlyExistsAtInvocationCwd(); + + const result = await new HarnessPrimitive().add({ + name: 'support', + modelProvider: 'bedrock', + modelId: 'anthropic.claude-3', + configBaseDir: CONFIG_BASE_DIR, + dockerfilePath: './Dockerfile', + dockerfileBaseDir: INVOCATION_CWD, + } as never); + + expect(result.success).toBe(true); + const [src, dest] = vi.mocked(copyFile).mock.calls.at(-1)!; + expect(src).toBe(resolve(INVOCATION_CWD, './Dockerfile')); + // Destination still lands under the project: agentcore/app//Dockerfile. + expect(dest).toBe(join('/invocation/cwd/proj', 'app', 'support', 'Dockerfile')); + }); + + it('falls back to projectRoot when dockerfileBaseDir is omitted (standalone add harness unchanged)', async () => { + mockReadProjectSpec.mockResolvedValue(baseProject()); + onlyExistsAtInvocationCwd(); + + const result = await new HarnessPrimitive().add({ + name: 'support', + modelProvider: 'bedrock', + modelId: 'anthropic.claude-3', + configBaseDir: CONFIG_BASE_DIR, + dockerfilePath: './Dockerfile', + } as never); + + expect(result.success).toBe(false); + expect((result as { error: Error }).error.name).toBe('ResourceNotFoundError'); + expect((result as { error: Error }).error.message).toContain('Dockerfile not found at'); + }); +}); diff --git a/src/cli/tui/screens/create/useCreateFlow.ts b/src/cli/tui/screens/create/useCreateFlow.ts index c1b909531..d6cfbabd7 100644 --- a/src/cli/tui/screens/create/useCreateFlow.ts +++ b/src/cli/tui/screens/create/useCreateFlow.ts @@ -636,6 +636,7 @@ export function useCreateFlow(cwd: string): CreateFlowState { ...memoryOptions, containerUri: addHarnessConfig.containerUri, dockerfilePath: addHarnessConfig.dockerfilePath, + dockerfileBaseDir: cwd, maxIterations: addHarnessConfig.maxIterations, maxTokens: addHarnessConfig.maxTokens, timeoutSeconds: addHarnessConfig.timeoutSeconds,