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,