Skip to content

Commit b143452

Browse files
authored
Merge pull request #19 from crunchloop/honor-user-env-probe
engine: honor userEnvProbe in Engine.Exec
2 parents 8d9a153 + bfcafbb commit b143452

7 files changed

Lines changed: 463 additions & 2 deletions

File tree

attach.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,18 @@ func (e *Engine) AttachWith(ctx context.Context, id WorkspaceID, opts AttachOpti
6363
localEnv = environAsMap(os.Environ())
6464
}
6565

66-
return &Workspace{
66+
ws := &Workspace{
6767
ID: id,
6868
Config: cfg,
6969
Container: details,
7070
subst: newSubstituter(cfg, details, localEnv),
71-
}, nil
71+
}
72+
73+
// Re-probe on attach so subsequent Exec calls see PATH additions
74+
// from the user's rc files. The original Up populated probedEnv,
75+
// but a fresh Attach doesn't share that workspace value.
76+
if probed, err := e.probeUserEnv(ctx, ws, cfg.UserEnvProbe); err == nil {
77+
ws.probedEnv = probed
78+
}
79+
return ws, nil
7280
}

engine_test.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -376,6 +376,9 @@ func TestExec_SubstitutesContainerEnv(t *testing.T) {
376376
t.Fatalf("Up: %v", err)
377377
}
378378

