Skip to content
Draft
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
6 changes: 5 additions & 1 deletion src/cli/commands/create/command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,10 @@ function printCreateHarnessSummary(projectName: string, harnessName: string): vo

/** Handle CLI mode for the harness path */
async function handleCreateHarnessCLI(options: CreateOptions): Promise<void> {
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;

Expand Down Expand Up @@ -274,6 +277,7 @@ async function handleCreateHarnessCLI(options: CreateOptions): Promise<void> {
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,
Expand Down
3 changes: 3 additions & 0 deletions src/cli/commands/create/harness-action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 9 additions & 1 deletion src/cli/primitives/HarnessPrimitive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -222,9 +229,10 @@ export class HarnessPrimitive extends BasePrimitive<AddHarnessOptions, Removable
let dockerfile: string | undefined;
if (options.dockerfilePath) {
const projectRoot = dirname(configBaseDir);
const dockerfileBaseDir = options.dockerfileBaseDir ?? projectRoot;
const srcPath = isAbsolute(options.dockerfilePath)
? options.dockerfilePath
: resolve(projectRoot, options.dockerfilePath);
: resolve(dockerfileBaseDir, options.dockerfilePath);
try {
await access(srcPath);
} catch {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { HarnessPrimitive } from '../HarnessPrimitive';
import { access, copyFile } from 'fs/promises';
import { join, resolve } from 'path';
import { afterEach, describe, expect, it, vi } from 'vitest';

// Relative Dockerfile resolution during `create`: configBaseDir points at the freshly-created
// project subdir (cwd/<projectName>/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<typeof access>[0]) => {

Check failure on line 41 in src/cli/primitives/__tests__/HarnessPrimitive.add.dockerfile.test.ts

View workflow job for this annotation

GitHub Actions / lint

Async arrow function has no 'await' expression
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/<name>/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');
});
});
1 change: 1 addition & 0 deletions src/cli/tui/screens/create/useCreateFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading