Skip to content

Commit 64b9b63

Browse files
authored
feat: add EFS and S3 filesystem mount support (#1436)
* feat: add EFS and S3 filesystem mount support (BYO agents and harness) Adds session storage, EFS access point, and S3 Files access point filesystem mounts across the full stack: CLI flags, TUI wizard steps, schema validation, CDK IAM permissions, and generated agent templates. CLI (agentcore create / add agent / add harness): - --session-storage-mount-path, --efs-access-point-arn/--efs-mount-path, --s3-access-point-arn/--s3-mount-path flags on create and add agent - Harness create path wires filesystem flags through to harness.json - Sync validation: ARN format, paired flags, max mounts, VPC requirement in both validateCreateOptions and validateCreateHarnessOptions - Async validation: L1 access point exists, L2 VPC/AZ topology, L3 SG in agent create, add agent, and harness create paths - Level 3 SG check uses EFS/S3 ARN region (not agent region) for mount target SG queries; validation reads deployment region from aws-targets.json TUI wizard: - EFS/S3 two-step ARN→path entry with add/edit/remove review screens - Shared useFilesystemMountState hook (generate wizard + BYO + harness) - Shared buildMountListItems helper - Session-storage advanced setting in harness wizard includes EFS/S3 steps - VPC warning and validation on harness EFS/S3 ARN steps - Harness TUI add flow forwards efsAccessPoints/s3AccessPoints to primitive Schema: - FilesystemConfigurationSchema union (sessionStorage | efsAccessPoint | s3FilesAccessPoint) with z.strictObject, duplicate path detection, max-count enforcement, VPC requirement - EFS_ACCESS_POINT_ARN_PATTERN / S3_FILES_ACCESS_POINT_ARN_PATTERN constants shared between CLI validators and Zod schema - HarnessSpec gains efsAccessPoints/s3AccessPoints with VPC enforcement and duplicate mount path validation CDK / deploy: - AgentCoreRuntime: typed filesystemConfigurations props (aws-cdk-lib 2.257) - AgentCoreHarnessRole: EFS ClientMount/ClientWrite and S3 Files ClientMount/ClientWrite IAM policies when mounts are configured - harness-mapper writes all three filesystem types; hasFilesystem uses correct boolean coercion; mount paths normalized (trailing slash stripped) - Vended cdk-stack.ts and bin/cdk.ts include new HarnessConfig fields Templates: - HTTP, A2A, AGUI, MCP Python templates render file_read/file_write/ list_files filesystem tools via {{#if needsOs}} blocks - needsOs uses || not ?? so S3-only agents correctly generate tools - EFS ARN regex constants shared (single source of truth) - regionFromEfsArn/regionFromS3FilesArn merged into single regionFromArn Tests: - filesystem-utils.test.ts: ARN format, path validation, pairing, mounts - filesystem-roundtrip.test.ts, filesystem-error-quality.test.ts: schema - harness-mapper.test.ts: EFS, S3, combined filesystem mapping - validate.test.ts: 16 new EFS/S3 validation cases for create path - harness-validate.test.ts: 12 new cases for harness create path - buildMountListItems.test.ts: 6 cases for mount list item builder - schema-mapper.test.ts: 12 filesystem configuration mapping cases - useFilesystemMountState.test.tsx: 15 hook handler tests - computeByoSteps.test.ts: filesystem step inclusion - useGenerateWizard.test.tsx: EFS/S3 flow, edit/remove, deselect * fix: address PR review comments on filesystem mount support - Extract duplicated EFS/S3 mount resolution and validation logic from handleCreateCLI and handleCreateHarnessCLI into a shared resolveAndValidateFilesystemMounts() helper in filesystem-utils.ts - Fix path traversal vulnerability in _safe_resolve: append os.sep before startswith check to prevent prefix collision (e.g. /mnt/a incorrectly matching /mnt/abc/secret) - Add JSDoc describing L1/L2/L3 validation levels on validateFilesystemMountsConfiguration - Add tests for buildFilesystemConfigurations and resolveAndValidateFilesystemMounts
1 parent 5e035d0 commit 64b9b63

61 files changed

Lines changed: 5010 additions & 629 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

npm-shrinkwrap.json

Lines changed: 208 additions & 235 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,12 +84,14 @@
8484
"@aws-sdk/client-bedrock-runtime": "^3.893.0",
8585
"@aws-sdk/client-cloudformation": "^3.893.0",
8686
"@aws-sdk/client-cloudwatch-logs": "^3.893.0",
87+
"@aws-sdk/client-efs": "^3.1049.0",
8788
"@aws-sdk/client-resource-groups-tagging-api": "^3.893.0",
8889
"@aws-sdk/client-s3": "^3.1012.0",
90+
"@aws-sdk/client-s3files": "^3.1049.0",
8991
"@aws-sdk/client-sts": "^3.893.0",
90-
"@aws-sdk/region-config-resolver": "^3.972.13",
9192
"@aws-sdk/client-xray": "^3.1003.0",
9293
"@aws-sdk/credential-providers": "^3.893.0",
94+
"@aws-sdk/region-config-resolver": "^3.972.13",
9395
"@aws/agent-inspector": "0.4.2",
9496
"@commander-js/extra-typings": "^14.0.0",
9597
"@opentelemetry/api": "^1.9.1",

src/assets/__tests__/__snapshots__/assets.snapshot.test.ts.snap

Lines changed: 445 additions & 143 deletions
Large diffs are not rendered by default.

src/assets/cdk/bin/cdk.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ async function main() {
6666
codeLocation?: string;
6767
tools?: { type: string; name: string }[];
6868
apiKeyArn?: string;
69+
efsAccessPoints?: { accessPointArn: string; mountPath: string }[];
70+
s3AccessPoints?: { accessPointArn: string; mountPath: string }[];
6971
}[] = [];
7072
for (const entry of specAny.harnesses ?? []) {
7173
const harnessDir = path.resolve(projectRoot, entry.path);
@@ -82,6 +84,8 @@ async function main() {
8284
codeLocation: harnessSpec.dockerfile ? harnessDir : undefined,
8385
tools: harnessSpec.tools,
8486
apiKeyArn: harnessSpec.model?.apiKeyArn,
87+
efsAccessPoints: harnessSpec.efsAccessPoints,
88+
s3AccessPoints: harnessSpec.s3AccessPoints,
8589
});
8690
} catch (err) {
8791
throw new Error(

src/assets/cdk/lib/cdk-stack.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ export interface HarnessConfig {
1717
codeLocation?: string;
1818
tools?: { type: string; name: string }[];
1919
apiKeyArn?: string;
20+
efsAccessPoints?: { accessPointArn: string; mountPath: string }[];
21+
s3AccessPoints?: { accessPointArn: string; mountPath: string }[];
2022
}
2123

2224
export interface AgentCoreStackProps extends StackProps {

src/assets/python/a2a/googleadk/base/main.py

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
{{#if needsOs}}
12
import os
3+
{{/if}}
24
from google.adk.agents import Agent
35
from google.adk.a2a.executor.a2a_agent_executor import A2aAgentExecutor
46
from google.adk.runners import Runner
@@ -17,18 +19,21 @@ def add_numbers(a: int, b: int) -> int:
1719

1820
tools = [add_numbers]
1921

20-
{{#if sessionStorageMountPath}}
21-
SESSION_STORAGE_PATH = "{{sessionStorageMountPath}}"
22+
{{#if needsOs}}
23+
_MOUNT_PATHS = [
24+
{{#if sessionStorageMountPath}}"{{sessionStorageMountPath}}",{{/if}}
25+
{{#each efsMounts}}"{{mountPath}}",{{/each}}
26+
{{#each s3Mounts}}"{{mountPath}}",{{/each}}
27+
]
2228

2329
def _safe_resolve(path: str) -> str:
24-
"""Resolve path safely within the storage boundary."""
25-
resolved = os.path.realpath(os.path.join(SESSION_STORAGE_PATH, path.lstrip("/")))
26-
if not resolved.startswith(os.path.realpath(SESSION_STORAGE_PATH)):
27-
raise ValueError(f"Path '{path}' is outside the storage boundary")
30+
resolved = os.path.realpath(path)
31+
if not any(resolved == os.path.realpath(m) or resolved.startswith(os.path.realpath(m) + os.sep) for m in _MOUNT_PATHS):
32+
raise ValueError(f"Path '{path}' is not within any configured mount ({', '.join(_MOUNT_PATHS)})")
2833
return resolved
2934

3035
def file_read(path: str) -> str:
31-
"""Read a file from persistent storage. The path is relative to the storage root."""
36+
"""Read a file from a mounted filesystem. Use the absolute path (e.g. /mnt/tools/data.txt)."""
3237
try:
3338
full_path = _safe_resolve(path)
3439
with open(full_path) as f:
@@ -39,7 +44,7 @@ def file_read(path: str) -> str:
3944
return f"Error reading '{path}': {e.strerror}"
4045

4146
def file_write(path: str, content: str) -> str:
42-
"""Write content to a file in persistent storage. The path is relative to the storage root."""
47+
"""Write a file to a mounted filesystem. Use the absolute path (e.g. /mnt/tools/data.txt)."""
4348
try:
4449
full_path = _safe_resolve(path)
4550
parent = os.path.dirname(full_path)
@@ -53,25 +58,28 @@ def file_write(path: str, content: str) -> str:
5358
except OSError as e:
5459
return f"Error writing '{path}': {e.strerror}"
5560

56-
def list_files(directory: str = "") -> str:
57-
"""List files in persistent storage. The directory is relative to the storage root."""
61+
def list_files(path: str) -> str:
62+
"""List files in a mounted filesystem directory. Use the absolute path (e.g. /mnt/tools)."""
5863
try:
59-
target = _safe_resolve(directory)
60-
entries = os.listdir(target)
64+
full_path = _safe_resolve(path)
65+
entries = os.listdir(full_path)
6166
return "\n".join(entries) if entries else "(empty directory)"
6267
except ValueError as e:
6368
return str(e)
6469
except OSError as e:
65-
return f"Error listing '{directory}': {e.strerror}"
70+
return f"Error listing '{path}': {e.strerror}"
6671

6772
tools.extend([file_read, file_write, list_files])
6873
{{/if}}
6974

7075
AGENT_INSTRUCTION = """
7176
You are a helpful assistant. Use tools when appropriate.
72-
{{#if sessionStorageMountPath}}
73-
You have persistent storage at {{sessionStorageMountPath}}. Use file tools to read and write files. Data persists across sessions.
74-
{{/if}}
77+
{{#if needsOs}}
78+
You have access to the following mounted filesystems. Use file_read, file_write, and list_files with full absolute paths:
79+
{{#if sessionStorageMountPath}}- {{sessionStorageMountPath}}: ephemeral session storage (lost when session ends)
80+
{{/if}}{{#each efsMounts}}- {{mountPath}}: EFS persistent storage (persists across sessions and agent restarts)
81+
{{/each}}{{#each s3Mounts}}- {{mountPath}}: S3 Files persistent storage (durable, backed by S3)
82+
{{/each}}{{/if}}
7583
"""
7684

7785
agent = Agent(

src/assets/python/a2a/langchain_langgraph/base/main.py

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
{{#if needsOs}}
12
import os
3+
{{/if}}
24
from langchain_core.tools import tool
35
from langgraph.prebuilt import create_react_agent
46
from opentelemetry.instrumentation.langchain import LangchainInstrumentor
@@ -21,19 +23,22 @@ def add_numbers(a: int, b: int) -> int:
2123

2224
tools = [add_numbers]
2325

24-
{{#if sessionStorageMountPath}}
25-
SESSION_STORAGE_PATH = "{{sessionStorageMountPath}}"
26+
{{#if needsOs}}
27+
_MOUNT_PATHS = [
28+
{{#if sessionStorageMountPath}}"{{sessionStorageMountPath}}",{{/if}}
29+
{{#each efsMounts}}"{{mountPath}}",{{/each}}
30+
{{#each s3Mounts}}"{{mountPath}}",{{/each}}
31+
]
2632

2733
def _safe_resolve(path: str) -> str:
28-
"""Resolve path safely within the storage boundary."""
29-
resolved = os.path.realpath(os.path.join(SESSION_STORAGE_PATH, path.lstrip("/")))
30-
if not resolved.startswith(os.path.realpath(SESSION_STORAGE_PATH)):
31-
raise ValueError(f"Path '{path}' is outside the storage boundary")
34+
resolved = os.path.realpath(path)
35+
if not any(resolved == os.path.realpath(m) or resolved.startswith(os.path.realpath(m) + os.sep) for m in _MOUNT_PATHS):
36+
raise ValueError(f"Path '{path}' is not within any configured mount ({', '.join(_MOUNT_PATHS)})")
3237
return resolved
3338

3439
@tool
3540
def file_read(path: str) -> str:
36-
"""Read a file from persistent storage. The path is relative to the storage root."""
41+
"""Read a file from a mounted filesystem. Use the absolute path (e.g. /mnt/tools/data.txt)."""
3742
try:
3843
full_path = _safe_resolve(path)
3944
with open(full_path) as f:
@@ -45,7 +50,7 @@ def file_read(path: str) -> str:
4550

4651
@tool
4752
def file_write(path: str, content: str) -> str:
48-
"""Write content to a file in persistent storage. The path is relative to the storage root."""
53+
"""Write a file to a mounted filesystem. Use the absolute path (e.g. /mnt/tools/data.txt)."""
4954
try:
5055
full_path = _safe_resolve(path)
5156
parent = os.path.dirname(full_path)
@@ -60,25 +65,28 @@ def file_write(path: str, content: str) -> str:
6065
return f"Error writing '{path}': {e.strerror}"
6166

6267
@tool
63-
def list_files(directory: str = "") -> str:
64-
"""List files in persistent storage. The directory is relative to the storage root."""
68+
def list_files(path: str) -> str:
69+
"""List files in a mounted filesystem directory. Use the absolute path (e.g. /mnt/tools)."""
6570
try:
66-
target = _safe_resolve(directory)
67-
entries = os.listdir(target)
71+
full_path = _safe_resolve(path)
72+
entries = os.listdir(full_path)
6873
return "\n".join(entries) if entries else "(empty directory)"
6974
except ValueError as e:
7075
return str(e)
7176
except OSError as e:
72-
return f"Error listing '{directory}': {e.strerror}"
77+
return f"Error listing '{path}': {e.strerror}"
7378

7479
tools.extend([file_read, file_write, list_files])
7580
{{/if}}
7681

7782
SYSTEM_PROMPT = """
7883
You are a helpful assistant. Use tools when appropriate.
79-
{{#if sessionStorageMountPath}}
80-
You have persistent storage at {{sessionStorageMountPath}}. Use file tools to read and write files. Data persists across sessions.
81-
{{/if}}
84+
{{#if needsOs}}
85+
You have access to the following mounted filesystems. Use file_read, file_write, and list_files with full absolute paths:
86+
{{#if sessionStorageMountPath}}- {{sessionStorageMountPath}}: ephemeral session storage (lost when session ends)
87+
{{/if}}{{#each efsMounts}}- {{mountPath}}: EFS persistent storage (persists across sessions and agent restarts)
88+
{{/each}}{{#each s3Mounts}}- {{mountPath}}: S3 Files persistent storage (durable, backed by S3)
89+
{{/each}}{{/if}}
8290
"""
8391

8492
model = load_model()

src/assets/python/a2a/strands/base/main.py

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
{{#if hasMemory}}
66
from memory.session import get_memory_session_manager
77
{{/if}}
8-
{{#if sessionStorageMountPath}}
8+
{{#if needsOs}}
99
import os
1010
{{/if}}
1111

@@ -18,19 +18,22 @@ def add_numbers(a: int, b: int) -> int:
1818

1919
tools = [add_numbers]
2020

21-
{{#if sessionStorageMountPath}}
22-
SESSION_STORAGE_PATH = "{{sessionStorageMountPath}}"
21+
{{#if needsOs}}
22+
_MOUNT_PATHS = [
23+
{{#if sessionStorageMountPath}}"{{sessionStorageMountPath}}",{{/if}}
24+
{{#each efsMounts}}"{{mountPath}}",{{/each}}
25+
{{#each s3Mounts}}"{{mountPath}}",{{/each}}
26+
]
2327

2428
def _safe_resolve(path: str) -> str:
25-
"""Resolve path safely within the storage boundary."""
26-
resolved = os.path.realpath(os.path.join(SESSION_STORAGE_PATH, path.lstrip("/")))
27-
if not resolved.startswith(os.path.realpath(SESSION_STORAGE_PATH)):
28-
raise ValueError(f"Path '{path}' is outside the storage boundary")
29+
resolved = os.path.realpath(path)
30+
if not any(resolved == os.path.realpath(m) or resolved.startswith(os.path.realpath(m) + os.sep) for m in _MOUNT_PATHS):
31+
raise ValueError(f"Path '{path}' is not within any configured mount ({', '.join(_MOUNT_PATHS)})")
2932
return resolved
3033

3134
@tool
3235
def file_read(path: str) -> str:
33-
"""Read a file from persistent storage. The path is relative to the storage root."""
36+
"""Read a file from a mounted filesystem. Use the absolute path (e.g. /mnt/tools/data.txt)."""
3437
try:
3538
full_path = _safe_resolve(path)
3639
with open(full_path) as f:
@@ -42,7 +45,7 @@ def file_read(path: str) -> str:
4245

4346
@tool
4447
def file_write(path: str, content: str) -> str:
45-
"""Write content to a file in persistent storage. The path is relative to the storage root."""
48+
"""Write a file to a mounted filesystem. Use the absolute path (e.g. /mnt/tools/data.txt)."""
4649
try:
4750
full_path = _safe_resolve(path)
4851
parent = os.path.dirname(full_path)
@@ -57,25 +60,28 @@ def file_write(path: str, content: str) -> str:
5760
return f"Error writing '{path}': {e.strerror}"
5861

5962
@tool
60-
def list_files(directory: str = "") -> str:
61-
"""List files in persistent storage. The directory is relative to the storage root."""
63+
def list_files(path: str) -> str:
64+
"""List files in a mounted filesystem directory. Use the absolute path (e.g. /mnt/tools)."""
6265
try:
63-
target = _safe_resolve(directory)
64-
entries = os.listdir(target)
66+
full_path = _safe_resolve(path)
67+
entries = os.listdir(full_path)
6568
return "\n".join(entries) if entries else "(empty directory)"
6669
except ValueError as e:
6770
return str(e)
6871
except OSError as e:
69-
return f"Error listing '{directory}': {e.strerror}"
72+
return f"Error listing '{path}': {e.strerror}"
7073

7174
tools.extend([file_read, file_write, list_files])
7275
{{/if}}
7376

7477
SYSTEM_PROMPT = """
7578
You are a helpful assistant. Use tools when appropriate.
76-
{{#if sessionStorageMountPath}}
77-
You have persistent storage at {{sessionStorageMountPath}}. Use file tools to read and write files. Data persists across sessions.
78-
{{/if}}
79+
{{#if needsOs}}
80+
You have access to the following mounted filesystems. Use file_read, file_write, and list_files with full absolute paths:
81+
{{#if sessionStorageMountPath}}- {{sessionStorageMountPath}}: ephemeral session storage (lost when session ends)
82+
{{/if}}{{#each efsMounts}}- {{mountPath}}: EFS persistent storage (persists across sessions and agent restarts)
83+
{{/each}}{{#each s3Mounts}}- {{mountPath}}: S3 Files persistent storage (durable, backed by S3)
84+
{{/each}}{{/if}}
7985
"""
8086

8187
{{#if hasMemory}}

src/assets/python/agui/googleadk/base/main.py

Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,76 @@
11
import os
22
import uvicorn
33
from google.adk.agents import LlmAgent
4+
from google.adk.tools import FunctionTool
45
from ag_ui_adk import ADKAgent, AGUIToolset, create_adk_app
56
from model.load import load_model
67

78
load_model()
89

10+
{{#if needsOs}}
11+
_MOUNT_PATHS = [
12+
{{#if sessionStorageMountPath}}"{{sessionStorageMountPath}}",{{/if}}
13+
{{#each efsMounts}}"{{mountPath}}",{{/each}}
14+
{{#each s3Mounts}}"{{mountPath}}",{{/each}}
15+
]
16+
17+
def _safe_resolve(path: str) -> str:
18+
resolved = os.path.realpath(path)
19+
if not any(resolved == os.path.realpath(m) or resolved.startswith(os.path.realpath(m) + os.sep) for m in _MOUNT_PATHS):
20+
raise ValueError(f"Path '{path}' is not within any configured mount ({', '.join(_MOUNT_PATHS)})")
21+
return resolved
22+
23+
def file_read(path: str) -> str:
24+
"""Read a file from a mounted filesystem. Use the absolute path (e.g. /mnt/tools/data.txt)."""
25+
try:
26+
full_path = _safe_resolve(path)
27+
with open(full_path) as f:
28+
return f.read()
29+
except ValueError as e:
30+
return str(e)
31+
except OSError as e:
32+
return f"Error reading '{path}': {e.strerror}"
33+
34+
def file_write(path: str, content: str) -> str:
35+
"""Write a file to a mounted filesystem. Use the absolute path (e.g. /mnt/tools/data.txt)."""
36+
try:
37+
full_path = _safe_resolve(path)
38+
parent = os.path.dirname(full_path)
39+
if parent:
40+
os.makedirs(parent, exist_ok=True)
41+
with open(full_path, "w") as f:
42+
f.write(content)
43+
return f"Written to {path}"
44+
except ValueError as e:
45+
return str(e)
46+
except OSError as e:
47+
return f"Error writing '{path}': {e.strerror}"
48+
49+
def list_files(path: str) -> str:
50+
"""List files in a mounted filesystem directory. Use the absolute path (e.g. /mnt/tools)."""
51+
try:
52+
full_path = _safe_resolve(path)
53+
entries = os.listdir(full_path)
54+
return "\n".join(entries) if entries else "(empty directory)"
55+
except ValueError as e:
56+
return str(e)
57+
except OSError as e:
58+
return f"Error listing '{path}': {e.strerror}"
59+
60+
_fs_tools = [FunctionTool(file_read), FunctionTool(file_write), FunctionTool(list_files)]
61+
{{/if}}
62+
963
agent = LlmAgent(
1064
name="{{ name }}",
1165
model="gemini-2.5-flash",
12-
instruction="You are a helpful assistant.",
13-
tools=[AGUIToolset()],
66+
instruction="""You are a helpful assistant.
67+
{{#if needsOs}}
68+
You have access to the following mounted filesystems. Use file_read, file_write, and list_files with full absolute paths:
69+
{{#if sessionStorageMountPath}}- {{sessionStorageMountPath}}: ephemeral session storage (lost when session ends)
70+
{{/if}}{{#each efsMounts}}- {{mountPath}}: EFS persistent storage (persists across sessions and agent restarts)
71+
{{/each}}{{#each s3Mounts}}- {{mountPath}}: S3 Files persistent storage (durable, backed by S3)
72+
{{/each}}{{/if}}""",
73+
tools=[AGUIToolset(), {{#if needsOs}}*_fs_tools{{/if}}],
1474
)
1575

1676
adk_agent = ADKAgent(

0 commit comments

Comments
 (0)