Skip to content

Commit 68a0199

Browse files
committed
Add regression tests for Linux attach env and podman compose
1 parent fe02eb0 commit 68a0199

File tree

3 files changed

+273
-8
lines changed

3 files changed

+273
-8
lines changed

internal/agent/runner.go

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ const loginBridgeFlagFile = ".login_bridge"
3131
const daemonSSHProxySock = "/home/construct/.ssh/agent.sock"
3232

3333
var containerHasUIDEntryFn = runtime.ContainerHasUIDEntry
34+
var startClipboardServerFn = clipboard.StartServer
35+
var execInteractiveAsUserFn = runtime.ExecInteractiveAsUser
3436

3537
// RunWithArgs executes an agent inside the container with optional network override.
3638
func RunWithArgs(args []string, networkFlag string) {
@@ -404,7 +406,7 @@ func runWithProviderEnv(args []string, cfg *config.Config, containerRuntime, con
404406
selected := strings.TrimSpace(string(output))
405407
switch {
406408
case strings.HasPrefix(selected, "Attach"):
407-
exitCode, err := execInRunningContainer(args, cfg, containerRuntime, containerName, mergedProviderEnv)
409+
exitCode, err := execInRunningContainer(args, cfg, containerRuntime, mergedProviderEnv)
408410
if err != nil {
409411
fmt.Fprintf(os.Stderr, "Error: Failed to attach: %v\n", err)
410412
os.Exit(1)
@@ -437,7 +439,7 @@ func runWithProviderEnv(args []string, cfg *config.Config, containerRuntime, con
437439

438440
switch basicChoice {
439441
case "1":
440-
exitCode, err := execInRunningContainer(args, cfg, containerRuntime, containerName, mergedProviderEnv)
442+
exitCode, err := execInRunningContainer(args, cfg, containerRuntime, mergedProviderEnv)
441443
if err != nil {
442444
fmt.Fprintf(os.Stderr, "Error: Failed to attach: %v\n", err)
443445
os.Exit(1)
@@ -499,7 +501,7 @@ func runWithProviderEnv(args []string, cfg *config.Config, containerRuntime, con
499501
if cfg != nil {
500502
clipboardHost = cfg.Sandbox.ClipboardHost
501503
}
502-
cbServer, err := clipboard.StartServer(clipboardHost)
504+
cbServer, err := startClipboardServerFn(clipboardHost)
503505
if err != nil {
504506
if ui.CurrentLogLevel >= ui.LogLevelInfo {
505507
fmt.Printf("Warning: Failed to start clipboard server: %v\n", err)
@@ -992,7 +994,7 @@ func execViaDaemon(args []string, cfg *config.Config, containerRuntime, daemonNa
992994
if cfg != nil {
993995
clipboardHost = cfg.Sandbox.ClipboardHost
994996
}
995-
cbServer, err := clipboard.StartServer(clipboardHost)
997+
cbServer, err := startClipboardServerFn(clipboardHost)
996998
if err != nil {
997999
if ui.CurrentLogLevel >= ui.LogLevelInfo {
9981000
fmt.Printf("Warning: Failed to start clipboard server: %v\n", err)
@@ -1047,7 +1049,7 @@ func execViaDaemon(args []string, cfg *config.Config, containerRuntime, daemonNa
10471049
}
10481050

10491051
// Execute interactively in daemon container
1050-
exitCode, err := runtime.ExecInteractiveAsUser(containerRuntime, daemonName, execArgs, envVars, workdir, execUser)
1052+
exitCode, err := execInteractiveAsUserFn(containerRuntime, daemonName, execArgs, envVars, workdir, execUser)
10511053
if err == nil && len(execArgs) > 0 && (exitCode == 126 || exitCode == 127) {
10521054
fmt.Printf("Hint: command '%s' may be missing from daemon PATH.\n", execArgs[0])
10531055
fmt.Println("Run 'construct sys doctor' and review Setup/Update logs for package installation errors.")
@@ -1056,7 +1058,7 @@ func execViaDaemon(args []string, cfg *config.Config, containerRuntime, daemonNa
10561058
return true, exitCode, err
10571059
}
10581060

1059-
func execInRunningContainer(args []string, cfg *config.Config, containerRuntime, containerName string, providerEnv []string) (int, error) {
1061+
func execInRunningContainer(args []string, cfg *config.Config, containerRuntime string, providerEnv []string) (int, error) {
10601062
envVars := make([]string, 0, len(providerEnv)+16)
10611063
envVars = append(envVars, providerEnv...)
10621064

@@ -1075,7 +1077,7 @@ func execInRunningContainer(args []string, cfg *config.Config, containerRuntime,
10751077
if cfg != nil {
10761078
clipboardHost = cfg.Sandbox.ClipboardHost
10771079
}
1078-
cbServer, err := clipboard.StartServer(clipboardHost)
1080+
cbServer, err := startClipboardServerFn(clipboardHost)
10791081
if err != nil {
10801082
if ui.CurrentLogLevel >= ui.LogLevelInfo {
10811083
fmt.Printf("Warning: Failed to start clipboard server: %v\n", err)
@@ -1106,8 +1108,9 @@ func execInRunningContainer(args []string, cfg *config.Config, containerRuntime,
11061108
execArgs = []string{execShell}
11071109
}
11081110

1111+
containerName := "construct-cli"
11091112
execUser := resolveExecUserForRunningContainer(cfg, containerRuntime, containerName)
1110-
return runtime.ExecInteractiveAsUser(containerRuntime, containerName, execArgs, envVars, "", execUser)
1113+
return execInteractiveAsUserFn(containerRuntime, containerName, execArgs, envVars, "", execUser)
11111114
}
11121115

11131116
func startDaemonSSHBridge(cfg *config.Config, containerRuntime, daemonName, execUser string) (*SSHBridge, []string, error) {

internal/agent/runner_test.go

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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
314517
func TestColortermEnvironment(t *testing.T) {
315518
// Test when COLORTERM is already set

internal/runtime/runtime_test.go

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,65 @@ func TestGetCheckImageCommand(t *testing.T) {
8787
}
8888
}
8989

90+
func TestBuildComposeCommandPodmanFallbackToComposePlugin(t *testing.T) {
91+
tmpDir := t.TempDir()
92+
origPath := os.Getenv("PATH")
93+
t.Cleanup(func() {
94+
os.Setenv("PATH", origPath)
95+
})
96+
97+
// No podman-compose binary available in PATH -> should use "podman compose".
98+
os.Setenv("PATH", tmpDir)
99+
100+
cmd, err := BuildComposeCommand("podman", tmpDir, "run", []string{"--rm", "construct-box", "true"})
101+
if err != nil {
102+
t.Fatalf("BuildComposeCommand failed: %v", err)
103+
}
104+
105+
if len(cmd.Args) < 2 {
106+
t.Fatalf("unexpected args for podman compose fallback: %v", cmd.Args)
107+
}
108+
if cmd.Args[0] != "podman" || cmd.Args[1] != "compose" {
109+
t.Fatalf("expected fallback to 'podman compose', got: %v", cmd.Args)
110+
}
111+
}
112+
113+
func TestBuildComposeCommandPodmanUsesPodmanComposeBinary(t *testing.T) {
114+
if stdruntime.GOOS == "windows" {
115+
t.Skip("test relies on POSIX executable bits")
116+
}
117+
118+
tmpDir := t.TempDir()
119+
binDir := filepath.Join(tmpDir, "bin")
120+
if err := os.MkdirAll(binDir, 0755); err != nil {
121+
t.Fatalf("failed to create bin dir: %v", err)
122+
}
123+
124+
// Provide a fake podman-compose binary so LookPath picks it.
125+
podmanComposePath := filepath.Join(binDir, "podman-compose")
126+
if err := os.WriteFile(podmanComposePath, []byte("#!/bin/sh\nexit 0\n"), 0755); err != nil {
127+
t.Fatalf("failed to create fake podman-compose: %v", err)
128+
}
129+
130+
origPath := os.Getenv("PATH")
131+
t.Cleanup(func() {
132+
os.Setenv("PATH", origPath)
133+
})
134+
os.Setenv("PATH", binDir)
135+
136+
cmd, err := BuildComposeCommand("podman", tmpDir, "run", []string{"--rm", "construct-box", "true"})
137+
if err != nil {
138+
t.Fatalf("BuildComposeCommand failed: %v", err)
139+
}
140+
141+
if len(cmd.Args) == 0 {
142+
t.Fatalf("unexpected empty args for podman-compose command")
143+
}
144+
if cmd.Args[0] != "podman-compose" {
145+
t.Fatalf("expected podman-compose binary path, got: %v", cmd.Args)
146+
}
147+
}
148+
90149
// TestGetOSInfo tests OS information retrieval
91150
// getOSInfo is not in runtime package anymore (it was private in main.go and I didn't export it in runtime.go because it seemed unused except for test?)
92151
// Wait, getOSInfo was in main.go. Did I move it?

0 commit comments

Comments
 (0)