379+
// Up performs a userEnvProbe exec; clear the recorded calls so this
380+
// test only asserts on the user-driven Exec.
381+
rt.execCalls = nil
379382
rt.execResult = runtime.ExecResult{ExitCode: 0, Stdout: "ok"}
380383
_, err = eng.Exec(context.Background(), wsObj, ExecOptions{
381384
Cmd: []string{"echo", "${containerEnv:HOME}"},

exec.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,21 @@ type ExecOptions struct {
2020
Stdin io.Reader
2121
Stdout io.Writer
2222
Stderr io.Writer
23+
24+
// SkipUserEnvProbe, when true, makes Exec bypass the merge of
25+
// probedEnv AND cfg.RemoteEnv into the process environment.
26+
// Only opts.Env (after substitution) plus whatever the runtime
27+
// inherits from the container reaches the exec'd process.
28+
//
29+
// Default false: every Exec inherits both probedEnv (so PATH and
30+
// other vars set by the user's rc files are visible — nvm/asdf/
31+
// feature-installed tools just work) and RemoteEnv (the
32+
// devcontainer.json author's declared env).
33+
//
34+
// Set this for callers that need a clean, deterministic
35+
// environment — internal probes, low-level fs operations,
36+
// DiscoverPath-style helpers that read raw container env.
37+
SkipUserEnvProbe bool
2338
}
2439

2540
// ExecResult is the outcome of Engine.Exec.
@@ -48,6 +63,11 @@ func (e *Engine) Exec(ctx context.Context, ws *Workspace, opts ExecOptions) (Exe
4863
user, _ := ws.subst.String(opts.User)
4964
wd, _ := ws.subst.String(opts.WorkingDir)
5065

66+
if !opts.SkipUserEnvProbe {
67+
remoteEnv, _ := ws.subst.Map(ws.Config.RemoteEnv)
68+
env = mergeProbedEnv(ws.probedEnv, remoteEnv, env, effectiveUser(ws.Config))
69+
}
70+
5171
res, err := e.runtime.ExecContainer(ctx, ws.Container.ID, runtime.ExecOptions{
5272
Cmd: cmd,
5373
Env: env,
Lines changed: 226 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
//go:build integration
2+
3+
package integration
4+
5+
import (
6+
"context"
7+
"strings"
8+
"testing"
9+
"time"
10+
11+
devcontainer "github.com/crunchloop/devcontainer"
12+
)
13+
14+
// bashImage is a tiny image with bash available so the userEnvProbe's
15+
// `bash -lic` invocation succeeds. plain alpine:3.20 ships only ash;
16+
// the probe falls back to printenv there, exercised in
17+
// TestUserEnvProbe_NoBashFallback.
18+
const bashImage = "bash:5.2-alpine3.20"
19+
20+
// TestUserEnvProbe_PathFromBashrc covers the original bug: a
21+
// postCreateCommand-installed tool writes its PATH addition to ~/.bashrc;
22+
// later Exec calls must see that PATH without per-call shell wrapping.
23+
func TestUserEnvProbe_PathFromBashrc(t *testing.T) {
24+
if testing.Short() {
25+
t.Skip()
26+
}
27+
eng, rt := newEngine(t)
28+
defer rt.Close()
29+
30+
ws := writeWorkspace(t, `{
31+
"image": "`+bashImage+`",
32+
"postCreateCommand": "echo 'export EXTRA_PATH=/from/bashrc' > /etc/profile.d/dc-go-test.sh"
33+
}`)
34+
35+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
36+
defer cancel()
37+
38+
wsObj, err := eng.Up(ctx, devcontainer.UpOptions{
39+
LocalWorkspaceFolder: ws,
40+
Recreate: true,
41+
})
42+
if err != nil {
43+
t.Fatalf("Up: %v", err)
44+
}
45+
defer func() { _ = eng.Down(context.Background(), wsObj, devcontainer.DownOptions{Remove: true}) }()
46+
47+
res, err := eng.Exec(ctx, wsObj, devcontainer.ExecOptions{
48+
Cmd: []string{"printenv", "EXTRA_PATH"},
49+
})
50+
if err != nil {
51+
t.Fatalf("Exec: %v", err)
52+
}
53+
if res.ExitCode != 0 {
54+
t.Fatalf("printenv EXTRA_PATH exit=%d stderr=%q", res.ExitCode, res.Stderr)
55+
}
56+
if got := strings.TrimSpace(res.Stdout); got != "/from/bashrc" {
57+
t.Errorf("EXTRA_PATH = %q, want %q (probedEnv didn't inject from .bashrc)", got, "/from/bashrc")
58+
}
59+
}
60+
61+
// TestUserEnvProbe_None disables probing via devcontainer.json. The
62+
// .bashrc-exported var should NOT be visible to subsequent execs.
63+
func TestUserEnvProbe_None(t *testing.T) {
64+
if testing.Short() {
65+
t.Skip()
66+
}
67+
eng, rt := newEngine(t)
68+
defer rt.Close()
69+
70+
ws := writeWorkspace(t, `{
71+
"image": "`+bashImage+`",
72+
"userEnvProbe": "none",
73+
"postCreateCommand": "echo 'export EXTRA_PATH=/from/bashrc' > /etc/profile.d/dc-go-test.sh"
74+
}`)
75+
76+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
77+
defer cancel()
78+
79+
wsObj, err := eng.Up(ctx, devcontainer.UpOptions{
80+
LocalWorkspaceFolder: ws,
81+
Recreate: true,
82+
})
83+
if err != nil {
84+
t.Fatalf("Up: %v", err)
85+
}
86+
defer func() { _ = eng.Down(context.Background(), wsObj, devcontainer.DownOptions{Remove: true}) }()
87+
88+
res, err := eng.Exec(ctx, wsObj, devcontainer.ExecOptions{
89+
Cmd: []string{"sh", "-c", "printenv EXTRA_PATH; true"},
90+
})
91+
if err != nil {
92+
t.Fatalf("Exec: %v", err)
93+
}
94+
if got := strings.TrimSpace(res.Stdout); got != "" {
95+
t.Errorf("EXTRA_PATH = %q, want empty (userEnvProbe=none should skip injection)", got)
96+
}
97+
}
98+
99+
// TestUserEnvProbe_SkipOption verifies the per-Exec opt-out: even with
100+
// the default probe, a caller passing SkipUserEnvProbe gets a clean env.
101+
func TestUserEnvProbe_SkipOption(t *testing.T) {
102+
if testing.Short() {
103+
t.Skip()
104+
}
105+
eng, rt := newEngine(t)
106+
defer rt.Close()
107+
108+
ws := writeWorkspace(t, `{
109+
"image": "`+bashImage+`",
110+
"postCreateCommand": "echo 'export EXTRA_PATH=/from/bashrc' > /etc/profile.d/dc-go-test.sh"
111+
}`)
112+
113+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
114+
defer cancel()
115+
116+
wsObj, err := eng.Up(ctx, devcontainer.UpOptions{
117+
LocalWorkspaceFolder: ws,
118+
Recreate: true,
119+
})
120+
if err != nil {
121+
t.Fatalf("Up: %v", err)
122+
}
123+
defer func() { _ = eng.Down(context.Background(), wsObj, devcontainer.DownOptions{Remove: true}) }()
124+
125+
// Default Exec sees it.
126+
res, err := eng.Exec(ctx, wsObj, devcontainer.ExecOptions{
127+
Cmd: []string{"printenv", "EXTRA_PATH"},
128+
})
129+
if err != nil {
130+
t.Fatalf("default Exec: %v", err)
131+
}
132+
if got := strings.TrimSpace(res.Stdout); got != "/from/bashrc" {
133+
t.Fatalf("default exec EXTRA_PATH = %q, want %q (sanity precondition failed)", got, "/from/bashrc")
134+
}
135+
136+
// SkipUserEnvProbe Exec doesn't.
137+
res, err = eng.Exec(ctx, wsObj, devcontainer.ExecOptions{
138+
Cmd: []string{"sh", "-c", "printenv EXTRA_PATH; true"},
139+
SkipUserEnvProbe: true,
140+
})
141+
if err != nil {
142+
t.Fatalf("skip Exec: %v", err)
143+
}
144+
if got := strings.TrimSpace(res.Stdout); got != "" {
145+
t.Errorf("SkipUserEnvProbe Exec saw EXTRA_PATH = %q, want empty", got)
146+
}
147+
}
148+
149+
// TestUserEnvProbe_NoBashFallback runs against a bash-less image
150+
// (alpine ships only ash). The probe's first attempt (bash -lic) fails;
151+
// we still want Up to succeed and Exec to work normally.
152+
func TestUserEnvProbe_NoBashFallback(t *testing.T) {
153+
if testing.Short() {
154+
t.Skip()
155+
}
156+
eng, rt := newEngine(t)
157+
defer rt.Close()
158+
159+
ws := writeWorkspace(t, `{"image":"`+testImage+`"}`)
160+
161+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
162+
defer cancel()
163+
164+
wsObj, err := eng.Up(ctx, devcontainer.UpOptions{
165+
LocalWorkspaceFolder: ws,
166+
Recreate: true,
167+
})
168+
if err != nil {
169+
t.Fatalf("Up: %v", err)
170+
}
171+
defer func() { _ = eng.Down(context.Background(), wsObj, devcontainer.DownOptions{Remove: true}) }()
172+
173+
// Exec a basic command — failure mode would be an Up-level error
174+
// (already caught above) or Exec returning nonzero / wrong output.
175+
res, err := eng.Exec(ctx, wsObj, devcontainer.ExecOptions{
176+
Cmd: []string{"sh", "-c", "echo ok"},
177+
})
178+
if err != nil {
179+
t.Fatalf("Exec: %v", err)
180+
}
181+
if !strings.Contains(res.Stdout, "ok") {
182+
t.Errorf("Exec stdout = %q, want contains 'ok'", res.Stdout)
183+
}
184+
}
185+
186+
// TestUserEnvProbe_RemoteEnvMerged confirms cfg.RemoteEnv reaches Exec
187+
// independently of whether the probe ran. RemoteEnv is the
188+
// devcontainer-author-declared env; it must layer on top of probedEnv
189+
// and be visible to every Exec call (matching @devcontainers/cli and
190+
// devpod).
191+
func TestUserEnvProbe_RemoteEnvMerged(t *testing.T) {
192+
if testing.Short() {
193+
t.Skip()
194+
}
195+
eng, rt := newEngine(t)
196+
defer rt.Close()
197+
198+
ws := writeWorkspace(t, `{
199+
"image": "`+bashImage+`",
200+
"remoteEnv": {
201+
"FROM_REMOTE": "remote-value"
202+
}
203+
}`)
204+
205+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
206+
defer cancel()
207+
208+
wsObj, err := eng.Up(ctx, devcontainer.UpOptions{
209+
LocalWorkspaceFolder: ws,
210+
Recreate: true,
211+
})
212+
if err != nil {
213+
t.Fatalf("Up: %v", err)
214+
}
215+
defer func() { _ = eng.Down(context.Background(), wsObj, devcontainer.DownOptions{Remove: true}) }()
216+
217+
res, err := eng.Exec(ctx, wsObj, devcontainer.ExecOptions{
218+
Cmd: []string{"printenv", "FROM_REMOTE"},
219+
})
220+
if err != nil {
221+
t.Fatalf("Exec: %v", err)
222+
}
223+
if got := strings.TrimSpace(res.Stdout); got != "remote-value" {
224+
t.Errorf("FROM_REMOTE = %q, want %q", got, "remote-value")
225+
}
226+
}

up.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,16 @@ func (e *Engine) Up(ctx context.Context, opts UpOptions) (*Workspace, error) {
163163
return nil, err
164164
}
165165
}
166+
167+
// Probe the user's interactive shell environment AFTER lifecycle —
168+
// postCreateCommand and friends typically install rc-modifying tools
169+
// (nvm, asdf, etc.) and we want their effects reflected in subsequent
170+
// Exec calls. Failures are non-fatal: we log via warnings and proceed
171+
// without probed env (matches devpod's behavior).
172+
probed, err := e.probeUserEnv(ctx, ws, ws.Config.UserEnvProbe)
173+
if err == nil {
174+
ws.probedEnv = probed
175+
}
166176
return ws, nil
167177
}
168178

0 commit comments

Comments
 (0)