carlos / platform Public

cli tests: the root test binary never works under an operator's .carlos/config (#226, #258)

The root tests pin HOME, but the CLI's config discovery walks up from the
working directory, and that is a t.TempDir() for every test that uses
useTempCredentials or runs the binary from a scratch cmd.Dir. Where TMPDIR
is inside a logged-in operator's home (the default in a sandboxed agent
session) the walk reached the real ~/.carlos/config: about two dozen tests
failed with "not logged in" to the operator's console, and
TestResolveModeExpiredOffersRelogin offered a re-login to it and waited
ten minutes.

TestMain now checks the temp root before any test runs. With no
.carlos/config above it (every CI run) nothing changes. Otherwise the tests
get a fresh directory under /var/tmp or /tmp, through TMPDIR so child
processes follow, removed when the run ends; if neither is clear and
writable the binary stops with the reason instead of running.

This is test-only. Pinning CARLOS_CONFIG in the shared helpers would switch
discovery off for the tests that write a .carlos/config of their own, and
stopping the production walk at HOME would not have helped: the scratch
cwd is a sibling of the pinned HOME, not under it.

TestShipVerbAPIMode loses the CARLOS_CONFIG pin it was given alone
(6fff5358, landed in the moves-phase1-4 squash); it is covered with the
rest now.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev 35a97d09f28627265937a077a70faa9284373918 parent 6dfbff1
4 files changed, +163 −13
  • AGENTS.md +6 −1
  • apimode_test.go +0 −5
  • auth_test.go +3 −1
  • main_test.go +154 −6
