diff --git a/internal/config/runtimeconfig/channels_test.go b/internal/config/runtimeconfig/channels_test.go index 9dbc93e..2d843ec 100644 --- a/internal/config/runtimeconfig/channels_test.go +++ b/internal/config/runtimeconfig/channels_test.go @@ -3,6 +3,7 @@ package runtimeconfig import ( "os" "path/filepath" + "runtime" "strings" "testing" @@ -176,8 +177,14 @@ func TestSaveChannelsRoundTrip(t *testing.T) { if err != nil { t.Fatalf("stat: %v", err) } - if info.Mode().Perm()&0o077 != 0 { - t.Fatalf("channels.json mode = %v, want owner-only (0600)", info.Mode().Perm()) + if runtime.GOOS != "windows" { + // Windows has no POSIX permission bits for regular files: Chmod only + // toggles the read-only flag and Stat reports 0666 regardless of the + // requested 0600, so this assertion is only meaningful on Unix-like + // platforms. SaveChannels still requests 0600 (see channels.go). + if info.Mode().Perm()&0o077 != 0 { + t.Fatalf("channels.json mode = %v, want owner-only (0600)", info.Mode().Perm()) + } } } diff --git a/internal/config/runtimeconfig/plugins_test.go b/internal/config/runtimeconfig/plugins_test.go index 039ca13..330f985 100644 --- a/internal/config/runtimeconfig/plugins_test.go +++ b/internal/config/runtimeconfig/plugins_test.go @@ -5,6 +5,7 @@ import ( "errors" "os" "path/filepath" + "runtime" "strings" "testing" ) @@ -39,8 +40,14 @@ func TestSaveLoadPlugins_RoundTrip(t *testing.T) { if err != nil { t.Fatalf("stat: %v", err) } - if perm := info.Mode().Perm(); perm != 0o600 { - t.Fatalf("mode = %o, want 600", perm) + if runtime.GOOS != "windows" { + // Windows has no POSIX permission bits for regular files: Chmod only + // toggles the read-only flag and Stat reports 0666 regardless of the + // requested 0600, so this assertion is only meaningful on Unix-like + // platforms. SavePlugins still requests 0600 (see plugins.go). + if perm := info.Mode().Perm(); perm != 0o600 { + t.Fatalf("mode = %o, want 600", perm) + } } got, err := LoadPlugins(path) if err != nil { diff --git a/internal/runtime/exec/terminal_test.go b/internal/runtime/exec/terminal_test.go index fe21157..c7eeccf 100644 --- a/internal/runtime/exec/terminal_test.go +++ b/internal/runtime/exec/terminal_test.go @@ -144,20 +144,51 @@ func TestTerminal_Injection_Rejected(t *testing.T) { func TestTerminal_WorkDir_DefaultToRoot(t *testing.T) { t.Parallel() - requireBin(t, "pwd") - root := t.TempDir() - ex := newEnabledExecutor(t, enabledCfg("pwd", root)) - res, err := ex.Execute(context.Background(), ExecRequest{Command: []string{"pwd"}}) + // Probe the default working directory without comparing rendered path + // strings. On Windows the command may resolve to Git's MSYS coreutils, which + // reports the Windows cwd it was handed as a POSIX path (the user Temp + // directory comes back as /tmp/...), so a "Stdout contains root" check is not + // portable. A marker file that exists only in the sandbox root, read through + // a relative name, is found only when the process really runs in that root. + const ( + probeFile = "cwd-probe.txt" + probeBody = "cwd-probe" + ) + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, probeFile), []byte(probeBody), 0o600); err != nil { + t.Fatalf("write probe: %v", err) + } + requireBin(t, "cat") + catEx := newEnabledExecutor(t, enabledCfg("cat", root)) + res, err := catEx.Execute(context.Background(), ExecRequest{Command: []string{"cat", probeFile}}) if err != nil { t.Fatalf("Execute: %v", err) } - realRoot := root - if r, err := filepath.EvalSymlinks(root); err == nil { - realRoot = r + if res.ExitCode != 0 || res.Stdout != probeBody { + t.Errorf("cat %s with no WorkDir: exit=%d stdout=%q stderr=%q, want %q read from the sandbox root %s", + probeFile, res.ExitCode, res.Stdout, res.Stderr, probeBody, root) } - if !strings.Contains(res.Stdout, realRoot) { - t.Errorf("Stdout = %q, want contain %q", res.Stdout, realRoot) + + // And the default must be the root itself, not a parent or a subdirectory: + // pwd with no WorkDir must print exactly what pwd prints for WorkDir=root. + // Both runs share pwd's own rendering, so the comparison stays platform + // independent. + requireBin(t, "pwd") + pwdEx := newEnabledExecutor(t, enabledCfg("pwd", root)) + deflt, err := pwdEx.Execute(context.Background(), ExecRequest{Command: []string{"pwd"}}) + if err != nil { + t.Fatalf("Execute pwd (default workdir): %v", err) + } + explicit, err := pwdEx.Execute(context.Background(), ExecRequest{ + Command: []string{"pwd"}, + WorkDir: root, + }) + if err != nil { + t.Fatalf("Execute pwd (WorkDir=%s): %v", root, err) + } + if got, want := strings.TrimSpace(deflt.Stdout), strings.TrimSpace(explicit.Stdout); got != want { + t.Errorf("pwd with no WorkDir = %q, want the sandbox root as pwd renders it with WorkDir=%s: %q", got, root, want) } } diff --git a/internal/runtime/task/task_test.go b/internal/runtime/task/task_test.go index 7945786..c367773 100644 --- a/internal/runtime/task/task_test.go +++ b/internal/runtime/task/task_test.go @@ -219,8 +219,12 @@ func TestManager_ForegroundTask_FastCompletes(t *testing.T) { if snap.Output.Stdout != "fast out" { t.Errorf("Stdout=%q, want fast out", snap.Output.Stdout) } - if !snap.FinishedAt.After(snap.StartedAt) { - t.Errorf("FinishedAt=%v should be after StartedAt=%v", snap.FinishedAt, snap.StartedAt) + // The monotonic clock may be too coarse to tick between StartedAt and + // FinishedAt, so a task that finishes in less than one tick legitimately + // reads the same stamp for both. Assert the platform-independent invariant + // instead: the finish time is never before the start time. + if snap.FinishedAt.Before(snap.StartedAt) { + t.Errorf("FinishedAt=%v should not be before StartedAt=%v", snap.FinishedAt, snap.StartedAt) } } diff --git a/internal/runtime/trace/trace_test.go b/internal/runtime/trace/trace_test.go index 073139f..506b2b6 100644 --- a/internal/runtime/trace/trace_test.go +++ b/internal/runtime/trace/trace_test.go @@ -72,8 +72,15 @@ func TestSpan_Lifecycle(t *testing.T) { if sp.EndedAt.Before(before) { t.Fatalf("EndedAt %v is before snapshot %v", sp.EndedAt, before) } - if sp.Duration <= 0 { - t.Fatalf("Duration = %v, want > 0", sp.Duration) + // The monotonic clock may be too coarse to tick between StartedAt and + // EndedAt, so a sub-tick span legitimately reads Duration == 0. Assert the + // platform-independent invariant instead: Duration is non-negative and is + // exactly the delta of the two stamps. + if sp.Duration < 0 { + t.Fatalf("Duration = %v, want >= 0", sp.Duration) + } + if want := sp.EndedAt.Sub(sp.StartedAt); sp.Duration != want { + t.Fatalf("Duration = %v, want EndedAt-StartedAt = %v", sp.Duration, want) } if len(sp.Events) != 1 || sp.Events[0].Type != "token" { t.Fatalf("Events = %+v, want one 'token' event", sp.Events) @@ -241,8 +248,13 @@ func TestJSONRecorder_Output(t *testing.T) { if childRec.Status != SpanStatusOK { t.Fatalf("childRec.Status = %q, want ok", childRec.Status) } - if childRec.Duration <= 0 { - t.Fatalf("childRec.Duration = %v, want > 0", childRec.Duration) + // As in TestSpan_Lifecycle: a sub-tick span reads Duration == 0 on platforms + // with a coarse monotonic clock, so assert the recorded delta invariant. + if childRec.Duration < 0 { + t.Fatalf("childRec.Duration = %v, want >= 0", childRec.Duration) + } + if want := childRec.EndedAt.Sub(childRec.StartedAt); childRec.Duration != want { + t.Fatalf("childRec.Duration = %v, want EndedAt-StartedAt = %v", childRec.Duration, want) } if childRec.Props["model"] != "gpt-test" { t.Fatalf("childRec.Props = %+v, want model key", childRec.Props) diff --git a/internal/user/save_test.go b/internal/user/save_test.go index 2e30694..25c63fa 100644 --- a/internal/user/save_test.go +++ b/internal/user/save_test.go @@ -3,6 +3,7 @@ package user import ( "os" "path/filepath" + "runtime" "testing" ) @@ -35,8 +36,14 @@ notes: "主人。陪伴对象。" if err != nil { t.Fatalf("stat saved profile: %v", err) } - if perm := info.Mode().Perm(); perm != 0o600 { - t.Errorf("saved file mode = %o, want 0600", perm) + if runtime.GOOS != "windows" { + // Windows has no POSIX permission bits for regular files: Chmod only + // toggles the read-only flag and Stat reports 0666 regardless of the + // requested 0600, so this assertion is only meaningful on Unix-like + // platforms. SaveOwnerProfile still requests 0600 (see save.go). + if perm := info.Mode().Perm(); perm != 0o600 { + t.Errorf("saved file mode = %o, want 0600", perm) + } } got, err := LoadOwnerProfile(dst)