Hardened subprocess stdio wiring and failure diagnostics

This commit is contained in:
2026-05-04 12:37:20 -05:00
parent 4550987bc0
commit 4a593f9dcb
5 changed files with 275 additions and 22 deletions

View File

@@ -54,23 +54,17 @@ func Run(ctx context.Context, req RunRequest) (RunResult, error) {
}
defer cancel()
stdoutFile, stdoutWriter, err := logWriter(req.StdoutLogPath)
logs, err := openLogWriters(req.StdoutLogPath, req.StderrLogPath)
if err != nil {
return RunResult{}, fmt.Errorf("open stdout log: %w", err)
return RunResult{}, err
}
defer closeFile(stdoutFile)
stderrFile, stderrWriter, err := logWriter(req.StderrLogPath)
if err != nil {
return RunResult{}, fmt.Errorf("open stderr log: %w", err)
}
defer closeFile(stderrFile)
defer logs.Close()
cmd := exec.CommandContext(runCtx, req.Executable, req.Args...)
cmd.Dir = req.WorkingDir
cmd.Env = mergeEnv(os.Environ(), req.EnvOverrides)
cmd.Stdout = stdoutWriter
cmd.Stderr = stderrWriter
cmd.Stdout = logs.Stdout
cmd.Stderr = logs.Stderr
started := time.Now().UTC()
result := RunResult{
@@ -105,17 +99,20 @@ func Run(ctx context.Context, req RunRequest) (RunResult, error) {
return result, nil
}
stderrTail := readRedactedTail(req.StderrLogPath, req.EnvOverrides, 2048)
diagnostics := buildDiagnostics(req, result, stderrTail)
if result.TimedOut {
return result, fmt.Errorf("command %q timed out after %s (args=%v)", req.Executable, req.Timeout, req.Args)
return result, fmt.Errorf("command timed out after %s (%s)", req.Timeout, diagnostics)
}
if result.Canceled {
return result, fmt.Errorf("command %q canceled (args=%v)", req.Executable, req.Args)
return result, fmt.Errorf("command canceled (%s)", diagnostics)
}
if exitErr, ok := waitErr.(*exec.ExitError); ok {
return result, fmt.Errorf("command %q failed with exit code %d (args=%v): %w", req.Executable, exitErr.ExitCode(), req.Args, waitErr)
return result, fmt.Errorf("command failed with exit code %d (%s): %w", exitErr.ExitCode(), diagnostics, waitErr)
}
return result, fmt.Errorf("command %q failed to run (args=%v): %w", req.Executable, req.Args, waitErr)
return result, fmt.Errorf("command failed to run (%s): %w", diagnostics, waitErr)
}
// WriteYAMLAtomic marshals value as YAML and atomically writes it to path.
@@ -175,18 +172,88 @@ func WriteFileAtomic(path string, data []byte, perm os.FileMode) error {
return nil
}
type logWriters struct {
files []*os.File
Stdout io.Writer
Stderr io.Writer
}
func (l *logWriters) Close() {
for _, f := range l.files {
_ = f.Close()
}
}
func openLogWriters(stdoutPath, stderrPath string) (*logWriters, error) {
cleanStdout := cleanLogPath(stdoutPath)
cleanStderr := cleanLogPath(stderrPath)
// Keep stdout/stderr on the same file descriptor when both paths target
// the same file to avoid descriptor aliasing surprises across runtimes.
if cleanStdout != "" && cleanStdout == cleanStderr {
f, err := openLogFile(cleanStdout)
if err != nil {
return nil, fmt.Errorf("open shared stdout/stderr log %q: %w", cleanStdout, err)
}
return &logWriters{
files: []*os.File{f},
Stdout: f,
Stderr: f,
}, nil
}
stdoutFile, stdoutWriter, err := logWriter(cleanStdout)
if err != nil {
return nil, fmt.Errorf("open stdout log: %w", err)
}
stderrFile, stderrWriter, err := logWriter(cleanStderr)
if err != nil {
closeFile(stdoutFile)
return nil, fmt.Errorf("open stderr log: %w", err)
}
files := make([]*os.File, 0, 2)
if stdoutFile != nil {
files = append(files, stdoutFile)
}
if stderrFile != nil {
files = append(files, stderrFile)
}
return &logWriters{
files: files,
Stdout: stdoutWriter,
Stderr: stderrWriter,
}, nil
}
func cleanLogPath(path string) string {
trimmed := strings.TrimSpace(path)
if trimmed == "" {
return ""
}
return filepath.Clean(trimmed)
}
func logWriter(path string) (*os.File, io.Writer, error) {
if strings.TrimSpace(path) == "" {
return nil, io.Discard, nil
}
f, err := openLogFile(path)
if err != nil {
return nil, nil, err
}
return f, f, nil
}
func openLogFile(path string) (*os.File, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, nil, fmt.Errorf("create log directory for %q: %w", path, err)
return nil, fmt.Errorf("create log directory for %q: %w", path, err)
}
f, err := os.Create(path)
if err != nil {
return nil, nil, fmt.Errorf("open log file %q: %w", path, err)
return nil, fmt.Errorf("open log file %q: %w", path, err)
}
return f, f, nil
return f, nil
}
func closeFile(f *os.File) {
@@ -195,6 +262,79 @@ func closeFile(f *os.File) {
}
}
func buildDiagnostics(req RunRequest, result RunResult, stderrTail string) string {
details := fmt.Sprintf(
"executable=%q args=%v cwd=%q timeout=%s exit_code=%d timed_out=%t canceled=%t stdout_log=%q stderr_log=%q",
req.Executable,
req.Args,
req.WorkingDir,
req.Timeout,
result.ExitCode,
result.TimedOut,
result.Canceled,
req.StdoutLogPath,
req.StderrLogPath,
)
if strings.TrimSpace(stderrTail) == "" {
return details
}
return details + fmt.Sprintf(" stderr_tail=%q", stderrTail)
}
func readRedactedTail(path string, envOverrides map[string]string, maxBytes int64) string {
if strings.TrimSpace(path) == "" || maxBytes <= 0 {
return ""
}
f, err := os.Open(path)
if err != nil {
return ""
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return ""
}
size := info.Size()
start := int64(0)
if size > maxBytes {
start = size - maxBytes
}
if _, err := f.Seek(start, io.SeekStart); err != nil {
return ""
}
data, err := io.ReadAll(f)
if err != nil {
return ""
}
tail := strings.TrimSpace(string(data))
if tail == "" {
return ""
}
return redactSensitiveTail(tail, envOverrides)
}
func redactSensitiveTail(tail string, envOverrides map[string]string) string {
out := tail
for k, v := range envOverrides {
if strings.TrimSpace(v) == "" {
continue
}
if looksSensitiveEnvKey(k) {
out = strings.ReplaceAll(out, v, "<redacted>")
}
}
return out
}
func looksSensitiveEnvKey(key string) bool {
k := strings.ToUpper(strings.TrimSpace(key))
return strings.Contains(k, "KEY") ||
strings.Contains(k, "TOKEN") ||
strings.Contains(k, "SECRET") ||
strings.Contains(k, "PASSWORD")
}
func mergeEnv(base []string, overrides map[string]string) []string {
if len(overrides) == 0 {
return base