Skip to content

Commit 4e354a4

Browse files
aledbfclaude
andcommitted
fix(lifecycle): stop surfacing the userEnvProbe's benign shell stderr
The userEnvProbe runs an interactive login shell (`bash -lic env`) to capture the container environment. On a container without a controlling TTY, bash prints "cannot set terminal process group (N): Inappropriate ioctl for device / no job control in this shell" to stderr — benign noise the TS CLI does not show. ShellServer.Exec surfaced *all* command stderr on the log at LevelInfo, so that probe noise leaked into the visible output of `up` (before postCreateCommand) and `exec` (before the command result). Make ShellServer.Exec quiet — it is only called directly by the probe — and move hook-stderr surfacing to ShellExecutor.Exec via a new ExecWithStderr, so failing/chatty lifecycle hooks still produce diagnostics. Verified end-to-end against mcr.microsoft.com/devcontainers/base:ubuntu: the pre-fix binary reproduces the "no job control" lines on `up`/`exec`; the fixed binary's output is clean while a real command's own stderr (e.g. "node: command not found") is still shown. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 8e28c45 commit 4e354a4

1 file changed

Lines changed: 25 additions & 10 deletions

File tree

‎internal/lifecycle/shell.go‎

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,38 +27,48 @@ type ShellServer struct {
2727
containerID string
2828
user string
2929
env []string // remoteEnv "KEY=VALUE", applied to every exec
30-
log log.Logger
3130
}
3231

3332
// NewShellServer binds a ShellServer to a container. user (docker exec -u) and
3433
// remoteEnv entries ("KEY=VALUE", docker exec -e) are applied to every command,
3534
// matching the TS CLI which runs lifecycle hooks and the userEnvProbe as the
3635
// remote user.
37-
func NewShellServer(ctx context.Context, exec ContainerExecutor, containerID, user string, logger log.Logger, remoteEnv ...string) (*ShellServer, error) {
36+
// The logger param is accepted for call-site stability and future use; the
37+
// server currently logs nothing itself (callers surface command output).
38+
func NewShellServer(ctx context.Context, exec ContainerExecutor, containerID, user string, _ log.Logger, remoteEnv ...string) (*ShellServer, error) {
3839
return &ShellServer{
3940
ctx: ctx,
4041
exec: exec,
4142
containerID: containerID,
4243
user: user,
4344
env: remoteEnv,
44-
log: logger,
4545
}, nil
4646
}
4747

4848
// Exec runs a command inside the container and returns its stdout and exit code.
49-
// The command's stderr is surfaced on the log (the TS CLI streams hook stderr)
50-
// rather than returned, so a failing hook still produces diagnostics.
49+
// stderr is intentionally NOT surfaced: the only direct caller is the internal
50+
// userEnvProbe, whose interactive-login shell (`bash -lic`) prints a benign
51+
// "cannot set terminal process group / no job control" warning to stderr on a
52+
// container without a controlling TTY — the TS CLI does not show it either.
53+
// Lifecycle hooks surface their own stderr via ShellExecutor (ExecWithStderr).
5154
func (s *ShellServer) Exec(command string) (stdout string, exitCode int, err error) {
52-
out, errText, code, err := s.exec.ExecInContainer(s.ctx, s.containerID, s.user, s.env, command)
55+
out, _, code, err := s.exec.ExecInContainer(s.ctx, s.containerID, s.user, s.env, command)
5356
if err != nil {
5457
return "", -1, err
5558
}
56-
if strings.TrimSpace(errText) != "" && s.log != nil {
57-
s.log.Write(errText, log.LevelInfo)
58-
}
5959
return out, code, nil
6060
}
6161

62+
// ExecWithStderr is Exec but also returns the command's stderr, for callers that
63+
// surface command diagnostics (lifecycle hooks).
64+
func (s *ShellServer) ExecWithStderr(command string) (stdout, stderr string, exitCode int, err error) {
65+
out, errText, code, err := s.exec.ExecInContainer(s.ctx, s.containerID, s.user, s.env, command)
66+
if err != nil {
67+
return "", "", -1, err
68+
}
69+
return out, errText, code, nil
70+
}
71+
6272
// Close is a no-op: each command is its own exec, so there is no persistent
6373
// process to tear down. Retained for API symmetry with callers that defer it.
6474
func (s *ShellServer) Close() error { return nil }
@@ -86,13 +96,18 @@ func (e *ShellExecutor) Exec(command string) error {
8696
if e.WorkDir != "" {
8797
command = fmt.Sprintf("cd %s && %s", shellSingleQuote(e.WorkDir), command)
8898
}
89-
stdout, code, err := e.Server.Exec(command)
99+
stdout, stderr, code, err := e.Server.ExecWithStderr(command)
90100
if err != nil {
91101
return err
92102
}
93103
if stdout != "" {
94104
e.Log.Write(stdout, log.LevelInfo)
95105
}
106+
// Surface hook stderr (the TS CLI streams lifecycle-hook stderr) so a failing
107+
// or chatty hook still produces diagnostics.
108+
if strings.TrimSpace(stderr) != "" {
109+
e.Log.Write(stderr, log.LevelInfo)
110+
}
96111
if code != 0 {
97112
return &CommandError{Command: originalCommand, ExitCode: code}
98113
}

0 commit comments

Comments
 (0)