Compare commits

...

2 Commits

Author SHA1 Message Date
cnzgray
62309d1429 fix(cleanup): resolve macOS symlink mismatch causing all log files to be kept (#155)
* fix(cleanup): resolve macOS symlink mismatch causing all log files to be kept

On macOS, os.TempDir() returns /var/folders/... while filepath.EvalSymlinks
resolves to /private/var/folders/... (since /var is a symlink to /private/var).

isUnsafeFile was comparing filepath.Abs(tempDir) against EvalSymlinks(file),
causing filepath.Rel to produce a path starting with "../../../../../private/..."
which triggered the "file is outside tempDir" guard. As a result, --cleanup
kept all 1367 log files instead of deleting any.

Fix: use evalSymlinksFn on tempDir as well, so both sides of the comparison
are resolved consistently. Falls back to filepath.Abs if symlink resolution fails.

* test(logger): fix isUnsafeFile eval symlinks stubs

---------

Co-authored-by: cexll <evanxian9@gmail.com>
2026-02-28 17:22:58 +08:00
cnzgray
33a94d2bc4 fix(executor): isolate CLAUDE_CODE_TMPDIR for nested claude to fix (no output) (#154)
* fix(executor): isolate CLAUDE_CODE_TMPDIR for nested claude to fix (no output)

Claude 2.1.45+ calls Nz7() in preAction to clean its tasks directory on
startup. When claude runs as a nested subprocess, it deletes the parent
session's *.output files, causing the parent to read an empty string and
display "(no output)".

Fix: assign each nested claude process its own unique CLAUDE_CODE_TMPDIR
(os.TempDir()/cc-nested-<pid>-<ns>) so it only cleans its own tasks
directory and never touches the parent's output files.

* fix(executor): use MkdirTemp for nested tmpdir

---------

Co-authored-by: cexll <evanxian9@gmail.com>
2026-02-27 22:15:19 +08:00
4 changed files with 95 additions and 10 deletions

View File

@@ -125,6 +125,9 @@ func TestEnvInjection_LogsToStderrAndMasksKey(t *testing.T) {
if cmd.env["ANTHROPIC_API_KEY"] != apiKey {
t.Fatalf("ANTHROPIC_API_KEY=%q, want %q", cmd.env["ANTHROPIC_API_KEY"], apiKey)
}
if cmd.env["CLAUDE_CODE_TMPDIR"] == "" {
t.Fatalf("expected CLAUDE_CODE_TMPDIR to be set for nested claude, got empty")
}
if !strings.Contains(got, "Env: ANTHROPIC_BASE_URL="+baseURL) {
t.Fatalf("stderr missing base URL env log; stderr=%q", got)
@@ -132,4 +135,7 @@ func TestEnvInjection_LogsToStderrAndMasksKey(t *testing.T) {
if !strings.Contains(got, "Env: ANTHROPIC_API_KEY=eyJh****test") {
t.Fatalf("stderr missing masked API key log; stderr=%q", got)
}
if !strings.Contains(got, "CLAUDE_CODE_TMPDIR: ") {
t.Fatalf("stderr missing CLAUDE_CODE_TMPDIR log; stderr=%q", got)
}
}

View File

@@ -1154,10 +1154,23 @@ func RunCodexTaskWithContext(parentCtx context.Context, taskSpec TaskSpec, backe
injectTempEnv(cmd)
// Claude Code sets CLAUDECODE=1 in its child processes. If we don't
// remove it, the spawned `claude -p` detects the variable and refuses
// to start ("cannot be launched inside another Claude Code session").
if commandName == "claude" {
// Claude 2.1.45+ calls Nz7() on startup to clean its tasks directory,
// which deletes the parent session's *.output files and causes "(no output)".
// Assign each nested claude its own isolated tmpdir so it only cleans its own files.
nestedTmpDir, err := os.MkdirTemp("", fmt.Sprintf("cc-nested-%d-", os.Getpid()))
if err != nil {
logWarnFn("Failed to create isolated CLAUDE_CODE_TMPDIR: " + err.Error())
} else {
cmd.SetEnv(map[string]string{"CLAUDE_CODE_TMPDIR": nestedTmpDir})
defer os.RemoveAll(nestedTmpDir) //nolint:errcheck
logInfoFn("CLAUDE_CODE_TMPDIR: " + nestedTmpDir)
fmt.Fprintln(os.Stderr, " CLAUDE_CODE_TMPDIR: "+nestedTmpDir)
}
// Claude Code sets CLAUDECODE=1 in its child processes. If we don't
// remove it, the spawned `claude -p` detects the variable and refuses
// to start ("cannot be launched inside another Claude Code session").
cmd.UnsetEnv("CLAUDECODE")
}

View File

@@ -569,10 +569,16 @@ func isUnsafeFile(path string, tempDir string) (bool, string) {
return true, fmt.Sprintf("path resolution failed: %v", err)
}
// Get absolute path of tempDir
absTempDir, err := filepath.Abs(tempDir)
// Get canonical path of tempDir, resolving symlinks to match resolvedPath.
// On macOS, os.TempDir() returns /var/folders/... but EvalSymlinks resolves
// files to /private/var/folders/..., causing a spurious "outside tempDir" mismatch.
absTempDir, err := evalSymlinksFn(tempDir)
if err != nil {
return true, fmt.Sprintf("tempDir resolution failed: %v", err)
// Fallback to Abs if symlink resolution fails
absTempDir, err = filepath.Abs(tempDir)
if err != nil {
return true, fmt.Sprintf("tempDir resolution failed: %v", err)
}
}
// Ensure resolved path is within tempDir

View File

@@ -515,7 +515,10 @@ func TestLoggerIsUnsafeFileSecurityChecks(t *testing.T) {
return fakeFileInfo{}, nil
})
outside := filepath.Join(filepath.Dir(absTempDir), "etc", "passwd")
stubEvalSymlinks(t, func(string) (string, error) {
stubEvalSymlinks(t, func(p string) (string, error) {
if p == tempDir {
return absTempDir, nil
}
return outside, nil
})
unsafe, reason := isUnsafeFile(filepath.Join("..", "..", "etc", "passwd"), tempDir)
@@ -529,16 +532,73 @@ func TestLoggerIsUnsafeFileSecurityChecks(t *testing.T) {
return fakeFileInfo{}, nil
})
otherDir := t.TempDir()
stubEvalSymlinks(t, func(string) (string, error) {
return filepath.Join(otherDir, "codeagent-wrapper-9.log"), nil
outsidePath := filepath.Join(otherDir, "codeagent-wrapper-9.log")
stubEvalSymlinks(t, func(p string) (string, error) {
if p == tempDir {
return absTempDir, nil
}
return outsidePath, nil
})
unsafe, reason := isUnsafeFile(filepath.Join(otherDir, "codeagent-wrapper-9.log"), tempDir)
unsafe, reason := isUnsafeFile(outsidePath, tempDir)
if !unsafe || reason != "file is outside tempDir" {
t.Fatalf("expected outside file to be rejected, got unsafe=%v reason=%q", unsafe, reason)
}
})
}
func TestLoggerIsUnsafeFileCanonicalizesTempDir(t *testing.T) {
stubFileStat(t, func(string) (os.FileInfo, error) {
return fakeFileInfo{}, nil
})
tempDir := filepath.FromSlash("/var/folders/abc/T")
canonicalTempDir := filepath.FromSlash("/private/var/folders/abc/T")
logPath := filepath.Join(tempDir, "codeagent-wrapper-1.log")
canonicalLogPath := filepath.Join(canonicalTempDir, "codeagent-wrapper-1.log")
stubEvalSymlinks(t, func(p string) (string, error) {
switch p {
case tempDir:
return canonicalTempDir, nil
case logPath:
return canonicalLogPath, nil
default:
return p, nil
}
})
unsafe, reason := isUnsafeFile(logPath, tempDir)
if unsafe {
t.Fatalf("expected canonicalized tempDir to be accepted, got unsafe=%v reason=%q", unsafe, reason)
}
}
func TestLoggerIsUnsafeFileFallsBackToAbsOnTempDirEvalFailure(t *testing.T) {
stubFileStat(t, func(string) (os.FileInfo, error) {
return fakeFileInfo{}, nil
})
tempDir := t.TempDir()
absTempDir, err := filepath.Abs(tempDir)
if err != nil {
t.Fatalf("filepath.Abs() error = %v", err)
}
logPath := filepath.Join(tempDir, "codeagent-wrapper-1.log")
absLogPath := filepath.Join(absTempDir, "codeagent-wrapper-1.log")
stubEvalSymlinks(t, func(p string) (string, error) {
if p == tempDir {
return "", errors.New("boom")
}
return absLogPath, nil
})
unsafe, reason := isUnsafeFile(logPath, tempDir)
if unsafe {
t.Fatalf("expected Abs fallback to allow file, got unsafe=%v reason=%q", unsafe, reason)
}
}
func TestLoggerPathAndRemove(t *testing.T) {
setTempDirEnv(t, t.TempDir())