From cc4f7838fe662b8f68b4f290a49acd69a848f0dd Mon Sep 17 00:00:00 2001 From: Padma Komarina Date: Wed, 24 Jun 2026 00:17:23 -0400 Subject: [PATCH 1/6] feat(export): export harnesses to standalone Strands agents with connections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend `agentcore export harness` to faithfully export harnesses that reference EXTERNAL AgentCore resources, in-project (--name) or by ARN (--arn), with no manual IAM/wiring overrides. - Auto-populate `connections[]` (memory/gateway/runtime/browser/codeInterpreter) on the exported runtime instead of emitting IAM prose notes. Discovery env-var names are derived from a single source of truth in schemas/connections.ts and kept in lockstep with @aws/agentcore-cdk, so the names baked into the generated agent code match what deploy injects. - New --arn source: fetch a harness from the control plane and map it (fetch-harness-spec.ts) — model (incl. LiteLLM), memory, runtime environment, skills (git auth carried through), and tools. - Add LiteLLM model-provider support (provider-prefixed model_ids; keyless via IAM for bedrock/, apiKeyArn otherwise) across schema, template, and telemetry. - Carry gateway outbound-auth (awsIam | none | oauth{providerArn, scopes, grantType, customParameters}) end-to-end; render customParameters as valid Python via safeJson and thread grantType -> auth_flow. - Copy path-skill directories for generated-Dockerfile Container builds. Verified live end-to-end: create harness -> export (in-project and by ARN) -> deploy -> invoke; the agent reaches the external memory + gateway at runtime. --- .../assets.snapshot.test.ts.snap | 74 ++- src/assets/python/http/strands/base/main.py | 14 +- .../http/strands/base/mcp_client/client.py | 5 +- .../python/http/strands/base/model/load.py | 54 ++ .../python/http/strands/base/pyproject.toml | 1 + src/cli/aws/agentcore-harness.ts | 27 +- .../export/__tests__/arn-s3-chain.test.ts | 54 ++ .../__tests__/fetch-harness-spec.test.ts | 315 +++++++++++ .../export/__tests__/harness-action.test.ts | 40 +- .../export/__tests__/harness-mapper.test.ts | 451 +++++++++++---- src/cli/commands/export/constants.ts | 13 +- src/cli/commands/export/fetch-harness-spec.ts | 277 ++++++++++ src/cli/commands/export/harness-action.ts | 182 +++++- src/cli/commands/export/harness-mapper.ts | 517 ++++++++++++------ src/cli/commands/export/harness-resolver.ts | 61 ++- src/cli/commands/export/index.ts | 14 +- src/cli/commands/export/types.ts | 19 + src/cli/templates/types.ts | 26 +- src/cli/tui/screens/agent/types.ts | 2 + src/schema/__tests__/constants.test.ts | 4 +- src/schema/constants.ts | 11 +- src/schema/llm-compacted/agentcore.ts | 32 ++ .../schemas/__tests__/connections.test.ts | 191 +++++++ src/schema/schemas/agent-env.ts | 11 + src/schema/schemas/connections.ts | 195 +++++++ src/schema/schemas/index.ts | 1 + src/schema/schemas/primitives/harness.ts | 4 + 27 files changed, 2234 insertions(+), 361 deletions(-) create mode 100644 src/cli/commands/export/__tests__/arn-s3-chain.test.ts create mode 100644 src/cli/commands/export/__tests__/fetch-harness-spec.test.ts create mode 100644 src/cli/commands/export/fetch-harness-spec.ts create mode 100644 src/schema/schemas/__tests__/connections.test.ts create mode 100644 src/schema/schemas/connections.ts diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index 4b42f93a5..3b4f24c3b 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -5368,10 +5368,20 @@ tools.append(add_numbers) {{/unless}} {{/if}} {{#if hasBrowser}} -tools.append(AgentCoreBrowser({{#if browserIdentifier}}identifier="{{browserIdentifier}}"{{/if}}).browser) +{{#if browserIdentifierEnvVar}} +_browser_id = os.getenv("{{browserIdentifierEnvVar}}") +tools.append(AgentCoreBrowser(**({"identifier": _browser_id} if _browser_id else {})).browser) +{{else}} +tools.append(AgentCoreBrowser().browser) +{{/if}} {{/if}} {{#if hasCodeInterpreter}} -tools.append(AgentCoreCodeInterpreter({{#if codeInterpreterIdentifier}}identifier="{{codeInterpreterIdentifier}}"{{/if}}).code_interpreter) +{{#if codeInterpreterIdentifierEnvVar}} +_code_interpreter_id = os.getenv("{{codeInterpreterIdentifierEnvVar}}") +tools.append(AgentCoreCodeInterpreter(**({"identifier": _code_interpreter_id} if _code_interpreter_id else {})).code_interpreter) +{{else}} +tools.append(AgentCoreCodeInterpreter().code_interpreter) +{{/if}} {{/if}} {{#if hasShell}} @tool @@ -5914,7 +5924,10 @@ from bedrock_agentcore.identity import requires_access_token @requires_access_token( provider_name="{{credentialProviderName}}", scopes=[{{#if scopes}}"{{scopes}}"{{/if}}], - auth_flow="M2M", + auth_flow="{{#if authFlow}}{{authFlow}}{{else}}M2M{{/if}}", +{{#if customParameters}} + custom_parameters={{safeJson customParameters}}, +{{/if}} ) def _get_bearer_token_{{snakeCase name}}(*, access_token: str): """Obtain OAuth access token via AgentCore Identity for {{name}}.""" @@ -6139,6 +6152,60 @@ def load_model() -> GeminiModel: model_id="{{#if modelId}}{{modelId}}{{else}}gemini-2.5-flash{{/if}}", ) {{/if}} +{{#if (eq modelProvider "LiteLLM")}} +import os +{{#if litellmAdditionalParams}} +import json +{{/if}} + +from strands.models.litellm import LiteLLMModel +{{#if identityProviders.[0].name}} +from bedrock_agentcore.identity.auth import requires_api_key + +IDENTITY_PROVIDER_NAME = "{{identityProviders.[0].name}}" +IDENTITY_ENV_VAR = "{{identityProviders.[0].envVarName}}" + + +@requires_api_key(provider_name=IDENTITY_PROVIDER_NAME) +def _agentcore_identity_api_key_provider(api_key: str) -> str: + """Fetch API key from AgentCore Identity.""" + return api_key + + +def _get_api_key() -> str: + """ + Uses AgentCore Identity for API key management in deployed environments. + For local development, run via 'agentcore dev' which loads agentcore/.env. + """ + if os.getenv("LOCAL_DEV") == "1": + api_key = os.getenv(IDENTITY_ENV_VAR) + if not api_key: + raise RuntimeError( + f"{IDENTITY_ENV_VAR} not found. Add {IDENTITY_ENV_VAR}=your-key to .env.local" + ) + return api_key + return _agentcore_identity_api_key_provider() +{{/if}} + + + + +def load_model() -> LiteLLMModel: + """Get a LiteLLM model client (proxies to the provider encoded in model_id).""" + client_args = {} + {{#if identityProviders.[0].name}} + client_args["api_key"] = _get_api_key() + {{/if}} + {{#if litellmApiBase}} + client_args["api_base"] = {{safeJson litellmApiBase}} + {{/if}} + params = {{#if litellmAdditionalParams}}json.loads({{pyJsonStr litellmAdditionalParams}}){{else}}{}{{/if}} + return LiteLLMModel( + client_args=client_args, + model_id="{{#if modelId}}{{modelId}}{{else}}bedrock/us.anthropic.claude-sonnet-4-5-20250514-v1:0{{/if}}", + params=params, + ) +{{/if}} " `; @@ -6161,6 +6228,7 @@ dependencies = [ {{#if (eq modelProvider "Gemini")}}"google-genai >= 1.0.0", {{/if}}"mcp >= 1.19.0", {{#if (eq modelProvider "OpenAI")}}"openai >= 1.0.0", + {{/if}}{{#if (eq modelProvider "LiteLLM")}}"litellm >= 1.0.0", {{/if}}"strands-agents >= 1.15.0", {{#if (or hasBrowser hasCodeInterpreter)}}"strands-agents-tools >= 0.1.0", {{/if}}{{#if hasBrowser}}"nest-asyncio >= 1.5.0", diff --git a/src/assets/python/http/strands/base/main.py b/src/assets/python/http/strands/base/main.py index 87a216c32..741e954ca 100644 --- a/src/assets/python/http/strands/base/main.py +++ b/src/assets/python/http/strands/base/main.py @@ -152,10 +152,20 @@ def add_numbers(a: int, b: int) -> int: {{/unless}} {{/if}} {{#if hasBrowser}} -tools.append(AgentCoreBrowser({{#if browserIdentifier}}identifier="{{browserIdentifier}}"{{/if}}).browser) +{{#if browserIdentifierEnvVar}} +_browser_id = os.getenv("{{browserIdentifierEnvVar}}") +tools.append(AgentCoreBrowser(**({"identifier": _browser_id} if _browser_id else {})).browser) +{{else}} +tools.append(AgentCoreBrowser().browser) +{{/if}} {{/if}} {{#if hasCodeInterpreter}} -tools.append(AgentCoreCodeInterpreter({{#if codeInterpreterIdentifier}}identifier="{{codeInterpreterIdentifier}}"{{/if}}).code_interpreter) +{{#if codeInterpreterIdentifierEnvVar}} +_code_interpreter_id = os.getenv("{{codeInterpreterIdentifierEnvVar}}") +tools.append(AgentCoreCodeInterpreter(**({"identifier": _code_interpreter_id} if _code_interpreter_id else {})).code_interpreter) +{{else}} +tools.append(AgentCoreCodeInterpreter().code_interpreter) +{{/if}} {{/if}} {{#if hasShell}} @tool diff --git a/src/assets/python/http/strands/base/mcp_client/client.py b/src/assets/python/http/strands/base/mcp_client/client.py index 72987c456..4de07e43a 100644 --- a/src/assets/python/http/strands/base/mcp_client/client.py +++ b/src/assets/python/http/strands/base/mcp_client/client.py @@ -18,7 +18,10 @@ @requires_access_token( provider_name="{{credentialProviderName}}", scopes=[{{#if scopes}}"{{scopes}}"{{/if}}], - auth_flow="M2M", + auth_flow="{{#if authFlow}}{{authFlow}}{{else}}M2M{{/if}}", +{{#if customParameters}} + custom_parameters={{safeJson customParameters}}, +{{/if}} ) def _get_bearer_token_{{snakeCase name}}(*, access_token: str): """Obtain OAuth access token via AgentCore Identity for {{name}}.""" diff --git a/src/assets/python/http/strands/base/model/load.py b/src/assets/python/http/strands/base/model/load.py index e1f013b89..90ed4ecce 100644 --- a/src/assets/python/http/strands/base/model/load.py +++ b/src/assets/python/http/strands/base/model/load.py @@ -121,3 +121,57 @@ def load_model() -> GeminiModel: model_id="{{#if modelId}}{{modelId}}{{else}}gemini-2.5-flash{{/if}}", ) {{/if}} +{{#if (eq modelProvider "LiteLLM")}} +import os +{{#if litellmAdditionalParams}} +import json +{{/if}} + +from strands.models.litellm import LiteLLMModel +{{#if identityProviders.[0].name}} +from bedrock_agentcore.identity.auth import requires_api_key + +IDENTITY_PROVIDER_NAME = "{{identityProviders.[0].name}}" +IDENTITY_ENV_VAR = "{{identityProviders.[0].envVarName}}" + + +@requires_api_key(provider_name=IDENTITY_PROVIDER_NAME) +def _agentcore_identity_api_key_provider(api_key: str) -> str: + """Fetch API key from AgentCore Identity.""" + return api_key + + +def _get_api_key() -> str: + """ + Uses AgentCore Identity for API key management in deployed environments. + For local development, run via 'agentcore dev' which loads agentcore/.env. + """ + if os.getenv("LOCAL_DEV") == "1": + api_key = os.getenv(IDENTITY_ENV_VAR) + if not api_key: + raise RuntimeError( + f"{IDENTITY_ENV_VAR} not found. Add {IDENTITY_ENV_VAR}=your-key to .env.local" + ) + return api_key + return _agentcore_identity_api_key_provider() +{{/if}} + + + + +def load_model() -> LiteLLMModel: + """Get a LiteLLM model client (proxies to the provider encoded in model_id).""" + client_args = {} + {{#if identityProviders.[0].name}} + client_args["api_key"] = _get_api_key() + {{/if}} + {{#if litellmApiBase}} + client_args["api_base"] = {{safeJson litellmApiBase}} + {{/if}} + params = {{#if litellmAdditionalParams}}json.loads({{pyJsonStr litellmAdditionalParams}}){{else}}{}{{/if}} + return LiteLLMModel( + client_args=client_args, + model_id="{{#if modelId}}{{modelId}}{{else}}bedrock/us.anthropic.claude-sonnet-4-5-20250514-v1:0{{/if}}", + params=params, + ) +{{/if}} diff --git a/src/assets/python/http/strands/base/pyproject.toml b/src/assets/python/http/strands/base/pyproject.toml index bed35447f..15ae87018 100644 --- a/src/assets/python/http/strands/base/pyproject.toml +++ b/src/assets/python/http/strands/base/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ {{#if (eq modelProvider "Gemini")}}"google-genai >= 1.0.0", {{/if}}"mcp >= 1.19.0", {{#if (eq modelProvider "OpenAI")}}"openai >= 1.0.0", + {{/if}}{{#if (eq modelProvider "LiteLLM")}}"litellm >= 1.0.0", {{/if}}"strands-agents >= 1.15.0", {{#if (or hasBrowser hasCodeInterpreter)}}"strands-agents-tools >= 0.1.0", {{/if}}{{#if hasBrowser}}"nest-asyncio >= 1.5.0", diff --git a/src/cli/aws/agentcore-harness.ts b/src/cli/aws/agentcore-harness.ts index dd7c0e281..18a913a8e 100644 --- a/src/cli/aws/agentcore-harness.ts +++ b/src/cli/aws/agentcore-harness.ts @@ -67,8 +67,8 @@ export type HarnessSystemPrompt = { text: string }[]; export interface HarnessTool { type: string; name: string; - browserArn?: string; - codeInterpreterArn?: string; + // Browser / code-interpreter ARNs are returned nested under config.agentCoreBrowser.browserArn / + // config.agentCoreCodeInterpreter.codeInterpreterArn (NOT top-level) — see mapTool. config?: Record; } @@ -97,13 +97,30 @@ export interface HarnessEnvironmentArtifact { containerConfiguration?: { containerUri: string }; } +/** A single filesystem mount on the runtime environment (tagged union — exactly one key set). */ +export interface HarnessFilesystemConfiguration { + sessionStorage?: { mountPath: string }; + efsAccessPoint?: { accessPointArn: string; mountPath: string }; + s3FilesAccessPoint?: { accessPointArn: string; mountPath: string }; +} + +export interface HarnessNetworkConfiguration { + networkMode?: 'PUBLIC' | 'VPC'; + networkModeConfig?: { securityGroups?: string[]; subnets?: string[]; requireServiceS3Endpoint?: boolean }; +} + +export interface HarnessLifecycleConfiguration { + idleRuntimeSessionTimeout?: number; + maxLifetime?: number; +} + export interface HarnessAgentCoreRuntimeEnvironment { agentRuntimeArn?: string; agentRuntimeId?: string; agentRuntimeName?: string; - lifecycleConfiguration?: Record; - networkConfiguration?: Record; - filesystemConfigurations?: Record[]; + lifecycleConfiguration?: HarnessLifecycleConfiguration; + networkConfiguration?: HarnessNetworkConfiguration; + filesystemConfigurations?: HarnessFilesystemConfiguration[]; } export interface HarnessEnvironmentProvider { diff --git a/src/cli/commands/export/__tests__/arn-s3-chain.test.ts b/src/cli/commands/export/__tests__/arn-s3-chain.test.ts new file mode 100644 index 000000000..524d3fd29 --- /dev/null +++ b/src/cli/commands/export/__tests__/arn-s3-chain.test.ts @@ -0,0 +1,54 @@ +import type { Harness } from '../../../aws/agentcore-harness'; +import { mapApiHarnessToSpec } from '../fetch-harness-spec'; +import { mapHarnessToExportConfig } from '../harness-mapper'; +import type { ResolvedHarnessContext } from '../types'; +import { describe, expect, it } from 'vitest'; + +/** + * Regression for the --arn S3-skill gap found in live e2e: a harness fetched by ARN returns its + * S3 skill in the control-plane shape `{ S3: { Uri } }`. The fetch mapper must normalize it to + * `{ s3Uri }` so the export mapper's isS3Skill branch fires and generates the s3-skills policy + * (otherwise S3 access is silently dropped for ARN-exported harnesses). + */ +describe('--arn export: S3 skill produces additionalPolicies', () => { + it('generates s3-skills-policy.json from a fetched { S3: { Uri } } skill', () => { + const apiHarness = { + harnessId: 'h-1', + harnessName: 'Fetched', + arn: 'arn:aws:bedrock-agentcore:us-east-1:111122223333:harness/h-1', + status: 'READY', + executionRoleArn: 'arn:aws:iam::111122223333:role/r', + model: { bedrockModelConfig: { modelId: 'anthropic.claude-3' } }, + skills: [{ S3: { Uri: 's3://fetched-bucket/skills/' } } as never], + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + } as Harness; + + const { spec } = mapApiHarnessToSpec(apiHarness); + const ctx: ResolvedHarnessContext = { + harnessName: 'Fetched', + targetAgentName: 'FetchedAgent', + spec, + systemPrompt: 'hi', + projectSpec: { name: 'p', runtimes: [], memories: [], credentials: [], harnesses: [] } as never, + deployedResources: null, + configBaseDir: '/p/agentcore', + projectRoot: '/p', + exportNotes: [], + region: 'us-east-1', + localEnvVars: {}, + generatedPolicyFiles: {}, + additionalPolicies: [], + }; + + const { agentEnvSpec } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(ctx.additionalPolicies).toContain('s3-skills-policy.json'); + expect(agentEnvSpec.additionalPolicies).toContain('s3-skills-policy.json'); + const doc = ctx.generatedPolicyFiles['s3-skills-policy.json'] as { + Statement: { Action: string; Resource: string[] }[]; + }; + expect(doc.Statement.find(s => s.Action === 's3:GetObject')!.Resource).toContain( + 'arn:aws:s3:::fetched-bucket/skills/*' + ); + }); +}); diff --git a/src/cli/commands/export/__tests__/fetch-harness-spec.test.ts b/src/cli/commands/export/__tests__/fetch-harness-spec.test.ts new file mode 100644 index 000000000..981f7be9c --- /dev/null +++ b/src/cli/commands/export/__tests__/fetch-harness-spec.test.ts @@ -0,0 +1,315 @@ +import type { Harness } from '../../../aws/agentcore-harness'; +import { harnessIdFromArn, mapApiHarnessToSpec } from '../fetch-harness-spec'; +import { describe, expect, it } from 'vitest'; + +function makeApiHarness(overrides: Partial = {}): Harness { + return { + harnessId: 'h-123', + harnessName: 'MyHarness', + arn: 'arn:aws:bedrock-agentcore:us-east-1:111122223333:harness/h-123', + status: 'READY', + executionRoleArn: 'arn:aws:iam::111122223333:role/harness-role', + model: { bedrockModelConfig: { modelId: 'anthropic.claude-3' } }, + systemPrompt: [{ text: 'You are helpful.' }], + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + ...overrides, + }; +} + +describe('harnessIdFromArn', () => { + it('extracts the id from a harness ARN', () => { + expect(harnessIdFromArn('arn:aws:bedrock-agentcore:us-east-1:111122223333:harness/h-123')).toBe('h-123'); + }); + + it('throws on a non-harness ARN', () => { + expect(() => harnessIdFromArn('arn:aws:bedrock-agentcore:us-east-1:111122223333:memory/m-1')).toThrow(); + }); +}); + +describe('mapApiHarnessToSpec', () => { + it('maps a bedrock harness to a HarnessSpec', () => { + const { spec, systemPrompt } = mapApiHarnessToSpec(makeApiHarness()); + expect(spec.name).toBe('MyHarness'); + expect(spec.model).toEqual({ provider: 'bedrock', modelId: 'anthropic.claude-3' }); + expect(systemPrompt).toBe('You are helpful.'); + }); + + it('does NOT carry the harness executionRoleArn — the exported agent gets its own CDK-managed role', () => { + // Reusing the harness's (imported, immutable) role would prevent CDK from attaching the runtime + // baseline (ECR pull for Container builds), connection grants, and additionalPolicies — which + // breaks Container deploys. The exported agent is a new, independent runtime. + const { spec } = mapApiHarnessToSpec(makeApiHarness()); + expect(spec.executionRoleArn).toBeUndefined(); + }); + + it('maps an openai harness with apiKeyArn', () => { + const { spec } = mapApiHarnessToSpec( + makeApiHarness({ model: { openAiModelConfig: { modelId: 'gpt-4.1', apiKeyArn: 'arn:aws:...:secret/k' } } }) + ); + expect(spec.model).toEqual({ provider: 'open_ai', modelId: 'gpt-4.1', apiKeyArn: 'arn:aws:...:secret/k' }); + }); + + it('maps a gemini harness with topK', () => { + const { spec } = mapApiHarnessToSpec( + makeApiHarness({ + model: { geminiModelConfig: { modelId: 'gemini-2.0', apiKeyArn: 'arn:aws:...:secret/g', topK: 40 } }, + }) + ); + expect(spec.model).toEqual({ + provider: 'gemini', + modelId: 'gemini-2.0', + apiKeyArn: 'arn:aws:...:secret/g', + topK: 40, + }); + }); + + it('maps a lite_llm harness with apiBase and additionalParams', () => { + const { spec } = mapApiHarnessToSpec( + makeApiHarness({ + model: { + liteLlmModelConfig: { + modelId: 'bedrock/claude', + apiBase: 'https://proxy.example', + additionalParams: { x: '1' }, + }, + }, + }) + ); + expect(spec.model).toEqual({ + provider: 'lite_llm', + modelId: 'bedrock/claude', + apiBase: 'https://proxy.example', + additionalParams: { x: '1' }, + }); + }); + + it('maps an external memory reference', () => { + const { spec } = mapApiHarnessToSpec( + makeApiHarness({ + memory: { + agentCoreMemoryConfiguration: { + arn: 'arn:aws:bedrock-agentcore:us-east-1:999:memory/external', + actorId: 'actor-1', + }, + }, + }) + ); + expect(spec.memory).toEqual({ + mode: 'existing', + arn: 'arn:aws:bedrock-agentcore:us-east-1:999:memory/external', + actorId: 'actor-1', + }); + }); + + it('maps a managed-memory harness to mode "managed"', () => { + const { spec } = mapApiHarnessToSpec(makeApiHarness({ memory: { managedMemoryConfiguration: {} } as any })); + expect(spec.memory).toEqual({ mode: 'managed' }); + }); + + it('maps an SDK-unknown managed memory member to mode "managed" (SDK lags service)', () => { + // When the bundled SDK model lacks the variant, the service member surfaces as SDK_UNKNOWN_MEMBER. + const { spec } = mapApiHarnessToSpec( + makeApiHarness({ memory: { SDK_UNKNOWN_MEMBER: { name: 'managedMemoryConfiguration' } } as any }) + ); + expect(spec.memory).toEqual({ mode: 'managed' }); + }); + + it('omits memory entirely for an unrecognized memory shape', () => { + const { spec } = mapApiHarnessToSpec( + makeApiHarness({ memory: { SDK_UNKNOWN_MEMBER: { name: 'somethingElse' } } as any }) + ); + expect('memory' in spec).toBe(false); + }); + + describe('runtime-environment + truncation (--arn fidelity fix)', () => { + it('carries the truncation config (control-plane shape matches local 1:1)', () => { + const { spec } = mapApiHarnessToSpec( + makeApiHarness({ truncation: { strategy: 'sliding_window', config: { slidingWindow: { messagesCount: 12 } } } }) + ); + expect(spec.truncation).toEqual({ strategy: 'sliding_window', config: { slidingWindow: { messagesCount: 12 } } }); + }); + + it('carries session storage from filesystemConfigurations', () => { + const { spec } = mapApiHarnessToSpec( + makeApiHarness({ + environment: { + agentCoreRuntimeEnvironment: { + filesystemConfigurations: [{ sessionStorage: { mountPath: '/mnt/data' } }], + }, + }, + }) + ); + expect(spec.sessionStoragePath).toBe('/mnt/data'); + }); + + it('carries lifecycle config (same field names)', () => { + const { spec } = mapApiHarnessToSpec( + makeApiHarness({ + environment: { + agentCoreRuntimeEnvironment: { + lifecycleConfiguration: { idleRuntimeSessionTimeout: 1200, maxLifetime: 7200 }, + }, + }, + }) + ); + expect(spec.lifecycleConfig).toEqual({ idleRuntimeSessionTimeout: 1200, maxLifetime: 7200 }); + }); + + it('carries VPC network mode + subnets/securityGroups', () => { + const { spec } = mapApiHarnessToSpec( + makeApiHarness({ + environment: { + agentCoreRuntimeEnvironment: { + networkConfiguration: { + networkMode: 'VPC', + networkModeConfig: { subnets: ['subnet-0123456789abcdef0'], securityGroups: ['sg-0123456789abcdef0'] }, + }, + }, + }, + }) + ); + expect(spec.networkMode).toBe('VPC'); + expect(spec.networkConfig).toEqual({ + subnets: ['subnet-0123456789abcdef0'], + securityGroups: ['sg-0123456789abcdef0'], + }); + }); + + it('does not set networkMode for PUBLIC (the implicit local default)', () => { + const { spec } = mapApiHarnessToSpec( + makeApiHarness({ + environment: { agentCoreRuntimeEnvironment: { networkConfiguration: { networkMode: 'PUBLIC' } } }, + }) + ); + expect('networkMode' in spec).toBe(false); + }); + + it('carries EFS and S3 access-point mounts from the filesystem tagged union', () => { + const efsArn = 'arn:aws:elasticfilesystem:us-east-1:111122223333:access-point/fsap-0123456789abcdef0'; + const s3Arn = + 'arn:aws:s3files:us-east-1:111122223333:file-system/fs-0123456789abcdef0/access-point/fsap-0123456789abcdef0'; + const { spec } = mapApiHarnessToSpec( + makeApiHarness({ + environment: { + agentCoreRuntimeEnvironment: { + filesystemConfigurations: [ + { efsAccessPoint: { accessPointArn: efsArn, mountPath: '/mnt/efsdata' } }, + { s3FilesAccessPoint: { accessPointArn: s3Arn, mountPath: '/mnt/s3data' } }, + ], + }, + }, + }) + ); + expect(spec.efsAccessPoints).toEqual([{ accessPointArn: efsArn, mountPath: '/mnt/efsdata' }]); + expect(spec.s3AccessPoints).toEqual([{ accessPointArn: s3Arn, mountPath: '/mnt/s3data' }]); + }); + + it('omits runtime-environment fields when the environment block is absent', () => { + const { spec } = mapApiHarnessToSpec(makeApiHarness()); + expect('sessionStoragePath' in spec).toBe(false); + expect('networkMode' in spec).toBe(false); + expect('lifecycleConfig' in spec).toBe(false); + expect('truncation' in spec).toBe(false); + }); + }); + + it('maps tools and allowedTools', () => { + const { spec } = mapApiHarnessToSpec( + makeApiHarness({ + tools: [ + { type: 'agentcore_gateway', name: 'gw', config: { agentCoreGateway: { gatewayArn: 'arn:...:gateway/g' } } }, + ], + allowedTools: ['gw'], + }) + ); + expect(spec.tools).toHaveLength(1); + expect(spec.tools[0]).toMatchObject({ type: 'agentcore_gateway', name: 'gw' }); + expect(spec.allowedTools).toEqual(['gw']); + }); + + it('throws when no model configuration is present', () => { + expect(() => mapApiHarnessToSpec(makeApiHarness({ model: undefined }))).toThrow(); + }); + + describe('skill normalization (control-plane → local shape)', () => { + it('normalizes a structured S3 skill { S3: { Uri } } to { s3Uri } so isS3Skill matches', () => { + const { spec } = mapApiHarnessToSpec( + makeApiHarness({ skills: [{ S3: { Uri: 's3://my-bucket/skills/weather/' } } as any] }) + ); + expect(spec.skills[0]).toEqual({ s3Uri: 's3://my-bucket/skills/weather/' }); + }); + + it('normalizes a structured Git skill { Git: { Url, Path } } to { gitUrl, path }', () => { + const { spec } = mapApiHarnessToSpec( + makeApiHarness({ skills: [{ Git: { Url: 'https://github.com/x/y', Path: 'skills' } } as any] }) + ); + expect(spec.skills[0]).toEqual({ gitUrl: 'https://github.com/x/y', path: 'skills' }); + }); + + it('normalizes a structured Path skill { Path } to { path }', () => { + const { spec } = mapApiHarnessToSpec(makeApiHarness({ skills: [{ Path: 'skills/local' } as any] })); + expect(spec.skills[0]).toEqual({ path: 'skills/local' }); + }); + + it('passes through an already-lowercase S3 skill { s3Uri }', () => { + const { spec } = mapApiHarnessToSpec(makeApiHarness({ skills: [{ s3Uri: 's3://b/p/' }] })); + expect(spec.skills[0]).toEqual({ s3Uri: 's3://b/p/' }); + }); + + it('normalizes a structured Git skill with no Path to { gitUrl } only', () => { + const { spec } = mapApiHarnessToSpec( + makeApiHarness({ skills: [{ Git: { Url: 'https://github.com/x/y' } } as any] }) + ); + expect(spec.skills[0]).toEqual({ gitUrl: 'https://github.com/x/y' }); + }); + + it('passes through an already-lowercase git skill { gitUrl, path }', () => { + const { spec } = mapApiHarnessToSpec( + makeApiHarness({ skills: [{ gitUrl: 'https://g/r', path: 'skills' } as any] }) + ); + expect(spec.skills[0]).toEqual({ gitUrl: 'https://g/r', path: 'skills' }); + }); + + it('carries private-repo git auth (credentialArn -> credentialName) + username', () => { + // The lowercase control-plane shape from GetHarness: git.auth.{credentialArn, username}. + // Without this, an --arn-exported private git skill would clone anonymously and fail. + const credentialArn = + 'arn:aws:bedrock-agentcore:us-east-1:111122223333:token-vault/default/apikeycredentialprovider/gitkey'; + const { spec } = mapApiHarnessToSpec( + makeApiHarness({ + skills: [ + { + git: { + url: 'https://github.com/me/private', + path: 'skills/x', + auth: { credentialArn, username: 'me' }, + }, + } as any, + ], + }) + ); + expect(spec.skills[0]).toEqual({ + gitUrl: 'https://github.com/me/private', + path: 'skills/x', + auth: { credentialName: credentialArn, username: 'me' }, + }); + }); + + it('carries git auth with default username when none is given', () => { + const credentialArn = + 'arn:aws:bedrock-agentcore:us-east-1:111122223333:token-vault/default/apikeycredentialprovider/gitkey'; + const { spec } = mapApiHarnessToSpec( + makeApiHarness({ skills: [{ git: { url: 'https://g/r', auth: { credentialArn } } } as any] }) + ); + expect(spec.skills[0]).toEqual({ gitUrl: 'https://g/r', auth: { credentialName: credentialArn } }); + }); + }); + + it('produces a spec that round-trips into export mapping inputs (no undefined keys)', () => { + const { spec } = mapApiHarnessToSpec(makeApiHarness()); + // optional fields absent from the API payload must not appear as `undefined` keys + expect('memory' in spec).toBe(false); + expect('containerUri' in spec).toBe(false); + }); +}); diff --git a/src/cli/commands/export/__tests__/harness-action.test.ts b/src/cli/commands/export/__tests__/harness-action.test.ts index 5823007a6..d3074a3d1 100644 --- a/src/cli/commands/export/__tests__/harness-action.test.ts +++ b/src/cli/commands/export/__tests__/harness-action.test.ts @@ -1,5 +1,14 @@ -import { CUSTOM_DOCKERFILE_NOTE_CATEGORY } from '../constants'; -import { buildCustomDockerfileNote, buildMissingDockerfileNote } from '../harness-action'; +import { + CUSTOM_DOCKERFILE_NOTE_CATEGORY, + PATH_SKILLS_COPIED_NOTE_CATEGORY, + PATH_SKILLS_VERIFY_BASE_IMAGE_NOTE_CATEGORY, +} from '../constants'; +import { + buildCustomDockerfileNote, + buildMissingDockerfileNote, + buildPathSkillsCopiedNote, + buildPathSkillsVerifyNote, +} from '../harness-action'; import { describe, expect, it } from 'vitest'; // ============================================================================ @@ -40,3 +49,30 @@ describe('buildMissingDockerfileNote', () => { expect(note.message).toContain('app/MyHarnessAgent/Harness.Dockerfile'); }); }); + +describe('buildPathSkillsCopiedNote', () => { + it('lists the copied skill dirs and states no manual step is required', () => { + const note = buildPathSkillsCopiedNote(['skills/greeting'], 'MyAgent'); + expect(note.category).toBe(PATH_SKILLS_COPIED_NOTE_CATEGORY); + expect(note.message).toContain('"skills/greeting"'); + expect(note.message).toContain('app/MyAgent/'); + expect(note.message).toContain('no manual step'); + }); + + it('pluralizes for multiple skills', () => { + const note = buildPathSkillsCopiedNote(['skills/a', 'skills/b'], 'MyAgent'); + expect(note.message).toContain('directories were copied'); + expect(note.message).toContain('"skills/a"'); + expect(note.message).toContain('"skills/b"'); + }); +}); + +describe('buildPathSkillsVerifyNote', () => { + it('tells the user the path was not found and must exist in the image', () => { + const note = buildPathSkillsVerifyNote(['skills/nonexistent'], 'MyAgent'); + expect(note.category).toBe(PATH_SKILLS_VERIFY_BASE_IMAGE_NOTE_CATEGORY); + expect(note.message).toContain('"skills/nonexistent"'); + expect(note.message).toContain('NOT copied'); + expect(note.message).toMatch(/base image|Dockerfile COPY/); + }); +}); diff --git a/src/cli/commands/export/__tests__/harness-mapper.test.ts b/src/cli/commands/export/__tests__/harness-mapper.test.ts index 99bfed754..08a595370 100644 --- a/src/cli/commands/export/__tests__/harness-mapper.test.ts +++ b/src/cli/commands/export/__tests__/harness-mapper.test.ts @@ -3,17 +3,15 @@ import { ALLOWED_TOOLS_NOTE_CATEGORY, AWS_SKILLS_NOTE_CATEGORY, BROWSER_CODZIP_NOTE_CATEGORY, - BROWSER_IAM_POLICY_NOTE_CATEGORY, - CODE_INTERPRETER_IAM_POLICY_NOTE_CATEGORY, CONTAINER_URI_ECR_PULL_NOTE_CATEGORY, CONTAINER_URI_NOTE_CATEGORY, - EXTERNAL_GATEWAY_NOTE_CATEGORY, - GATEWAY_IAM_POLICY_NOTE_CATEGORY, + GATEWAY_GRANT_TYPE_NOTE_CATEGORY, GIT_SKILLS_CONTAINER_NOTE_CATEGORY, + LITELLM_NO_API_KEY_NOTE_CATEGORY, + MALFORMED_S3_SKILL_NOTE_CATEGORY, + MALFORMED_TOOL_ARN_NOTE_CATEGORY, MCP_HEADER_CREDS_NOTE_CATEGORY, - MEMORY_ARN_NOTE_CATEGORY, PATH_SKILLS_NOTE_CATEGORY, - S3_SKILLS_IAM_POLICY_NOTE_CATEGORY, } from '../constants'; import { mapHarnessToExportConfig } from '../harness-mapper'; import type { ResolvedHarnessContext } from '../types'; @@ -48,6 +46,9 @@ function baseContext( projectRoot: '/project', exportNotes: [], region: 'us-east-1', + localEnvVars: {}, + generatedPolicyFiles: {}, + additionalPolicies: [], ...contextOverrides, }; } @@ -125,11 +126,22 @@ describe('browser tool handling', () => { expect(noteCategories(ctx)).toContain(BROWSER_CODZIP_NOTE_CATEGORY); }); - it('sets hasBrowser=true and emits IAM note for Container build', () => { + it('does not populate browserIdentifierEnvVar on a CodeZip build even with a custom ARN', () => { + // The identifier env var and hasBrowser come from one resolution gated on Container; a CodeZip + // build must not advertise an env var that no connection injects. + const arn = 'arn:aws:bedrock-agentcore:us-east-1:123456789012:browser-custom/my_browser_id'; + const ctx = baseContext({ tools: [{ ...browserTool, config: { agentCoreBrowser: { browserArn: arn } } }] }); + const { renderConfig, agentEnvSpec } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.hasBrowser).toBe(false); + expect(renderConfig.browserIdentifierEnvVar).toBeUndefined(); + expect(agentEnvSpec.connections?.some(c => c.to.type === 'browser')).toBeFalsy(); + }); + + it('sets hasBrowser=true and adds a browser connection (not an IAM note) for Container build', () => { const ctx = baseContext({ tools: [browserTool] }); - const { renderConfig } = mapHarnessToExportConfig(ctx, 'Container'); + const { renderConfig, agentEnvSpec } = mapHarnessToExportConfig(ctx, 'Container'); expect(renderConfig.hasBrowser).toBe(true); - expect(noteCategories(ctx)).toContain(BROWSER_IAM_POLICY_NOTE_CATEGORY); + expect(agentEnvSpec.connections?.some(c => c.to.type === 'browser')).toBe(true); }); it('CodeZip note re-export hint uses --name flag', () => { @@ -140,27 +152,46 @@ describe('browser tool handling', () => { expect(note.message).not.toContain('--harness'); }); - it('IAM note uses default browser ARN when no custom browserArn', () => { + it('adds a default-browser connection (no env var) when no custom browserArn', () => { const ctx = baseContext({ tools: [browserTool] }); - mapHarnessToExportConfig(ctx, 'Container'); - const note = ctx.exportNotes.find(n => n.category === BROWSER_IAM_POLICY_NOTE_CATEGORY)!; - expect(note.message).toContain(':aws:browser/*'); + const { renderConfig, agentEnvSpec } = mapHarnessToExportConfig(ctx, 'Container'); + expect(renderConfig.browserIdentifierEnvVar).toBeUndefined(); + const conn = agentEnvSpec.connections?.find(c => c.to.type === 'browser'); + expect(conn?.to).toEqual({ type: 'browser' }); }); - it('IAM note uses custom browserArn when provided', () => { + it('adds a browser connection with the custom ARN + env var when provided', () => { + const arn = 'arn:aws:bedrock-agentcore:us-east-1:123456789012:browser-custom/my_browser_id'; const ctx = baseContext({ - tools: [ - { - ...browserTool, - config: { - agentCoreBrowser: { browserArn: 'arn:aws:bedrock-agentcore:us-east-1:123:browser-custom/my_browser_id' }, - }, - }, - ], + tools: [{ ...browserTool, config: { agentCoreBrowser: { browserArn: arn } } }], + }); + const { renderConfig, agentEnvSpec } = mapHarnessToExportConfig(ctx, 'Container'); + const conn = agentEnvSpec.connections?.find(c => c.to.type === 'browser'); + expect(conn?.to).toMatchObject({ type: 'browser', arn }); + expect(renderConfig.browserIdentifierEnvVar).toBeTruthy(); + }); + + it('emits a malformed-ARN note and falls back to the default when browserArn is invalid', () => { + const badArn = 'arn:aws:bedrock-agentcore:us-east-1:123456789012:brower/typo'; // misspelled segment + const ctx = baseContext({ + tools: [{ ...browserTool, config: { agentCoreBrowser: { browserArn: badArn } } }], + }); + const { agentEnvSpec } = mapHarnessToExportConfig(ctx, 'Container'); + expect(noteCategories(ctx)).toContain(MALFORMED_TOOL_ARN_NOTE_CATEGORY); + const note = ctx.exportNotes.find(n => n.category === MALFORMED_TOOL_ARN_NOTE_CATEGORY); + expect(note?.message).toContain(badArn); + // Falls back to the AWS-managed default (no arn on the connection). + const conn = agentEnvSpec.connections?.find(c => c.to.type === 'browser'); + expect(conn?.to).toEqual({ type: 'browser' }); + }); + + it('emits no malformed-ARN note when browserArn is well-formed', () => { + const arn = 'arn:aws:bedrock-agentcore:us-east-1:123456789012:browser-custom/my_browser_id'; + const ctx = baseContext({ + tools: [{ ...browserTool, config: { agentCoreBrowser: { browserArn: arn } } }], }); mapHarnessToExportConfig(ctx, 'Container'); - const note = ctx.exportNotes.find(n => n.category === BROWSER_IAM_POLICY_NOTE_CATEGORY)!; - expect(note.message).toContain('arn:aws:bedrock-agentcore:us-east-1:123:browser-custom/my_browser_id'); + expect(noteCategories(ctx)).not.toContain(MALFORMED_TOOL_ARN_NOTE_CATEGORY); }); }); @@ -177,29 +208,34 @@ describe('code interpreter tool handling', () => { expect(renderConfig.hasCodeInterpreter).toBe(true); }); - it('emits IAM note with default ARN', () => { + it('adds a default code-interpreter connection (no env var) when no custom ARN', () => { const ctx = baseContext({ tools: [ciTool] }); - mapHarnessToExportConfig(ctx, 'CodeZip'); - const note = ctx.exportNotes.find(n => n.category === CODE_INTERPRETER_IAM_POLICY_NOTE_CATEGORY)!; - expect(note.message).toContain(':aws:code-interpreter/*'); + const { renderConfig, agentEnvSpec } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.codeInterpreterIdentifierEnvVar).toBeUndefined(); + const conn = agentEnvSpec.connections?.find(c => c.to.type === 'codeInterpreter'); + expect(conn?.to).toEqual({ type: 'codeInterpreter' }); }); - it('emits IAM note with custom codeInterpreterArn when provided', () => { + it('adds a code-interpreter connection with the custom ARN + env var when provided', () => { + const arn = 'arn:aws:bedrock-agentcore:us-east-1:123456789012:code-interpreter-custom/my_ci_id'; const ctx = baseContext({ - tools: [ - { - ...ciTool, - config: { - agentCoreCodeInterpreter: { - codeInterpreterArn: 'arn:aws:bedrock-agentcore:us-east-1:123:code-interpreter-custom/my_ci_id', - }, - }, - }, - ], + tools: [{ ...ciTool, config: { agentCoreCodeInterpreter: { codeInterpreterArn: arn } } }], }); - mapHarnessToExportConfig(ctx, 'CodeZip'); - const note = ctx.exportNotes.find(n => n.category === CODE_INTERPRETER_IAM_POLICY_NOTE_CATEGORY)!; - expect(note.message).toContain('arn:aws:bedrock-agentcore:us-east-1:123:code-interpreter-custom/my_ci_id'); + const { renderConfig, agentEnvSpec } = mapHarnessToExportConfig(ctx, 'CodeZip'); + const conn = agentEnvSpec.connections?.find(c => c.to.type === 'codeInterpreter'); + expect(conn?.to).toMatchObject({ type: 'codeInterpreter', arn }); + expect(renderConfig.codeInterpreterIdentifierEnvVar).toBeTruthy(); + }); + + it('emits a malformed-ARN note and falls back to the default when codeInterpreterArn is invalid', () => { + const badArn = 'not-an-arn'; + const ctx = baseContext({ + tools: [{ ...ciTool, config: { agentCoreCodeInterpreter: { codeInterpreterArn: badArn } } }], + }); + const { agentEnvSpec } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(noteCategories(ctx)).toContain(MALFORMED_TOOL_ARN_NOTE_CATEGORY); + const conn = agentEnvSpec.connections?.find(c => c.to.type === 'codeInterpreter'); + expect(conn?.to).toEqual({ type: 'codeInterpreter' }); }); }); @@ -215,22 +251,29 @@ describe('custom tool identifier extraction', () => { type: 'agentcore_browser' as const, name: 'browser', config: { - agentCoreBrowser: { browserArn: 'arn:aws:bedrock-agentcore:us-east-1:123:browser-custom/browser_abc123' }, + agentCoreBrowser: { + browserArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:browser-custom/browser_abc123', + }, }, }, ], }); - const { renderConfig } = mapHarnessToExportConfig(ctx, 'Container'); - expect(renderConfig.browserIdentifier).toBe('browser_abc123'); + const { renderConfig, agentEnvSpec } = mapHarnessToExportConfig(ctx, 'Container'); + // The identifier is read at runtime from the connection-injected env var, not baked in. + expect(renderConfig.browserIdentifierEnvVar).toBe('BROWSER_BROWSER_BROWSER_ABC123_ID'); + // A browser connection is added so the CDK grants the browser IAM. + expect(agentEnvSpec.connections?.some(c => c.to.type === 'browser')).toBe(true); }); - it('sets browserIdentifier=undefined when no custom browserArn', () => { + it('uses the AWS-managed default browser (no env var) when no custom browserArn', () => { const ctx = baseContext({ tools: [{ type: 'agentcore_browser' as const, name: 'browser' }] }); - const { renderConfig } = mapHarnessToExportConfig(ctx, 'Container'); - expect(renderConfig.browserIdentifier).toBeUndefined(); + const { renderConfig, agentEnvSpec } = mapHarnessToExportConfig(ctx, 'Container'); + expect(renderConfig.browserIdentifierEnvVar).toBeUndefined(); + // Still adds a connection (for the default browser/* IAM grant). + expect(agentEnvSpec.connections?.some(c => c.to.type === 'browser')).toBe(true); }); - it('extracts codeInterpreterIdentifier from codeInterpreterArn', () => { + it('wires a code-interpreter connection + env var from codeInterpreterArn', () => { const ctx = baseContext({ tools: [ { @@ -238,20 +281,22 @@ describe('custom tool identifier extraction', () => { name: 'ci', config: { agentCoreCodeInterpreter: { - codeInterpreterArn: 'arn:aws:bedrock-agentcore:us-east-1:123:code-interpreter-custom/ci_xyz789', + codeInterpreterArn: 'arn:aws:bedrock-agentcore:us-east-1:123456789012:code-interpreter-custom/ci_xyz789', }, }, }, ], }); - const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); - expect(renderConfig.codeInterpreterIdentifier).toBe('ci_xyz789'); + const { renderConfig, agentEnvSpec } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.codeInterpreterIdentifierEnvVar).toBe('CODE_INTERPRETER_CODEINTERPRETER_CI_XYZ789_ID'); + expect(agentEnvSpec.connections?.some(c => c.to.type === 'codeInterpreter')).toBe(true); }); - it('sets codeInterpreterIdentifier=undefined when no custom codeInterpreterArn', () => { + it('uses the AWS-managed default code interpreter (no env var) when no custom codeInterpreterArn', () => { const ctx = baseContext({ tools: [{ type: 'agentcore_code_interpreter' as const, name: 'ci' }] }); - const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); - expect(renderConfig.codeInterpreterIdentifier).toBeUndefined(); + const { renderConfig, agentEnvSpec } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.codeInterpreterIdentifierEnvVar).toBeUndefined(); + expect(agentEnvSpec.connections?.some(c => c.to.type === 'codeInterpreter')).toBe(true); }); }); @@ -302,16 +347,16 @@ describe('allowedTools filtering', () => { expect(noteCategories(ctx)).not.toContain(ALLOWED_TOOLS_NOTE_CATEGORY); }); - it('does not emit browser IAM note when browser excluded by allowedTools on Container build', () => { + it('adds no browser connection when browser is excluded by allowedTools on Container build', () => { const ctx = baseContext({ tools: [browserTool, ciTool], allowedTools: ['code-interpreter'] }); - mapHarnessToExportConfig(ctx, 'Container'); - expect(noteCategories(ctx)).not.toContain(BROWSER_IAM_POLICY_NOTE_CATEGORY); + const { agentEnvSpec } = mapHarnessToExportConfig(ctx, 'Container'); + expect(agentEnvSpec.connections?.some(c => c.to.type === 'browser')).toBeFalsy(); }); - it('does not emit code interpreter IAM note when CI excluded by allowedTools', () => { + it('adds no code-interpreter connection when CI is excluded by allowedTools', () => { const ctx = baseContext({ tools: [browserTool, ciTool], allowedTools: ['browser'] }); - mapHarnessToExportConfig(ctx, 'Container'); - expect(noteCategories(ctx)).not.toContain(CODE_INTERPRETER_IAM_POLICY_NOTE_CATEGORY); + const { agentEnvSpec } = mapHarnessToExportConfig(ctx, 'Container'); + expect(agentEnvSpec.connections?.some(c => c.to.type === 'codeInterpreter')).toBeFalsy(); }); }); @@ -440,58 +485,93 @@ describe('skills notes', () => { expect(noteCategories(ctx)).not.toContain(GIT_SKILLS_CONTAINER_NOTE_CATEGORY); }); - it('emits s3 skills IAM note with GetObject + ListBucket ARNs for CodeZip', () => { + /** The single S3-skills statement set from the generated policy file. */ + function s3PolicyStatements(ctx: ReturnType): { Action: string; Resource: string[] }[] { + const doc = ctx.generatedPolicyFiles['s3-skills-policy.json'] as + | { Statement: { Action: string; Resource: string[] }[] } + | undefined; + return doc?.Statement ?? []; + } + + it('generates an s3-skills policy file + additionalPolicies entry (no manual IAM note) for CodeZip', () => { const ctx = baseContext({ skills: [{ s3Uri: 's3://my-bucket/skills/weather/' }] }, { targetAgentName: 'MyAgent' }); - mapHarnessToExportConfig(ctx, 'CodeZip'); - expect(noteCategories(ctx)).toContain(S3_SKILLS_IAM_POLICY_NOTE_CATEGORY); - const note = ctx.exportNotes.find(n => n.category === S3_SKILLS_IAM_POLICY_NOTE_CATEGORY); - // Object-level read scoped to the prefix - expect(note?.message).toContain("actions: ['s3:GetObject']"); - expect(note?.message).toContain("'arn:aws:s3:::my-bucket/skills/weather/*'"); - // List scoped to the bucket - expect(note?.message).toContain("actions: ['s3:ListBucket']"); - expect(note?.message).toContain("'arn:aws:s3:::my-bucket'"); - // Snippet targets the renamed agent - expect(note?.message).toContain("this.application.environments.get('MyAgent')"); - }); - - it('emits s3 skills IAM note for Container builds too (independent of build type)', () => { + const { agentEnvSpec } = mapHarnessToExportConfig(ctx, 'CodeZip'); + + // No manual IAM note; instead a generated policy file referenced from additionalPolicies. + expect(ctx.additionalPolicies).toContain('s3-skills-policy.json'); + expect(agentEnvSpec.additionalPolicies).toContain('s3-skills-policy.json'); + + const stmts = s3PolicyStatements(ctx); + const get = stmts.find(s => s.Action === 's3:GetObject')!; + const list = stmts.find(s => s.Action === 's3:ListBucket')!; + expect(get.Resource).toContain('arn:aws:s3:::my-bucket/skills/weather/*'); + expect(list.Resource).toContain('arn:aws:s3:::my-bucket'); + }); + + it('generates the s3-skills policy for Container builds too (independent of build type)', () => { const ctx = baseContext({ skills: [{ s3Uri: 's3://my-bucket/skills/weather/' }] }); mapHarnessToExportConfig(ctx, 'Container'); - expect(noteCategories(ctx)).toContain(S3_SKILLS_IAM_POLICY_NOTE_CATEGORY); + expect(ctx.additionalPolicies).toContain('s3-skills-policy.json'); }); it('uses bucket-root object ARN when the s3 URI has no prefix', () => { const ctx = baseContext({ skills: [{ s3Uri: 's3://my-bucket' }] }); mapHarnessToExportConfig(ctx, 'CodeZip'); - const note = ctx.exportNotes.find(n => n.category === S3_SKILLS_IAM_POLICY_NOTE_CATEGORY); - expect(note?.message).toContain("'arn:aws:s3:::my-bucket/*'"); - expect(note?.message).toContain("'arn:aws:s3:::my-bucket'"); + const stmts = s3PolicyStatements(ctx); + expect(stmts.find(s => s.Action === 's3:GetObject')!.Resource).toContain('arn:aws:s3:::my-bucket/*'); + expect(stmts.find(s => s.Action === 's3:ListBucket')!.Resource).toContain('arn:aws:s3:::my-bucket'); }); it('deduplicates ARNs across multiple s3 skills in the same bucket', () => { - const ctx = baseContext({ - skills: [{ s3Uri: 's3://shared/a/' }, { s3Uri: 's3://shared/b/' }], - }); + const ctx = baseContext({ skills: [{ s3Uri: 's3://shared/a/' }, { s3Uri: 's3://shared/b/' }] }); mapHarnessToExportConfig(ctx, 'CodeZip'); - const note = ctx.exportNotes.find(n => n.category === S3_SKILLS_IAM_POLICY_NOTE_CATEGORY); - // One ListBucket resource for the shared bucket, two distinct object ARNs - expect(note?.message).toContain("'arn:aws:s3:::shared/a/*'"); - expect(note?.message).toContain("'arn:aws:s3:::shared/b/*'"); - expect(note?.message.match(/arn:aws:s3:::shared'/g)?.length).toBe(1); + const stmts = s3PolicyStatements(ctx); + expect(stmts.find(s => s.Action === 's3:GetObject')!.Resource).toEqual( + expect.arrayContaining(['arn:aws:s3:::shared/a/*', 'arn:aws:s3:::shared/b/*']) + ); + // One ListBucket resource for the shared bucket. + expect(stmts.find(s => s.Action === 's3:ListBucket')!.Resource).toEqual(['arn:aws:s3:::shared']); }); it('uses the GovCloud partition prefix for gov regions', () => { const ctx = baseContext({ skills: [{ s3Uri: 's3://gov-bucket/skills/' }] }, { region: 'us-gov-west-1' }); mapHarnessToExportConfig(ctx, 'CodeZip'); - const note = ctx.exportNotes.find(n => n.category === S3_SKILLS_IAM_POLICY_NOTE_CATEGORY); - expect(note?.message).toContain("'arn:aws-us-gov:s3:::gov-bucket/skills/*'"); + expect(s3PolicyStatements(ctx).find(s => s.Action === 's3:GetObject')!.Resource).toContain( + 'arn:aws-us-gov:s3:::gov-bucket/skills/*' + ); }); - it('does not emit s3 skills IAM note when there are no s3 skills', () => { + it('does not generate an s3-skills policy when there are no s3 skills', () => { const ctx = baseContext({ skills: [{ path: 'skills/local' }] }); mapHarnessToExportConfig(ctx, 'CodeZip'); - expect(noteCategories(ctx)).not.toContain(S3_SKILLS_IAM_POLICY_NOTE_CATEGORY); + expect(ctx.additionalPolicies).not.toContain('s3-skills-policy.json'); + expect(Object.keys(ctx.generatedPolicyFiles)).toHaveLength(0); + }); + + it('emits a malformed-S3 note and generates no policy when every s3 URI is bucketless', () => { + // `s3://` is schema-valid (≥5 chars, s3:// prefix) but has no bucket to parse. + const ctx = baseContext({ skills: [{ s3Uri: 's3://' }] }); + mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(noteCategories(ctx)).toContain(MALFORMED_S3_SKILL_NOTE_CATEGORY); + expect(ctx.additionalPolicies).not.toContain('s3-skills-policy.json'); + expect(Object.keys(ctx.generatedPolicyFiles)).toHaveLength(0); + }); + + it('warns about the malformed URI but still generates a policy for the valid ones', () => { + const ctx = baseContext({ skills: [{ s3Uri: 's3://good-bucket/skills/' }, { s3Uri: 's3://' }] }); + mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(noteCategories(ctx)).toContain(MALFORMED_S3_SKILL_NOTE_CATEGORY); + // Valid URI still produces its policy. + expect(ctx.additionalPolicies).toContain('s3-skills-policy.json'); + expect(s3PolicyStatements(ctx).find(s => s.Action === 's3:GetObject')!.Resource).toContain( + 'arn:aws:s3:::good-bucket/skills/*' + ); + }); + + it('emits no malformed-S3 note when all s3 URIs are well-formed', () => { + const ctx = baseContext({ skills: [{ s3Uri: 's3://my-bucket/skills/' }] }); + mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(noteCategories(ctx)).not.toContain(MALFORMED_S3_SKILL_NOTE_CATEGORY); }); }); @@ -550,9 +630,57 @@ describe('skills render config mapping', () => { // ============================================================================ describe('resolveModelProvider', () => { - it('rejects the lite_llm provider (unsupported by Strands export)', () => { - const ctx = baseContext({ model: { provider: 'lite_llm', modelId: 'some-model' } as never }); - expect(() => mapHarnessToExportConfig(ctx, 'CodeZip')).toThrow(/lite_llm.*does not support/); + it('supports the lite_llm provider, threading apiBase + additionalParams into the render config', () => { + const ctx = baseContext({ + model: { + provider: 'lite_llm', + modelId: 'bedrock/some-model', + apiBase: 'https://proxy.example/v1', + additionalParams: { timeout: 120 }, + } as never, + }); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.modelProvider).toBe('LiteLLM'); + expect(renderConfig.modelId).toBe('bedrock/some-model'); + expect(renderConfig.litellmApiBase).toBe('https://proxy.example/v1'); + expect(renderConfig.litellmAdditionalParams).toEqual({ timeout: 120 }); + }); + + it('supports a minimal lite_llm provider (no apiBase / additionalParams)', () => { + const ctx = baseContext({ model: { provider: 'lite_llm', modelId: 'openai/gpt-4o' } as never }); + const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.modelProvider).toBe('LiteLLM'); + expect(renderConfig.litellmApiBase).toBeUndefined(); + expect(renderConfig.litellmAdditionalParams).toBeUndefined(); + }); + + it('notes a keyless non-Bedrock lite_llm model (openai/...) that likely needs an API key', () => { + const ctx = baseContext({ model: { provider: 'lite_llm', modelId: 'openai/gpt-4o' } as never }); + mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(noteCategories(ctx)).toContain(LITELLM_NO_API_KEY_NOTE_CATEGORY); + expect(ctx.exportNotes.find(n => n.category === LITELLM_NO_API_KEY_NOTE_CATEGORY)?.message).toContain( + 'openai/gpt-4o' + ); + }); + + it('does NOT note a keyless bedrock/... lite_llm model (authenticates via execution role)', () => { + const ctx = baseContext({ + model: { provider: 'lite_llm', modelId: 'bedrock/us.anthropic.claude-sonnet-4-6' } as never, + }); + mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(noteCategories(ctx)).not.toContain(LITELLM_NO_API_KEY_NOTE_CATEGORY); + }); + + it('does NOT note a non-Bedrock lite_llm model when apiKeyArn is set', () => { + const ctx = baseContext({ + model: { + provider: 'lite_llm', + modelId: 'openai/gpt-4o', + apiKeyArn: 'arn:aws:bedrock-agentcore:us-east-1:111122223333:token-vault/default/apikeycredentialprovider/k', + } as never, + }); + mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(noteCategories(ctx)).not.toContain(LITELLM_NO_API_KEY_NOTE_CATEGORY); }); }); @@ -560,8 +688,8 @@ describe('resolveModelProvider', () => { // extractToolIdentifier edge cases // ============================================================================ -describe('extractToolIdentifier edge cases', () => { - it('returns undefined when ARN has no slash', () => { +describe('browser/code-interpreter ARN edge cases', () => { + it('falls back to the default browser (no env var) when the ARN is malformed', () => { const ctx = baseContext({ tools: [ { @@ -571,11 +699,13 @@ describe('extractToolIdentifier edge cases', () => { }, ], }); - const { renderConfig } = mapHarnessToExportConfig(ctx, 'Container'); - expect(renderConfig.browserIdentifier).toBeUndefined(); + const { renderConfig, agentEnvSpec } = mapHarnessToExportConfig(ctx, 'Container'); + expect(renderConfig.browserIdentifierEnvVar).toBeUndefined(); + // A default browser connection is still added (grants browser/*), and never fails validation. + expect(agentEnvSpec.connections?.some(c => c.to.type === 'browser')).toBe(true); }); - it('returns undefined when browserArn is empty string', () => { + it('falls back to the default browser when browserArn is an empty string', () => { const ctx = baseContext({ tools: [ { @@ -586,7 +716,7 @@ describe('extractToolIdentifier edge cases', () => { ], }); const { renderConfig } = mapHarnessToExportConfig(ctx, 'Container'); - expect(renderConfig.browserIdentifier).toBeUndefined(); + expect(renderConfig.browserIdentifierEnvVar).toBeUndefined(); }); }); @@ -667,15 +797,28 @@ describe('resolveMemoryProviders', () => { expect(renderConfig.memoryProviders?.at(0)!.envVarName).toBe('MEMORY_DEPLOYEDMEM_ID'); }); - it('falls back to MEMORY_ARN env var for external memory ARN and emits note', () => { - const ctx = baseContext( - { memory: { mode: 'existing', arn: 'arn:aws:bedrock-agentcore:us-east-1:999:memory/external' } }, - { deployedResources: null } - ); - const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); + it('models external memory as a connection (IAM generated at deploy, no manual note)', () => { + const arn = 'arn:aws:bedrock-agentcore:us-east-1:999:memory/external'; + const ctx = baseContext({ memory: { mode: 'existing', arn } }, { deployedResources: null }); + const { renderConfig, agentEnvSpec } = mapHarnessToExportConfig(ctx, 'CodeZip'); + expect(renderConfig.hasMemory).toBe(true); - expect(renderConfig.memoryProviders?.at(0)!.envVarName).toBe('MEMORY_ARN'); - expect(noteCategories(ctx)).toContain(MEMORY_ARN_NOTE_CATEGORY); + + // A memory connection is added to the exported agent. + const conn = agentEnvSpec.connections?.find(c => c.to.type === 'memory'); + expect(conn).toBeDefined(); + expect(conn!.to).toMatchObject({ type: 'memory', arn }); + // readwrite: the agent writes events (CreateEvent) to memory, not just reads. + expect(conn!.access).toBe('readwrite'); + + // The render config env-var name lines up with the connection's id-derived token. + const envVarName = renderConfig.memoryProviders?.at(0)!.envVarName; + expect(envVarName).toBe(`MEMORY_${conn!.id!.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_ID`); + + // The discovery value is written to .env.local for local dev (matching the CDK deploy-time + // injection: MEMORY__ID = the memory id). Without this, `agentcore dev` silently disables + // memory (session.py returns None when the env var is unset). + expect(ctx.localEnvVars[envVarName]).toBe('external'); // resourceIdFromArn('...:memory/external') }); }); @@ -822,7 +965,6 @@ describe('resolveGatewayProviders', () => { expect(renderConfig.hasGateway).toBe(true); expect(renderConfig.gatewayProviders?.at(0)!.name).toBe('MyGateway'); expect(renderConfig.gatewayProviders?.at(0)!.authType).toBe('AWS_IAM'); - expect(noteCategories(ctx)).not.toContain(GATEWAY_IAM_POLICY_NOTE_CATEGORY); }); it('resolves same-project CUSTOM_JWT gateway with discoveryUrl and scopes', () => { @@ -858,17 +1000,21 @@ describe('resolveGatewayProviders', () => { expect(provider?.authType).toBe('CUSTOM_JWT'); expect(provider?.discoveryUrl).toBe('https://auth.example.com/.well-known/openid-configuration'); expect(provider?.scopes).toBe('read write'); - expect(noteCategories(ctx)).not.toContain(GATEWAY_IAM_POLICY_NOTE_CATEGORY); }); - it('hardcodes URL for external gateway and emits external note + IAM note', () => { + it('models an external gateway as a connection — URL via env var, no hardcoded URL, no IAM note', () => { const ctx = baseContext({ tools: [gatewayTool] }, { deployedResources: null }); - const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); + const { renderConfig, agentEnvSpec } = mapHarnessToExportConfig(ctx, 'CodeZip'); expect(renderConfig.hasGateway).toBe(true); const provider = renderConfig.gatewayProviders.find(() => true); - expect(provider?.hardcodedUrl).toContain('gateway.bedrock-agentcore'); - expect(noteCategories(ctx)).toContain(EXTERNAL_GATEWAY_NOTE_CATEGORY); - expect(noteCategories(ctx)).toContain(GATEWAY_IAM_POLICY_NOTE_CATEGORY); + // URL now comes from the connection-injected env var, not a hardcoded literal. + expect(provider?.hardcodedUrl).toBeUndefined(); + expect(provider?.envVarName).toMatch(/^GATEWAY_.*_URL$/); + // The URL value is written to .env.local for local dev. + expect(Object.keys(ctx.localEnvVars).some(k => k.endsWith('_URL'))).toBe(true); + // A gateway connection (default awsIam outbound) is added so the CDK grants InvokeGateway. + const conn = agentEnvSpec.connections?.find(c => c.to.type === 'gateway'); + expect(conn?.to).toMatchObject({ type: 'gateway', arn: gatewayArn }); }); it('excludes gateway tool filtered out by allowedTools', () => { @@ -876,6 +1022,79 @@ describe('resolveGatewayProviders', () => { const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); expect(renderConfig.hasGateway).toBe(false); }); + + describe('external oauth gateway connection (toConnectionGatewayAuth)', () => { + const providerArn = + 'arn:aws:bedrock-agentcore:us-east-1:123456789012:token-vault/default/oauth2credentialprovider/partner'; + const oauthTool = (grantType?: 'CLIENT_CREDENTIALS' | 'USER_FEDERATION') => + ({ + type: 'agentcore_gateway' as const, + name: 'my-gateway', + config: { + agentCoreGateway: { + gatewayArn, + outboundAuth: { oauth: { providerArn, scopes: ['read'], ...(grantType && { grantType }) } }, + }, + }, + }) as unknown as HarnessSpec['tools'][number]; + + function gwProvider(ctx: ReturnType) { + const { renderConfig } = mapHarnessToExportConfig(ctx, 'CodeZip'); + return renderConfig.gatewayProviders.find(() => true); + } + + it('maps a CLIENT_CREDENTIALS harness grant to M2M auth_flow, no note', () => { + const ctx = baseContext({ tools: [oauthTool('CLIENT_CREDENTIALS')] }, { deployedResources: null }); + const provider = gwProvider(ctx); + expect(provider?.authFlow).toBe('M2M'); + // credential provider name is derived from the providerArn, not the tool name. + expect(provider?.credentialProviderName).toBe('partner'); + expect(noteCategories(ctx)).not.toContain(GATEWAY_GRANT_TYPE_NOTE_CATEGORY); + }); + + it('remaps USER_FEDERATION to AUTHORIZATION_CODE on the connection AND threads USER_FEDERATION auth_flow, no note', () => { + const ctx = baseContext({ tools: [oauthTool('USER_FEDERATION')] }, { deployedResources: null }); + const { renderConfig, agentEnvSpec } = mapHarnessToExportConfig(ctx, 'CodeZip'); + // Connection carries the runtime/Smithy grant value (AUTHORIZATION_CODE)... + const conn = agentEnvSpec.connections?.find(c => c.to.type === 'gateway'); + expect((conn?.to as { outboundAuth: { oauth: { grantType: string } } }).outboundAuth.oauth.grantType).toBe( + 'AUTHORIZATION_CODE' + ); + // ...and the generated client gets the corresponding USER_FEDERATION auth_flow (loopy parity). + expect(renderConfig.gatewayProviders.find(() => true)?.authFlow).toBe('USER_FEDERATION'); + // USER_FEDERATION is now expressible by the decorator → no manual-step note. + expect(noteCategories(ctx)).not.toContain(GATEWAY_GRANT_TYPE_NOTE_CATEGORY); + }); + + it('defaults to M2M auth_flow when the harness specifies no grant type', () => { + const ctx = baseContext({ tools: [oauthTool()] }, { deployedResources: null }); + const provider = gwProvider(ctx); + // No explicit authFlow set → template default applies; mapper leaves it M2M. + expect(provider?.authFlow).toBe('M2M'); + expect(noteCategories(ctx)).not.toContain(GATEWAY_GRANT_TYPE_NOTE_CATEGORY); + }); + + it('carries customParameters as an OBJECT (not a pre-stringified string) so safeJson renders valid Python', () => { + // Regression: a pre-stringified value rendered via an escaped mustache produced " in the + // generated client.py (SyntaxError). The render config must carry the object; the template uses + // {{safeJson customParameters}} (a SafeString) to emit a valid Python dict literal. + const tool = { + type: 'agentcore_gateway' as const, + name: 'my-gateway', + config: { + agentCoreGateway: { + gatewayArn, + outboundAuth: { + oauth: { providerArn, scopes: ['read'], customParameters: { audience: 'https://api.example.com' } }, + }, + }, + }, + } as unknown as HarnessSpec['tools'][number]; + const provider = gwProvider(baseContext({ tools: [tool] }, { deployedResources: null })); + expect(provider?.customParameters).toEqual({ audience: 'https://api.example.com' }); + expect(typeof provider?.customParameters).toBe('object'); + }); + }); }); // ============================================================================ diff --git a/src/cli/commands/export/constants.ts b/src/cli/commands/export/constants.ts index d06bcd300..dd55a8f0e 100644 --- a/src/cli/commands/export/constants.ts +++ b/src/cli/commands/export/constants.ts @@ -2,18 +2,19 @@ export const EXPORT_NOTES_FILENAME = 'EXPORT_NOTES.md'; export const DEFAULT_SYSTEM_PROMPT = 'You are a helpful assistant.'; -export const EXTERNAL_GATEWAY_NOTE_CATEGORY = 'External gateway ARNs hardcoded'; -export const MEMORY_ARN_NOTE_CATEGORY = 'Memory ARN requires IAM policy'; export const CONTAINER_URI_NOTE_CATEGORY = 'containerUri: verify Python in base image'; export const CUSTOM_DOCKERFILE_NOTE_CATEGORY = 'Custom harness Dockerfile needs the agent build layer'; export const CONTAINER_URI_ECR_PULL_NOTE_CATEGORY = 'containerUri base image requires ECR pull permission'; export const ALLOWED_TOOLS_NOTE_CATEGORY = 'allowedTools: per-invocation overrides dropped'; export const PATH_SKILLS_NOTE_CATEGORY = 'path skills require container filesystem'; +export const PATH_SKILLS_COPIED_NOTE_CATEGORY = 'path skills copied into agent directory'; +export const PATH_SKILLS_VERIFY_BASE_IMAGE_NOTE_CATEGORY = + 'path skill not found locally — verify it exists in the base image'; export const MCP_HEADER_CREDS_NOTE_CATEGORY = 'MCP tool header credentials'; export const GIT_SKILLS_CONTAINER_NOTE_CATEGORY = 'git skills require git in container image'; -export const GATEWAY_IAM_POLICY_NOTE_CATEGORY = 'Gateway requires InvokeGateway IAM permission'; -export const BROWSER_IAM_POLICY_NOTE_CATEGORY = 'Browser tool requires IAM permissions'; +export const GATEWAY_GRANT_TYPE_NOTE_CATEGORY = 'Gateway OAuth grant type not supported by generated client (M2M only)'; export const BROWSER_CODZIP_NOTE_CATEGORY = 'Browser tool requires Container build — excluded from CodeZip export'; -export const CODE_INTERPRETER_IAM_POLICY_NOTE_CATEGORY = 'Code interpreter tool requires IAM permissions'; export const AWS_SKILLS_NOTE_CATEGORY = 'AWS skills omitted — not available outside managed harness'; -export const S3_SKILLS_IAM_POLICY_NOTE_CATEGORY = 'S3 skills require S3 read IAM permission'; +export const MALFORMED_TOOL_ARN_NOTE_CATEGORY = 'Browser/code-interpreter ARN is malformed — using AWS-managed default'; +export const MALFORMED_S3_SKILL_NOTE_CATEGORY = 'S3 skill URI is malformed — no S3 read permission generated'; +export const LITELLM_NO_API_KEY_NOTE_CATEGORY = 'LiteLLM model may require an API key'; diff --git a/src/cli/commands/export/fetch-harness-spec.ts b/src/cli/commands/export/fetch-harness-spec.ts new file mode 100644 index 000000000..aa454501d --- /dev/null +++ b/src/cli/commands/export/fetch-harness-spec.ts @@ -0,0 +1,277 @@ +import { ValidationError } from '../../../lib/errors/types'; +import type { HarnessSpec } from '../../../schema'; +import type { + HarnessSkill as ApiHarnessSkill, + HarnessTool as ApiHarnessTool, + Harness, + HarnessAgentCoreRuntimeEnvironment, + HarnessModelConfiguration, +} from '../../aws/agentcore-harness'; +import { getHarness } from '../../aws/agentcore-harness'; + +/** + * Fetch a harness by ARN from the control plane and map it to a local HarnessSpec — the same + * shape `resolveHarnessContext` produces for an in-project harness. This is the source path for + * exporting a harness that was created OUTSIDE this CLI project (`--arn`): with no deployed + * state, every resource it references is external and becomes a connection at mapping time. + */ +export async function fetchHarnessSpecByArn( + arn: string, + region: string +): Promise<{ spec: HarnessSpec; systemPrompt?: string }> { + const harnessId = harnessIdFromArn(arn); + const { harness } = await getHarness({ region, harnessId }); + return mapApiHarnessToSpec(harness); +} + +/** Extract the harness id from a harness ARN (`.../harness/` -> ``). */ +export function harnessIdFromArn(arn: string): string { + const match = /:harness\/([^/]+)$/.exec(arn); + if (!match?.[1]) { + throw new ValidationError(`"${arn}" is not a valid harness ARN (expected …:harness/).`); + } + return match[1]; +} + +/** Map a control-plane Harness (API shape) to the local HarnessSpec shape. */ +export function mapApiHarnessToSpec(harness: Harness): { spec: HarnessSpec; systemPrompt?: string } { + const model = mapModel(harness.model); + const joinedPrompt = harness.systemPrompt?.map(b => b.text).join('\n'); + const systemPrompt = joinedPrompt && joinedPrompt.length > 0 ? joinedPrompt : undefined; + + const spec: HarnessSpec = { + name: harness.harnessName, + model, + ...(systemPrompt ? { systemPrompt } : {}), + tools: (harness.tools ?? []).map(mapTool), + skills: (harness.skills ?? []).map(mapSkill), + ...(harness.allowedTools ? { allowedTools: harness.allowedTools } : {}), + ...(() => { + const memory = harness.memory ? mapMemory(harness.memory) : undefined; + return memory ? { memory } : {}; + })(), + ...(harness.maxIterations != null ? { maxIterations: harness.maxIterations } : {}), + ...(harness.maxTokens != null ? { maxTokens: harness.maxTokens } : {}), + ...(harness.timeoutSeconds != null ? { timeoutSeconds: harness.timeoutSeconds } : {}), + ...(harness.environmentArtifact?.containerConfiguration?.containerUri + ? { containerUri: harness.environmentArtifact.containerConfiguration.containerUri } + : {}), + // NOTE: deliberately do NOT carry the harness's executionRoleArn. The exported agent is a NEW, + // independent runtime (a different resource in a different project) — it must get its own + // CDK-managed execution role so the construct can attach the runtime baseline (Bedrock, ECR + // pull for Container builds, logs), the connection grants, and additionalPolicies. Reusing the + // harness's role (imported → { mutable: false }) means CDK can't attach any of those, and the + // Container runtime fails ECR-URI validation at deploy. + ...(harness.environmentVariables ? { environmentVariables: harness.environmentVariables } : {}), + ...(harness.tags ? { tags: harness.tags } : {}), + // Conversation-truncation strategy. The control-plane shape matches the local schema 1:1. + ...(harness.truncation ? { truncation: harness.truncation } : {}), + // Runtime-environment config (network mode/VPC, lifecycle, filesystem mounts) lives under + // environment.agentCoreRuntimeEnvironment in the GetHarness response. + ...mapRuntimeEnvironment(harness.environment?.agentCoreRuntimeEnvironment), + } as HarnessSpec; + + return { spec, systemPrompt }; +} + +function mapModel(model: HarnessModelConfiguration | undefined): HarnessSpec['model'] { + if (model?.bedrockModelConfig) { + const c = model.bedrockModelConfig; + return clean({ + provider: 'bedrock', + modelId: c.modelId, + apiFormat: c.apiFormat, + temperature: c.temperature, + topP: c.topP, + maxTokens: c.maxTokens, + }); + } + if (model?.openAiModelConfig) { + const c = model.openAiModelConfig; + return clean({ + provider: 'open_ai', + modelId: c.modelId, + apiKeyArn: c.apiKeyArn, + apiFormat: c.apiFormat, + temperature: c.temperature, + topP: c.topP, + maxTokens: c.maxTokens, + }); + } + if (model?.geminiModelConfig) { + const c = model.geminiModelConfig; + return clean({ + provider: 'gemini', + modelId: c.modelId, + apiKeyArn: c.apiKeyArn, + temperature: c.temperature, + topP: c.topP, + topK: c.topK, + maxTokens: c.maxTokens, + }); + } + if (model?.liteLlmModelConfig) { + const c = model.liteLlmModelConfig; + return clean({ + provider: 'lite_llm', + modelId: c.modelId, + apiKeyArn: c.apiKeyArn, + apiBase: c.apiBase, + temperature: c.temperature, + topP: c.topP, + maxTokens: c.maxTokens, + additionalParams: c.additionalParams, + }); + } + throw new ValidationError('Fetched harness has no recognized model configuration.'); +} + +function mapTool(tool: ApiHarnessTool): HarnessSpec['tools'][number] { + // The API tool shape (type/name/config) lines up with the local HarnessToolSchema; pass it + // through, dropping undefined keys. Browser/code-interpreter ARNs live nested in config + // (config.agentCoreBrowser.browserArn etc.) and are carried by the config spread. + return clean({ + type: tool.type, + name: tool.name, + ...(tool.config ? { config: tool.config } : {}), + }) as HarnessSpec['tools'][number]; +} + +/** Git-skill auth as returned by GetHarness. */ +interface GitAuthShape { + credentialArn?: string; + username?: string; +} + +/** + * Normalize a control-plane skill into the local HarnessSpec skill shape. + * + * The service returns skills in the structured CFN/control-plane form — S3: `{ S3: { Uri } }`, + * Git: `{ Git: { Url, Path?, ... } }`, Path: `{ Path: ... }` — whereas the local spec uses + * `{ s3Uri }` / `{ gitUrl, path? }` / `{ path }`. Passing the raw API shape through would leave + * S3/git skills undetected by isS3Skill/isGitSkill in the export mapper (so an S3 skill would + * silently skip its generated additionalPolicies). Handle both the structured (PascalCase) form + * and the already-lowercased form defensively. + */ +function mapSkill(skill: ApiHarnessSkill): HarnessSpec['skills'][number] { + const s = skill as Record; + + // S3: { S3: { Uri } } | { s3Uri } + const s3 = (s.S3 ?? s.s3) as { Uri?: string; uri?: string } | undefined; + const s3Uri = s3?.Uri ?? s3?.uri ?? (s.s3Uri as string | undefined); + if (s3Uri) return { s3Uri }; + + // Git: { Git: { Url, Path?, Auth? } } | { git: { url, path?, auth? } } | { gitUrl, path?, auth? } + const git = (s.Git ?? s.git) as + | { Url?: string; url?: string; Path?: string; path?: string; Auth?: GitAuthShape; auth?: GitAuthShape } + | undefined; + const gitUrl = git?.Url ?? git?.url ?? (s.gitUrl as string | undefined); + if (gitUrl) { + const path = git?.Path ?? git?.path ?? (s.path as string | undefined); + // Private-repo auth: the control plane returns auth.credentialArn (a token-vault provider ARN); + // the local spec stores it as auth.credentialName (the runtime extracts the provider name from + // either form). Without carrying this, an exported --arn private git skill would clone anonymously. + const rawAuth = git?.Auth ?? git?.auth ?? (s.auth as GitAuthShape | undefined); + const credentialName = rawAuth?.credentialArn; + const out: { gitUrl: string; path?: string; auth?: { credentialName: string; username?: string } } = { gitUrl }; + if (path) out.path = path; + if (credentialName) { + out.auth = rawAuth?.username ? { credentialName, username: rawAuth.username } : { credentialName }; + } + return out as HarnessSpec['skills'][number]; + } + + // Path: { Path } | { path } + const path = (s.Path as string | undefined) ?? (s.path as string | undefined); + if (path) return { path }; + + // Unknown shape — pass through (best effort; export will surface anything it can't map). + return skill as HarnessSpec['skills'][number]; +} + +/** + * Map the control-plane harness memory onto the local HarnessSpec memory ref. The service memory is + * a tagged union; we handle each variant defensively because the CLI's bundled SDK model may lag the + * service (an unmodeled variant arrives as `{ SDK_UNKNOWN_MEMBER: { name } }`): + * - agentCoreMemoryConfiguration -> existing (bring-your-own, by arn) + * - managedMemoryConfiguration -> managed (service-managed; no arn to carry) + * - anything else / unknown -> undefined (omit memory; the exported agent gets none) + */ +function mapMemory(memory: NonNullable): NonNullable | undefined { + const m = memory.agentCoreMemoryConfiguration; + if (m?.arn) { + return clean({ + mode: 'existing', + arn: m.arn, + actorId: m.actorId, + messagesCount: m.messagesCount, + }) as NonNullable; + } + // Managed memory (or an SDK-unknown variant that resolves to managed) — there is no external ARN + // to reference; export it as a managed memory request so deploy provisions a fresh one. + const asRecord = memory as unknown as Record; + if ('managedMemoryConfiguration' in asRecord || hasUnknownManagedMember(asRecord)) { + return { mode: 'managed' } as NonNullable; + } + return undefined; +} + +/** True when the SDK surfaced an unmodeled memory member that names the managed configuration. */ +function hasUnknownManagedMember(memory: Record): boolean { + const unknown = memory.SDK_UNKNOWN_MEMBER as { name?: string } | undefined; + return unknown?.name === 'managedMemoryConfiguration'; +} + +/** + * Map the control-plane runtime-environment block onto the local HarnessSpec fields: + * - networkConfiguration -> networkMode + networkConfig (VPC subnets/securityGroups) + * - lifecycleConfiguration -> lifecycleConfig (idle/maxLifetime; same field names) + * - filesystemConfigurations[] (tagged union) -> sessionStoragePath / efsAccessPoints / s3AccessPoints + * Returns a partial spec spread into the HarnessSpec; emits only the fields that are present. + */ +function mapRuntimeEnvironment(env: HarnessAgentCoreRuntimeEnvironment | undefined): Partial { + if (!env) return {}; + const out: Record = {}; + + // Network: PUBLIC is the implicit default locally, so only carry VPC (with its config). + const net = env.networkConfiguration; + if (net?.networkMode === 'VPC') { + out.networkMode = 'VPC'; + const subnets = net.networkModeConfig?.subnets; + const securityGroups = net.networkModeConfig?.securityGroups; + if (subnets?.length && securityGroups?.length) { + out.networkConfig = { subnets, securityGroups }; + } + } + + // Lifecycle: same field names; drop unset members. + const lc = env.lifecycleConfiguration; + if (lc && (lc.idleRuntimeSessionTimeout != null || lc.maxLifetime != null)) { + out.lifecycleConfig = clean({ + idleRuntimeSessionTimeout: lc.idleRuntimeSessionTimeout, + maxLifetime: lc.maxLifetime, + }); + } + + // Filesystem mounts: a tagged-union list -> the flat local fields. + const efs: { accessPointArn: string; mountPath: string }[] = []; + const s3: { accessPointArn: string; mountPath: string }[] = []; + for (const fs of env.filesystemConfigurations ?? []) { + if (fs.sessionStorage?.mountPath) { + out.sessionStoragePath = fs.sessionStorage.mountPath; + } else if (fs.efsAccessPoint?.accessPointArn && fs.efsAccessPoint.mountPath) { + efs.push({ accessPointArn: fs.efsAccessPoint.accessPointArn, mountPath: fs.efsAccessPoint.mountPath }); + } else if (fs.s3FilesAccessPoint?.accessPointArn && fs.s3FilesAccessPoint.mountPath) { + s3.push({ accessPointArn: fs.s3FilesAccessPoint.accessPointArn, mountPath: fs.s3FilesAccessPoint.mountPath }); + } + } + if (efs.length) out.efsAccessPoints = efs; + if (s3.length) out.s3AccessPoints = s3; + + return out as Partial; +} + +/** Drop undefined-valued keys so optional fields don't serialize as `undefined`. */ +function clean>(obj: T): T { + return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined)) as T; +} diff --git a/src/cli/commands/export/harness-action.ts b/src/cli/commands/export/harness-action.ts index d95acdea9..86bd99cd3 100644 --- a/src/cli/commands/export/harness-action.ts +++ b/src/cli/commands/export/harness-action.ts @@ -12,13 +12,19 @@ import { standardize, } from '../../telemetry/schemas/common-shapes.js'; import { StrandsRenderer } from '../../templates/StrandsRenderer'; -import { CUSTOM_DOCKERFILE_NOTE_CATEGORY, EXPORT_NOTES_FILENAME } from './constants'; -import { mapHarnessToExportConfig } from './harness-mapper'; +import { + CUSTOM_DOCKERFILE_NOTE_CATEGORY, + EXPORT_NOTES_FILENAME, + PATH_SKILLS_COPIED_NOTE_CATEGORY, + PATH_SKILLS_VERIFY_BASE_IMAGE_NOTE_CATEGORY, +} from './constants'; +import { fetchHarnessSpecByArn } from './fetch-harness-spec'; +import { isPathSkill, mapHarnessToExportConfig } from './harness-mapper'; import { resolveHarnessContext } from './harness-resolver'; import type { ExportHarnessOptions, ExportNote, ResolvedHarnessContext } from './types'; import { execSync } from 'node:child_process'; import { copyFileSync, cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; -import { basename, join } from 'node:path'; +import { basename, isAbsolute, join, resolve, sep } from 'node:path'; export interface ExportHarnessProgress { onProgress?: (message: string) => void; @@ -36,24 +42,14 @@ export async function handleExportHarness( 'export.harness', {} as CommandAttrs<'export.harness'>, async (recorder: AttributeRecorder>) => { - const harnessName = options.name; - if (!harnessName) { - return { success: false as const, error: new ValidationError('--name is required in non-interactive mode') }; - } - - const targetAgentName = options.targetAgentName ?? `${harnessName}Agent`; - const parsedAgentName = AgentNameSchema.safeParse(targetAgentName); - if (!parsedAgentName.success) { + if (!!options.name === !!options.arn) { return { success: false as const, - error: new ValidationError( - `Invalid --target-agent-name "${targetAgentName}": ${parsedAgentName.error.issues[0]?.message ?? 'invalid name'}` - ), + error: new ValidationError('Specify exactly one of --name (local harness) or --arn (fetched harness).'), }; } const buildOverride = options.build as BuildType | undefined; - const VALID_BUILD_TYPES = new Set(['CodeZip', 'Container']); if (buildOverride && !VALID_BUILD_TYPES.has(buildOverride)) { return { @@ -62,21 +58,52 @@ export async function handleExportHarness( }; } - // 1. Resolve all on-disk inputs + // For --arn, fetch the harness from the service first so we can derive its name. The fetch + // needs a region — taken from the project's first deployment target. + let prefetched: { spec: import('../../../schema').HarnessSpec; systemPrompt?: string } | undefined; + if (options.arn) { + log('Fetching harness from service'); + const region = await resolveExportRegion(); + if (!region) { + return { + success: false as const, + error: new ValidationError( + 'No AWS region configured. Add a deployment target (agentcore/aws-targets.json) before exporting by ARN.' + ), + }; + } + try { + prefetched = await fetchHarnessSpecByArn(options.arn, region); + } catch (err) { + return { success: false as const, error: err instanceof Error ? err : new Error(String(err)) }; + } + } + + const harnessName = options.name ?? prefetched!.spec.name; + const targetAgentName = options.targetAgentName ?? `${harnessName}Agent`; + const parsedAgentName = AgentNameSchema.safeParse(targetAgentName); + if (!parsedAgentName.success) { + return { + success: false as const, + error: new ValidationError( + `Invalid --target-agent-name "${targetAgentName}": ${parsedAgentName.error.issues[0]?.message ?? 'invalid name'}` + ), + }; + } + + // 1. Resolve all on-disk inputs (+ the prefetched spec for the --arn path) log('Reading harness configuration'); let context: Awaited>; try { - context = await resolveHarnessContext(harnessName, targetAgentName); + context = await resolveHarnessContext(harnessName, targetAgentName, undefined, prefetched); } catch (err) { return { success: false as const, error: err instanceof Error ? err : new Error(String(err)) }; } // 2. Map harness spec to render config + agent env spec log('Mapping to Strands template config'); - const { renderConfig, agentEnvSpec, credentialEntry, mcpCredentialEntries } = mapHarnessToExportConfig( - context, - buildOverride - ); + const { renderConfig, agentEnvSpec, credentialEntry, mcpCredentialEntries, gitCredentialEntries } = + mapHarnessToExportConfig(context, buildOverride); // The target directory is guaranteed not to pre-exist (resolveHarnessContext throws if it // does), so anything written below is created by this export. Remove it on failure to avoid @@ -115,6 +142,42 @@ export async function handleExportHarness( } } + // 3b. Path skills + CLI-generated Dockerfile: the custom-dockerfile branch above already + // copies the whole harness dir, but a plain `--build Container` / containerUri build uses + // a generated Dockerfile (`COPY . .`) and the harness dir is NOT otherwise copied. Per the + // documented contract, copy each path-skill directory that resolves locally under the + // harness dir into the agent dir so the generated Dockerfile bundles it (no manual step); + // unresolvable paths (absolute, traversal, or not found) are assumed to live in the base + // image and get a verify-note instead. (Container only — CodeZip path skills are unsupported + // and already noted by the mapper.) + if (!context.spec.dockerfile && renderConfig.buildType === 'Container') { + const harnessDir = join(context.projectRoot, 'app', harnessName); + const copied: string[] = []; + const unresolved: string[] = []; + for (const skill of context.spec.skills) { + // Only pure path skills (a git skill carries `path` as a repo subdir, not a local dir). + if (!isPathSkill(skill) || !skill.path) continue; + const skillPath = skill.path; + const skillSrc = join(harnessDir, skillPath); + // Reject absolute paths and traversal — must resolve to a dir inside the harness dir. + const escapesHarness = isAbsolute(skillPath) || !resolve(skillSrc).startsWith(resolve(harnessDir) + sep); + if (!escapesHarness && existsSync(skillSrc)) { + const skillDest = join(agentDir, skillPath); + mkdirSync(skillDest, { recursive: true }); + cpSync(skillSrc, skillDest, { recursive: true }); + copied.push(skillPath); + } else { + unresolved.push(skillPath); + } + } + if (copied.length > 0) { + context.exportNotes.push(buildPathSkillsCopiedNote(copied, targetAgentName)); + } + if (unresolved.length > 0) { + context.exportNotes.push(buildPathSkillsVerifyNote(unresolved, targetAgentName)); + } + } + // 4. Generate Dockerfile stub for containerUri if (context.spec.containerUri && renderConfig.buildType === 'Container') { mkdirSync(agentDir, { recursive: true }); @@ -158,7 +221,13 @@ export async function handleExportHarness( // 6. Write agent to agentcore.json log('Updating agentcore.json'); try { - await writeExportedAgentToProject(agentEnvSpec, context, credentialEntry, mcpCredentialEntries); + await writeExportedAgentToProject( + agentEnvSpec, + context, + credentialEntry, + mcpCredentialEntries, + gitCredentialEntries + ); } catch (err) { cleanupAgentDir(); return { success: false as const, error: err instanceof Error ? err : new Error(String(err)) }; @@ -169,6 +238,20 @@ export async function handleExportHarness( await setEnvVar(envVarName, value, context.configBaseDir); } + // 6d. Write static connection discovery values (external gateway URL, browser/code-interpreter + // id) to .env.local so `agentcore dev` resolves them locally. At deploy the CDK connection + // wiring injects the same env vars onto the runtime. + for (const [envVarName, value] of Object.entries(context.localEnvVars)) { + await setEnvVar(envVarName, value, context.configBaseDir); + } + + // 6e. Write generated IAM policy files (e.g. S3 skills) into the agent's code dir. They are + // referenced from AgentEnvSpec.additionalPolicies and attached to the role at deploy. + for (const [fileName, policyDoc] of Object.entries(context.generatedPolicyFiles)) { + mkdirSync(agentDir, { recursive: true }); + writeFileSync(join(agentDir, fileName), JSON.stringify(policyDoc, null, 2) + '\n'); + } + // 6b. Warn if no deploy targets are configured const configIO = new ConfigIO({ baseDir: context.configBaseDir }); const targets = await configIO.readAWSDeploymentTargets().catch(() => []); @@ -191,7 +274,13 @@ export async function handleExportHarness( // Record telemetry attrs after all work is done recorder.set({ build_type: standardize(TelemetryBuildType, renderConfig.buildType ?? 'CodeZip'), - model_provider: standardize(TelemetryModelProvider, renderConfig.modelProvider), + // renderConfig.modelProvider is the CLI ModelProvider enum (e.g. 'LiteLLM'); standardize only + // lowercases, so 'LiteLLM' -> 'litellm' would miss the telemetry token 'lite_llm'. Normalize + // the one value whose lowercase != its telemetry token before standardizing. + model_provider: standardize( + TelemetryModelProvider, + renderConfig.modelProvider === 'LiteLLM' ? 'lite_llm' : renderConfig.modelProvider + ), has_memory: renderConfig.hasMemory, has_gateway: renderConfig.hasGateway, has_container: renderConfig.buildType === 'Container', @@ -213,11 +302,22 @@ export async function handleExportHarness( // Write agent entry to agentcore.json // ============================================================================ +/** Region for the --arn fetch: the current project's first deployment target. */ +async function resolveExportRegion(): Promise { + try { + const targets = await new ConfigIO().readAWSDeploymentTargets(); + return targets[0]?.region; + } catch { + return undefined; + } +} + async function writeExportedAgentToProject( agentEnvSpec: AgentEnvSpec, context: ResolvedHarnessContext, credentialEntry: Credential | null, - mcpCredentialEntries: { credential: Credential }[] + mcpCredentialEntries: { credential: Credential }[], + gitCredentialEntries: Credential[] = [] ): Promise { const configIO = new ConfigIO({ baseDir: context.configBaseDir }); const project = await configIO.readProjectSpec(); @@ -238,6 +338,13 @@ async function writeExportedAgentToProject( } } + // Git-skill API-key credential references (private repo clone auth). + for (const credential of gitCredentialEntries) { + if (!project.credentials.some(c => c.name === credential.name)) { + project.credentials.push(credential); + } + } + await configIO.writeProjectSpec(project); } @@ -293,6 +400,33 @@ export function buildMissingDockerfileNote( }; } +/** Note emitted when local path-skill directories were copied into the generated agent dir. */ +export function buildPathSkillsCopiedNote(paths: string[], targetAgentName: string): ExportNote { + return { + category: PATH_SKILLS_COPIED_NOTE_CATEGORY, + message: + `The following path-skill ${paths.length === 1 ? 'directory was' : 'directories were'} copied into ` + + `app/${targetAgentName}/ so the generated Dockerfile's \`COPY . .\` step bundles ${paths.length === 1 ? 'it' : 'them'} ` + + `into the image — no manual step required: ${paths.map(p => `"${p}"`).join(', ')}.`, + }; +} + +/** + * Note emitted when a path skill could not be resolved to a local directory (absolute path, path + * traversal, or not found under the harness dir). It is assumed to be provided by the base image. + */ +export function buildPathSkillsVerifyNote(paths: string[], targetAgentName: string): ExportNote { + return { + category: PATH_SKILLS_VERIFY_BASE_IMAGE_NOTE_CATEGORY, + message: + `The following path ${paths.length === 1 ? 'skill was' : 'skills were'} not found locally under the ` + + `harness directory, so ${paths.length === 1 ? 'it was' : 'they were'} NOT copied into app/${targetAgentName}/: ` + + `${paths.map(p => `"${p}"`).join(', ')}. The exported agent loads ${paths.length === 1 ? 'this path' : 'these paths'} ` + + `at runtime — ensure ${paths.length === 1 ? 'it exists' : 'they exist'} on the container filesystem (e.g. installed ` + + `in your base image or added via a Dockerfile COPY) before \`agentcore deploy\`.`, + }; +} + // ============================================================================ // Write EXPORT_NOTES.md // ============================================================================ diff --git a/src/cli/commands/export/harness-mapper.ts b/src/cli/commands/export/harness-mapper.ts index 988e3aa72..489175381 100644 --- a/src/cli/commands/export/harness-mapper.ts +++ b/src/cli/commands/export/harness-mapper.ts @@ -1,8 +1,17 @@ import { APP_DIR } from '../../../lib'; import { ValidationError } from '../../../lib/errors/types'; +import { + BROWSER_ARN_PATTERN, + CODE_INTERPRETER_ARN_PATTERN, + connectionEnvToken, + connectionIdForTarget, + resourceIdFromArn, +} from '../../../schema'; import type { AgentEnvSpec, BuildType, + Connection, + ConnectionTarget, Credential, DirectoryPath, FilePath, @@ -39,19 +48,17 @@ import { ALLOWED_TOOLS_NOTE_CATEGORY, AWS_SKILLS_NOTE_CATEGORY, BROWSER_CODZIP_NOTE_CATEGORY, - BROWSER_IAM_POLICY_NOTE_CATEGORY, - CODE_INTERPRETER_IAM_POLICY_NOTE_CATEGORY, CONTAINER_URI_ECR_PULL_NOTE_CATEGORY, CONTAINER_URI_NOTE_CATEGORY, - EXTERNAL_GATEWAY_NOTE_CATEGORY, - GATEWAY_IAM_POLICY_NOTE_CATEGORY, + GATEWAY_GRANT_TYPE_NOTE_CATEGORY, GIT_SKILLS_CONTAINER_NOTE_CATEGORY, + LITELLM_NO_API_KEY_NOTE_CATEGORY, + MALFORMED_S3_SKILL_NOTE_CATEGORY, + MALFORMED_TOOL_ARN_NOTE_CATEGORY, MCP_HEADER_CREDS_NOTE_CATEGORY, - MEMORY_ARN_NOTE_CATEGORY, PATH_SKILLS_NOTE_CATEGORY, - S3_SKILLS_IAM_POLICY_NOTE_CATEGORY, } from './constants'; -import type { HarnessMappingResult, ResolvedHarnessContext } from './types'; +import type { ExportNote, HarnessMappingResult, ResolvedHarnessContext } from './types'; // ============================================================================ // Public entry point @@ -75,10 +82,26 @@ export function mapHarnessToExportConfig( const modelProvider = resolveModelProvider(spec.model.provider); const allowedToolPatterns = spec.allowedTools ?? ['*']; const identityResult = resolveIdentityProvider(spec, context); + + // LiteLLM keyless-but-key-requiring warning. A `bedrock/...` LiteLLM model authenticates via the + // execution role (no key needed) — the common, valid case. Any other provider prefix (openai/, + // anthropic/, ...) typically needs an API key; if the harness set no apiKeyArn, the generated + // client is built keyless and fails at first invocation. Keyless is schema- and runtime-valid + // (matches loopy), so this is a note, not an error — export is just the right place to flag it. + if (spec.model.provider === 'lite_llm' && !spec.model.apiKeyArn && !spec.model.modelId.startsWith('bedrock/')) { + context.exportNotes.push({ + category: LITELLM_NO_API_KEY_NOTE_CATEGORY, + message: + `The LiteLLM model "${spec.model.modelId}" is not a Bedrock-backed (bedrock/...) model, but the ` + + `harness has no apiKeyArn. The exported agent constructs LiteLLMModel without an API key and will ` + + `fail at first invocation if the provider requires one. Add an API-key credential to the harness ` + + `(model apiKeyArn), or use a bedrock/ model id (which authenticates via the execution role).`, + }); + } const memoryResult = resolveMemoryProviders(spec, context); const gatewayResult = resolveGatewayProviders(spec, context, allowedToolPatterns); const hasGateway = gatewayResult.providers.length > 0; - addBrowserCodeInterpreterNotes(spec, allowedToolPatterns, buildType, context); + const toolResult = resolveBrowserCodeInterpreterConnections(spec, allowedToolPatterns, buildType, context); const hasExecutionLimits = spec.maxIterations !== undefined || spec.maxTokens !== undefined || spec.timeoutSeconds !== undefined; const hasSkillsFetcher = spec.skills.length > 0; @@ -124,37 +147,44 @@ export function mapHarnessToExportConfig( // agent fails at first invocation with an opaque S3 AccessDenied. Independent of build type. const s3Skills = spec.skills.filter(isS3Skill); if (s3Skills.length > 0) { - const arns = s3Skills - .map(s => parseS3SkillArns(s.s3Uri, context.region)) - .filter((a): a is NonNullable => a !== undefined); - if (arns.length > 0) { - const objectResources = [...new Set(arns.map(a => a.objectArn))]; - const bucketResources = [...new Set(arns.map(a => a.bucketArn))]; - const fmt = (rs: string[]) => rs.map(r => `'${r}'`).join(', '); - const agentName = context.targetAgentName ?? 'YourAgentName'; + // Parse each URI ONCE, partitioning into resolvable ARNs vs malformed URIs. (A second parse pass + // would be wasted work and risks the two passes ever classifying a URI differently.) + const malformedUris: string[] = []; + const arns: NonNullable>[] = []; + for (const { s3Uri } of s3Skills) { + const parsed = parseS3SkillArns(s3Uri, context.region); + if (parsed) arns.push(parsed); + else malformedUris.push(s3Uri); + } + // Warn on malformed URIs instead of silently shipping a skills fetcher with no matching S3 IAM + // (which would fail at runtime with an opaque AccessDenied). + if (malformedUris.length > 0) { context.exportNotes.push({ - category: S3_SKILLS_IAM_POLICY_NOTE_CATEGORY, + category: MALFORMED_S3_SKILL_NOTE_CATEGORY, message: - `This agent downloads its S3 skills (${s3Skills.map(s => s.s3Uri).join(', ')}) at runtime with boto3. ` + - `The exported runtime execution role is not automatically granted permission to read them, so the ` + - `agent will fail on its first invocation with an S3 AccessDenied.\n\n` + - `Add the following to agentcore/cdk/lib/cdk-stack.ts after \`this.application\` is created,\n` + - `replacing "${agentName}" if you renamed the agent:\n\n` + - ` const agentEnv = this.application.environments.get('${agentName}');\n` + - ` agentEnv?.runtime.role.addToPrincipalPolicy(\n` + - ` new iam.PolicyStatement({\n` + - ` actions: ['s3:GetObject'],\n` + - ` resources: [${fmt(objectResources)}],\n` + - ` })\n` + - ` );\n` + - ` agentEnv?.runtime.role.addToPrincipalPolicy(\n` + - ` new iam.PolicyStatement({\n` + - ` actions: ['s3:ListBucket'],\n` + - ` resources: [${fmt(bucketResources)}],\n` + - ` })\n` + - ` );`, + `These S3 skill URIs could not be parsed into a bucket, so no S3 read permission was ` + + `generated for them: ${malformedUris.map(u => `"${u}"`).join(', ')}. The exported agent ` + + `still attempts to fetch these skills at runtime and will fail with S3 AccessDenied. Fix the ` + + `s3Uri values (expected \`s3:///\`) on this agent's skills in ` + + `agentcore/agentcore.json and re-deploy.`, }); } + if (arns.length > 0) { + const objectResources = [...new Set(arns.map(a => a.objectArn))]; + const bucketResources = [...new Set(arns.map(a => a.bucketArn))]; + // Generate an inline IAM policy file (opaque AWS access — not a typed connection) and wire it + // via AgentEnvSpec.additionalPolicies. The CDK attaches it to the runtime role at deploy. + const policyDoc = { + Version: '2012-10-17', + Statement: [ + { Effect: 'Allow', Action: 's3:GetObject', Resource: objectResources }, + { Effect: 'Allow', Action: 's3:ListBucket', Resource: bucketResources }, + ], + }; + const policyFile = 's3-skills-policy.json'; + context.generatedPolicyFiles[policyFile] = policyDoc; + if (!context.additionalPolicies.includes(policyFile)) context.additionalPolicies.push(policyFile); + } } // AWS skills: managed-only feature, cannot export @@ -234,22 +264,51 @@ export function mapHarnessToExportConfig( // Skills (path/s3/git) — consumed by main.py + skills/fetcher.py templates ...buildSkillsRenderConfig(spec, hasSkillsFetcher), // Inline + builtin + browser/code-interpreter tools (after allowedTools filter) - ...buildToolsRenderConfig(spec, allowedToolPatterns, buildType), + ...buildToolsRenderConfig(spec, allowedToolPatterns, toolResult), hasExecutionLimits, isExportHarness: true, modelId: spec.model.modelId, + // LiteLLM-only model config (apiBase + additionalParams), threaded into load.py. The model + // schema is a flat object, so apiBase/additionalParams are always typed-present — only the + // provider check + a truthiness check are needed. + ...(spec.model.provider === 'lite_llm' && spec.model.apiBase ? { litellmApiBase: spec.model.apiBase } : {}), + ...(spec.model.provider === 'lite_llm' && + spec.model.additionalParams && + Object.keys(spec.model.additionalParams).length > 0 + ? { litellmAdditionalParams: spec.model.additionalParams } + : {}), // System prompt (written verbatim into main.py) systemPromptText: context.systemPrompt, actorId: spec.memory?.mode === 'existing' ? spec.memory.actorId : undefined, }; - const agentEnvSpec = buildAgentEnvSpec(context, targetAgentName, buildType); + const connections = [ + ...(memoryResult.connections ?? []), + ...(gatewayResult.connections ?? []), + ...toolResult.connections, + ]; + const agentEnvSpec = buildAgentEnvSpec(context, targetAgentName, buildType, connections); + + // Private git skills reference an API-key credential provider for clone auth. Persist a name-only + // credential entry per distinct provider so the deployed agent's role is granted GetResourceApiKey + // (via wireCredentialsToAgents → grantCredentialAccess). The provider itself already exists in + // AgentCore Identity (created out-of-band or in the source project); export only references it. + const gitCredentialEntries: Credential[] = []; + const seenGitCred = new Set(); + for (const skill of spec.skills) { + if (!isGitSkill(skill) || !skill.auth?.credentialName) continue; + const credName = resourceIdFromArn(skill.auth.credentialName); // bare name or last ARN segment + if (seenGitCred.has(credName)) continue; + seenGitCred.add(credName); + gitCredentialEntries.push({ authorizerType: 'ApiKeyCredentialProvider', name: credName }); + } return { renderConfig, agentEnvSpec, credentialEntry: identityResult.credentialEntry, mcpCredentialEntries: mcpResolution.credentialEntries, + gitCredentialEntries, }; } @@ -296,33 +355,31 @@ function buildSkillsRenderConfig( }; } -/** Inline, builtin, and browser/code-interpreter tools (after allowedTools filter). */ +/** + * Inline + builtin tools (after allowedTools filter), plus the browser/code-interpreter render + * fields lifted straight from the already-resolved BrowserCodeInterpreterResult — the single source + * for those values, so the identifier env var here always matches the connection that injects it. + */ function buildToolsRenderConfig( spec: HarnessSpec, allowedToolPatterns: string[], - buildType: BuildType + toolResult: BrowserCodeInterpreterResult ): Pick< AgentRenderConfig, | 'inlineFunctionTools' | 'hasBrowser' - | 'browserIdentifier' + | 'browserIdentifierEnvVar' | 'hasCodeInterpreter' - | 'codeInterpreterIdentifier' + | 'codeInterpreterIdentifierEnvVar' | 'hasShell' | 'hasFileOperations' > { return { inlineFunctionTools: resolveInlineFunctionTools(spec, allowedToolPatterns), - // Browser requires a Container build (Playwright driver can't spawn subprocesses in CodeZip Lambda sandbox). - hasBrowser: isToolIncluded('agentcore_browser', spec, allowedToolPatterns) && buildType === 'Container', - browserIdentifier: extractToolIdentifier(spec, 'agentcore_browser', 'agentCoreBrowser', 'browserArn'), - hasCodeInterpreter: isToolIncluded('agentcore_code_interpreter', spec, allowedToolPatterns), - codeInterpreterIdentifier: extractToolIdentifier( - spec, - 'agentcore_code_interpreter', - 'agentCoreCodeInterpreter', - 'codeInterpreterArn' - ), + hasBrowser: toolResult.hasBrowser, + browserIdentifierEnvVar: toolResult.browserIdentifierEnvVar, + hasCodeInterpreter: toolResult.hasCodeInterpreter, + codeInterpreterIdentifierEnvVar: toolResult.codeInterpreterIdentifierEnvVar, // Builtin tools — always available in the Harness runtime, included unless filtered out by allowedTools hasShell: isBuiltinIncluded('shell', allowedToolPatterns), hasFileOperations: isBuiltinIncluded('file_operations', allowedToolPatterns), @@ -336,7 +393,8 @@ function buildToolsRenderConfig( function buildAgentEnvSpec( context: ResolvedHarnessContext, targetAgentName: string, - buildType: BuildType + buildType: BuildType, + connections: Connection[] = [] ): AgentEnvSpec { const { spec } = context; const codeLocation = `${APP_DIR}/${targetAgentName}/` as DirectoryPath; @@ -367,11 +425,89 @@ function buildAgentEnvSpec( }), ...(spec.executionRoleArn && { executionRoleArn: spec.executionRoleArn }), ...(envVars.length > 0 && { envVars }), + ...(connections.length > 0 && { connections }), + ...(context.additionalPolicies.length > 0 && { additionalPolicies: context.additionalPolicies }), ...(spec.tags && { tags: spec.tags }), ...buildFilesystemConfigurations(spec.sessionStoragePath, spec.efsAccessPoints, spec.s3AccessPoints), }; } +// ============================================================================ +// Connection helpers (external resources) +// ============================================================================ + +/** + * Stable connection id for an external target, by kind + arn. Thin adapter over the schema's + * canonical `connectionIdForTarget` (the single source of truth shared with the CDK) — kept because + * the call sites here have a kind+arn rather than a built target object. + */ +function externalConnectionId( + kind: 'memory' | 'gateway' | 'runtime' | 'browser' | 'codeInterpreter', + arn: string +): string { + return connectionIdForTarget({ type: kind, arn } as ConnectionTarget); +} + +/** + * Discovery env-var name `__` from a connection id. The token derivation is + * the schema's canonical `connectionEnvToken` — the single source of truth the CDK wiring also uses, + * so the name baked into generated code always matches what deploy injects. + */ +function connectionEnvVarName(prefix: string, connectionId: string, suffix: string): string { + return `${prefix}_${connectionEnvToken(connectionId)}_${suffix}`; +} + +/** + * Map a harness gateway outboundAuth onto the connection's GatewayOutboundAuth. The shapes match + * except for the oauth grant-type enum: the harness uses CLIENT_CREDENTIALS | USER_FEDERATION, + * while the connection (matching the runtime/Smithy model) uses CLIENT_CREDENTIALS | + * AUTHORIZATION_CODE | TOKEN_EXCHANGE. USER_FEDERATION maps to AUTHORIZATION_CODE (loopy's + * _GRANT_TYPE_TO_FLOW maps AUTHORIZATION_CODE -> USER_FEDERATION auth flow). + */ +function toConnectionGatewayAuth( + outboundAuth: HarnessGatewayOutboundAuth | undefined +): Extract['outboundAuth'] { + if (!outboundAuth) return undefined; + if ('awsIam' in outboundAuth) return { awsIam: {} }; + if ('none' in outboundAuth) return { none: {} }; + const { providerArn, scopes, grantType, customParameters } = outboundAuth.oauth; + const mappedGrant = + grantType === 'USER_FEDERATION' + ? 'AUTHORIZATION_CODE' + : grantType === 'CLIENT_CREDENTIALS' + ? 'CLIENT_CREDENTIALS' + : undefined; + return { + oauth: { + providerArn, + scopes, + ...(mappedGrant && { grantType: mappedGrant }), + ...(customParameters && { customParameters }), + }, + }; +} + +/** + * Map a HARNESS OAuth grant type to the AgentCore Identity auth flow consumed by the generated + * client's `@requires_access_token(auth_flow=...)`. The harness enum is CLIENT_CREDENTIALS | + * USER_FEDERATION (see HarnessGatewayOutboundAuth); both have a decorator equivalent, mirroring + * loopy's `_GRANT_TYPE_TO_FLOW`: + * CLIENT_CREDENTIALS -> M2M, USER_FEDERATION -> USER_FEDERATION. + * The SDK decorator's auth_flow is `Literal["M2M","USER_FEDERATION"]`. Unset grant defaults to M2M. + * Returns undefined only for an unrecognized value (caller emits a manual-step note). + */ +function grantTypeToAuthFlow(grantType: string | undefined): string | undefined { + switch (grantType) { + case undefined: + case 'CLIENT_CREDENTIALS': + return 'M2M'; + case 'USER_FEDERATION': + return 'USER_FEDERATION'; + default: + return undefined; // unrecognized — not expressible via the decorator + } +} + // ============================================================================ // Model provider // ============================================================================ @@ -385,10 +521,7 @@ function resolveModelProvider(provider: 'bedrock' | 'open_ai' | 'gemini' | 'lite case 'gemini': return 'Gemini'; case 'lite_llm': - throw new ValidationError( - 'Harness uses the "lite_llm" model provider, which the Strands export does not support. ' + - 'Switch the harness to bedrock, open_ai, or gemini before exporting.' - ); + return 'LiteLLM'; } } @@ -451,6 +584,8 @@ function resolveIdentityProvider(spec: HarnessSpec, context: ResolvedHarnessCont interface MemoryResult { providers: MemoryProviderRenderConfig[]; + /** Connections to external memories (IAM + env wiring generated at deploy). */ + connections?: Connection[]; } function resolveMemoryProviders(spec: HarnessSpec, context: ResolvedHarnessContext): MemoryResult { @@ -484,16 +619,21 @@ function resolveMemoryProviders(spec: HarnessSpec, context: ResolvedHarnessConte }; } - // External memory — hardcode ARN as env var - context.exportNotes.push({ - category: MEMORY_ARN_NOTE_CATEGORY, - message: - `The harness memory was referenced by ARN (${memArn}) and could not be matched to a ` + - 'same-project memory. A MEMORY_ARN env var will be used. Ensure the runtime IAM execution role ' + - 'has bedrock-agentcore:GetMemory and bedrock-agentcore:InvokeMemory on the above ARN.', - }); + // External memory — model as a connection. The CDK generates the correct IAM (the full + // memory action set scoped to the ARN) and injects the discovery env var at deploy. The + // env var name MUST match what the connection wiring injects (MEMORY__ID). + // access: 'readwrite' — the agent both reads conversation history and writes events + // (CreateEvent) to memory at runtime, so read-only would fail with AccessDenied on writes. + const connectionId = externalConnectionId('memory', memArn); + const envVarName = connectionEnvVarName('MEMORY', connectionId, 'ID'); + // Write the discovery value to .env.local for local dev (`agentcore dev`), mirroring the + // gateway/browser/code-interpreter branches. At deploy the CDK connection wiring injects the + // same MEMORY__ID = resourceIdFromArn(arn); without this, the var is unset locally and + // session.py silently disables memory (get_memory_session_manager returns None). + context.localEnvVars[envVarName] = resourceIdFromArn(memArn); return { - providers: [{ name: 'ExternalMemory', envVarName: 'MEMORY_ARN', strategies: [] }], + providers: [{ name: connectionId, envVarName, strategies: [] }], + connections: [{ id: connectionId, to: { type: 'memory', arn: memArn }, access: 'readwrite' }], }; } @@ -506,6 +646,8 @@ function resolveMemoryProviders(spec: HarnessSpec, context: ResolvedHarnessConte interface GatewayResult { providers: GatewayProviderRenderConfig[]; + /** Connections to external gateways (IAM generated at deploy from outboundAuth). */ + connections?: Connection[]; } function resolveGatewayProviders( @@ -514,6 +656,7 @@ function resolveGatewayProviders( allowedToolPatterns: string[] ): GatewayResult { const providers: GatewayProviderRenderConfig[] = []; + const connections: Connection[] = []; for (const tool of spec.tools) { if (tool.type !== 'agentcore_gateway') continue; @@ -555,16 +698,10 @@ function resolveGatewayProviders( // Same-project gateway: AgentCoreMcp.wireGatewayUrlsToAgents() auto-grants InvokeGateway // to all runtime environments — no manual IAM step needed. } else { - // External gateway — derive URL from ARN - const hardcodedUrl = deriveGatewayUrl(gatewayArn); - context.exportNotes.push({ - category: EXTERNAL_GATEWAY_NOTE_CATEGORY, - message: - `Gateway tool "${tool.name}" (ARN: ${gatewayArn}) was not found in this project's deployed state. ` + - `The URL has been hardcoded as "${hardcodedUrl}" in mcp_client/client.py. ` + - 'If the ARN changes (e.g. after re-deployment), update mcp_client/client.py manually.', - }); - + // External gateway — model as a connection. The CDK generates the correct IAM + // (InvokeGateway for awsIam, token-fetch for oauth) AND injects the discovery env vars + // (GATEWAY__URL derived from the ARN, AUTH_TYPE, credential provider) at deploy, so the + // generated client reads the URL from the env var rather than a hardcoded literal. const outboundAuth = gwConfig.outboundAuth; const authType = outboundAuth ? 'oauth' in outboundAuth @@ -574,44 +711,58 @@ function resolveGatewayProviders( : 'NONE' : 'AWS_IAM'; - if (authType === 'AWS_IAM') { - context.exportNotes.push({ - category: GATEWAY_IAM_POLICY_NOTE_CATEGORY, - message: - `Gateway tool "${tool.name}" (ARN: ${gatewayArn}) uses AWS_IAM auth. ` + - `The exported runtime execution role is not automatically granted permission to invoke it.\n\n` + - `Add the following to agentcore/cdk/lib/cdk-stack.ts after \`this.application\` is created,\n` + - `replacing "YourAgentName" with the name of the exported agent (e.g. "${context.targetAgentName ?? 'MyHarnessAgent'}"):\n\n` + - ` const agentEnv = this.application.environments.get('${context.targetAgentName ?? 'YourAgentName'}');\n` + - ` agentEnv?.runtime.role.addToPrincipalPolicy(\n` + - ` new iam.PolicyStatement({\n` + - ` actions: ['bedrock-agentcore:InvokeGateway'],\n` + - ` resources: ['${gatewayArn}'],\n` + - ` })\n` + - ` );`, - }); - } + const connectionId = externalConnectionId('gateway', gatewayArn); + connections.push({ + id: connectionId, + to: { type: 'gateway', arn: gatewayArn, outboundAuth: toConnectionGatewayAuth(outboundAuth) }, + }); + + // The env var the connection wiring injects (GATEWAY__URL) — the generated client + // reads it via os.environ.get. Also written to .env.local for local dev (static, from ARN). + const urlEnvVar = connectionEnvVarName('GATEWAY', connectionId, 'URL'); + context.localEnvVars[urlEnvVar] = deriveGatewayUrl(gatewayArn); const provider: GatewayProviderRenderConfig = { name: tool.name, - envVarName: '', + envVarName: urlEnvVar, authType, - hardcodedUrl, }; if (authType === 'CUSTOM_JWT' && outboundAuth && 'oauth' in outboundAuth) { - provider.credentialProviderName = computeManagedOAuthCredentialName(tool.name); + // The credential provider name the generated client passes to @requires_access_token MUST + // be the real provider behind the ARN (the harness already created it) — derive it from the + // providerArn, not a name synthesized from the tool. + provider.credentialProviderName = resourceIdFromArn(outboundAuth.oauth.providerArn); const scopes = outboundAuth.oauth.scopes; if (scopes?.length) { provider.scopes = scopes.join(' '); } + // Thread the grant type through to the generated client's auth_flow (mirrors loopy), so a + // USER_FEDERATION harness gateway exports a USER_FEDERATION client instead of a hardcoded M2M. + const authFlow = grantTypeToAuthFlow(outboundAuth.oauth.grantType); + if (authFlow) provider.authFlow = authFlow; + if (outboundAuth.oauth.customParameters && Object.keys(outboundAuth.oauth.customParameters).length > 0) { + provider.customParameters = outboundAuth.oauth.customParameters; + } + // Only TOKEN_EXCHANGE has no @requires_access_token equivalent (auth_flow is + // Literal["M2M","USER_FEDERATION"]) — surface that as the one remaining manual case. + if (!authFlow) { + context.exportNotes.push({ + category: GATEWAY_GRANT_TYPE_NOTE_CATEGORY, + message: + `Gateway tool "${tool.name}" uses OAuth grant type "${outboundAuth.oauth.grantType}", which the ` + + `generated MCP client cannot express (the AgentCore Identity decorator supports only M2M and ` + + `USER_FEDERATION flows). Update mcp_client/client.py to perform the token exchange manually, or ` + + `reconfigure the gateway to use CLIENT_CREDENTIALS or AUTHORIZATION_CODE.`, + }); + } } providers.push(provider); } } - return { providers }; + return { providers, connections }; } // ============================================================================ @@ -747,7 +898,7 @@ function ecrArnFromUri(uri: string, region?: string): string | undefined { return `${arnPrefix(ecrRegion)}:ecr:${ecrRegion}:${account}:repository/${repoName}`; } -function isPathSkill(skill: HarnessSkill): skill is HarnessSkillPathSource { +export function isPathSkill(skill: HarnessSkill): skill is HarnessSkillPathSource { return 'path' in skill && !('gitUrl' in skill); } @@ -843,36 +994,49 @@ function resolveTruncationConfig(truncation: HarnessTruncationConfig | undefined return undefined; } -function extractToolIdentifier( - spec: HarnessSpec, - toolType: HarnessToolType, - configKey: string, - arnField: string -): string | undefined { - const tool = spec.tools.find(t => t.type === toolType); - if (!tool?.config || !(configKey in tool.config)) return undefined; - const arn = (tool.config as Record>)[configKey]?.[arnField]; - if (!arn) return undefined; - // ARN format: arn:aws:bedrock-agentcore:::/ - const slashIdx = arn.lastIndexOf('/'); - return slashIdx === -1 ? undefined : arn.slice(slashIdx + 1); -} - function isBuiltinIncluded(builtinName: string, patterns: string[]): boolean { // Mirrors Harness runtime: builtins are keyed as "builtin/", so only @builtin or @builtin/ patterns match. // Plain "shell" does NOT match the "builtin/shell" builtin (it would match a tool literally named "shell"). return matchesAllowedTools(`builtin/${builtinName}`, patterns); } -function addBrowserCodeInterpreterNotes( +/** + * Build connections for the browser / code-interpreter tools (so the CDK generates their IAM at + * deploy) and emit only the genuinely-non-IAM note (browser requires a Container build). Returns + * the connections to add to the exported agent. + */ +interface BrowserCodeInterpreterResult { + /** Browser/code-interpreter connections (IAM + env wiring generated at deploy). */ + connections: Connection[]; + /** True when the browser tool is included AND the build can run it (Container). */ + hasBrowser: boolean; + /** True when the code-interpreter tool is included. */ + hasCodeInterpreter: boolean; + /** Discovery env var the generated code reads for the browser identifier (custom ARN only). */ + browserIdentifierEnvVar?: string; + /** Discovery env var the generated code reads for the code-interpreter identifier (custom ARN only). */ + codeInterpreterIdentifierEnvVar?: string; +} + +/** + * The single owner of browser/code-interpreter resolution: extracts + validates each tool's ARN + * exactly once and derives the connection, the local discovery env var, and the render-config + * identifier env var from the same values — so the IAM grant, the injected env var, and the name + * baked into the generated code can never diverge. Browser requires a Container build; in CodeZip + * it is excluded (no connection, no identifier) with an explanatory note. + */ +function resolveBrowserCodeInterpreterConnections( spec: HarnessSpec, allowedToolPatterns: string[], buildType: BuildType, context: ResolvedHarnessContext -): void { +): BrowserCodeInterpreterResult { + const result: BrowserCodeInterpreterResult = { connections: [], hasBrowser: false, hasCodeInterpreter: false }; const agentName = context.targetAgentName; if (isToolIncluded('agentcore_browser', spec, allowedToolPatterns)) { + // Browser requires a Container build (Playwright driver can't spawn subprocesses in the CodeZip + // Lambda sandbox). In CodeZip it is excluded entirely — note only, no connection/identifier. if (buildType !== 'Container') { context.exportNotes.push({ category: BROWSER_CODZIP_NOTE_CATEGORY, @@ -883,64 +1047,83 @@ function addBrowserCodeInterpreterNotes( ` agentcore export harness --name ${spec.name} --target-agent-name ${agentName} --build Container`, }); } else { - const browserTool = spec.tools.find(t => t.type === 'agentcore_browser'); - const customArn = - browserTool?.config && 'agentCoreBrowser' in browserTool.config - ? (browserTool.config as { agentCoreBrowser: { browserArn?: string } }).agentCoreBrowser.browserArn - : undefined; - const resource = customArn ?? `arn:*:bedrock-agentcore:\${Stack.of(this).region}:aws:browser/*`; - context.exportNotes.push({ - category: BROWSER_IAM_POLICY_NOTE_CATEGORY, - message: - `The exported runtime execution role is not automatically granted permission to use the browser tool.\n\n` + - `Add the following to agentcore/cdk/lib/cdk-stack.ts after \`this.application\` is created:\n\n` + - ` const agentEnv = this.application.environments.get('${agentName}');\n` + - ` agentEnv?.runtime.role.addToPrincipalPolicy(\n` + - ` new iam.PolicyStatement({\n` + - ` actions: [\n` + - ` 'bedrock-agentcore:StartBrowserSession',\n` + - ` 'bedrock-agentcore:StopBrowserSession',\n` + - ` 'bedrock-agentcore:GetBrowserSession',\n` + - ` 'bedrock-agentcore:ListBrowserSessions',\n` + - ` 'bedrock-agentcore:UpdateBrowserStream',\n` + - ` 'bedrock-agentcore:ConnectBrowserAutomationStream',\n` + - ` 'bedrock-agentcore:ConnectBrowserLiveViewStream',\n` + - ` ],\n` + - ` resources: [\`${resource}\`],\n` + - ` })\n` + - ` );`, - }); + result.hasBrowser = true; + const rawArn = extractRawToolArn(spec, 'agentcore_browser', 'agentCoreBrowser', 'browserArn'); + const arn = validToolArn(rawArn, 'browser'); + if (arn) { + const id = externalConnectionId('browser', arn); + const envVar = connectionEnvVarName('BROWSER', id, 'ID'); + result.connections.push({ id, to: { type: 'browser', arn } }); + result.browserIdentifierEnvVar = envVar; + context.localEnvVars[envVar] = resourceIdFromArn(arn); + } else { + // No custom ARN (or malformed) → AWS-managed default browser; connection grants browser/*. + if (rawArn) context.exportNotes.push(buildMalformedToolArnNote('browser', rawArn)); + result.connections.push({ id: 'browser-default', to: { type: 'browser' } }); + } } } if (isToolIncluded('agentcore_code_interpreter', spec, allowedToolPatterns)) { - const ciTool = spec.tools.find(t => t.type === 'agentcore_code_interpreter'); - const customArn = - ciTool?.config && 'agentCoreCodeInterpreter' in ciTool.config - ? (ciTool.config as { agentCoreCodeInterpreter: { codeInterpreterArn?: string } }).agentCoreCodeInterpreter - .codeInterpreterArn - : undefined; - const resource = customArn ?? `arn:*:bedrock-agentcore:\${Stack.of(this).region}:aws:code-interpreter/*`; - context.exportNotes.push({ - category: CODE_INTERPRETER_IAM_POLICY_NOTE_CATEGORY, - message: - `The exported runtime execution role is not automatically granted permission to use the code interpreter tool.\n\n` + - `Add the following to agentcore/cdk/lib/cdk-stack.ts after \`this.application\` is created:\n\n` + - ` const agentEnv = this.application.environments.get('${agentName}');\n` + - ` agentEnv?.runtime.role.addToPrincipalPolicy(\n` + - ` new iam.PolicyStatement({\n` + - ` actions: [\n` + - ` 'bedrock-agentcore:StartCodeInterpreterSession',\n` + - ` 'bedrock-agentcore:StopCodeInterpreterSession',\n` + - ` 'bedrock-agentcore:GetCodeInterpreterSession',\n` + - ` 'bedrock-agentcore:ListCodeInterpreterSessions',\n` + - ` 'bedrock-agentcore:InvokeCodeInterpreter',\n` + - ` ],\n` + - ` resources: [\`${resource}\`],\n` + - ` })\n` + - ` );`, - }); + result.hasCodeInterpreter = true; + const rawArn = extractRawToolArn( + spec, + 'agentcore_code_interpreter', + 'agentCoreCodeInterpreter', + 'codeInterpreterArn' + ); + const arn = validToolArn(rawArn, 'code-interpreter'); + if (arn) { + const id = externalConnectionId('codeInterpreter', arn); + const envVar = connectionEnvVarName('CODE_INTERPRETER', id, 'ID'); + result.connections.push({ id, to: { type: 'codeInterpreter', arn } }); + result.codeInterpreterIdentifierEnvVar = envVar; + context.localEnvVars[envVar] = resourceIdFromArn(arn); + } else { + if (rawArn) context.exportNotes.push(buildMalformedToolArnNote('code-interpreter', rawArn)); + result.connections.push({ id: 'codeInterpreter-default', to: { type: 'codeInterpreter' } }); + } } + + return result; +} + +/** + * Note emitted when a harness supplies a browser/code-interpreter ARN that does not match the + * expected ARN shape. The exported agent falls back to the AWS-managed default (a different + * resource, with a broader `*` IAM grant), so the user is told to verify and fix the value rather + * than silently get the wrong tool target. + */ +function buildMalformedToolArnNote(kind: 'browser' | 'code-interpreter', rawArn: string): ExportNote { + return { + category: MALFORMED_TOOL_ARN_NOTE_CATEGORY, + message: + `The ${kind} tool specified ARN "${rawArn}", which is not a valid bedrock-agentcore ${kind} ARN. ` + + `The exported agent will use the AWS-managed default ${kind} instead (a different resource, granted ` + + `broader \`${kind}/*\` permissions). If you intended to target a specific ${kind}, fix the ARN in the ` + + `connection on this agent in agentcore/agentcore.json and re-deploy.`, + }; +} + +/** Raw ARN string from a tool's config (no validation). */ +function extractRawToolArn( + spec: HarnessSpec, + toolType: HarnessToolType, + configKey: string, + arnField: string +): string | undefined { + const tool = spec.tools.find(t => t.type === toolType); + if (!tool?.config || !(configKey in tool.config)) return undefined; + return (tool.config as Record>)[configKey]?.[arnField]; +} + +/** Returns the ARN only if it is a well-formed browser/code-interpreter ARN; else undefined (→ default). */ +function validToolArn(arn: string | undefined, kind: 'browser' | 'code-interpreter'): string | undefined { + if (!arn) return undefined; + // Reuse the canonical schema patterns (single source of truth for the two legitimate + // resource-segment forms: customer-owned `-custom/...` and AWS-managed `/...`). + const re = kind === 'browser' ? BROWSER_ARN_PATTERN : CODE_INTERPRETER_ARN_PATTERN; + return re.test(arn) ? arn : undefined; } function fnmatch(pattern: string, str: string): boolean { diff --git a/src/cli/commands/export/harness-resolver.ts b/src/cli/commands/export/harness-resolver.ts index c88a3f1bd..a4672db87 100644 --- a/src/cli/commands/export/harness-resolver.ts +++ b/src/cli/commands/export/harness-resolver.ts @@ -1,6 +1,6 @@ import { ConfigIO, requireConfigRoot } from '../../../lib'; import { ValidationError } from '../../../lib/errors/types'; -import type { DeployedResourceState } from '../../../schema'; +import type { DeployedResourceState, HarnessSpec } from '../../../schema'; import { DEFAULT_SYSTEM_PROMPT } from './constants'; import type { ResolvedHarnessContext } from './types'; import { existsSync, readFileSync } from 'node:fs'; @@ -10,23 +10,36 @@ import { join } from 'node:path'; * Read and validate all on-disk inputs for the harness export. * Throws ValidationError for user-fixable problems. */ +/** + * A harness spec + system prompt fetched out-of-band (the `--arn` path), used instead of reading + * a local in-project harness. When provided, the local harness-registry lookup and file reads are + * skipped; the current project is still used for target validation, deployed state, and region. + */ +export interface PrefetchedHarness { + spec: HarnessSpec; + systemPrompt?: string; +} + export async function resolveHarnessContext( harnessName: string, targetAgentName: string, - configBaseDir?: string + configBaseDir?: string, + prefetched?: PrefetchedHarness ): Promise { const baseDir = configBaseDir ?? requireConfigRoot(); const configIO = new ConfigIO({ baseDir }); const projectRoot = join(baseDir, '..'); - // 1. Read project spec and validate harness exists before any harness file I/O + // 1. Read project spec. For a local harness, validate it is registered before any file I/O. const projectSpec = await configIO.readProjectSpec(); - const harnessEntry = projectSpec.harnesses?.find(h => h.name === harnessName); - if (!harnessEntry) { - throw new ValidationError( - `Harness "${harnessName}" not found in agentcore.json. Available harnesses: ${(projectSpec.harnesses ?? []).map(h => h.name).join(', ') || 'none'}` - ); + if (!prefetched) { + const harnessEntry = projectSpec.harnesses?.find(h => h.name === harnessName); + if (!harnessEntry) { + throw new ValidationError( + `Harness "${harnessName}" not found in agentcore.json. Available harnesses: ${(projectSpec.harnesses ?? []).map(h => h.name).join(', ') || 'none'}` + ); + } } // 2. Validate target agent name not already taken @@ -46,19 +59,26 @@ export async function resolveHarnessContext( ); } - // 3. Read harness spec - const spec = await configIO.readHarnessSpec(harnessName); - - // 4. Read system prompt — harness app files live in projectRoot/app// - const harnessDir = join(projectRoot, 'app', harnessName); - const systemPromptPath = join(harnessDir, 'system-prompt.md'); + // 3 + 4. Resolve the harness spec and system prompt — from the fetched payload (`--arn`) or + // from local files (in-project harness). + let spec: HarnessSpec; let systemPrompt: string; - if (existsSync(systemPromptPath)) { - systemPrompt = readFileSync(systemPromptPath, 'utf8').trim(); - } else if (spec.systemPrompt) { - systemPrompt = spec.systemPrompt; + if (prefetched) { + spec = prefetched.spec; + const trimmedPrompt = prefetched.systemPrompt?.trim(); + const nonEmptyPrompt = trimmedPrompt && trimmedPrompt.length > 0 ? trimmedPrompt : undefined; + systemPrompt = nonEmptyPrompt ?? prefetched.spec.systemPrompt ?? DEFAULT_SYSTEM_PROMPT; } else { - systemPrompt = DEFAULT_SYSTEM_PROMPT; + spec = await configIO.readHarnessSpec(harnessName); + const harnessDir = join(projectRoot, 'app', harnessName); + const systemPromptPath = join(harnessDir, 'system-prompt.md'); + if (existsSync(systemPromptPath)) { + systemPrompt = readFileSync(systemPromptPath, 'utf8').trim(); + } else if (spec.systemPrompt) { + systemPrompt = spec.systemPrompt; + } else { + systemPrompt = DEFAULT_SYSTEM_PROMPT; + } } // 5. Read deployed state (optional — absent before first deploy) @@ -91,5 +111,8 @@ export async function resolveHarnessContext( projectRoot, exportNotes: [], region, + localEnvVars: {}, + generatedPolicyFiles: {}, + additionalPolicies: [], }; } diff --git a/src/cli/commands/export/index.ts b/src/cli/commands/export/index.ts index 24587eb82..c320055f8 100644 --- a/src/cli/commands/export/index.ts +++ b/src/cli/commands/export/index.ts @@ -15,8 +15,9 @@ export function registerExport(program: Command): void { exportCmd .command('harness') - .description('Export an in-project harness to a Python Strands runtime agent') - .option('--name ', 'Harness name [non-interactive]') + .description('Export a harness to a Python Strands runtime agent (in-project via --name, or by --arn)') + .option('--name ', 'In-project harness name [non-interactive]') + .option('--arn ', 'ARN of a harness created outside this project — fetched from the service [non-interactive]') .option( '--target-agent-name ', 'Name for the generated runtime agent (default: Agent) [non-interactive]' @@ -24,9 +25,9 @@ export function registerExport(program: Command): void { .option('--build ', 'Build type: CodeZip or Container [non-interactive]') .option('--json', 'Output results as JSON') .action(async options => { - if (!options.name) { + if (!options.name && !options.arn) { if (options.json) { - console.log(JSON.stringify({ success: false, error: '--name is required in non-interactive mode' })); + console.log(JSON.stringify({ success: false, error: '--name or --arn is required in non-interactive mode' })); process.exit(1); } await renderTUI({ initialRoute: { name: 'export-harness' }, actionOnBack: 'exit' }); @@ -64,10 +65,11 @@ export function registerExport(program: Command): void { process.exit(1); } - const targetAgentName = options.targetAgentName ?? `${options.name}Agent`; + const targetAgentName = result.agentName; + const harnessLabel = options.name ?? options.arn; console.log(''); - console.log(`${green}Exported harness ${options.name} → runtime agent ${targetAgentName}${reset}`); + console.log(`${green}Exported harness ${harnessLabel} → runtime agent ${targetAgentName}${reset}`); console.log(''); console.log(`${dim}Generated:${reset}`); console.log(` app/${targetAgentName}/ Python agent (Strands)`); diff --git a/src/cli/commands/export/types.ts b/src/cli/commands/export/types.ts index 188f9d63b..9b150f986 100644 --- a/src/cli/commands/export/types.ts +++ b/src/cli/commands/export/types.ts @@ -12,6 +12,8 @@ import type { export interface ExportHarnessOptions { name?: string; + /** ARN of a harness created outside this project — fetched from the service. Mutually exclusive with name. */ + arn?: string; targetAgentName?: string; build?: string; json?: boolean; @@ -34,6 +36,20 @@ export interface ResolvedHarnessContext { exportNotes: ExportNote[]; /** AWS region from the first deployment target, or undefined if not configured */ region?: string; + /** + * Static, non-secret discovery values (e.g. external gateway URL, browser/code-interpreter id) + * to write into the exported project's .env.local for local dev. At deploy the CDK connection + * wiring injects the same env vars; this makes `agentcore dev` resolve them without a deploy. + */ + localEnvVars: Record; + /** + * Generated IAM policy documents to write into the agent's codeLocation, keyed by filename. + * Referenced from AgentEnvSpec.additionalPolicies for opaque AWS access (e.g. S3 skills) the CLI + * does not model as a typed connection. Written by the export action alongside the agent code. + */ + generatedPolicyFiles: Record; + /** Filenames (relative to codeLocation) + managed-policy ARNs for AgentEnvSpec.additionalPolicies. */ + additionalPolicies: string[]; } // ============================================================================ @@ -56,6 +72,9 @@ export interface HarnessMappingResult { credentialEntry: Credential | null; /** One credential entry per MCP header that carries a secret value */ mcpCredentialEntries: { credential: Credential; envVarName: string; value: string }[]; + /** API-key credential references for private git-skill auth (name-only; the provider already + * exists in AgentCore Identity). Persisted so the deployed agent is granted GetResourceApiKey. */ + gitCredentialEntries: Credential[]; } // ============================================================================ diff --git a/src/cli/templates/types.ts b/src/cli/templates/types.ts index ec89bc103..c0742a424 100644 --- a/src/cli/templates/types.ts +++ b/src/cli/templates/types.ts @@ -38,6 +38,15 @@ export interface GatewayProviderRenderConfig { discoveryUrl?: string; /** Space-separated scopes for token request (CUSTOM_JWT only) */ scopes?: string; + /** + * AgentCore Identity auth flow for @requires_access_token (CUSTOM_JWT only). + * Mapped from the OAuth grant type: CLIENT_CREDENTIALS→M2M, AUTHORIZATION_CODE→USER_FEDERATION. + * Defaults to M2M when unset. (TOKEN_EXCHANGE is not supported by the decorator.) + */ + authFlow?: string; + /** Custom OAuth parameters for @requires_access_token (CUSTOM_JWT only). Rendered via the safeJson + * helper (SafeString) so the JSON is NOT HTML-escaped into invalid Python. */ + customParameters?: Record; /** Hardcoded URL for external gateways not found in deployed-state.json */ hardcodedUrl?: string; } @@ -116,12 +125,14 @@ export interface AgentRenderConfig { /** True when agentcore_browser tool is present and allowed */ hasBrowser?: boolean; - /** Custom browser identifier (resource ID extracted from browserArn) */ - browserIdentifier?: string; + /** Env var holding the custom browser identifier, injected by the browser connection at deploy. + * Undefined when the AWS-managed default browser is used (no custom ARN). */ + browserIdentifierEnvVar?: string; /** True when agentcore_code_interpreter tool is present and allowed */ hasCodeInterpreter?: boolean; - /** Custom code interpreter identifier (resource ID extracted from codeInterpreterArn) */ - codeInterpreterIdentifier?: string; + /** Env var holding the custom code-interpreter identifier, injected by the connection at deploy. + * Undefined when the AWS-managed default is used (no custom ARN). */ + codeInterpreterIdentifierEnvVar?: string; /** True when the builtin shell tool is enabled (export harness only) */ hasShell?: boolean; /** True when the builtin file_operations tool is enabled (export harness only) */ @@ -132,6 +143,13 @@ export interface AgentRenderConfig { /** Model ID to use in load.py (export path only — overrides the provider-specific template default) */ modelId?: string; + /** LiteLLM-only: base URL for the model endpoint (export path). */ + litellmApiBase?: string; + + /** LiteLLM-only: extra LiteLLM params merged into the model client (export path). Rendered via + * pyJsonStr + json.loads so JSON booleans/null parse correctly at runtime. */ + litellmAdditionalParams?: Record; + /** True when generating from a harness export (suppresses placeholder tools) */ isExportHarness?: boolean; /** System prompt text written verbatim into main.py (export path) */ diff --git a/src/cli/tui/screens/agent/types.ts b/src/cli/tui/screens/agent/types.ts index b8ac52a3d..04961476c 100644 --- a/src/cli/tui/screens/agent/types.ts +++ b/src/cli/tui/screens/agent/types.ts @@ -226,5 +226,7 @@ export function getProviderInfo(provider: ModelProvider): { name: string; envVar return { name: 'Google Gemini', envVarName: 'GEMINI_API_KEY' }; case 'Bedrock': return { name: 'Amazon Bedrock', envVarName: '' }; + case 'LiteLLM': + return { name: 'LiteLLM', envVarName: 'LITELLM_API_KEY' }; } } diff --git a/src/schema/__tests__/constants.test.ts b/src/schema/__tests__/constants.test.ts index 0c52b8f2c..45d3381e4 100644 --- a/src/schema/__tests__/constants.test.ts +++ b/src/schema/__tests__/constants.test.ts @@ -86,8 +86,8 @@ describe('NetworkModeSchema', () => { }); describe('getSupportedModelProviders', () => { - it('returns all 4 providers for Strands', () => { - expect(getSupportedModelProviders('Strands')).toEqual(['Bedrock', 'Anthropic', 'OpenAI', 'Gemini']); + it('returns all providers (incl. LiteLLM) for Strands', () => { + expect(getSupportedModelProviders('Strands')).toEqual(['Bedrock', 'Anthropic', 'OpenAI', 'Gemini', 'LiteLLM']); }); it('returns only Gemini for GoogleADK', () => { diff --git a/src/schema/constants.ts b/src/schema/constants.ts index 35d09fc61..bba816241 100644 --- a/src/schema/constants.ts +++ b/src/schema/constants.ts @@ -10,11 +10,11 @@ export type SDKFramework = z.infer; export const TargetLanguageSchema = z.enum(['Python', 'TypeScript', 'Other']); export type TargetLanguage = z.infer; -export const ModelProviderSchema = z.enum(['Bedrock', 'Gemini', 'OpenAI', 'Anthropic']); +export const ModelProviderSchema = z.enum(['Bedrock', 'Gemini', 'OpenAI', 'Anthropic', 'LiteLLM']); export type ModelProvider = z.infer; -/** Providers that use credentials (Bedrock uses IAM, no credential needed). */ -export const CREDENTIAL_PROVIDERS = ['Gemini', 'OpenAI', 'Anthropic'] as const; +/** Providers that use credentials (Bedrock uses IAM, no credential needed). LiteLLM's API key is optional. */ +export const CREDENTIAL_PROVIDERS = ['Gemini', 'OpenAI', 'Anthropic', 'LiteLLM'] as const; /** * Case-insensitively match a user-provided value against a Zod enum's options. @@ -34,6 +34,8 @@ export const DEFAULT_MODEL_IDS: Record = { Anthropic: 'claude-sonnet-4-5-20250514', OpenAI: 'gpt-4.1', Gemini: 'gemini-2.5-flash', + // LiteLLM model ids are provider-prefixed (e.g. "bedrock/...", "openai/gpt-4o"); no single default. + LiteLLM: 'bedrock/us.anthropic.claude-sonnet-4-5-20250514-v1:0', }; /** @@ -43,7 +45,8 @@ export const DEFAULT_MODEL_IDS: Record = { * - OpenAIAgents only supports OpenAI (uses OpenAI's native API) */ export const SDK_MODEL_PROVIDER_MATRIX: Record = { - Strands: ['Bedrock', 'Anthropic', 'OpenAI', 'Gemini'] as const, + // LiteLLM is supported for Strands (used by harness export) — it proxies to any provider. + Strands: ['Bedrock', 'Anthropic', 'OpenAI', 'Gemini', 'LiteLLM'] as const, LangChain_LangGraph: ['Bedrock', 'Anthropic', 'OpenAI', 'Gemini'] as const, GoogleADK: ['Gemini'] as const, OpenAIAgents: ['OpenAI'] as const, diff --git a/src/schema/llm-compacted/agentcore.ts b/src/schema/llm-compacted/agentcore.ts index d224bc86a..13e2ba719 100644 --- a/src/schema/llm-compacted/agentcore.ts +++ b/src/schema/llm-compacted/agentcore.ts @@ -111,8 +111,40 @@ interface AgentEnvSpec { protocol?: ProtocolMode; // default 'HTTP' tags?: Record; filesystemConfigurations?: FilesystemConfiguration[]; // max 5 total, max 1 sessionStorage, max 2 efsAccessPoint, max 2 s3FilesAccessPoint; efsAccessPoint/s3FilesAccessPoint require networkMode: VPC + connections?: Connection[]; // Access to EXTERNAL AgentCore resources (memory/gateway/runtime/browser/codeInterpreter); generates IAM + discovery env vars on the execution role } +// ───────────────────────────────────────────────────────────────────────────── +// CONNECTIONS — access to EXTERNAL AgentCore resources (not in this project). +// In-project access stays implicit (all-to-all). Connections only ADD external grants. +// ───────────────────────────────────────────────────────────────────────────── + +interface Connection { + id?: string; // @regex ^[a-zA-Z][a-zA-Z0-9_-]{0,63}$ + to: ConnectionTarget; + access?: 'read' | 'readwrite'; // memory only; default 'read' + description?: string; // @max 200 +} + +type ConnectionTarget = + | { type: 'memory'; arn: string; namespaces?: string[] } // external memory ARN; namespaces scope retrieval + | { type: 'gateway'; arn: string; outboundAuth?: GatewayOutboundAuth } // external gateway ARN + | { type: 'runtime'; arn: string; exec?: boolean } // external agent runtime ARN; exec adds container-exec + | { type: 'browser'; arn?: string } // customer-owned browser ARN; omit for the AWS-managed default + | { type: 'codeInterpreter'; arn?: string }; // customer-owned code-interpreter ARN; omit for the AWS-managed default + +type GatewayOutboundAuth = + | { awsIam: {} } // SigV4 with the execution role (default) + | { none: {} } + | { + oauth: { + providerArn: string; + scopes: string[]; + grantType?: 'CLIENT_CREDENTIALS' | 'AUTHORIZATION_CODE' | 'TOKEN_EXCHANGE'; + customParameters?: Record; + }; + }; + interface Instrumentation { enableOtel: boolean; // default true - wrap entrypoint with opentelemetry-instrument } diff --git a/src/schema/schemas/__tests__/connections.test.ts b/src/schema/schemas/__tests__/connections.test.ts new file mode 100644 index 000000000..6ece0f375 --- /dev/null +++ b/src/schema/schemas/__tests__/connections.test.ts @@ -0,0 +1,191 @@ +import { ConnectionSchema, ConnectionTargetSchema, GatewayOutboundAuthSchema } from '../connections'; +import { describe, expect, it } from 'vitest'; + +const MEMORY_ARN = 'arn:aws:bedrock-agentcore:us-east-1:111122223333:memory/abc123'; +const GATEWAY_ARN = 'arn:aws:bedrock-agentcore:us-east-1:111122223333:gateway/gw-xyz'; +const RUNTIME_ARN = 'arn:aws:bedrock-agentcore:us-east-1:111122223333:runtime/rt-xyz'; +const PROVIDER_ARN = + 'arn:aws:bedrock-agentcore:us-east-1:111122223333:token-vault/default/oauth2credentialprovider/partner'; + +describe('ConnectionSchema', () => { + describe('memory target', () => { + it('accepts a memory connection with namespaces and access', () => { + const result = ConnectionSchema.safeParse({ + to: { type: 'memory', arn: MEMORY_ARN, namespaces: ['agent/*'] }, + access: 'readwrite', + }); + expect(result.success).toBe(true); + }); + + it('accepts a memory connection with just an arn', () => { + expect(ConnectionSchema.safeParse({ to: { type: 'memory', arn: MEMORY_ARN } }).success).toBe(true); + }); + + it('rejects a malformed memory arn', () => { + expect(ConnectionSchema.safeParse({ to: { type: 'memory', arn: 'not-an-arn' } }).success).toBe(false); + }); + + it('accepts a gov-cloud partition arn', () => { + const arn = 'arn:aws-us-gov:bedrock-agentcore:us-gov-west-1:111122223333:memory/abc123'; + expect(ConnectionSchema.safeParse({ to: { type: 'memory', arn } }).success).toBe(true); + }); + }); + + describe('gateway target', () => { + it('accepts awsIam outbound auth', () => { + expect( + ConnectionSchema.safeParse({ to: { type: 'gateway', arn: GATEWAY_ARN, outboundAuth: { awsIam: {} } } }).success + ).toBe(true); + }); + + it('accepts none outbound auth', () => { + expect( + ConnectionSchema.safeParse({ to: { type: 'gateway', arn: GATEWAY_ARN, outboundAuth: { none: {} } } }).success + ).toBe(true); + }); + + it('accepts oauth outbound auth with all four fields', () => { + const result = ConnectionSchema.safeParse({ + to: { + type: 'gateway', + arn: GATEWAY_ARN, + outboundAuth: { + oauth: { + providerArn: PROVIDER_ARN, + scopes: ['read'], + grantType: 'CLIENT_CREDENTIALS', + customParameters: { foo: 'bar' }, + }, + }, + }, + }); + expect(result.success).toBe(true); + }); + + it('accepts a gateway connection with no outboundAuth (defaults applied at wiring)', () => { + expect(ConnectionSchema.safeParse({ to: { type: 'gateway', arn: GATEWAY_ARN } }).success).toBe(true); + }); + + it('rejects oauth without scopes', () => { + const result = GatewayOutboundAuthSchema.safeParse({ oauth: { providerArn: PROVIDER_ARN } }); + expect(result.success).toBe(false); + }); + + it('rejects oauth without providerArn', () => { + const result = GatewayOutboundAuthSchema.safeParse({ oauth: { scopes: ['read'] } }); + expect(result.success).toBe(false); + }); + + it('rejects an unknown grantType', () => { + const result = GatewayOutboundAuthSchema.safeParse({ + oauth: { providerArn: PROVIDER_ARN, scopes: ['read'], grantType: 'PASSWORD' }, + }); + expect(result.success).toBe(false); + }); + + it('rejects a malformed gateway arn', () => { + expect(ConnectionSchema.safeParse({ to: { type: 'gateway', arn: 'not-an-arn' } }).success).toBe(false); + }); + + it('rejects a gateway arn pointing at the wrong resource type', () => { + expect(ConnectionSchema.safeParse({ to: { type: 'gateway', arn: MEMORY_ARN } }).success).toBe(false); + }); + }); + + describe('runtime target', () => { + it('accepts a runtime connection with exec', () => { + expect(ConnectionSchema.safeParse({ to: { type: 'runtime', arn: RUNTIME_ARN, exec: true } }).success).toBe(true); + }); + + it('rejects a malformed runtime arn', () => { + expect(ConnectionSchema.safeParse({ to: { type: 'runtime', arn: 'not-an-arn' } }).success).toBe(false); + }); + }); + + describe('browser / code-interpreter targets', () => { + // Real customer-owned ARNs from CreateBrowser/CreateCodeInterpreter use the `-custom` segment. + const BROWSER_ARN = 'arn:aws:bedrock-agentcore:us-east-1:111122223333:browser-custom/browser_tool_3ok0y-ube4pqdHQ7'; + const CI_ARN = + 'arn:aws:bedrock-agentcore:us-east-1:111122223333:code-interpreter-custom/code_interpreter_9ejb4-dOCHBAd5OT'; + + it('accepts a customer-owned browser ARN (browser-custom/ segment)', () => { + expect(ConnectionSchema.safeParse({ to: { type: 'browser', arn: BROWSER_ARN } }).success).toBe(true); + }); + + it('accepts a browser connection with no ARN (AWS-managed default)', () => { + expect(ConnectionSchema.safeParse({ to: { type: 'browser' } }).success).toBe(true); + }); + + it('accepts the AWS-managed default browser ARN (:aws: account, browser/ segment)', () => { + const arn = 'arn:aws:bedrock-agentcore:us-east-1:aws:browser/aws.browser.v1'; + expect(ConnectionSchema.safeParse({ to: { type: 'browser', arn } }).success).toBe(true); + }); + + it('accepts a customer-owned code-interpreter ARN (code-interpreter-custom/ segment)', () => { + expect(ConnectionSchema.safeParse({ to: { type: 'codeInterpreter', arn: CI_ARN } }).success).toBe(true); + }); + + it('accepts a code-interpreter connection with no ARN', () => { + expect(ConnectionSchema.safeParse({ to: { type: 'codeInterpreter' } }).success).toBe(true); + }); + + it('accepts the AWS-managed default code-interpreter ARN (:aws: account, code-interpreter/ segment)', () => { + const arn = 'arn:aws:bedrock-agentcore:us-east-1:aws:code-interpreter/aws.codeinterpreter.v1'; + expect(ConnectionSchema.safeParse({ to: { type: 'codeInterpreter', arn } }).success).toBe(true); + }); + + it('rejects a malformed code-interpreter arn', () => { + expect(ConnectionSchema.safeParse({ to: { type: 'codeInterpreter', arn: 'not-an-arn' } }).success).toBe(false); + }); + + it('rejects a browser ARN of the wrong resource type', () => { + const arn = 'arn:aws:bedrock-agentcore:us-east-1:111122223333:memory/m-1'; + expect(ConnectionSchema.safeParse({ to: { type: 'browser', arn } }).success).toBe(false); + }); + }); + + describe('shape', () => { + it('rejects an unknown target type', () => { + expect(ConnectionTargetSchema.safeParse({ type: 's3', bucket: 'x' }).success).toBe(false); + }); + + it('rejects unknown top-level keys (strict)', () => { + expect(ConnectionSchema.safeParse({ to: { type: 'memory', arn: MEMORY_ARN }, bogus: 1 }).success).toBe(false); + }); + + it('accepts an optional id and description', () => { + const result = ConnectionSchema.safeParse({ + id: 'partner-memory', + to: { type: 'memory', arn: MEMORY_ARN }, + description: 'reads partner memory', + }); + expect(result.success).toBe(true); + }); + + it('rejects an invalid id', () => { + expect(ConnectionSchema.safeParse({ id: '1bad', to: { type: 'memory', arn: MEMORY_ARN } }).success).toBe(false); + }); + + it('rejects an id longer than 64 chars', () => { + const id = 'a' + 'b'.repeat(64); // 65 chars + expect(ConnectionSchema.safeParse({ id, to: { type: 'memory', arn: MEMORY_ARN } }).success).toBe(false); + }); + + it('accepts an id at the 64-char boundary', () => { + const id = 'a' + 'b'.repeat(63); // 64 chars + expect(ConnectionSchema.safeParse({ id, to: { type: 'memory', arn: MEMORY_ARN } }).success).toBe(true); + }); + + it('rejects an invalid access value', () => { + expect(ConnectionSchema.safeParse({ to: { type: 'memory', arn: MEMORY_ARN }, access: 'admin' }).success).toBe( + false + ); + }); + + it('rejects a description longer than 200 chars', () => { + expect( + ConnectionSchema.safeParse({ to: { type: 'memory', arn: MEMORY_ARN }, description: 'x'.repeat(201) }).success + ).toBe(false); + }); + }); +}); diff --git a/src/schema/schemas/agent-env.ts b/src/schema/schemas/agent-env.ts index cfffa111c..91d258e19 100644 --- a/src/schema/schemas/agent-env.ts +++ b/src/schema/schemas/agent-env.ts @@ -10,6 +10,7 @@ import { } from '../constants'; import type { DirectoryPath, FilePath } from '../types'; import { AuthorizerConfigSchema, RuntimeAuthorizerTypeSchema } from './auth'; +import { ConnectionSchema } from './connections'; import { TagsSchema } from './primitives/tags'; import { z } from 'zod'; @@ -333,6 +334,13 @@ export const AgentEnvSpecSchema = z requestHeaderAllowlist: RequestHeaderAllowlistSchema.optional(), /** ARN of an existing IAM execution role to use instead of creating a new one. */ executionRoleArn: z.string().optional(), + /** + * Additional IAM policies attached to the runtime execution role. Each entry is either a + * managed-policy ARN (attached directly) or a `.json` policy-document file path relative to + * `codeLocation` (attached as an inline policy). For opaque AWS access (e.g. S3) the CLI does + * not model as a typed connection. + */ + additionalPolicies: z.array(z.string().min(1)).optional(), /** Authorizer type for inbound requests. Defaults to AWS_IAM. */ authorizerType: RuntimeAuthorizerTypeSchema.optional(), /** Authorizer configuration. Required when authorizerType is CUSTOM_JWT. */ @@ -344,6 +352,9 @@ export const AgentEnvSpecSchema = z filesystemConfigurations: z.array(FilesystemConfigurationSchema).optional(), /** Named endpoints (version aliases) for this runtime. Keys are endpoint names. */ endpoints: z.record(RuntimeEndpointNameSchema, RuntimeEndpointSchema).optional(), + /** Connections to external AgentCore resources (memory/gateway/runtime). The construct + * generates IAM + discovery env vars onto this runtime's execution role. */ + connections: z.array(ConnectionSchema).optional(), }) .superRefine((data, ctx) => { if (data.networkMode === 'VPC' && !data.networkConfig) { diff --git a/src/schema/schemas/connections.ts b/src/schema/schemas/connections.ts new file mode 100644 index 000000000..61e872364 --- /dev/null +++ b/src/schema/schemas/connections.ts @@ -0,0 +1,195 @@ +import { z } from 'zod'; + +// ============================================================================ +// Resource Connections +// +// A connection declares that a principal (an agent runtime or a harness) accesses +// an EXTERNAL AgentCore resource (a memory/gateway/runtime that is NOT part of this +// project). The construct generates the correct least-privilege IAM onto the +// principal's execution role AND injects the discovery env vars from each connection. +// +// Connections are embedded on the source resource (AgentEnvSpec.connections / +// HarnessSpec.connections); the enclosing resource IS the source, so there is no +// `from` field. In-project access remains implicit (all-to-all wiring) and is +// untouched — connections only ADD grants for external targets. +// +// FUTURE (additive, not in this milestone): targets gain an optional `name` to +// reference an in-project resource (managed reference + access gating); new target +// kinds (s3, secret) are added as union members. +// ============================================================================ + +/** + * OAuth grant type for an external gateway, matching the runtime/Smithy model. + * The harness runtime maps these to AgentCore Identity auth flows: + * CLIENT_CREDENTIALS -> M2M, AUTHORIZATION_CODE -> USER_FEDERATION, TOKEN_EXCHANGE -> TOKEN_EXCHANGE. + */ +export const GatewayGrantTypeSchema = z.enum(['CLIENT_CREDENTIALS', 'AUTHORIZATION_CODE', 'TOKEN_EXCHANGE']); +export type GatewayGrantType = z.infer; + +/** + * Outbound auth a caller uses to reach an external gateway. This is the union the + * gateway/harness runtime actually consumes — NOT the gateway's inbound authorizerType + * (which lives on the gateway resource and is invisible to an external caller). + * - awsIam: SigV4-sign with the execution role (grants InvokeGateway) + * - none: no auth + * - oauth: fetch an OAuth token via AgentCore Identity (grants token-fetch perms on + * the provider). All four oauth fields are consumed at runtime. + */ +export const GatewayOutboundAuthSchema = z.union([ + z.object({ awsIam: z.object({}).strict() }).strict(), + z.object({ none: z.object({}).strict() }).strict(), + z + .object({ + oauth: z + .object({ + providerArn: z.string().min(1), + scopes: z.array(z.string().min(1)), + grantType: GatewayGrantTypeSchema.optional(), + customParameters: z.record(z.string(), z.string()).optional(), + }) + .strict(), + }) + .strict(), +]); +export type GatewayOutboundAuth = z.infer; + +// ---- Connection targets (external AgentCore resources, ARN-addressed) ---- +// Partition-agnostic ARN prefix (arn:[^:]+:) per multi-partition rules. + +const MEMORY_ARN_PATTERN = /^arn:[^:]+:bedrock-agentcore:[a-z0-9-]+:\d{12}:memory\/.+$/; +const GATEWAY_ARN_PATTERN = /^arn:[^:]+:bedrock-agentcore:[a-z0-9-]+:\d{12}:gateway\/.+$/; +const RUNTIME_ARN_PATTERN = /^arn:[^:]+:bedrock-agentcore:[a-z0-9-]+:\d{12}:runtime\/.+$/; +// Browser / code-interpreter ARNs come in two legitimate resource-segment forms: +// - customer-owned (CreateBrowser/CreateCodeInterpreter): `-custom/` with a 12-digit account +// - AWS-managed default (SYSTEM): `/` with the `aws` account +export const BROWSER_ARN_PATTERN = /^arn:[^:]+:bedrock-agentcore:[a-z0-9-]+:(\d{12}|aws):browser(-custom)?\/.+$/; +export const CODE_INTERPRETER_ARN_PATTERN = + /^arn:[^:]+:bedrock-agentcore:[a-z0-9-]+:(\d{12}|aws):code-interpreter(-custom)?\/.+$/; + +const MemoryTargetSchema = z + .object({ + type: z.literal('memory'), + arn: z.string().regex(MEMORY_ARN_PATTERN, 'Must be a valid bedrock-agentcore memory ARN'), + /** Optional namespace templates to scope List/RetrieveMemoryRecords via the + * bedrock-agentcore:namespace / namespacePath condition keys. */ + namespaces: z.array(z.string().min(1)).optional(), + }) + .strict(); + +const GatewayTargetSchema = z + .object({ + type: z.literal('gateway'), + arn: z.string().regex(GATEWAY_ARN_PATTERN, 'Must be a valid bedrock-agentcore gateway ARN'), + /** How the caller authenticates outbound to the gateway. Defaults to awsIam (SigV4). */ + outboundAuth: GatewayOutboundAuthSchema.optional(), + }) + .strict(); + +const RuntimeTargetSchema = z + .object({ + type: z.literal('runtime'), + arn: z.string().regex(RUNTIME_ARN_PATTERN, 'Must be a valid bedrock-agentcore runtime ARN'), + /** Also grant InvokeAgentRuntimeCommand (container exec) in addition to invoke. */ + exec: z.boolean().optional(), + }) + .strict(); + +const BrowserTargetSchema = z + .object({ + type: z.literal('browser'), + /** Customer-owned browser ARN. Omit to use the AWS-managed default browser. */ + arn: z.string().regex(BROWSER_ARN_PATTERN, 'Must be a valid bedrock-agentcore browser ARN').optional(), + }) + .strict(); + +const CodeInterpreterTargetSchema = z + .object({ + type: z.literal('codeInterpreter'), + /** Customer-owned code-interpreter ARN. Omit to use the AWS-managed default. */ + arn: z + .string() + .regex(CODE_INTERPRETER_ARN_PATTERN, 'Must be a valid bedrock-agentcore code-interpreter ARN') + .optional(), + }) + .strict(); + +export const ConnectionTargetSchema = z.discriminatedUnion('type', [ + MemoryTargetSchema, + GatewayTargetSchema, + RuntimeTargetSchema, + BrowserTargetSchema, + CodeInterpreterTargetSchema, +]); +export type ConnectionTarget = z.infer; + +/** + * A single connection from the enclosing principal to an external resource. + * `access` is meaningful only for memory (read | readwrite); ignored for invoke-only + * targets. Defaults to `read` (least-privilege). + */ +export const ConnectionSchema = z + .object({ + id: z + .string() + .regex(/^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/, 'Connection id must match [a-zA-Z][a-zA-Z0-9_-]{0,63}') + .optional(), + to: ConnectionTargetSchema, + access: z.enum(['read', 'readwrite']).optional(), + description: z.string().max(200).optional(), + }) + .strict(); +export type Connection = z.infer; + +export const ConnectionsSchema = z.array(ConnectionSchema); + +// ============================================================================ +// Connection discovery env-var naming — SINGLE SOURCE OF TRUTH. +// +// The discovery env var (e.g. MEMORY__ID, GATEWAY__URL) is the handshake between the +// CLI export (which bakes the NAME into the generated agent code) and the CDK deploy (which injects +// the VALUE onto the runtime). Both sides MUST compute the identical name, so the derivation lives +// here — the one file already kept in lockstep across the CLI and @aws/agentcore-cdk repos — and is +// called from both `harness-mapper` (CLI) and `wire-connections` (CDK). Do not re-implement it. +// ============================================================================ + +/** Maximum length of a connection id (matches the ConnectionSchema id regex bound). */ +export const CONNECTION_ID_MAX_LENGTH = 64; + +/** Last segment of an ARN (the resource id): `memory/mem-123` -> `mem-123`. */ +export function resourceIdFromArn(arn: string): string { + const afterColon = arn.split(':').pop() ?? arn; + const slash = afterColon.lastIndexOf('/'); + return slash >= 0 ? afterColon.slice(slash + 1) : afterColon; +} + +/** + * Stable connection id for an external target: `-`, sanitized to the schema id + * charset and length. Used as the connection's `id` AND as the basis for its discovery env-var + * token, so the name baked into generated code matches what deploy injects. + */ +export function connectionIdForTarget(target: ConnectionTarget): string { + const suffix = 'arn' in target && target.arn ? resourceIdFromArn(target.arn) : target.type; + const id = `${target.type}-${suffix}`; + // Conform to ConnectionSchema id regex: start with a letter, then [a-zA-Z0-9_-], max length. + return id.replace(/[^a-zA-Z0-9_-]/g, '-').slice(0, CONNECTION_ID_MAX_LENGTH); +} + +/** Uppercase, underscore-safe token for env-var naming, derived from a connection id. */ +export function connectionEnvToken(id: string): string { + return id.toUpperCase().replace(/[^A-Z0-9]/g, '_'); +} + +/** + * Resolve the env-var token for a connection. Prefers the explicit `id`; otherwise derives the same + * `-` id the CLI would have assigned — so an id-less connection (schema permits it, + * e.g. a hand-authored config or a direct @aws/agentcore-cdk consumer) yields the SAME token on both + * sides instead of diverging. + */ +export function connectionTokenFor(connection: Connection): string { + return connectionEnvToken(connection.id ?? connectionIdForTarget(connection.to)); +} + +/** Build a discovery env-var NAME: `__` (e.g. MEMORY__ID). */ +export function connectionEnvVarName(prefix: string, connection: Connection, suffix: string): string { + return `${prefix}_${connectionTokenFor(connection)}_${suffix}`; +} diff --git a/src/schema/schemas/index.ts b/src/schema/schemas/index.ts index 6505a483a..d1ca62c6b 100644 --- a/src/schema/schemas/index.ts +++ b/src/schema/schemas/index.ts @@ -3,6 +3,7 @@ export * from './agent-env'; export * from './agentcore-project'; export * from './auth'; export * from './aws-targets'; +export * from './connections'; export * from './deployed-state'; export * from './mcp'; export * from './mcp-defs'; diff --git a/src/schema/schemas/primitives/harness.ts b/src/schema/schemas/primitives/harness.ts index c165e933e..b70933e7e 100644 --- a/src/schema/schemas/primitives/harness.ts +++ b/src/schema/schemas/primitives/harness.ts @@ -7,6 +7,7 @@ import { SessionStorageSchema, } from '../agent-env'; import { AuthorizerConfigSchema, RuntimeAuthorizerTypeSchema } from '../auth'; +import { ConnectionSchema } from '../connections'; import { uniqueBy } from '../zod-util'; import { TagsSchema } from './tags'; import { z } from 'zod'; @@ -605,6 +606,9 @@ export const HarnessSpecSchema = z authorizerType: RuntimeAuthorizerTypeSchema.optional(), /** Authorizer configuration. Required when authorizerType is CUSTOM_JWT. */ authorizerConfiguration: AuthorizerConfigSchema.optional(), + /** Connections to external AgentCore resources (memory/gateway/runtime). The construct + * generates IAM + discovery env vars onto this harness's execution role. */ + connections: z.array(ConnectionSchema).optional(), tags: TagsSchema.optional(), }) .superRefine((data, ctx) => { From f8a6ed62bf5a1090d8e1600f4f46cb7e27ed91cf Mon Sep 17 00:00:00 2001 From: Padma Komarina Date: Wed, 24 Jun 2026 10:24:31 -0400 Subject: [PATCH 2/6] fix(export): correct generated-agent os import, VPC --arn crash, and dead helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes for the harness-export connections feature: - main.py template: emit `import os` when browserIdentifierEnvVar or codeInterpreterIdentifierEnvVar is set. A custom browser/code-interpreter ARN renders `os.getenv(...)` at module scope, but `import os` was gated only on filesystem mounts / file_operations / git-cred skills — so a Container export with a custom tool ARN and none of those produced a main.py that crashed at import with NameError. Snapshot updated. - fetch-harness-spec: throw an early ValidationError when an --arn VPC harness is missing explicit subnets/securityGroups, instead of emitting networkMode:'VPC' with no networkConfig and crashing later in writeProjectSpec's schema validation after the agent dir/code were written. - connections.ts: drop the dead exported `connectionEnvVarName` (imported by neither repo) and correct the "single source of truth" comment to name the helpers actually shared across repos (connectionEnvToken / connectionTokenFor). - harness-action: move the inline `import('../../../schema').HarnessSpec` type to the top-of-file import (AGENTS.md: no inline imports). --- .../assets.snapshot.test.ts.snap | 2 +- src/assets/python/http/strands/base/main.py | 2 +- .../__tests__/fetch-harness-spec.test.ts | 31 +++++++++++++++++++ src/cli/commands/export/fetch-harness-spec.ts | 18 ++++++++--- src/cli/commands/export/harness-action.ts | 4 +-- src/schema/schemas/connections.ts | 13 +++----- 6 files changed, 54 insertions(+), 16 deletions(-) diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index 6f8dd00bd..20e7708e2 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -5276,7 +5276,7 @@ from mcp_client.client import get_streamable_http_mcp_client from memory.session import get_memory_session_manager {{/if}} {{#unless hasFileOperations}} -{{#if (or needsOs (some gitSkills "credentialArn"))}} +{{#if (or needsOs browserIdentifierEnvVar codeInterpreterIdentifierEnvVar (some gitSkills "credentialArn"))}} import os {{/if}} {{/unless}} diff --git a/src/assets/python/http/strands/base/main.py b/src/assets/python/http/strands/base/main.py index 741e954ca..85889cf89 100644 --- a/src/assets/python/http/strands/base/main.py +++ b/src/assets/python/http/strands/base/main.py @@ -66,7 +66,7 @@ from memory.session import get_memory_session_manager {{/if}} {{#unless hasFileOperations}} -{{#if (or needsOs (some gitSkills "credentialArn"))}} +{{#if (or needsOs browserIdentifierEnvVar codeInterpreterIdentifierEnvVar (some gitSkills "credentialArn"))}} import os {{/if}} {{/unless}} diff --git a/src/cli/commands/export/__tests__/fetch-harness-spec.test.ts b/src/cli/commands/export/__tests__/fetch-harness-spec.test.ts index 981f7be9c..6851391e6 100644 --- a/src/cli/commands/export/__tests__/fetch-harness-spec.test.ts +++ b/src/cli/commands/export/__tests__/fetch-harness-spec.test.ts @@ -176,6 +176,37 @@ describe('mapApiHarnessToSpec', () => { }); }); + it('throws early when a VPC harness is missing securityGroups (would otherwise crash post-write)', () => { + // The local AgentEnvSpec schema requires BOTH subnets and securityGroups for VPC. A VPC harness + // with only one (or AWS-default subnets) must fail here, during the pre-write fetch — not emit + // networkMode:'VPC' with no networkConfig and blow up later in writeProjectSpec's validation + // after the agent dir and code were already written. + expect(() => + mapApiHarnessToSpec( + makeApiHarness({ + environment: { + agentCoreRuntimeEnvironment: { + networkConfiguration: { + networkMode: 'VPC', + networkModeConfig: { subnets: ['subnet-0123456789abcdef0'] }, + }, + }, + }, + }) + ) + ).toThrow(/VPC/); + }); + + it('throws when a VPC harness has no networkModeConfig at all', () => { + expect(() => + mapApiHarnessToSpec( + makeApiHarness({ + environment: { agentCoreRuntimeEnvironment: { networkConfiguration: { networkMode: 'VPC' } } }, + }) + ) + ).toThrow(/VPC/); + }); + it('does not set networkMode for PUBLIC (the implicit local default)', () => { const { spec } = mapApiHarnessToSpec( makeApiHarness({ diff --git a/src/cli/commands/export/fetch-harness-spec.ts b/src/cli/commands/export/fetch-harness-spec.ts index aa454501d..37bd3eb26 100644 --- a/src/cli/commands/export/fetch-harness-spec.ts +++ b/src/cli/commands/export/fetch-harness-spec.ts @@ -233,15 +233,25 @@ function mapRuntimeEnvironment(env: HarnessAgentCoreRuntimeEnvironment | undefin if (!env) return {}; const out: Record = {}; - // Network: PUBLIC is the implicit default locally, so only carry VPC (with its config). + // Network: PUBLIC is the implicit default locally, so only carry VPC (with its config). The local + // AgentEnvSpec schema requires BOTH subnets and securityGroups when networkMode is VPC; a VPC + // harness missing either (e.g. AWS-default subnets) can't be expressed. Fail here — during the + // pre-write fetch — with a clear message rather than emitting networkMode:'VPC' with no + // networkConfig and crashing later in writeProjectSpec's schema validation, after the agent dir + // and code have already been written. const net = env.networkConfiguration; if (net?.networkMode === 'VPC') { - out.networkMode = 'VPC'; const subnets = net.networkModeConfig?.subnets; const securityGroups = net.networkModeConfig?.securityGroups; - if (subnets?.length && securityGroups?.length) { - out.networkConfig = { subnets, securityGroups }; + if (!subnets?.length || !securityGroups?.length) { + throw new ValidationError( + 'This harness runs in a VPC but its network configuration is missing explicit subnets and/or ' + + 'security groups, which the exported agent requires. Re-create the harness with explicit VPC ' + + 'subnets and security groups, or export a non-VPC harness.' + ); } + out.networkMode = 'VPC'; + out.networkConfig = { subnets, securityGroups }; } // Lifecycle: same field names; drop unset members. diff --git a/src/cli/commands/export/harness-action.ts b/src/cli/commands/export/harness-action.ts index 86bd99cd3..228f7035c 100644 --- a/src/cli/commands/export/harness-action.ts +++ b/src/cli/commands/export/harness-action.ts @@ -1,7 +1,7 @@ import { AgentAlreadyExistsError, ConfigIO, setEnvVar } from '../../../lib'; import { ExportHarnessError, ValidationError } from '../../../lib/errors/types'; import { AgentNameSchema } from '../../../schema'; -import type { AgentEnvSpec, BuildType, Credential } from '../../../schema'; +import type { AgentEnvSpec, BuildType, Credential, HarnessSpec } from '../../../schema'; import { getErrorMessage } from '../../errors'; import type { AttributeRecorder } from '../../telemetry/cli-command-run.js'; import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js'; @@ -60,7 +60,7 @@ export async function handleExportHarness( // For --arn, fetch the harness from the service first so we can derive its name. The fetch // needs a region — taken from the project's first deployment target. - let prefetched: { spec: import('../../../schema').HarnessSpec; systemPrompt?: string } | undefined; + let prefetched: { spec: HarnessSpec; systemPrompt?: string } | undefined; if (options.arn) { log('Fetching harness from service'); const region = await resolveExportRegion(); diff --git a/src/schema/schemas/connections.ts b/src/schema/schemas/connections.ts index 61e872364..bff82b2c9 100644 --- a/src/schema/schemas/connections.ts +++ b/src/schema/schemas/connections.ts @@ -147,9 +147,11 @@ export const ConnectionsSchema = z.array(ConnectionSchema); // // The discovery env var (e.g. MEMORY__ID, GATEWAY__URL) is the handshake between the // CLI export (which bakes the NAME into the generated agent code) and the CDK deploy (which injects -// the VALUE onto the runtime). Both sides MUST compute the identical name, so the derivation lives -// here — the one file already kept in lockstep across the CLI and @aws/agentcore-cdk repos — and is -// called from both `harness-mapper` (CLI) and `wire-connections` (CDK). Do not re-implement it. +// the VALUE onto the runtime). Both sides MUST compute the identical , so the token derivation +// lives here — the one file already kept in lockstep across the CLI and @aws/agentcore-cdk repos. +// `connectionEnvToken` is used by `harness-mapper` (CLI) and `connectionTokenFor` by +// `wire-connections` (CDK); do not re-implement the token derivation. Each side then assembles the +// final `__` name inline (the prefixes/suffixes differ per resource kind). // ============================================================================ /** Maximum length of a connection id (matches the ConnectionSchema id regex bound). */ @@ -188,8 +190,3 @@ export function connectionEnvToken(id: string): string { export function connectionTokenFor(connection: Connection): string { return connectionEnvToken(connection.id ?? connectionIdForTarget(connection.to)); } - -/** Build a discovery env-var NAME: `__` (e.g. MEMORY__ID). */ -export function connectionEnvVarName(prefix: string, connection: Connection, suffix: string): string { - return `${prefix}_${connectionTokenFor(connection)}_${suffix}`; -} From b2eb4e3f67d8a6647f8871ccba02addf4fa1ea4b Mon Sep 17 00:00:00 2001 From: Padma Komarina Date: Wed, 24 Jun 2026 12:36:57 -0400 Subject: [PATCH 3/6] feat(export): derive --arn export region from the ARN, fall back to targets/env MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Export-by-ARN previously hard-required a configured deployment target (agentcore/aws-targets.json) just to obtain a region for the get-harness fetch — even though the harness ARN already names its region. Resolve the region in priority order instead: ARN-embedded region → first deployment target → AWS_REGION / AWS_DEFAULT_REGION. Only error when none yield a region, and update the message to list all three sources. Reuses regionFromHarnessArn (operations/harness/orphan). Adds unit tests for the precedence chain. Verified live: `export harness --arn` now succeeds with an empty aws-targets.json (region taken from the ARN). --- .../__tests__/resolve-export-region.test.ts | 72 +++++++++++++++++++ src/cli/commands/export/harness-action.ts | 26 +++++-- 2 files changed, 93 insertions(+), 5 deletions(-) create mode 100644 src/cli/commands/export/__tests__/resolve-export-region.test.ts diff --git a/src/cli/commands/export/__tests__/resolve-export-region.test.ts b/src/cli/commands/export/__tests__/resolve-export-region.test.ts new file mode 100644 index 000000000..f25fa30c1 --- /dev/null +++ b/src/cli/commands/export/__tests__/resolve-export-region.test.ts @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// Control what the deployment-targets read returns per test. handleExportHarness pulls in a lot of +// modules, but resolveExportRegion only needs ConfigIO.readAWSDeploymentTargets — mock the lib +// barrel so the rest of the real module loads unchanged. +const mockReadTargets = vi.fn<() => Promise<{ region: string }[]>>(); + +vi.mock('../../../../lib', async importOriginal => { + const actual = await importOriginal(); + return { + ...actual, + ConfigIO: class { + readAWSDeploymentTargets = mockReadTargets; + }, + }; +}); + +// Imported after the mock is registered. +const { resolveExportRegion } = await import('../harness-action'); + +const ARN_US_WEST_2 = 'arn:aws:bedrock-agentcore:us-west-2:111122223333:harness/MyHarness-abc123'; +const ARN_NO_REGION = 'arn:aws:bedrock-agentcore::111122223333:harness/MyHarness-abc123'; + +describe('resolveExportRegion', () => { + beforeEach(() => { + mockReadTargets.mockReset(); + mockReadTargets.mockResolvedValue([]); + delete process.env.AWS_REGION; + delete process.env.AWS_DEFAULT_REGION; + }); + + afterEach(() => { + delete process.env.AWS_REGION; + delete process.env.AWS_DEFAULT_REGION; + }); + + it('prefers the region embedded in the ARN over targets and env', async () => { + mockReadTargets.mockResolvedValue([{ region: 'eu-central-1' }]); + process.env.AWS_REGION = 'ap-south-1'; + await expect(resolveExportRegion(ARN_US_WEST_2)).resolves.toBe('us-west-2'); + // ARN had a region, so targets are never consulted. + expect(mockReadTargets).not.toHaveBeenCalled(); + }); + + it('falls back to the first deployment target when the ARN has no region', async () => { + mockReadTargets.mockResolvedValue([{ region: 'eu-central-1' }]); + await expect(resolveExportRegion(ARN_NO_REGION)).resolves.toBe('eu-central-1'); + }); + + it('falls back to AWS_REGION when the ARN and targets yield nothing', async () => { + mockReadTargets.mockResolvedValue([]); + process.env.AWS_REGION = 'us-east-2'; + await expect(resolveExportRegion(ARN_NO_REGION)).resolves.toBe('us-east-2'); + }); + + it('falls back to AWS_DEFAULT_REGION when AWS_REGION is unset', async () => { + mockReadTargets.mockResolvedValue([]); + process.env.AWS_DEFAULT_REGION = 'us-west-1'; + await expect(resolveExportRegion(ARN_NO_REGION)).resolves.toBe('us-west-1'); + }); + + it('still resolves via env when reading targets throws', async () => { + mockReadTargets.mockRejectedValue(new Error('no project')); + process.env.AWS_REGION = 'ca-central-1'; + await expect(resolveExportRegion(ARN_NO_REGION)).resolves.toBe('ca-central-1'); + }); + + it('returns undefined when no source yields a region', async () => { + mockReadTargets.mockResolvedValue([]); + await expect(resolveExportRegion(ARN_NO_REGION)).resolves.toBeUndefined(); + }); +}); diff --git a/src/cli/commands/export/harness-action.ts b/src/cli/commands/export/harness-action.ts index 228f7035c..73b88b2df 100644 --- a/src/cli/commands/export/harness-action.ts +++ b/src/cli/commands/export/harness-action.ts @@ -3,6 +3,7 @@ import { ExportHarnessError, ValidationError } from '../../../lib/errors/types'; import { AgentNameSchema } from '../../../schema'; import type { AgentEnvSpec, BuildType, Credential, HarnessSpec } from '../../../schema'; import { getErrorMessage } from '../../errors'; +import { regionFromHarnessArn } from '../../operations/harness/orphan'; import type { AttributeRecorder } from '../../telemetry/cli-command-run.js'; import { withCommandRunTelemetry } from '../../telemetry/cli-command-run.js'; import type { CommandAttrs } from '../../telemetry/schemas/command-run.js'; @@ -63,12 +64,13 @@ export async function handleExportHarness( let prefetched: { spec: HarnessSpec; systemPrompt?: string } | undefined; if (options.arn) { log('Fetching harness from service'); - const region = await resolveExportRegion(); + const region = await resolveExportRegion(options.arn); if (!region) { return { success: false as const, error: new ValidationError( - 'No AWS region configured. Add a deployment target (agentcore/aws-targets.json) before exporting by ARN.' + 'No AWS region configured. Pass an ARN that includes a region, configure a deployment ' + + 'target (agentcore/aws-targets.json), or set AWS_REGION before exporting by ARN.' ), }; } @@ -303,13 +305,27 @@ export async function handleExportHarness( // ============================================================================ /** Region for the --arn fetch: the current project's first deployment target. */ -async function resolveExportRegion(): Promise { +/** + * Resolve the region used to fetch a harness by ARN, in priority order: + * 1. the region embedded in the harness ARN (`arn:

:bedrock-agentcore::...`) — this is + * the region the harness actually lives in, so it is the most correct source; + * 2. the first configured deployment target (agentcore/aws-targets.json); + * 3. the AWS_REGION / AWS_DEFAULT_REGION environment variables. + * Returns undefined only when none of these yield a region, so export-by-ARN no longer requires a + * configured deployment target when the ARN (or the environment) already names a region. + */ +export async function resolveExportRegion(arn: string): Promise { + const arnRegion = regionFromHarnessArn(arn); + if (arnRegion) return arnRegion; + try { const targets = await new ConfigIO().readAWSDeploymentTargets(); - return targets[0]?.region; + if (targets[0]?.region) return targets[0].region; } catch { - return undefined; + // fall through to env } + + return process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? undefined; } async function writeExportedAgentToProject( From 3570d2d42503596372fb45ee7b12b8b491c58ddd Mon Sep 17 00:00:00 2001 From: Padma Komarina Date: Wed, 24 Jun 2026 14:11:13 -0400 Subject: [PATCH 4/6] fix(export): wire a harness's managed memory by ARN instead of dropping it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A harness-owned ("managed") memory is a real resource with a service-populated ARN once the harness is READY. mapMemory previously collapsed it to a bare `{ mode: 'managed' }`, which resolveMemoryProviders ignores — so the exported agent silently got no memory at all. Map managedMemoryConfiguration.arn to `{ mode: 'existing', arn }` so it flows through the existing external-memory path: a `memory` connection with access: readwrite (IAM scoped to the ARN + discovery env var at deploy). The bare `{ mode: 'managed' }` is now only a fallback for the no-ARN case (unprovisioned harness, or an SDK-unknown variant); comments call out that this fallback is not resolved downstream and where to wire it if needed. - Type managedMemoryConfiguration on the harness client interface (was read via `as Record`); agentCoreMemoryConfiguration is now optional since exactly one arm is populated. - Tests: provisioned managed memory (with arn) -> existing-by-arn; managed without arn -> managed; SDK-unknown -> managed. Verified live: re-exporting a code-interpreter harness whose managed memory has an ARN now emits a memory connection in agentcore.json (previously absent). --- src/cli/aws/agentcore-harness.ts | 16 +++++++++++- .../__tests__/fetch-harness-spec.test.ts | 18 ++++++++++++- src/cli/commands/export/fetch-harness-spec.ts | 25 ++++++++++++++++--- 3 files changed, 54 insertions(+), 5 deletions(-) diff --git a/src/cli/aws/agentcore-harness.ts b/src/cli/aws/agentcore-harness.ts index 18a913a8e..c60478af7 100644 --- a/src/cli/aws/agentcore-harness.ts +++ b/src/cli/aws/agentcore-harness.ts @@ -84,8 +84,22 @@ export interface HarnessAgentCoreMemoryConfiguration { retrievalConfig?: Record; } +/** + * Memory the harness created and owns itself. Despite being "managed", it is a concrete resource + * with a real, service-populated ARN once the harness is READY — so it can be referenced (and IAM- + * scoped) by ARN exactly like a bring-your-own memory. + */ +export interface HarnessManagedMemoryConfiguration { + arn?: string; + strategies?: string[]; + eventExpiryDuration?: number; + encryptionKeyArn?: string; +} + +// Exactly one configuration arm is populated on a given harness. export interface HarnessMemoryConfiguration { - agentCoreMemoryConfiguration: HarnessAgentCoreMemoryConfiguration; + agentCoreMemoryConfiguration?: HarnessAgentCoreMemoryConfiguration; + managedMemoryConfiguration?: HarnessManagedMemoryConfiguration; } export interface HarnessTruncationConfiguration { diff --git a/src/cli/commands/export/__tests__/fetch-harness-spec.test.ts b/src/cli/commands/export/__tests__/fetch-harness-spec.test.ts index 6851391e6..643362183 100644 --- a/src/cli/commands/export/__tests__/fetch-harness-spec.test.ts +++ b/src/cli/commands/export/__tests__/fetch-harness-spec.test.ts @@ -102,7 +102,23 @@ describe('mapApiHarnessToSpec', () => { }); }); - it('maps a managed-memory harness to mode "managed"', () => { + it('maps a provisioned managed memory (with arn) to existing-by-arn', () => { + // A harness-owned "managed" memory still has a concrete, service-populated ARN once READY, so it + // is referenced by ARN like any external memory (export then wires it as a memory connection). + const { spec } = mapApiHarnessToSpec( + makeApiHarness({ + memory: { + managedMemoryConfiguration: { arn: 'arn:aws:bedrock-agentcore:us-east-1:999:memory/harness_x_a9c0-zvOY' }, + } as any, + }) + ); + expect(spec.memory).toEqual({ + mode: 'existing', + arn: 'arn:aws:bedrock-agentcore:us-east-1:999:memory/harness_x_a9c0-zvOY', + }); + }); + + it('maps a managed memory WITHOUT an arn to mode "managed" (not yet provisioned)', () => { const { spec } = mapApiHarnessToSpec(makeApiHarness({ memory: { managedMemoryConfiguration: {} } as any })); expect(spec.memory).toEqual({ mode: 'managed' }); }); diff --git a/src/cli/commands/export/fetch-harness-spec.ts b/src/cli/commands/export/fetch-harness-spec.ts index 37bd3eb26..7235ecf5e 100644 --- a/src/cli/commands/export/fetch-harness-spec.ts +++ b/src/cli/commands/export/fetch-harness-spec.ts @@ -194,7 +194,13 @@ function mapSkill(skill: ApiHarnessSkill): HarnessSpec['skills'][number] { * a tagged union; we handle each variant defensively because the CLI's bundled SDK model may lag the * service (an unmodeled variant arrives as `{ SDK_UNKNOWN_MEMBER: { name } }`): * - agentCoreMemoryConfiguration -> existing (bring-your-own, by arn) - * - managedMemoryConfiguration -> managed (service-managed; no arn to carry) + * - managedMemoryConfiguration -> existing BY ARN when the harness-owned memory has been + * provisioned (it has a concrete, service-populated arn once the harness is READY); the + * exported agent references it like any external memory (connection + IAM scoped to the arn). + * When no arn is present yet (managed-but-unprovisioned, or an SDK-unknown variant) it returns + * `{ mode: 'managed' }`, which the downstream wiring does NOT resolve — so that case currently + * yields no memory on the exported agent. This is acceptable only because a READY harness + * always carries the arn and so takes the existing-by-arn path above. * - anything else / unknown -> undefined (omit memory; the exported agent gets none) */ function mapMemory(memory: NonNullable): NonNullable | undefined { @@ -207,8 +213,21 @@ function mapMemory(memory: NonNullable): NonNullable; } - // Managed memory (or an SDK-unknown variant that resolves to managed) — there is no external ARN - // to reference; export it as a managed memory request so deploy provisions a fresh one. + + // Managed memory the harness created and owns. Once READY it carries a real ARN, so reference it + // by ARN exactly like a bring-your-own memory — the export then wires it as an external memory + // connection (IAM + discovery env var) instead of silently dropping it. + const managedArn = memory.managedMemoryConfiguration?.arn; + if (managedArn) { + return { mode: 'existing', arn: managedArn } as NonNullable; + } + + // No ARN to reference yet (managed-but-unprovisioned, or an SDK-unknown variant that resolves to + // managed). Return the `managed` marker for completeness, but note resolveMemoryProviders only + // wires `existing` refs — so this path produces NO memory on the exported agent today. It is a + // rare fallback: a READY harness always has the arn and takes the existing-by-arn path above. If + // managed-without-arn ever needs real handling, wire it (provision a project memory or emit a note) + // in resolveMemoryProviders rather than here. const asRecord = memory as unknown as Record; if ('managedMemoryConfiguration' in asRecord || hasUnknownManagedMember(asRecord)) { return { mode: 'managed' } as NonNullable; From 51cea26c498ddb5e2863b41232e21005d8601312 Mon Sep 17 00:00:00 2001 From: Padma Komarina Date: Wed, 24 Jun 2026 15:50:01 -0400 Subject: [PATCH 5/6] feat(export): support Bedrock Mantle (OpenAI-compatible) models MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A harness bedrockModelConfig whose apiFormat is `responses`/`chat_completions` (e.g. openai.gpt-5.5, openai.gpt-oss-120b) is served via the Bedrock Mantle OpenAI-compatible endpoint, NOT the Converse API. Export previously keyed only on the provider, generating a plain BedrockModel for these — so invocation failed at runtime with "ConverseStream ... The provided model identifier is invalid". Generate an OpenAI-style Mantle client instead: - load.py: build OpenAIModel (chat_completions) / OpenAIResponsesModel (proprietary responses) / MantleCompatResponsesModel (open-source responses), against the Mantle base URL (/openai/v1 for proprietary, /v1 for gpt-oss), authenticated with a short-lived Bedrock bearer token (region from AWS_REGION). - harness-mapper: detect Mantle, thread apiFormat/proprietary/params into the render config, and emit a bedrock-mantle IAM policy (CreateInference + CallWithBearerToken) via additionalPolicies — the runtime role's default bedrock:InvokeModel grant does not cover the bedrock-mantle service. - Add openai + aws-bedrock-token-generator deps and vend model/mantle_compat.py. Verified live end-to-end: export by ARN -> deploy -> invoke an openai.gpt-5.5 Mantle harness returns a real completion (previously the ConverseStream error). --- .../assets.snapshot.test.ts.snap | 90 ++++++++++++++++++ .../python/http/strands/base/model/load.py | 62 +++++++++++++ .../http/strands/base/model/mantle_compat.py | 21 +++++ .../python/http/strands/base/pyproject.toml | 2 + .../export/__tests__/harness-mapper.test.ts | 2 +- src/cli/commands/export/harness-mapper.ts | 91 +++++++++++++++++-- src/cli/templates/types.ts | 21 +++++ 7 files changed, 281 insertions(+), 8 deletions(-) create mode 100644 src/assets/python/http/strands/base/model/mantle_compat.py diff --git a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap index 20e7708e2..3e611c48f 100644 --- a/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap +++ b/src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap @@ -831,6 +831,7 @@ exports[`Assets Directory Snapshots > File listing > should match the expected f "python/http/strands/base/mcp_client/client.py", "python/http/strands/base/model/__init__.py", "python/http/strands/base/model/load.py", + "python/http/strands/base/model/mantle_compat.py", "python/http/strands/base/pyproject.toml", "python/http/strands/base/skills/fetcher.py", "python/http/strands/capabilities/execution-limits/hooks/execution_limits.py", @@ -6024,6 +6025,67 @@ exports[`Assets Directory Snapshots > Python framework assets > python/python/ht exports[`Assets Directory Snapshots > Python framework assets > python/python/http/strands/base/model/load.py should match snapshot 1`] = ` "{{#if (eq modelProvider "Bedrock")}} +{{#if bedrockMantle}} +import os + +from aws_bedrock_token_generator import provide_token +{{#if (eq mantleApiFormat "chat_completions")}} +from strands.models.openai import OpenAIModel +{{else}} +{{#if mantleProprietary}} +from strands.models.openai_responses import OpenAIResponsesModel +{{else}} +from model.mantle_compat import MantleCompatResponsesModel +{{/if}} +{{/if}} + +MODEL_ID = "{{modelId}}" + + +def load_model(): + """ + Get a Bedrock Mantle model client. These OpenAI-compatible models (e.g. openai.gpt-5.5, + openai.gpt-oss-120b) are served via the Bedrock Mantle endpoint, NOT the Converse API — so they + are invoked through an OpenAI-style client authenticated with a short-lived Bedrock bearer token. + Region is read from AWS_REGION (set by the AgentCore runtime). + """ + region = os.environ.get("AWS_REGION", os.environ.get("AWS_DEFAULT_REGION", "us-east-1")) + token = provide_token(region=region) + {{#if mantleProprietary}} + # Proprietary OpenAI models only work on the /openai/v1 Mantle path. + base_url = f"https://bedrock-mantle.{region}.api.aws/openai/v1" + {{else}} + # Open-source OpenAI models (gpt-oss-*) only work on the /v1 Mantle path. + base_url = f"https://bedrock-mantle.{region}.api.aws/v1" + {{/if}} + client_args = {"api_key": token, "base_url": base_url} + + params = {} + {{#if modelMaxTokens}} + {{#if (eq mantleApiFormat "chat_completions")}} + params["max_completion_tokens"] = {{modelMaxTokens}} + {{else}} + params["max_output_tokens"] = {{modelMaxTokens}} + {{/if}} + {{/if}} + {{#if modelTemperature}} + params["temperature"] = {{modelTemperature}} + {{/if}} + {{#if modelTopP}} + params["top_p"] = {{modelTopP}} + {{/if}} + {{#if (eq mantleApiFormat "chat_completions")}} + return OpenAIModel(client_args=client_args, model_id=MODEL_ID, params=params) + {{else}} + # Responses API: Mantle does not persist responses, so disable server-side storage. + params["store"] = False + {{#if mantleProprietary}} + return OpenAIResponsesModel(client_args=client_args, model_id=MODEL_ID, params=params) + {{else}} + return MantleCompatResponsesModel(client_args=client_args, model_id=MODEL_ID, params=params) + {{/if}} + {{/if}} +{{else}} from strands.models.bedrock import BedrockModel @@ -6031,6 +6093,7 @@ def load_model() -> BedrockModel: """Get Bedrock model client using IAM credentials.""" return BedrockModel(model_id="{{#if modelId}}{{modelId}}{{else}}global.anthropic.claude-sonnet-4-5-20250929-v1:0{{/if}}") {{/if}} +{{/if}} {{#if (eq modelProvider "Anthropic")}} import os @@ -6203,6 +6266,31 @@ def load_model() -> LiteLLMModel: " `; +exports[`Assets Directory Snapshots > Python framework assets > python/python/http/strands/base/model/mantle_compat.py should match snapshot 1`] = ` +"from strands.models.openai_responses import OpenAIResponsesModel + + +class MantleCompatResponsesModel(OpenAIResponsesModel): + """Workaround for Bedrock Mantle rejecting output_text in EasyInputMessage content arrays. + + Mantle's Pydantic validation only accepts content as a plain string for assistant messages, while + real OpenAI accepts both formats. Flatten assistant content arrays to strings so multi-turn works. + Used for open-source OpenAI models (gpt-oss-*) on the /v1 Mantle path; proprietary models use the + plain OpenAIResponsesModel on /openai/v1. + """ + + @classmethod + def _format_request_messages(cls, messages): + formatted = super()._format_request_messages(messages) + for msg in formatted: + if msg.get("role") == "assistant" and isinstance(msg.get("content"), list): + msg["content"] = "".join( + part.get("text", "") for part in msg["content"] if part.get("type") == "output_text" + ) + return formatted +" +`; + exports[`Assets Directory Snapshots > Python framework assets > python/python/http/strands/base/pyproject.toml should match snapshot 1`] = ` "[build-system] requires = ["hatchling"] @@ -6223,6 +6311,8 @@ dependencies = [ {{/if}}"mcp >= 1.19.0", {{#if (eq modelProvider "OpenAI")}}"openai >= 1.0.0", {{/if}}{{#if (eq modelProvider "LiteLLM")}}"litellm >= 1.0.0", + {{/if}}{{#if bedrockMantle}}"openai >= 1.0.0", + "aws-bedrock-token-generator >= 1.0.0", {{/if}}"strands-agents >= 1.15.0", {{#if (or hasBrowser hasCodeInterpreter)}}"strands-agents-tools >= 0.1.0", {{/if}}{{#if hasBrowser}}"nest-asyncio >= 1.5.0", diff --git a/src/assets/python/http/strands/base/model/load.py b/src/assets/python/http/strands/base/model/load.py index 90ed4ecce..d45e500dc 100644 --- a/src/assets/python/http/strands/base/model/load.py +++ b/src/assets/python/http/strands/base/model/load.py @@ -1,4 +1,65 @@ {{#if (eq modelProvider "Bedrock")}} +{{#if bedrockMantle}} +import os + +from aws_bedrock_token_generator import provide_token +{{#if (eq mantleApiFormat "chat_completions")}} +from strands.models.openai import OpenAIModel +{{else}} +{{#if mantleProprietary}} +from strands.models.openai_responses import OpenAIResponsesModel +{{else}} +from model.mantle_compat import MantleCompatResponsesModel +{{/if}} +{{/if}} + +MODEL_ID = "{{modelId}}" + + +def load_model(): + """ + Get a Bedrock Mantle model client. These OpenAI-compatible models (e.g. openai.gpt-5.5, + openai.gpt-oss-120b) are served via the Bedrock Mantle endpoint, NOT the Converse API — so they + are invoked through an OpenAI-style client authenticated with a short-lived Bedrock bearer token. + Region is read from AWS_REGION (set by the AgentCore runtime). + """ + region = os.environ.get("AWS_REGION", os.environ.get("AWS_DEFAULT_REGION", "us-east-1")) + token = provide_token(region=region) + {{#if mantleProprietary}} + # Proprietary OpenAI models only work on the /openai/v1 Mantle path. + base_url = f"https://bedrock-mantle.{region}.api.aws/openai/v1" + {{else}} + # Open-source OpenAI models (gpt-oss-*) only work on the /v1 Mantle path. + base_url = f"https://bedrock-mantle.{region}.api.aws/v1" + {{/if}} + client_args = {"api_key": token, "base_url": base_url} + + params = {} + {{#if modelMaxTokens}} + {{#if (eq mantleApiFormat "chat_completions")}} + params["max_completion_tokens"] = {{modelMaxTokens}} + {{else}} + params["max_output_tokens"] = {{modelMaxTokens}} + {{/if}} + {{/if}} + {{#if modelTemperature}} + params["temperature"] = {{modelTemperature}} + {{/if}} + {{#if modelTopP}} + params["top_p"] = {{modelTopP}} + {{/if}} + {{#if (eq mantleApiFormat "chat_completions")}} + return OpenAIModel(client_args=client_args, model_id=MODEL_ID, params=params) + {{else}} + # Responses API: Mantle does not persist responses, so disable server-side storage. + params["store"] = False + {{#if mantleProprietary}} + return OpenAIResponsesModel(client_args=client_args, model_id=MODEL_ID, params=params) + {{else}} + return MantleCompatResponsesModel(client_args=client_args, model_id=MODEL_ID, params=params) + {{/if}} + {{/if}} +{{else}} from strands.models.bedrock import BedrockModel @@ -6,6 +67,7 @@ def load_model() -> BedrockModel: """Get Bedrock model client using IAM credentials.""" return BedrockModel(model_id="{{#if modelId}}{{modelId}}{{else}}global.anthropic.claude-sonnet-4-5-20250929-v1:0{{/if}}") {{/if}} +{{/if}} {{#if (eq modelProvider "Anthropic")}} import os diff --git a/src/assets/python/http/strands/base/model/mantle_compat.py b/src/assets/python/http/strands/base/model/mantle_compat.py new file mode 100644 index 000000000..4607a3517 --- /dev/null +++ b/src/assets/python/http/strands/base/model/mantle_compat.py @@ -0,0 +1,21 @@ +from strands.models.openai_responses import OpenAIResponsesModel + + +class MantleCompatResponsesModel(OpenAIResponsesModel): + """Workaround for Bedrock Mantle rejecting output_text in EasyInputMessage content arrays. + + Mantle's Pydantic validation only accepts content as a plain string for assistant messages, while + real OpenAI accepts both formats. Flatten assistant content arrays to strings so multi-turn works. + Used for open-source OpenAI models (gpt-oss-*) on the /v1 Mantle path; proprietary models use the + plain OpenAIResponsesModel on /openai/v1. + """ + + @classmethod + def _format_request_messages(cls, messages): + formatted = super()._format_request_messages(messages) + for msg in formatted: + if msg.get("role") == "assistant" and isinstance(msg.get("content"), list): + msg["content"] = "".join( + part.get("text", "") for part in msg["content"] if part.get("type") == "output_text" + ) + return formatted diff --git a/src/assets/python/http/strands/base/pyproject.toml b/src/assets/python/http/strands/base/pyproject.toml index 15ae87018..3679cbebd 100644 --- a/src/assets/python/http/strands/base/pyproject.toml +++ b/src/assets/python/http/strands/base/pyproject.toml @@ -17,6 +17,8 @@ dependencies = [ {{/if}}"mcp >= 1.19.0", {{#if (eq modelProvider "OpenAI")}}"openai >= 1.0.0", {{/if}}{{#if (eq modelProvider "LiteLLM")}}"litellm >= 1.0.0", + {{/if}}{{#if bedrockMantle}}"openai >= 1.0.0", + "aws-bedrock-token-generator >= 1.0.0", {{/if}}"strands-agents >= 1.15.0", {{#if (or hasBrowser hasCodeInterpreter)}}"strands-agents-tools >= 0.1.0", {{/if}}{{#if hasBrowser}}"nest-asyncio >= 1.5.0", diff --git a/src/cli/commands/export/__tests__/harness-mapper.test.ts b/src/cli/commands/export/__tests__/harness-mapper.test.ts index 08a595370..08c15a5cd 100644 --- a/src/cli/commands/export/__tests__/harness-mapper.test.ts +++ b/src/cli/commands/export/__tests__/harness-mapper.test.ts @@ -1060,7 +1060,7 @@ describe('resolveGatewayProviders', () => { expect((conn?.to as { outboundAuth: { oauth: { grantType: string } } }).outboundAuth.oauth.grantType).toBe( 'AUTHORIZATION_CODE' ); - // ...and the generated client gets the corresponding USER_FEDERATION auth_flow (loopy parity). + // ...and the generated client gets the corresponding USER_FEDERATION auth_flow. expect(renderConfig.gatewayProviders.find(() => true)?.authFlow).toBe('USER_FEDERATION'); // USER_FEDERATION is now expressible by the decorator → no manual-step note. expect(noteCategories(ctx)).not.toContain(GATEWAY_GRANT_TYPE_NOTE_CATEGORY); diff --git a/src/cli/commands/export/harness-mapper.ts b/src/cli/commands/export/harness-mapper.ts index 489175381..a4dbac355 100644 --- a/src/cli/commands/export/harness-mapper.ts +++ b/src/cli/commands/export/harness-mapper.ts @@ -86,8 +86,8 @@ export function mapHarnessToExportConfig( // LiteLLM keyless-but-key-requiring warning. A `bedrock/...` LiteLLM model authenticates via the // execution role (no key needed) — the common, valid case. Any other provider prefix (openai/, // anthropic/, ...) typically needs an API key; if the harness set no apiKeyArn, the generated - // client is built keyless and fails at first invocation. Keyless is schema- and runtime-valid - // (matches loopy), so this is a note, not an error — export is just the right place to flag it. + // client is built keyless and fails at first invocation. Keyless is schema- and runtime-valid, + // so this is a note, not an error — export is just the right place to flag it. if (spec.model.provider === 'lite_llm' && !spec.model.apiKeyArn && !spec.model.modelId.startsWith('bedrock/')) { context.exportNotes.push({ category: LITELLM_NO_API_KEY_NOTE_CATEGORY, @@ -98,6 +98,11 @@ export function mapHarnessToExportConfig( `(model apiKeyArn), or use a bedrock/ model id (which authenticates via the execution role).`, }); } + // Bedrock Mantle models are invoked via the bedrock-mantle service (not bedrock:InvokeModel), so + // the runtime role's default Bedrock grant is insufficient. Generate the bedrock-mantle:CreateInference + // policy and wire it via additionalPolicies, mirroring the S3-skills opaque-AWS-access pattern. + resolveBedrockMantlePolicy(spec, context); + const memoryResult = resolveMemoryProviders(spec, context); const gatewayResult = resolveGatewayProviders(spec, context, allowedToolPatterns); const hasGateway = gatewayResult.providers.length > 0; @@ -277,6 +282,9 @@ export function mapHarnessToExportConfig( Object.keys(spec.model.additionalParams).length > 0 ? { litellmAdditionalParams: spec.model.additionalParams } : {}), + // Bedrock Mantle (OpenAI-compatible Bedrock models served via the Mantle endpoint, not Converse). + // Empty for ordinary Converse Bedrock models and all other providers. + ...buildBedrockMantleRenderConfig(spec), // System prompt (written verbatim into main.py) systemPromptText: context.systemPrompt, actorId: spec.memory?.mode === 'existing' ? spec.memory.actorId : undefined, @@ -461,8 +469,7 @@ function connectionEnvVarName(prefix: string, connectionId: string, suffix: stri * Map a harness gateway outboundAuth onto the connection's GatewayOutboundAuth. The shapes match * except for the oauth grant-type enum: the harness uses CLIENT_CREDENTIALS | USER_FEDERATION, * while the connection (matching the runtime/Smithy model) uses CLIENT_CREDENTIALS | - * AUTHORIZATION_CODE | TOKEN_EXCHANGE. USER_FEDERATION maps to AUTHORIZATION_CODE (loopy's - * _GRANT_TYPE_TO_FLOW maps AUTHORIZATION_CODE -> USER_FEDERATION auth flow). + * AUTHORIZATION_CODE | TOKEN_EXCHANGE. USER_FEDERATION maps to AUTHORIZATION_CODE. */ function toConnectionGatewayAuth( outboundAuth: HarnessGatewayOutboundAuth | undefined @@ -490,8 +497,7 @@ function toConnectionGatewayAuth( /** * Map a HARNESS OAuth grant type to the AgentCore Identity auth flow consumed by the generated * client's `@requires_access_token(auth_flow=...)`. The harness enum is CLIENT_CREDENTIALS | - * USER_FEDERATION (see HarnessGatewayOutboundAuth); both have a decorator equivalent, mirroring - * loopy's `_GRANT_TYPE_TO_FLOW`: + * USER_FEDERATION (see HarnessGatewayOutboundAuth); both have a decorator equivalent: * CLIENT_CREDENTIALS -> M2M, USER_FEDERATION -> USER_FEDERATION. * The SDK decorator's auth_flow is `Literal["M2M","USER_FEDERATION"]`. Unset grant defaults to M2M. * Returns undefined only for an unrecognized value (caller emits a manual-step note). @@ -525,6 +531,77 @@ function resolveModelProvider(provider: 'bedrock' | 'open_ai' | 'gemini' | 'lite } } +/** + * Proprietary OpenAI models (e.g. openai.gpt-5.4, openai.gpt-5.5) are served on the Bedrock Mantle + * `/openai/v1` path; open-source OpenAI models (openai.gpt-oss-*) use `/v1`. + */ +function isProprietaryOpenAiModel(modelId: string): boolean { + return modelId.startsWith('openai.') && !modelId.includes('gpt-oss'); +} + +/** + * Bedrock Mantle render config. A Bedrock model whose apiFormat is `responses`/`chat_completions` + * (not `converse_stream`) is an OpenAI-compatible model served via the Bedrock Mantle endpoint, NOT + * the Converse API — building a plain BedrockModel for it makes invocation fail with + * "The provided model identifier is invalid" on ConverseStream. When that combination is detected, + * emit the fields load.py needs to construct the OpenAI-style Mantle client (base URL is derived at + * runtime from AWS_REGION). Returns {} for ordinary Converse Bedrock models. + */ +function buildBedrockMantleRenderConfig(spec: HarnessSpec): Partial { + if (!isBedrockMantleModel(spec)) return {}; + const apiFormat = spec.model.apiFormat as 'responses' | 'chat_completions'; + return { + bedrockMantle: true, + mantleApiFormat: apiFormat, + mantleProprietary: isProprietaryOpenAiModel(spec.model.modelId), + modelTemperature: spec.model.temperature, + modelTopP: spec.model.topP, + modelMaxTokens: spec.model.maxTokens, + }; +} + +/** A Bedrock model whose apiFormat routes it through the OpenAI-compatible Mantle endpoint. */ +function isBedrockMantleModel(spec: HarnessSpec): boolean { + return ( + spec.model.provider === 'bedrock' && + (spec.model.apiFormat === 'responses' || spec.model.apiFormat === 'chat_completions') + ); +} + +/** + * Bedrock Mantle invocation goes through the `bedrock-mantle` service (CreateInference), NOT + * `bedrock:InvokeModel`, so the runtime role's default Bedrock grant does not cover it. Emit an + * inline IAM policy (opaque AWS access, like S3 skills) granting bedrock-mantle:CreateInference on + * the account's default Mantle project, wired via AgentEnvSpec.additionalPolicies. + */ +function resolveBedrockMantlePolicy(spec: HarnessSpec, context: ResolvedHarnessContext): void { + if (!isBedrockMantleModel(spec)) return; + // arnPrefix returns `arn:` (e.g. arn:aws); region defaults to us-east-1 only to pick the + // partition — the ARN itself wildcards region/account so the runtime works wherever it deploys. + const prefix = arnPrefix(context.region ?? 'us-east-1'); + const policyDoc = { + Version: '2012-10-17', + Statement: [ + { + Effect: 'Allow', + Action: 'bedrock-mantle:CreateInference', + // The Mantle endpoint resolves the caller's account; access is scoped to the default project. + Resource: `${prefix}:bedrock-mantle:*:*:project/default`, + }, + { + // The bearer-token auth path (provide_token -> Mantle) requires CallWithBearerToken, which + // the service authorizes against resource `*` (it is not resource-scoped). + Effect: 'Allow', + Action: 'bedrock-mantle:CallWithBearerToken', + Resource: '*', + }, + ], + }; + const policyFile = 'bedrock-mantle-policy.json'; + context.generatedPolicyFiles[policyFile] = policyDoc; + if (!context.additionalPolicies.includes(policyFile)) context.additionalPolicies.push(policyFile); +} + // ============================================================================ // Identity provider (non-Bedrock model credential) // ============================================================================ @@ -737,7 +814,7 @@ function resolveGatewayProviders( if (scopes?.length) { provider.scopes = scopes.join(' '); } - // Thread the grant type through to the generated client's auth_flow (mirrors loopy), so a + // Thread the grant type through to the generated client's auth_flow, so a // USER_FEDERATION harness gateway exports a USER_FEDERATION client instead of a hardcoded M2M. const authFlow = grantTypeToAuthFlow(outboundAuth.oauth.grantType); if (authFlow) provider.authFlow = authFlow; diff --git a/src/cli/templates/types.ts b/src/cli/templates/types.ts index c0742a424..f025aa874 100644 --- a/src/cli/templates/types.ts +++ b/src/cli/templates/types.ts @@ -150,6 +150,27 @@ export interface AgentRenderConfig { * pyJsonStr + json.loads so JSON booleans/null parse correctly at runtime. */ litellmAdditionalParams?: Record; + /** + * Bedrock Mantle (export path): a Bedrock model whose apiFormat is `responses`/`chat_completions` + * (e.g. openai.gpt-5.5, openai.gpt-oss-120b) is served via the Bedrock Mantle OpenAI-compatible + * endpoint, NOT the Converse API. When true, load.py builds an OpenAI-style client against the + * Mantle base URL instead of BedrockModel. + */ + bedrockMantle?: boolean; + /** Mantle apiFormat: 'responses' or 'chat_completions' — selects the client class + token param. */ + mantleApiFormat?: 'responses' | 'chat_completions'; + /** + * True for proprietary OpenAI models (openai.* without gpt-oss), which require the `/openai/v1` + * Mantle path + OpenAIResponsesModel; open-source models use `/v1` + MantleCompatResponsesModel. + */ + mantleProprietary?: boolean; + /** Model temperature (export path, Mantle): merged into the client params when set. */ + modelTemperature?: number; + /** Model nucleus-sampling top_p (export path, Mantle): merged into the client params when set. */ + modelTopP?: number; + /** Model max output tokens (export path, Mantle): mapped to max_output_tokens / max_completion_tokens. */ + modelMaxTokens?: number; + /** True when generating from a harness export (suppresses placeholder tools) */ isExportHarness?: boolean; /** System prompt text written verbatim into main.py (export path) */ From 5849a7a29ea242df2230a720baa44cb9b935bd0f Mon Sep 17 00:00:00 2001 From: Padma Komarina Date: Wed, 24 Jun 2026 15:55:19 -0400 Subject: [PATCH 6/6] chore(export): drop the empty-aws-targets export note Removed the "No AWS deployment target configured" note. It warned that `agentcore deploy` would fail with `Target "default" not found` when aws-targets.json is empty, but deploy already handles that case interactively (ensure-target.ts prompts for account/region and writes a default target). The note described a failure the deploy flow prevents, so it was noise. --- src/cli/commands/export/harness-action.ts | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/src/cli/commands/export/harness-action.ts b/src/cli/commands/export/harness-action.ts index 73b88b2df..89aa105c6 100644 --- a/src/cli/commands/export/harness-action.ts +++ b/src/cli/commands/export/harness-action.ts @@ -254,21 +254,6 @@ export async function handleExportHarness( writeFileSync(join(agentDir, fileName), JSON.stringify(policyDoc, null, 2) + '\n'); } - // 6b. Warn if no deploy targets are configured - const configIO = new ConfigIO({ baseDir: context.configBaseDir }); - const targets = await configIO.readAWSDeploymentTargets().catch(() => []); - if (targets.length === 0) { - context.exportNotes.push({ - category: 'No AWS deployment target configured', - message: - 'aws-targets.json is empty — running `agentcore deploy` will fail with "Target \\"default\\" not found". ' + - 'Add a deployment target first:\n\n' + - ' agentcore deploy (interactive mode will prompt for account/region)\n\n' + - 'Or edit agentcore/aws-targets.json manually:\n\n' + - ' [{ "name": "default", "account": "", "region": "" }]', - }); - } - // 7. Write EXPORT_NOTES.md log('Writing EXPORT_NOTES.md'); writeExportNotes(context.exportNotes, harnessName, targetAgentName, agentDir);