@@ -10,6 +10,7 @@ import (
1010 "strings"
1111 "testing"
1212
13+ "github.com/EstebanForge/construct-cli/internal/clipboard"
1314 "github.com/EstebanForge/construct-cli/internal/config"
1415 "github.com/EstebanForge/construct-cli/internal/constants"
1516 "github.com/EstebanForge/construct-cli/internal/env"
@@ -310,6 +311,208 @@ func containsEnv(envVars []string, value string) bool {
310311 return false
311312}
312313
314+ func envValue (envVars []string , key string ) string {
315+ prefix := key + "="
316+ for _ , envVar := range envVars {
317+ if strings .HasPrefix (envVar , prefix ) {
318+ return strings .TrimPrefix (envVar , prefix )
319+ }
320+ }
321+ return ""
322+ }
323+
324+ func TestExecInRunningContainerInjectsConstructHomeAndCodexEnv (t * testing.T ) {
325+ origStartClipboard := startClipboardServerFn
326+ origExecInteractive := execInteractiveAsUserFn
327+ origColorterm := os .Getenv ("COLORTERM" )
328+ t .Cleanup (func () {
329+ startClipboardServerFn = origStartClipboard
330+ execInteractiveAsUserFn = origExecInteractive
331+ if origColorterm == "" {
332+ os .Unsetenv ("COLORTERM" )
333+ return
334+ }
335+ os .Setenv ("COLORTERM" , origColorterm )
336+ })
337+ os .Setenv ("COLORTERM" , "truecolor" )
338+
339+ startClipboardServerFn = func (host string ) (* clipboard.Server , error ) {
340+ if host != "clip.local" {
341+ t .Fatalf ("expected clipboard host clip.local, got %s" , host )
342+ }
343+ return & clipboard.Server {
344+ URL : "http://clip.local:1234" ,
345+ Token : "clip-token" ,
346+ }, nil
347+ }
348+
349+ var gotRuntime , gotContainer , gotWorkdir , gotUser string
350+ var gotCmdArgs []string
351+ var gotEnvVars []string
352+ execInteractiveAsUserFn = func (containerRuntime , containerName string , cmdArgs []string , envVars []string , workdir , user string ) (int , error ) {
353+ gotRuntime = containerRuntime
354+ gotContainer = containerName
355+ gotWorkdir = workdir
356+ gotUser = user
357+ gotCmdArgs = append ([]string {}, cmdArgs ... )
358+ gotEnvVars = append ([]string {}, envVars ... )
359+ return 0 , nil
360+ }
361+
362+ cfg := & config.Config {
363+ Sandbox : config.SandboxConfig {
364+ ClipboardHost : "clip.local" ,
365+ ExecAsHostUser : false ,
366+ ForwardSSHAgent : false ,
367+ },
368+ Agents : config.AgentsConfig {
369+ ClipboardImagePatch : true ,
370+ },
371+ }
372+
373+ exitCode , err := execInRunningContainer (
374+ []string {"codex" , "help" },
375+ cfg ,
376+ "docker" ,
377+ []string {"OPENAI_API_KEY=test-key" },
378+ )
379+ if err != nil {
380+ t .Fatalf ("expected no error, got %v" , err )
381+ }
382+ if exitCode != 0 {
383+ t .Fatalf ("expected exit code 0, got %d" , exitCode )
384+ }
385+
386+ if gotRuntime != "docker" {
387+ t .Fatalf ("expected docker runtime, got %s" , gotRuntime )
388+ }
389+ if gotContainer != "construct-cli" {
390+ t .Fatalf ("expected construct-cli container, got %s" , gotContainer )
391+ }
392+ if gotWorkdir != "" {
393+ t .Fatalf ("expected empty workdir for attach exec, got %q" , gotWorkdir )
394+ }
395+ if gotUser != "" {
396+ t .Fatalf ("expected empty exec user when exec_as_host_user disabled, got %q" , gotUser )
397+ }
398+
399+ if len (gotCmdArgs ) != 2 || gotCmdArgs [0 ] != "codex" || gotCmdArgs [1 ] != "help" {
400+ t .Fatalf ("unexpected command args: %v" , gotCmdArgs )
401+ }
402+
403+ requiredEnv := []string {
404+ "HOME=/home/construct" ,
405+ "CONSTRUCT_CLIPBOARD_IMAGE_PATCH=1" ,
406+ "CONSTRUCT_AGENT_NAME=codex" ,
407+ "CODEX_HOME=/home/construct/.codex" ,
408+ "WSL_DISTRO_NAME=Ubuntu" ,
409+ "WSL_INTEROP=/run/WSL/8_interop" ,
410+ "DISPLAY=" ,
411+ "CONSTRUCT_CLIPBOARD_URL=http://clip.local:1234" ,
412+ "CONSTRUCT_CLIPBOARD_TOKEN=clip-token" ,
413+ "CONSTRUCT_FILE_PASTE_AGENTS=" + constants .FileBasedPasteAgents ,
414+ "OPENAI_API_KEY=test-key" ,
415+ "COLORTERM=truecolor" ,
416+ }
417+ for _ , envVar := range requiredEnv {
418+ if ! containsEnv (gotEnvVars , envVar ) {
419+ t .Fatalf ("expected env var %q, got env: %v" , envVar , gotEnvVars )
420+ }
421+ }
422+
423+ expectedPath := env .BuildConstructPath ("/home/construct" )
424+ if pathValue := envValue (gotEnvVars , "PATH" ); pathValue != expectedPath {
425+ t .Fatalf ("expected PATH=%q, got %q" , expectedPath , pathValue )
426+ }
427+ if constructPath := envValue (gotEnvVars , "CONSTRUCT_PATH" ); constructPath != expectedPath {
428+ t .Fatalf ("expected CONSTRUCT_PATH=%q, got %q" , expectedPath , constructPath )
429+ }
430+ }
431+
432+ func TestExecInRunningContainerUsesConfiguredShellWhenNoArgs (t * testing.T ) {
433+ origStartClipboard := startClipboardServerFn
434+ origExecInteractive := execInteractiveAsUserFn
435+ t .Cleanup (func () {
436+ startClipboardServerFn = origStartClipboard
437+ execInteractiveAsUserFn = origExecInteractive
438+ })
439+
440+ startClipboardServerFn = func (string ) (* clipboard.Server , error ) {
441+ return nil , fmt .Errorf ("clipboard unavailable" )
442+ }
443+
444+ var gotCmdArgs []string
445+ execInteractiveAsUserFn = func (_ , _ string , cmdArgs []string , _ []string , _ , _ string ) (int , error ) {
446+ gotCmdArgs = append ([]string {}, cmdArgs ... )
447+ return 0 , nil
448+ }
449+
450+ cfg := & config.Config {
451+ Sandbox : config.SandboxConfig {
452+ Shell : "/bin/zsh" ,
453+ ExecAsHostUser : false ,
454+ },
455+ }
456+
457+ exitCode , err := execInRunningContainer (nil , cfg , "docker" , nil )
458+ if err != nil {
459+ t .Fatalf ("expected no error, got %v" , err )
460+ }
461+ if exitCode != 0 {
462+ t .Fatalf ("expected exit code 0, got %d" , exitCode )
463+ }
464+ if len (gotCmdArgs ) != 1 || gotCmdArgs [0 ] != "/bin/zsh" {
465+ t .Fatalf ("expected shell fallback /bin/zsh, got %v" , gotCmdArgs )
466+ }
467+ }
468+
469+ func TestExecInRunningContainerUsesHostUserOnLinuxDocker (t * testing.T ) {
470+ if stdruntime .GOOS != "linux" {
471+ t .Skip ("linux-specific host user mapping behavior" )
472+ }
473+
474+ origStartClipboard := startClipboardServerFn
475+ origExecInteractive := execInteractiveAsUserFn
476+ origHasUIDEntry := containerHasUIDEntryFn
477+ t .Cleanup (func () {
478+ startClipboardServerFn = origStartClipboard
479+ execInteractiveAsUserFn = origExecInteractive
480+ containerHasUIDEntryFn = origHasUIDEntry
481+ })
482+
483+ startClipboardServerFn = func (string ) (* clipboard.Server , error ) {
484+ return nil , fmt .Errorf ("clipboard unavailable" )
485+ }
486+ containerHasUIDEntryFn = func (_ , _ string , uid int ) (bool , error ) {
487+ return uid == os .Getuid (), nil
488+ }
489+
490+ var gotUser string
491+ execInteractiveAsUserFn = func (_ , _ string , _ []string , envVars []string , _ , user string ) (int , error ) {
492+ gotUser = user
493+ if ! containsEnv (envVars , "HOME=/home/construct" ) {
494+ t .Fatalf ("expected HOME to be forced to /home/construct, got %v" , envVars )
495+ }
496+ return 0 , nil
497+ }
498+
499+ cfg := & config.Config {
500+ Sandbox : config.SandboxConfig {
501+ ExecAsHostUser : true ,
502+ },
503+ }
504+
505+ _ , err := execInRunningContainer ([]string {"claude" }, cfg , "docker" , nil )
506+ if err != nil {
507+ t .Fatalf ("expected no error, got %v" , err )
508+ }
509+
510+ wantUser := fmt .Sprintf ("%d:%d" , os .Getuid (), os .Getgid ())
511+ if gotUser != wantUser {
512+ t .Fatalf ("expected host exec user %q, got %q" , wantUser , gotUser )
513+ }
514+ }
515+
313516// TestColortermEnvironment verifies COLORTERM handling
314517func TestColortermEnvironment (t * testing.T ) {
315518 // Test when COLORTERM is already set
0 commit comments