11import { afterEach , describe , expect , test } from "bun:test" ;
22import { createHash } from "node:crypto" ;
3- import { mkdir , mkdtemp , readFile , rm , writeFile } from "node:fs/promises" ;
3+ import { mkdir , mkdtemp , readFile , rm , stat , symlink , writeFile } from "node:fs/promises" ;
44import { tmpdir } from "node:os" ;
55import { join , resolve } from "node:path" ;
6+ import { parseEnv } from "node:util" ;
67import { InputValidationError , InvalidEnvironmentError } from "../../errors" ;
78import type { DevEvent , DevServerInput } from "../../handlers/project/dev/types" ;
89import {
@@ -17,6 +18,7 @@ import { ContainerDevRunner } from "./container";
1718type ProcessCall = {
1819 command : string [ ] ;
1920 options : StreamProcessOptions ;
21+ envFile ?: { path : string ; contents : string ; mode : number } ;
2022} ;
2123
2224type StreamBehavior = (
@@ -61,11 +63,20 @@ function harness(
6163 config : {
6264 available ?: ( tool : string , probeArgs ?: string [ ] ) => Promise < boolean > ;
6365 stream ?: StreamBehavior ;
66+ awsDirectory ?: string ;
67+ processEnv ?: NodeJS . ProcessEnv ;
6468 } = { } ,
6569) {
6670 const calls : ProcessCall [ ] = [ ] ;
6771 const fakeStreamProcess : ProcessStreamer = async function * ( command , options ) {
68- calls . push ( { command, options } ) ;
72+ const call : ProcessCall = { command, options } ;
73+ calls . push ( call ) ;
74+ const envFileFlag = command . indexOf ( "--env-file" ) ;
75+ if ( envFileFlag >= 0 ) {
76+ const path = command [ envFileFlag + 1 ] ! ;
77+ const [ contents , metadata ] = await Promise . all ( [ readFile ( path , "utf8" ) , stat ( path ) ] ) ;
78+ call . envFile = { path, contents, mode : metadata . mode & 0o777 } ;
79+ }
6980 if ( config . stream ) yield * config . stream ( command , options ) ;
7081 } ;
7182 return {
@@ -77,6 +88,11 @@ function harness(
7788 ( async ( tool ) => {
7889 return tool === "docker" ;
7990 } ) ,
91+ awsDirectory : config . awsDirectory ?? join ( tmpdir ( ) , "agentcore-container-no-aws" ) ,
92+ processEnv : config . processEnv ?? {
93+ AWS_ACCESS_KEY_ID : "test-access-key" ,
94+ AWS_SECRET_ACCESS_KEY : "test-secret-key" ,
95+ } ,
8096 } ) ,
8197 } ;
8298}
@@ -154,7 +170,10 @@ describe("ContainerDevRunner", () => {
154170 "." ,
155171 ] ) ;
156172 expect ( build . options . cwd ) . toBe ( root ) ;
157- expect ( build . options . env ) . toBe ( process . env ) ;
173+ expect ( build . options . env ) . toEqual ( {
174+ AWS_ACCESS_KEY_ID : "test-access-key" ,
175+ AWS_SECRET_ACCESS_KEY : "test-secret-key" ,
176+ } ) ;
158177 expect ( build . options . redactedCommand ) . toContain ( "AGENT_NAME=<redacted>" ) ;
159178 expect ( build . options . redactedCommand ) . toContain ( "TARGET=<redacted>" ) ;
160179 expect ( build . options . redactedCommand ?. join ( " " ) ) . not . toContain ( "hello-world" ) ;
@@ -188,18 +207,63 @@ describe("ContainerDevRunner", () => {
188207 containerName ( root ) ,
189208 "-p" ,
190209 `127.0.0.1:3000:${ containerPort } ` ,
191- "-e" ,
192- "API_KEY=super-secret" ,
193- "-e" ,
194- `PORT=${ containerPort } ` ,
195- "-e" ,
196- "LOCAL_DEV=1" ,
197- ...( protocol === "MCP" ? [ "-e" , "FASTMCP_PORT=8000" ] : [ ] ) ,
210+ "--env-file" ,
211+ run . envFile ! . path ,
198212 imageTag ( root ) ,
199213 ] ) ;
200- expect ( run . options . env ) . toBe ( process . env ) ;
201- expect ( run . options . redactedCommand ) . toContain ( "API_KEY=<redacted>" ) ;
202- expect ( run . options . redactedCommand ?. join ( " " ) ) . not . toContain ( "super-secret" ) ;
214+ expect ( run . options . env ) . toEqual ( {
215+ AWS_ACCESS_KEY_ID : "test-access-key" ,
216+ AWS_SECRET_ACCESS_KEY : "test-secret-key" ,
217+ } ) ;
218+ expect ( parseEnv ( run . envFile ! . contents ) ) . toEqual ( {
219+ AWS_ACCESS_KEY_ID : "test-access-key" ,
220+ AWS_SECRET_ACCESS_KEY : "test-secret-key" ,
221+ API_KEY : "super-secret" ,
222+ PORT : String ( containerPort ) ,
223+ LOCAL_DEV : "1" ,
224+ ...( protocol === "MCP" ? { FASTMCP_PORT : "8000" } : { } ) ,
225+ } ) ;
226+ if ( process . platform !== "win32" ) expect ( run . envFile ! . mode ) . toBe ( 0o600 ) ;
227+ await expect ( readFile ( run . envFile ! . path , "utf8" ) ) . rejects . toThrow ( ) ;
228+ expect ( run . command . join ( " " ) ) . not . toContain ( "super-secret" ) ;
229+ expect ( run . command . join ( " " ) ) . not . toContain ( "test-secret-key" ) ;
230+ } ) ;
231+
232+ test ( "uses a shared AWS config and rejects missing credentials" , async ( ) => {
233+ const projectRuntime = runtime ( ) ;
234+ const root = await projectRoot ( projectRuntime ) ;
235+ const awsDirectory = join ( root , ".aws" ) ;
236+ await mkdir ( awsDirectory ) ;
237+ await writeFile ( join ( awsDirectory , "config" ) , "[profile sandbox]\nregion=us-east-1\n" ) ;
238+ const { calls, runner } = harness ( {
239+ awsDirectory,
240+ processEnv : { AWS_PROFILE : "sandbox" , AWS_REGION : "us-east-1" } ,
241+ } ) ;
242+
243+ await collect ( runner . run ( input ( root , projectRuntime ) ) ) ;
244+
245+ const run = commandCall ( calls , "run" ) ;
246+ expect ( run . command ) . toContain ( `${ awsDirectory } :/aws-config:ro` ) ;
247+ expect ( run . command ) . not . toContain ( "AWS_PROFILE" ) ;
248+ expect ( run . command ) . not . toContain ( "AWS_CONFIG_FILE" ) ;
249+ expect ( parseEnv ( run . envFile ! . contents ) ) . toMatchObject ( {
250+ AWS_PROFILE : "sandbox" ,
251+ AWS_REGION : "us-east-1" ,
252+ AWS_CONFIG_FILE : "/aws-config/config" ,
253+ AWS_SHARED_CREDENTIALS_FILE : "/aws-config/credentials" ,
254+ } ) ;
255+ expect ( run . command . join ( " " ) ) . not . toContain ( "sandbox" ) ;
256+
257+ const missing = harness ( {
258+ awsDirectory : join ( root , "missing-aws" ) ,
259+ processEnv : { } ,
260+ } ) ;
261+ const missingCredentials = collect ( missing . runner . run ( input ( root , projectRuntime ) ) ) ;
262+ await expect ( missingCredentials ) . rejects . toBeInstanceOf ( InvalidEnvironmentError ) ;
263+ await expect ( missingCredentials ) . rejects . toThrow (
264+ "Unable to resolve AWS credentials for the container" ,
265+ ) ;
266+ expect ( missing . calls ) . toHaveLength ( 0 ) ;
203267 } ) ;
204268
205269 test ( "preserves an existing build context .dockerignore" , async ( ) => {
@@ -244,7 +308,7 @@ describe("ContainerDevRunner", () => {
244308 ) ;
245309 } ) ;
246310
247- test ( "keeps app variables out of the container CLI environment" , async ( ) => {
311+ test ( "keeps app variables out of the container CLI control environment" , async ( ) => {
248312 const projectRuntime = runtime ( ) ;
249313 const root = await projectRoot ( projectRuntime ) ;
250314 const { calls, runner } = harness ( ) ;
@@ -254,9 +318,10 @@ describe("ContainerDevRunner", () => {
254318 await collect ( runner . run ( runInput ) ) ;
255319
256320 const run = commandCall ( calls , "run" ) ;
257- expect ( run . command ) . toContain ( "DOCKER_HOST=tcp://application-value" ) ;
258- expect ( run . options . env ) . toBe ( process . env ) ;
259- expect ( run . options . redactedCommand ) . toContain ( "DOCKER_HOST=<redacted>" ) ;
321+ expect ( run . command ) . not . toContain ( "DOCKER_HOST" ) ;
322+ expect ( run . command . join ( " " ) ) . not . toContain ( "tcp://application-value" ) ;
323+ expect ( run . options . env ?. DOCKER_HOST ) . toBeUndefined ( ) ;
324+ expect ( parseEnv ( run . envFile ! . contents ) . DOCKER_HOST ) . toBe ( "tcp://application-value" ) ;
260325 } ) ;
261326
262327 test ( "selects the first tool that supports container builds" , async ( ) => {
@@ -409,6 +474,33 @@ describe("ContainerDevRunner", () => {
409474 expect ( calls . map ( ( { command } ) => command [ 1 ] ) ) . toEqual ( [ "rm" ] ) ;
410475 } ) ;
411476
477+ test ( "rejects build contexts outside the project root, including symlinks" , async ( ) => {
478+ const root = await mkdtemp ( join ( tmpdir ( ) , "agentcore-container-" ) ) ;
479+ const outside = await mkdtemp ( join ( tmpdir ( ) , "agentcore-container-outside-" ) ) ;
480+ tempDirectories . push ( root , outside ) ;
481+ await symlink ( outside , join ( root , "linked" ) , process . platform === "win32" ? "junction" : "dir" ) ;
482+ const probes : string [ ] = [ ] ;
483+
484+ for ( const buildContextPath of [ ".." , "linked" ] ) {
485+ const { calls, runner } = harness ( {
486+ available : async ( tool ) => {
487+ probes . push ( tool ) ;
488+ return true ;
489+ } ,
490+ } ) ;
491+
492+ const escapedContext = collect ( runner . run ( input ( root , runtime ( { buildContextPath } ) ) ) ) ;
493+ await expect ( escapedContext ) . rejects . toBeInstanceOf ( InputValidationError ) ;
494+ await expect ( escapedContext ) . rejects . toThrow (
495+ "container build context must be within the project root" ,
496+ ) ;
497+ expect ( calls ) . toHaveLength ( 0 ) ;
498+ }
499+
500+ expect ( probes ) . toHaveLength ( 0 ) ;
501+ await expect ( readFile ( join ( outside , ".dockerignore" ) , "utf8" ) ) . rejects . toThrow ( ) ;
502+ } ) ;
503+
412504 test ( "rejects a build context that is not a directory" , async ( ) => {
413505 const root = await mkdtemp ( join ( tmpdir ( ) , "agentcore-container-" ) ) ;
414506 tempDirectories . push ( root ) ;
0 commit comments