diff --git a/AGENTS.md b/AGENTS.md
index cedb4c57c..5eda67132 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -296,7 +296,12 @@ anything.
**and set `cmd.Dir` to a scratch dir** — the binary walks cwd ancestors for
`.carlos/config`. The fallback is the operator's real login and default
console; a logged-in laptop running `go test ./...` minted 40+ real accounts
- on the production console before this was isolated.
+ on the production console before this was isolated. A `t.TempDir()` is
+ scratch in that sense only because the root package's `TestMain`
+ (`isolateTempRoot`, main_test.go) moves the temp root when a
+ `.carlos/config` sits above it, which it does wherever TMPDIR is under a
+ logged-in home (#226, #258). A test that leaves its cwd at the checkout is
+ not covered by that.
- **A sidecar is a member that nothing can dial.** `Kind == "sidecar"` rows
carry no socket and no addr, and are refused by the edge ON KIND — not
merely unmatched by it — before dispatch ever looks at shape. That refusal
diff --git a/apimode_test.go b/apimode_test.go
index d3296c017..33abb4f9d 100644
--- a/apimode_test.go
+++ b/apimode_test.go
@@ -1054,11 +1054,6 @@ func TestShipVerbAPIMode(t *testing.T) {
useTempCredentials(t)
clearDeploymentEnv(t)
checkNoRegistry := requireNoRegistryTouch(t)
- // The working directory useTempCredentials picks is under $TMPDIR, which
- // can sit inside the operator's real home; the .carlos/config walk goes
- // up from it and would find the real ~/.carlos/config. Name a config
- // file of the test's own (missing, so no opinion) and skip discovery.
- t.Setenv("CARLOS_CONFIG", filepath.Join(t.TempDir(), "config"))
var reqCount int32
mux := http.NewServeMux()
diff --git a/auth_test.go b/auth_test.go
index 5a51b4955..b15d338bb 100644
--- a/auth_test.go
+++ b/auth_test.go
@@ -17,7 +17,9 @@ import (
// the credentials file, $HOME, the working directory, CARLOS_CONSOLE — at
// scratch space for the length of one test. Config resolution (config.go)
// consults all four; a developer's real ~/.carlos/config must never change
-// a test's answer.
+// a test's answer. The working directory is only scratch space because
+// TestMain (main_test.go isolateTempRoot) keeps every t.TempDir() clear of
+// a .carlos/config above it: discovery walks up from here.
func useTempCredentials(t *testing.T) string {
t.Helper()
home := t.TempDir()
diff --git a/main_test.go b/main_test.go
index b8711d2d9..ae9398bb1 100644
--- a/main_test.go
+++ b/main_test.go
@@ -1,16 +1,164 @@
package main
import (
+ "errors"
+ "fmt"
"os"
+ "path/filepath"
+ "runtime"
+ "strings"
"testing"
)
-// TestMain keeps an exported deploy token out of the root package's tests.
-// resolveMode reads CARLOS_SERVICE_TOKEN, and a CI shell running `go test`
-// may well export one. In-process tests that call run() or a verb directly
-// would then inherit it. Tests that want a token set it with t.Setenv, which
-// still works after this Unsetenv.
+// TestMain keeps the operator's own state out of the root package's tests.
+//
+// An exported deploy token: resolveMode reads CARLOS_SERVICE_TOKEN, and a CI
+// shell running `go test` may well export one. In-process tests that call
+// run() or a verb directly would then inherit it. Tests that want a token set
+// it with t.Setenv, which still works after this Unsetenv.
+//
+// The operator's .carlos/config: see isolateTempRoot. The binary refuses to
+// run rather than work under one.
func TestMain(m *testing.M) {
os.Unsetenv("CARLOS_SERVICE_TOKEN")
- os.Exit(m.Run())
+ moved, cleanup, err := isolateTempRoot([]string{"/var/tmp", "/tmp"})
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ if moved != "" {
+ fmt.Fprintln(os.Stderr, moved)
+ }
+ code := m.Run()
+ cleanup()
+ os.Exit(code)
+}
+
+// isolateTempRoot makes sure no .carlos/config sits above the directory
+// t.TempDir() hands out, before any test asks for one.
+//
+// The tests pin HOME, but the CLI's config discovery (config.go
+// walkConfigFiles) walks up from the working directory, and that directory
+// is a t.TempDir() for every test that uses useTempCredentials or runs the
+// binary from a scratch cmd.Dir. Where the temp root is inside a logged-in
+// operator's home (TMPDIR=~/.cache/tmp/..., the default in a sandboxed agent
+// session) the walk reached the real ~/.carlos/config: two dozen tests
+// failed with "not logged in" to the operator's console, and one offered a
+// re-login to it and waited ten minutes (#226, #258). Pinning CARLOS_CONFIG
+// in the shared helpers is not the fix, because it switches discovery off
+// for the tests that write a .carlos/config of their own.
+//
+// A temp root with nothing above it is left alone, which is every CI run.
+// Otherwise the tests get a fresh directory under the first of bases that is
+// clear, through TMPDIR so that child processes follow: moved says where,
+// and the returned cleanup removes it. When none can be made the error says
+// so and TestMain stops.
+func isolateTempRoot(bases []string) (moved string, cleanup func(), err error) {
+ root := os.TempDir()
+ found := configAbove(root)
+ if found == "" {
+ return "", func() {}, nil
+ }
+ for _, base := range bases {
+ dir, err := os.MkdirTemp(base, "carlos-test-")
+ if err != nil {
+ continue
+ }
+ os.Setenv("TMPDIR", dir)
+ // Asked of os.TempDir() again rather than assumed: the base may sit
+ // under a config too, and not every platform reads TMPDIR.
+ if configAbove(os.TempDir()) == "" {
+ moved = fmt.Sprintf("carlos tests: %s is above the temp directory %s, so the tests work in %s instead", found, root, dir)
+ return moved, func() { os.RemoveAll(dir) }, nil
+ }
+ os.Remove(dir)
+ }
+ return "", nil, fmt.Errorf("carlos tests: %s is above the temp directory %s and the CLI's config walk would read it; set TMPDIR to a directory with no .carlos/config above it", found, root)
+}
+
+// configAbove is the first .carlos/config the CLI's discovery would read
+// walking up from dir, "" when there is none. The walk is over the path as
+// written, which is what discovery follows after a t.Chdir, and over the
+// path with its symlinks resolved, which is what it follows when PWD is
+// unset. Anything but "does not exist" counts as there: discovery fails on
+// a config it cannot read.
+func configAbove(dir string) string {
+ if abs, err := filepath.Abs(dir); err == nil {
+ dir = abs
+ }
+ starts := []string{dir}
+ if resolved, err := filepath.EvalSymlinks(dir); err == nil && resolved != dir {
+ starts = append(starts, resolved)
+ }
+ for _, d := range starts {
+ for {
+ path := filepath.Join(d, ".carlos", "config")
+ if _, err := os.Stat(path); !errors.Is(err, os.ErrNotExist) {
+ return path
+ }
+ parent := filepath.Dir(d)
+ if parent == d {
+ break
+ }
+ d = parent
+ }
+ }
+ return ""
+}
+
+// The guard, against a tree of its own: a temp root under a config moves to
+// the first base that is clear, the move is undone by the cleanup alone,
+// and with no clear base the binary is told to stop.
+func TestIsolateTempRoot(t *testing.T) {
+ if p := configAbove(os.TempDir()); p != "" {
+ t.Fatalf("TestMain let the tests run with %s above the temp directory", p)
+ }
+ if runtime.GOOS == "windows" {
+ t.Skip("os.TempDir does not read TMPDIR on windows, so there is no moving it")
+ }
+
+ home := t.TempDir()
+ planted := writeConfig(t, home, `{"console": "https://operator.example"}`)
+ under := filepath.Join(home, "cache", "tmp")
+ alsoUnder := filepath.Join(home, "var")
+ clear := t.TempDir()
+ for _, d := range []string{under, alsoUnder} {
+ if err := os.MkdirAll(d, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ }
+ if got := configAbove(under); got != planted {
+ t.Fatalf("configAbove(%s) = %q, want %s", under, got, planted)
+ }
+
+ t.Setenv("TMPDIR", under)
+ said, cleanup, err := isolateTempRoot([]string{filepath.Join(home, "missing"), alsoUnder, clear})
+ if err != nil {
+ t.Fatal(err)
+ }
+ moved := os.TempDir()
+ if filepath.Dir(moved) != clear || !strings.Contains(said, planted) || !strings.Contains(said, moved) {
+ t.Fatalf("the temp root moved to %s (%q), want a directory under %s and both paths named", moved, said, clear)
+ }
+ if left, _ := os.ReadDir(alsoUnder); len(left) != 0 {
+ t.Fatalf("the refused base kept %v", left)
+ }
+ cleanup()
+ if _, err := os.Stat(moved); !errors.Is(err, os.ErrNotExist) {
+ t.Fatalf("the cleanup left %s (%v)", moved, err)
+ }
+ if _, err := os.Stat(clear); err != nil {
+ t.Fatalf("the cleanup took the base with it: %v", err)
+ }
+
+ t.Setenv("TMPDIR", under)
+ if _, _, err := isolateTempRoot([]string{alsoUnder}); err == nil || !strings.Contains(err.Error(), planted) {
+ t.Fatalf("no clear base = %v, want an error naming %s", err, planted)
+ }
+
+ // A clear temp root is not moved at all.
+ t.Setenv("TMPDIR", clear)
+ if said, _, err := isolateTempRoot([]string{filepath.Join(home, "missing")}); err != nil || said != "" || os.TempDir() != clear {
+ t.Fatalf("a clear temp root = %s, %q, %v; want it left alone", os.TempDir(), said, err)
+ }
}