Skip to content

Commit a7e1382

Browse files
committed
feat: sandbox file injection — docker cp ctx files into sandbox
- setupSandbox returns container name (breaking change to signature) - injectFilesToSandbox copies --ctx files into sandbox via docker cp → Relative paths preserved (/workspace/subdir/file.txt) → Absolute paths outside cwd use basename (/workspace/file.txt) → Non-existent files logged as warnings, directories skipped → Errors on actual docker cp failure - Wired into run() after sandbox setup, logs 'copied N file(s)' - Updated all 6 setupSandbox callers (run, serve, repl, subagent, continue, mcp) - 4 unit tests: file not found, directory skip, empty list, docker cp failure - 4 E2E tests: basic injection, nested path, absolute path, multiple files (all gated by ODEK_E2E=true)
1 parent 1348597 commit a7e1382

7 files changed

Lines changed: 425 additions & 20 deletions

File tree

cmd/odek/main.go

Lines changed: 77 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -742,11 +742,22 @@ func run(args []string) error {
742742
}
743743

744744
if resolved.Sandbox {
745-
cleanup, err := setupSandbox(tools, sbCfg)
745+
var containerName string
746+
containerName, sandboxCleanup, err = setupSandbox(tools, sbCfg)
746747
if err != nil {
747748
return fmt.Errorf("sandbox: %w", err)
748749
}
749-
sandboxCleanup = cleanup
750+
751+
// Inject --ctx files into the sandbox container
752+
if len(f.Ctx) > 0 {
753+
injected, injectErr := injectFilesToSandbox(containerName, f.Ctx, cwd)
754+
if injectErr != nil {
755+
return fmt.Errorf("sandbox: inject ctx files: %w", injectErr)
756+
}
757+
if injected > 0 {
758+
fmt.Fprintf(os.Stderr, "odek: copied %d file(s) into sandbox\n", injected)
759+
}
760+
}
750761
}
751762

752763
// Create terminal renderer for colored step-by-step output.
@@ -902,37 +913,37 @@ func run(args []string) error {
902913
// - --security-opt no-new-privileges: setuid binaries can't escalate
903914
// - --tmpfs /tmp:noexec: no executable files in temp
904915
// - --rm: container destroyed on agent exit
905-
func setupSandbox(tools []odek.Tool, cfg sandboxConfig) (func() error, error) {
916+
func setupSandbox(tools []odek.Tool, cfg sandboxConfig) (containerName string, cleanup func() error, err error) {
906917
// Resolve the Docker image (explicit, Dockerfile.odek, or default)
907918
image, err := resolveSandboxImage(cfg)
908919
if err != nil {
909-
return nil, err
920+
return "", nil, err
910921
}
911922

912-
containerName := fmt.Sprintf("odek-%d", os.Getpid())
923+
containerName = fmt.Sprintf("odek-%d", os.Getpid())
913924
fmt.Fprintf(os.Stderr, "odek: starting sandbox container %s (image: %s)...\n", containerName, image)
914925

915926
wd, err := os.Getwd()
916927
if err != nil {
917-
return nil, fmt.Errorf("getwd: %w", err)
928+
return "", nil, fmt.Errorf("getwd: %w", err)
918929
}
919930

920931
args := buildSandboxArgs(cfg, containerName, wd, image)
921932

922933
createCmd := exec.Command("docker", args...)
923934
createCmd.Stderr = os.Stderr
924935
if err := createCmd.Run(); err != nil {
925-
return nil, fmt.Errorf("failed to create container: %w", err)
936+
return "", nil, fmt.Errorf("failed to create container: %w", err)
926937
}
927938

928-
cleanup := func() error {
939+
cleanup = func() error {
929940
fmt.Fprintf(os.Stderr, "odek: destroying sandbox container %s...\n", containerName)
930941
return exec.Command("docker", "rm", "-f", containerName).Run()
931942
}
932943

933944
// Wire the shell tool to execute commands inside the sandbox.
934945
tools[0].(*shellTool).containerName = containerName
935-
return cleanup, nil
946+
return containerName, cleanup, nil
936947
}
937948

938949
// buildSandboxArgs builds the docker run arguments from a sandboxConfig.
@@ -1003,6 +1014,60 @@ func buildSandboxArgs(cfg sandboxConfig, containerName, workdir, image string) [
10031014
return args
10041015
}
10051016

1017+
// injectFilesToSandbox copies ctx files into a running sandbox container
1018+
// using docker cp. Returns the number of files successfully injected.
1019+
// Files are placed at /workspace/<relative-path-from-cwd> in the container.
1020+
// Absolute-path files are placed at /workspace/<basename>.
1021+
// Skips files that don't exist (logs warning), returns error only on docker failure.
1022+
func injectFilesToSandbox(containerName string, files []string, cwd string) (int, error) {
1023+
injected := 0
1024+
for _, f := range files {
1025+
f = strings.TrimSpace(f)
1026+
if f == "" {
1027+
continue
1028+
}
1029+
1030+
// Resolve to absolute path
1031+
absPath := f
1032+
if !filepath.IsAbs(absPath) {
1033+
absPath = filepath.Join(cwd, absPath)
1034+
}
1035+
absPath = filepath.Clean(absPath)
1036+
1037+
// Verify file exists and is a regular file
1038+
info, err := os.Stat(absPath)
1039+
if err != nil {
1040+
fmt.Fprintf(os.Stderr, "odek: warning: ctx file %q not found, skipping sandbox injection\n", f)
1041+
continue
1042+
}
1043+
if info.IsDir() {
1044+
fmt.Fprintf(os.Stderr, "odek: warning: ctx path %q is a directory, skipping sandbox injection\n", f)
1045+
continue
1046+
}
1047+
1048+
// Determine destination path inside container
1049+
// For files under cwd: preserve relative path
1050+
// For files outside cwd: use basename
1051+
dest := filepath.Base(absPath) // default: just the filename
1052+
if strings.HasPrefix(absPath, cwd+string(filepath.Separator)) || absPath == cwd {
1053+
rel, err := filepath.Rel(cwd, absPath)
1054+
if err == nil {
1055+
dest = rel
1056+
}
1057+
}
1058+
1059+
// docker cp into container:/workspace/<dest>
1060+
containerDest := fmt.Sprintf("%s:/workspace/%s", containerName, dest)
1061+
cpCmd := exec.Command("docker", "cp", absPath, containerDest)
1062+
output, err := cpCmd.CombinedOutput()
1063+
if err != nil {
1064+
return injected, fmt.Errorf("docker cp %q: %w\n%s", f, err, string(output))
1065+
}
1066+
injected++
1067+
}
1068+
return injected, nil
1069+
}
1070+
10061071
func builtinTools(dc danger.DangerousConfig, sm *skills.SkillManager, approver danger.Approver, maxConcurrency int) []odek.Tool {
10071072
tools := []odek.Tool{
10081073
&shellTool{
@@ -1447,11 +1512,12 @@ func continueCmd(args []string) error {
14471512
Env: resolved.SandboxEnv,
14481513
Volumes: resolved.SandboxVolumes,
14491514
}
1450-
cleanup, err := setupSandbox(tools, sbCfg)
1515+
var contContainerName string
1516+
contContainerName, sandboxCleanup, err = setupSandbox(tools, sbCfg)
14511517
if err != nil {
14521518
return fmt.Errorf("sandbox: %w", err)
14531519
}
1454-
sandboxCleanup = cleanup
1520+
_ = contContainerName
14551521
}
14561522

14571523
// Renderer

cmd/odek/mcp.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,10 +95,12 @@ Flags:
9595
// Sandbox setup (must happen after tools are created)
9696
var sandboxCleanup func() error
9797
if resolved.Sandbox {
98-
cleanup, err := setupSandbox(toolSet, sbCfg)
98+
var mcpContainerName string
99+
mcpContainerName, cleanup, err := setupSandbox(toolSet, sbCfg)
99100
if err != nil {
100-
return fmt.Errorf("sandbox: %w", err)
101+
return fmt.Errorf("setup sandbox: %w", err)
101102
}
103+
_ = mcpContainerName
102104
sandboxCleanup = cleanup
103105
defer sandboxCleanup()
104106
}

cmd/odek/repl.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,10 +100,12 @@ func replCmd(args []string) error {
100100
Env: resolved.SandboxEnv,
101101
Volumes: resolved.SandboxVolumes,
102102
}
103-
cleanup, err := setupSandbox(tools, sbCfg)
103+
var replContainerName string
104+
replContainerName, cleanup, err := setupSandbox(tools, sbCfg)
104105
if err != nil {
105106
return fmt.Errorf("sandbox: %w", err)
106107
}
108+
_ = replContainerName // not used in REPL mode
107109
sandboxCleanup = cleanup
108110
}
109111

cmd/odek/sandbox_e2e_test.go

Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"os/exec"
7+
"path/filepath"
8+
"strings"
9+
"testing"
10+
"time"
11+
)
12+
13+
// ─────────────────────────────────────────────────────────────────────
14+
// E2E Tests: Sandbox file injection
15+
// ─────────────────────────────────────────────────────────────────────
16+
//
17+
// These tests create a real Docker sandbox container and verify that
18+
// ctx files are correctly injected into it via docker cp.
19+
//
20+
// Gated by ODEK_E2E=true.
21+
// ─────────────────────────────────────────────────────────────────────
22+
23+
// TestE2E_SandboxFileInjection verifies that --ctx files are copied
24+
// into the sandbox container and accessible via the container's shell.
25+
func TestE2E_SandboxFileInjection(t *testing.T) {
26+
skipIfNoE2E(t)
27+
28+
// Create temp file to inject
29+
workDir := t.TempDir()
30+
testContent := "sandbox-injection-test-content"
31+
testFile := filepath.Join(workDir, "injected.txt")
32+
if err := os.WriteFile(testFile, []byte(testContent), 0644); err != nil {
33+
t.Fatalf("write test file: %v", err)
34+
}
35+
36+
// Create a sandbox container using the same machinery as odek run
37+
containerName := fmt.Sprintf("odek-test-inject-%d", time.Now().UnixNano())
38+
args := buildSandboxArgs(sandboxConfig{
39+
Image: "alpine:latest",
40+
Network: "none",
41+
}, containerName, workDir, "alpine:latest")
42+
43+
createCmd := exec.Command("docker", args...)
44+
createCmd.Stderr = os.Stderr
45+
if err := createCmd.Run(); err != nil {
46+
t.Fatalf("create sandbox container: %v", err)
47+
}
48+
defer func() {
49+
exec.Command("docker", "rm", "-f", containerName).Run()
50+
}()
51+
52+
// Wait for container to be ready
53+
time.Sleep(500 * time.Millisecond)
54+
55+
// Inject the file
56+
count, err := injectFilesToSandbox(containerName, []string{"injected.txt"}, workDir)
57+
if err != nil {
58+
t.Fatalf("injectFilesToSandbox: %v", err)
59+
}
60+
if count != 1 {
61+
t.Errorf("expected 1 file injected, got %d", count)
62+
}
63+
64+
// Verify the file exists in the container at /workspace/injected.txt
65+
verifyCmd := exec.Command("docker", "exec", "-w", "/workspace", containerName, "cat", "injected.txt")
66+
output, err := verifyCmd.Output()
67+
if err != nil {
68+
t.Fatalf("docker exec cat: %v", err)
69+
}
70+
if strings.TrimSpace(string(output)) != testContent {
71+
t.Errorf("file content = %q, want %q", strings.TrimSpace(string(output)), testContent)
72+
}
73+
}
74+
75+
// TestE2E_SandboxFileInjection_NestedPath verifies that --ctx files
76+
// in subdirectories preserve their relative path in the container.
77+
func TestE2E_SandboxFileInjection_NestedPath(t *testing.T) {
78+
skipIfNoE2E(t)
79+
80+
workDir := t.TempDir()
81+
subDir := filepath.Join(workDir, "subdir")
82+
if err := os.Mkdir(subDir, 0755); err != nil {
83+
t.Fatalf("mkdir subdir: %v", err)
84+
}
85+
testContent := "nested-file-content"
86+
nestedFile := filepath.Join(subDir, "nested.txt")
87+
if err := os.WriteFile(nestedFile, []byte(testContent), 0644); err != nil {
88+
t.Fatalf("write nested file: %v", err)
89+
}
90+
91+
containerName := fmt.Sprintf("odek-test-nested-%d", time.Now().UnixNano())
92+
args := buildSandboxArgs(sandboxConfig{
93+
Image: "alpine:latest",
94+
Network: "none",
95+
}, containerName, workDir, "alpine:latest")
96+
97+
createCmd := exec.Command("docker", args...)
98+
createCmd.Stderr = os.Stderr
99+
if err := createCmd.Run(); err != nil {
100+
t.Fatalf("create container: %v", err)
101+
}
102+
defer exec.Command("docker", "rm", "-f", containerName).Run()
103+
104+
time.Sleep(500 * time.Millisecond)
105+
106+
// Inject with relative path from cwd: subdir/nested.txt
107+
count, err := injectFilesToSandbox(containerName, []string{"subdir/nested.txt"}, workDir)
108+
if err != nil {
109+
t.Fatalf("injectFilesToSandbox: %v", err)
110+
}
111+
if count != 1 {
112+
t.Errorf("expected 1 file injected, got %d", count)
113+
}
114+
115+
// Verify file exists at /workspace/subdir/nested.txt
116+
verifyCmd := exec.Command("docker", "exec", "-w", "/workspace", containerName, "cat", "subdir/nested.txt")
117+
output, err := verifyCmd.Output()
118+
if err != nil {
119+
t.Fatalf("docker exec cat nested: %v", err)
120+
}
121+
if strings.TrimSpace(string(output)) != testContent {
122+
t.Errorf("nested file content = %q, want %q", strings.TrimSpace(string(output)), testContent)
123+
}
124+
}
125+
126+
// TestE2E_SandboxFileInjection_AbsolutePath verifies that absolute
127+
// path files outside cwd are injected by basename into /workspace/.
128+
func TestE2E_SandboxFileInjection_AbsolutePath(t *testing.T) {
129+
skipIfNoE2E(t)
130+
131+
// Create a file outside the working directory
132+
externalDir := t.TempDir()
133+
absContent := "absolute-path-content"
134+
absFile := filepath.Join(externalDir, "external.txt")
135+
if err := os.WriteFile(absFile, []byte(absContent), 0644); err != nil {
136+
t.Fatalf("write external file: %v", err)
137+
}
138+
139+
workDir := t.TempDir()
140+
141+
containerName := fmt.Sprintf("odek-test-abs-%d", time.Now().UnixNano())
142+
args := buildSandboxArgs(sandboxConfig{
143+
Image: "alpine:latest",
144+
Network: "none",
145+
}, containerName, workDir, "alpine:latest")
146+
147+
createCmd := exec.Command("docker", args...)
148+
createCmd.Stderr = os.Stderr
149+
if err := createCmd.Run(); err != nil {
150+
t.Fatalf("create container: %v", err)
151+
}
152+
defer exec.Command("docker", "rm", "-f", containerName).Run()
153+
154+
time.Sleep(500 * time.Millisecond)
155+
156+
// Inject with absolute path (outside cwd) — should use basename
157+
count, err := injectFilesToSandbox(containerName, []string{absFile}, workDir)
158+
if err != nil {
159+
t.Fatalf("injectFilesToSandbox: %v", err)
160+
}
161+
if count != 1 {
162+
t.Errorf("expected 1 file injected, got %d", count)
163+
}
164+
165+
// Should exist at /workspace/external.txt (basename)
166+
verifyCmd := exec.Command("docker", "exec", "-w", "/workspace", containerName, "cat", "external.txt")
167+
output, err := verifyCmd.Output()
168+
if err != nil {
169+
t.Fatalf("docker exec cat external: %v", err)
170+
}
171+
if strings.TrimSpace(string(output)) != absContent {
172+
t.Errorf("external file content = %q, want %q", strings.TrimSpace(string(output)), absContent)
173+
}
174+
}
175+
176+
// TestE2E_SandboxFileInjection_MultipleFiles verifies injecting
177+
// multiple files at once.
178+
func TestE2E_SandboxFileInjection_MultipleFiles(t *testing.T) {
179+
skipIfNoE2E(t)
180+
181+
workDir := t.TempDir()
182+
os.WriteFile(filepath.Join(workDir, "a.txt"), []byte("file A"), 0644)
183+
os.WriteFile(filepath.Join(workDir, "b.txt"), []byte("file B"), 0644)
184+
185+
containerName := fmt.Sprintf("odek-test-multi-%d", time.Now().UnixNano())
186+
args := buildSandboxArgs(sandboxConfig{
187+
Image: "alpine:latest",
188+
Network: "none",
189+
}, containerName, workDir, "alpine:latest")
190+
191+
createCmd := exec.Command("docker", args...)
192+
createCmd.Stderr = os.Stderr
193+
if err := createCmd.Run(); err != nil {
194+
t.Fatalf("create container: %v", err)
195+
}
196+
defer exec.Command("docker", "rm", "-f", containerName).Run()
197+
198+
time.Sleep(500 * time.Millisecond)
199+
200+
count, err := injectFilesToSandbox(containerName, []string{"a.txt", "b.txt"}, workDir)
201+
if err != nil {
202+
t.Fatalf("injectFilesToSandbox: %v", err)
203+
}
204+
if count != 2 {
205+
t.Errorf("expected 2 files injected, got %d", count)
206+
}
207+
208+
// Verify both files
209+
for _, name := range []string{"a.txt", "b.txt"} {
210+
out, err := exec.Command("docker", "exec", "-w", "/workspace", containerName, "cat", name).Output()
211+
if err != nil {
212+
t.Errorf("cat %s: %v", name, err)
213+
}
214+
if strings.TrimSpace(string(out)) == "" {
215+
t.Errorf("file %s is empty", name)
216+
}
217+
}
218+
}

0 commit comments

Comments
 (0)