diff --git a/core/accounts/accounts.go b/core/accounts/accounts.go index b56d7513..46a1e2e6 100644 --- a/core/accounts/accounts.go +++ b/core/accounts/accounts.go @@ -40,7 +40,9 @@ var kinds = map[string]kind{ // .claude.json holds who is signed in; its backups would offer the // default account's back. "claude": {env: "CLAUDE_CONFIG_DIR", home: ".claude", own: []string{".credentials.json", ".claude.json", ".claude.json.lock", "backups"}}, - "codex": {env: "CODEX_HOME", home: ".codex", own: []string{"auth.json"}}, + // Codex also keeps databases and daemon state private; shareCodex + // permits only known settings and canonical conversation paths. + "codex": {env: "CODEX_HOME", home: ".codex", own: []string{"auth.json"}}, // https://www.kimi.com/code/docs/en/kimi-code-cli/configuration/data-locations.html "kimi": {env: "KIMI_CODE_HOME", home: ".kimi-code", own: []string{"credentials"}, docsOnly: true}, // https://docs.mistral.ai/mistral-vibe/terminal/configuration @@ -271,6 +273,19 @@ func (s *Store) Env(agent, id string) ([]string, error) { return nil, nil // a key or an API: the agent's own home } k := kinds[agent] + // The config's explicit home wins at launch. Do not prepare or share + // an unused account home before that override is applied. + if agent == "codex" && acc.Member == "" { + for i := len(acc.Env) - 1; i >= 0; i-- { + name, value, _ := strings.Cut(acc.Env[i], "=") + if sameEnvName(name, k.env) { + if value == "" { + return nil, errors.New("accounts: an explicit Codex home must not be empty") + } + return []string{acc.Env[i]}, nil + } + } + } home := s.Home(id) link := share if acc.Member != "" { @@ -324,6 +339,9 @@ func share(k kind, home string) error { return err } src := filepath.Join(userHome, k.home) + if k.env == "CODEX_HOME" { + return shareCodex(src, home) + } entries, err := os.ReadDir(src) if errors.Is(err, os.ErrNotExist) { return nil diff --git a/core/accounts/codex_share.go b/core/accounts/codex_share.go new file mode 100644 index 00000000..b8ec07c7 --- /dev/null +++ b/core/accounts/codex_share.go @@ -0,0 +1,96 @@ +package accounts + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "slices" +) + +// Share only known settings and conversation files. An exclusion list would +// share newly introduced databases, transient SQLite sidecars or daemon state. +var codexShared = []string{ + "config.toml", "AGENTS.md", "skills", "prompts", "rules", "plugins", + "history.jsonl", "session_index.jsonl", + "sessions", "archived_sessions", "thread-writer-locks", +} + +var codexCanonicalDirs = []string{"sessions", "archived_sessions", "thread-writer-locks"} + +// Account starts in this daemon serialize link preparation. Concurrent Windows +// symlink creation can fail with transient privilege or identity errors. +var codexShareGate = make(chan struct{}, 1) + +// shareCodex leaves runtime files private, including absent journals that +// SQLite may recreate. Legacy runtime links require an offline review: even +// unlinking a live socket or journal can disrupt a running agent. +func shareCodex(src, home string) error { + codexShareGate <- struct{}{} + defer func() { <-codexShareGate }() + + root, err := os.Lstat(home) + if err != nil { + return fmt.Errorf("accounts: inspect Codex home: %w", err) + } + if root.Mode()&os.ModeSymlink != 0 { + return fmt.Errorf("accounts: Codex account home %s is a link; use a private directory", home) + } + entries, err := os.ReadDir(home) + if err != nil { + return fmt.Errorf("accounts: inspect Codex home: %w", err) + } + for _, e := range entries { + if !slices.Contains(codexShared, e.Name()) && e.Type()&os.ModeSymlink != 0 { + return fmt.Errorf("accounts: Codex home has a private runtime link %s; stop users of this home and review it offline before starting (no files changed)", filepath.Join(home, e.Name())) + } + } + // Rollouts and their writer locks must have one namespace, including + // when the default agent has not created those directories yet. + for _, name := range codexCanonicalDirs { + target, link := filepath.Join(src, name), filepath.Join(home, name) + if err := os.MkdirAll(target, 0o700); err != nil { + return fmt.Errorf("accounts: prepare canonical Codex %s: %w", name, err) + } + if _, err := os.Lstat(link); errors.Is(err, os.ErrNotExist) { + if err := os.Symlink(target, link); err != nil { + // Windows may report a privilege error when a concurrent start + // wins creation. Only accept an existing, validated directory. + if _, statErr := os.Lstat(link); statErr != nil { + return fmt.Errorf("accounts: link canonical Codex %s: %w", name, err) + } + } + } else if err != nil { + return fmt.Errorf("accounts: inspect Codex %s: %w", name, err) + } + // A simultaneous start may have made the link. Validate its identity + // instead of replacing it, preserving any writer lock already held. + a, err := os.Stat(link) + if err != nil { + return fmt.Errorf("accounts: inspect Codex %s: %w", name, err) + } + b, err := os.Stat(target) + if err != nil || !os.SameFile(a, b) { + return fmt.Errorf("accounts: Codex %s must use %s; review existing data and writer locks offline", link, target) + } + } + for _, name := range codexShared { + if slices.Contains(codexCanonicalDirs, name) { + continue + } + target, link := filepath.Join(src, name), filepath.Join(home, name) + if _, err := os.Lstat(link); err == nil { + continue + } else if !errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("accounts: inspect Codex %s: %w", name, err) + } + if _, err := os.Lstat(target); errors.Is(err, os.ErrNotExist) { + continue + } else if err != nil { + return fmt.Errorf("accounts: inspect shared Codex %s: %w", name, err) + } + // Optional settings retain the other agents' best-effort sharing. + _ = os.Symlink(target, link) + } + return nil +} diff --git a/core/accounts/codex_share_test.go b/core/accounts/codex_share_test.go new file mode 100644 index 00000000..bfb31e70 --- /dev/null +++ b/core/accounts/codex_share_test.go @@ -0,0 +1,361 @@ +package accounts + +import ( + "database/sql" + "os" + "path/filepath" + "testing" + + _ "modernc.org/sqlite" +) + +func codexFixture(t *testing.T) (*Store, string) { + t.Helper() + user := t.TempDir() + t.Setenv("HOME", user) + t.Setenv("USERPROFILE", user) + t.Setenv("ENOWX_HOME", filepath.Join(user, "enowx")) + t.Setenv("CODEX_HOME", filepath.Join(user, ".codex")) + src := filepath.Join(user, ".codex") + if err := os.MkdirAll(src, 0700); err != nil { + t.Fatal(err) + } + probe := filepath.Join(user, "probe") + if err := os.Symlink(src, probe); err != nil { + t.Fatalf("symlink capability required: %v", err) + } + if err := os.Remove(probe); err != nil { + t.Fatal(err) + } + s, err := Open(os.Getenv("ENOWX_HOME")) + if err != nil { + t.Fatal(err) + } + return s, src +} + +func writeFixture(t *testing.T, path, value string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(value), 0600); err != nil { + t.Fatal(err) + } +} + +func openFixtureDB(t *testing.T, path string) *sql.DB { + t.Helper() + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + db.SetMaxOpenConns(1) + t.Cleanup(func() { db.Close() }) + if _, err := db.Exec("PRAGMA journal_mode=WAL; CREATE TABLE IF NOT EXISTS records (value TEXT); INSERT INTO records VALUES ('synthetic')"); err != nil { + t.Fatal(err) + } + return db +} + +// A physical account database loses its own WAL/SHM at close. Sharing again +// must never replace those missing files with another database's journals. +func TestCodexShareSQLiteCloseReopen(t *testing.T) { + s, src := codexFixture(t) + mainDB := openFixtureDB(t, filepath.Join(src, "state_5.sqlite")) + defer mainDB.Close() + writeFixture(t, filepath.Join(src, "config.toml"), "# shared settings") + writeFixture(t, filepath.Join(src, "skills", "fixture", "SKILL.md"), "synthetic skill") + writeFixture(t, filepath.Join(src, "auth.json"), "default synthetic auth") + for _, name := range []string{"state_5.sqlite-journal", "other.db", "other.db-wal", "future.sqlite3", "future.sqlite3-journal", "app-server-daemon/socket", "app-server-control/daemon.json", "future.sock", "daemon.pid", "future-runtime/state"} { + writeFixture(t, filepath.Join(src, name), "synthetic runtime") + } + var homes, ids []string + for _, name := range []string{"one", "two"} { + acc, err := s.Add("codex", name) + if err != nil { + t.Fatal(err) + } + homes = append(homes, s.Home(acc.ID)) + ids = append(ids, acc.ID) + } + for _, home := range homes { + if err := os.MkdirAll(home, 0700); err != nil { + t.Fatal(err) + } + writeFixture(t, filepath.Join(home, "auth.json"), "account synthetic auth") + db := openFixtureDB(t, filepath.Join(home, "state_5.sqlite")) + if err := db.Close(); err != nil { + t.Fatal(err) + } + } + for cycle := 0; cycle < 4; cycle++ { + for i, home := range homes { + for _, suffix := range []string{"-wal", "-shm"} { + if _, err := os.Lstat(filepath.Join(home, "state_5.sqlite"+suffix)); !os.IsNotExist(err) { + t.Fatalf("cycle %d: sidecar survived close: %v", cycle, err) + } + } + reopened, err := Open(os.Getenv("ENOWX_HOME")) + if err != nil { + t.Fatal(err) + } + if env, err := reopened.Env("codex", ids[i]); err != nil || len(env) != 1 || env[0] != "CODEX_HOME="+home { + t.Fatalf("account restart: %q %v", env, err) + } + for _, name := range []string{"state_5.sqlite-wal", "state_5.sqlite-shm", "state_5.sqlite-journal", "other.db", "other.db-wal", "future.sqlite3", "future.sqlite3-journal", "app-server-daemon", "app-server-control", "future.sock", "daemon.pid", "future-runtime"} { + if _, err := os.Lstat(filepath.Join(home, name)); !os.IsNotExist(err) { + t.Fatalf("cycle %d: runtime %s reached account: %v", cycle, name, err) + } + } + for _, name := range []string{"config.toml", "skills/fixture/SKILL.md"} { + if _, err := os.ReadFile(filepath.Join(home, name)); err != nil { + t.Fatal(err) + } + } + if got, err := os.ReadFile(filepath.Join(home, "auth.json")); err != nil || string(got) != "account synthetic auth" { + t.Fatalf("auth changed: %q %v", got, err) + } + db := openFixtureDB(t, filepath.Join(home, "state_5.sqlite")) + var integrity string + if err := db.QueryRow("PRAGMA integrity_check").Scan(&integrity); err != nil || integrity != "ok" { + t.Fatalf("integrity: %q %v", integrity, err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + } + } + var count int + if err := mainDB.QueryRow("SELECT count(*) FROM records").Scan(&count); err != nil || count != 1 { + t.Fatalf("default database changed: %d %v", count, err) + } + for _, home := range homes { + db, err := sql.Open("sqlite", filepath.Join(home, "state_5.sqlite")) + if err != nil { + t.Fatal(err) + } + if err := db.QueryRow("SELECT count(*) FROM records").Scan(&count); err != nil || count != 5 { + t.Fatalf("account writes not private: %d %v", count, err) + } + db.Close() + } +} + +func TestCodexShareKeepsCanonicalRolloutsAndWriterLocks(t *testing.T) { + s, src := codexFixture(t) + for _, dir := range []string{"sessions", "archived_sessions", "thread-writer-locks"} { + if err := os.MkdirAll(filepath.Join(src, dir), 0700); err != nil { + t.Fatal(err) + } + } + writeFixture(t, filepath.Join(src, "sessions", "thread.jsonl"), "synthetic rollout") + lockPath := filepath.Join(src, "thread-writer-locks", "thread.lock") + lock, err := os.OpenFile(lockPath, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0600) + if err != nil { + t.Fatal(err) + } + defer lock.Close() + if err := tryWriterLock(lock); err != nil { + t.Fatal(err) + } + for cycle := 0; cycle < 4; cycle++ { + for _, home := range []string{s.Home("one"), s.Home("two")} { + if err := share(kinds["codex"], home); err != nil { + t.Fatal(err) + } + for _, dir := range []string{"sessions", "archived_sessions", "thread-writer-locks"} { + got, err := filepath.EvalSymlinks(filepath.Join(home, dir)) + want, wantErr := filepath.EvalSymlinks(filepath.Join(src, dir)) + if err != nil || wantErr != nil || got != want { + t.Fatalf("split %s: %q %v", dir, got, err) + } + } + alias := filepath.Join(home, "thread-writer-locks", "thread.lock") + if f, err := os.OpenFile(alias, os.O_CREATE|os.O_EXCL|os.O_RDWR, 0600); !os.IsExist(err) { + if f != nil { + f.Close() + } + t.Fatalf("writer lock bypassed: %v", err) + } + a, err := os.Stat(alias) + if err != nil { + t.Fatal(err) + } + b, err := lock.Stat() + if err != nil || !os.SameFile(a, b) { + t.Fatalf("writer lock replaced: %v", err) + } + contender, err := os.OpenFile(alias, os.O_RDWR, 0o600) + if err != nil { + t.Fatal(err) + } + err = tryWriterLock(contender) + contender.Close() + if err == nil { + t.Fatal("active writer lock bypassed through account home") + } + } + } +} + +func TestCodexShareRefusesLegacyRuntimeLinks(t *testing.T) { + for _, name := range []string{"state_5.sqlite", "state_5.sqlite-wal", "state_5.sqlite-shm", "state_5.sqlite-journal", "auth.json", "app-server-daemon", "app-server-control", "unknown-runtime"} { + t.Run(name, func(t *testing.T) { + s, src := codexFixture(t) + target := filepath.Join(src, name) + writeFixture(t, target, "preserve target") + home := s.Home("legacy") + if err := os.MkdirAll(home, 0700); err != nil { + t.Fatal(err) + } + link := filepath.Join(home, name) + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + if err := share(kinds["codex"], home); err == nil { + t.Fatal("legacy runtime link allowed") + } + if got, err := os.Readlink(link); err != nil || got != target { + t.Fatalf("link altered: %q %v", got, err) + } + if got, err := os.ReadFile(target); err != nil || string(got) != "preserve target" { + t.Fatalf("target altered: %q %v", got, err) + } + }) + } +} + +func TestCodexShareCreatesCanonicalDirectoriesBeforeFirstLaunch(t *testing.T) { + s, src := codexFixture(t) + for _, home := range []string{s.Home("one"), s.Home("two")} { + if err := share(kinds["codex"], home); err != nil { + t.Fatal(err) + } + for _, dir := range []string{"sessions", "archived_sessions", "thread-writer-locks"} { + got, err := filepath.EvalSymlinks(filepath.Join(home, dir)) + want, wantErr := filepath.EvalSymlinks(filepath.Join(src, dir)) + if err != nil || wantErr != nil || got != want { + t.Fatalf("%s not canonical: %q %v", dir, got, err) + } + } + } +} + +func TestCodexExplicitHomeBypassesAccountSharing(t *testing.T) { + s, src := codexFixture(t) + first, last := t.TempDir(), t.TempDir() + acc, err := s.Add("codex", "isolated") + if err != nil { + t.Fatal(err) + } + acc.Env = []string{"CODEX_HOME=" + first, "CODEX_HOME=" + last} + if _, err := s.Save(acc); err != nil { + t.Fatal(err) + } + target := filepath.Join(src, "state_5.sqlite-wal") + writeFixture(t, target, "synthetic journal") + link := filepath.Join(s.Home(acc.ID), "state_5.sqlite-wal") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + for cycle := 0; cycle < 4; cycle++ { + reopened, err := Open(os.Getenv("ENOWX_HOME")) + if err != nil { + t.Fatal(err) + } + env, err := reopened.Env("codex", acc.ID) + if err != nil || len(env) != 1 || env[0] != "CODEX_HOME="+last { + t.Fatalf("override not honored: %q %v", env, err) + } + if _, err := os.Lstat(filepath.Join(last, "sessions")); !os.IsNotExist(err) { + t.Fatalf("explicit home modified: %v", err) + } + if got, err := os.Readlink(link); err != nil || got != target { + t.Fatalf("unused home modified: %q %v", got, err) + } + } +} + +func TestCodexShareRefusesSplitWriterLocks(t *testing.T) { + for _, name := range codexCanonicalDirs { + t.Run(name, func(t *testing.T) { + s, src := codexFixture(t) + home := s.Home("split") + writeFixture(t, filepath.Join(src, name, "canonical"), "keep canonical") + writeFixture(t, filepath.Join(home, name, "private"), "keep private") + if err := share(kinds["codex"], home); err == nil { + t.Fatal("split namespace accepted") + } + for path, want := range map[string]string{filepath.Join(src, name, "canonical"): "keep canonical", filepath.Join(home, name, "private"): "keep private"} { + if got, err := os.ReadFile(path); err != nil || string(got) != want { + t.Fatalf("data changed: %q %v", got, err) + } + } + }) + } +} + +func TestCodexShareRefusesDanglingRuntimeAndLinkedHome(t *testing.T) { + s, src := codexFixture(t) + home := s.Home("dangling") + if err := os.MkdirAll(home, 0700); err != nil { + t.Fatal(err) + } + link := filepath.Join(home, "state_5.sqlite-shm") + target := filepath.Join(src, "absent.sqlite-shm") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + if err := share(kinds["codex"], home); err == nil { + t.Fatal("dangling runtime link accepted") + } + if got, err := os.Readlink(link); err != nil || got != target { + t.Fatalf("dangling link changed: %q %v", got, err) + } + linkedHome := s.Home("linked") + if err := os.Symlink(src, linkedHome); err != nil { + t.Fatal(err) + } + if err := share(kinds["codex"], linkedHome); err == nil { + t.Fatal("linked account home accepted") + } +} + +func TestCodexEmptyExplicitHomeIsRefused(t *testing.T) { + s, _ := codexFixture(t) + acc, err := s.Add("codex", "empty") + if err != nil { + t.Fatal(err) + } + acc.Env = []string{"CODEX_HOME="} + if _, err := s.Save(acc); err != nil { + t.Fatal(err) + } + if _, err := s.Env("codex", acc.ID); err == nil { + t.Fatal("empty home could fall back to the default sign-in") + } +} + +func TestCodexConcurrentAccountStarts(t *testing.T) { + s, _ := codexFixture(t) + acc, err := s.Add("codex", "simultaneous") + if err != nil { + t.Fatal(err) + } + start := make(chan struct{}) + results := make(chan error, 8) + for range 8 { + go func() { + <-start + _, err := s.Env("codex", acc.ID) + results <- err + }() + } + close(start) + for range 8 { + if err := <-results; err != nil { + t.Error(err) + } + } +} diff --git a/core/accounts/codex_unix_test.go b/core/accounts/codex_unix_test.go new file mode 100644 index 00000000..ad17feb5 --- /dev/null +++ b/core/accounts/codex_unix_test.go @@ -0,0 +1,13 @@ +//go:build unix + +package accounts + +import ( + "os" + + "golang.org/x/sys/unix" +) + +func tryWriterLock(f *os.File) error { + return unix.Flock(int(f.Fd()), unix.LOCK_EX|unix.LOCK_NB) +} diff --git a/core/accounts/codex_windows_test.go b/core/accounts/codex_windows_test.go new file mode 100644 index 00000000..549568ed --- /dev/null +++ b/core/accounts/codex_windows_test.go @@ -0,0 +1,29 @@ +package accounts + +import ( + "os" + "testing" + + "golang.org/x/sys/windows" +) + +func tryWriterLock(f *os.File) error { + return windows.LockFileEx(windows.Handle(f.Fd()), windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, new(windows.Overlapped)) +} + +func TestCodexExplicitHomeWindowsCase(t *testing.T) { + s, _ := codexFixture(t) + acc, err := s.Add("codex", "case") + if err != nil { + t.Fatal(err) + } + home := t.TempDir() + acc.Env = []string{"CODEX_HOME=" + t.TempDir(), "codex_home=" + home} + if _, err := s.Save(acc); err != nil { + t.Fatal(err) + } + env, err := s.Env("codex", acc.ID) + if err != nil || len(env) != 1 || env[0] != "codex_home="+home { + t.Fatalf("Windows home precedence: %q %v", env, err) + } +} diff --git a/core/accounts/envname_unix.go b/core/accounts/envname_unix.go new file mode 100644 index 00000000..2c86a427 --- /dev/null +++ b/core/accounts/envname_unix.go @@ -0,0 +1,5 @@ +//go:build !windows + +package accounts + +func sameEnvName(a, b string) bool { return a == b } diff --git a/core/accounts/envname_windows.go b/core/accounts/envname_windows.go new file mode 100644 index 00000000..f5268da5 --- /dev/null +++ b/core/accounts/envname_windows.go @@ -0,0 +1,5 @@ +package accounts + +import "strings" + +func sameEnvName(a, b string) bool { return strings.EqualFold(a, b) } diff --git a/daemon/accounts_restore_test.go b/daemon/accounts_restore_test.go new file mode 100644 index 00000000..53e5803e --- /dev/null +++ b/daemon/accounts_restore_test.go @@ -0,0 +1,95 @@ +package daemon + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/enowx/enowx/core/accounts" +) + +// Refusing an old runtime link must survive daemon restoration: the session +// stays stopped instead of starting without its account's environment. +func TestCodexRestoreRefusesUnsafeAccountHome(t *testing.T) { + for _, midTurn := range []bool{false, true} { + name := "idle" + if midTurn { + name = "mid-turn-tab" + } + t.Run(name, func(t *testing.T) { codexRestoreRefusesUnsafeAccountHome(t, midTurn) }) + } +} + +func codexRestoreRefusesUnsafeAccountHome(t *testing.T, midTurn bool) { + t.Helper() + user := t.TempDir() + dir := filepath.Join(user, "enowx") + t.Setenv("HOME", user) + t.Setenv("USERPROFILE", user) + t.Setenv("ENOWX_HOME", dir) + t.Setenv("CODEX_HOME", filepath.Join(user, ".codex")) + store, err := accounts.Open(dir) + if err != nil { + t.Fatal(err) + } + acc, err := store.Add("codex", "synthetic") + if err != nil { + t.Fatal(err) + } + target := filepath.Join(user, "synthetic.sqlite-wal") + if err := os.WriteFile(target, []byte("preserve synthetic journal"), 0o600); err != nil { + t.Fatal(err) + } + link := filepath.Join(store.Home(acc.ID), "state_5.sqlite-wal") + if err := os.Symlink(target, link); err != nil { + t.Fatal(err) + } + saved := []record{{ID: "unsafe", Dir: user, Agent: "codex", Account: acc.ID, + Relaunch: true, Command: []string{"synthetic-original"}, Restart: []string{"synthetic-must-not-start"}}} + if midTurn { + saved[0].MidTurn = true + saved[0].TabAgent = []string{"synthetic-tab-must-not-start"} + saved[0].Lost = []string{"synthetic interrupted command"} + } + data, err := json.Marshal(saved) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "sessions.json"), data, 0o600); err != nil { + t.Fatal(err) + } + // A regression must never find the machine's real agent executable. + t.Setenv("PATH", t.TempDir()) + srv, err := NewServer(dir) + if err != nil { + t.Fatal(err) + } + t.Cleanup(srv.Close) + c := connect(t, srv, nil) + list, err := c.Sessions() + if err != nil { + t.Fatal(err) + } + if len(list) != 1 || !list[0].Exited || list[0].ExitCode != -1 || list[0].Account != acc.ID { + t.Fatalf("unsafe account resumed: %+v", list) + } + if _, err := srv.continueSession("unsafe"); err == nil { + t.Fatal("continuing unsafe account succeeded") + } + if midTurn { + if err := srv.waitSessionReady("unsafe"); err == nil { + t.Fatal("unsafe account became ready for the restart carry-on prompt") + } + plan, err := c.RestartPlan() + if err != nil || len(plan.Lost) != 1 || plan.Lost[0].What != saved[0].Lost[0] { + t.Fatalf("restart lost-command report changed: %+v, %v", plan.Lost, err) + } + } + if got, err := os.ReadFile(target); err != nil || string(got) != "preserve synthetic journal" { + t.Fatalf("journal changed: %q %v", got, err) + } + if got, err := os.Readlink(link); err != nil || got != target { + t.Fatalf("link changed: %q %v", got, err) + } +} diff --git a/daemon/persist.go b/daemon/persist.go index bae69089..78d68efa 100644 --- a/daemon/persist.go +++ b/daemon/persist.go @@ -295,6 +295,11 @@ func (s *Server) restore() { s.logger().Info("a session waits for an app to lend its key", "id", r.ID) s.waitForKey(r.ID) spec.Exited, spec.ExitCode, spec.Command = true, -1, r.Command + } else if accountEnv, err := s.agentEnv(spec.Meta); err != nil { + // An unsafe or unavailable account home must not fall back to + // the host's sign-in when the daemon restores its sessions. + s.logger().Warn("could not restore a session's account", "id", r.ID, "err", err) + spec.Exited, spec.ExitCode, spec.Command = true, -1, r.Command } else if run, err := s.configRunFor(r.ID, spec.Meta); err != nil { // Its config's key can't be read yet (no app to lend it): it // waits, stopped, to be continued, rather than start as another. @@ -308,11 +313,7 @@ func (s *Server) restore() { env, names := s.restartVaultEnv(r.ID, spec.Meta) spec.Env, spec.Meta.Creds = append(append(spec.Env, env...), run.environ()...), names // its config's API over any key lent } - if env, err := s.agentEnv(spec.Meta); err == nil { - spec.Env = append(spec.Env, env...) - } else { - s.logger().Warn("could not restore a session's account", "id", r.ID, "err", err) - } + spec.Env = append(spec.Env, accountEnv...) if preset, ok := agent.Find(r.Agent); ok && len(r.Restart) > 0 && len(preset.Continue) > 0 { // The command saved has the hooks and guide of the daemon // that saved it. diff --git a/docs/accounts.md b/docs/accounts.md index df5c1d0a..9d1f0c1f 100644 --- a/docs/accounts.md +++ b/docs/accounts.md @@ -12,7 +12,23 @@ Other agents have a home variable but no other account, as their docs don't name - **New session** picks the config: Default is the agent's own. - **Command line**: `enowx-app spawn --config Work …` (or `--account`). -Each account's home is `~/.enowx-app/accounts/` on the host (`core/accounts`). It links to what is in the agent's own home (`~/.claude`, `~/.codex`, and so on): settings, skills, subagents, commands, plugins and conversations are the same whichever account a session uses, so the chat view, history and Settings see them all. Only the sign-in is the account's own (for Claude Code also `.claude.json`, which holds who is signed in, so MCP servers added with `claude mcp add --scope user` are per account). A session keeps its account when continued, after hibernation or a restart. +Each account's home is `~/.enowx-app/accounts/` on the host (`core/accounts`). It links settings, skills and conversations from the agent's own home (`~/.claude`, `~/.codex`, and so on). The sign-in stays private (for Claude Code also `.claude.json`, which holds who is signed in, so MCP servers added with `claude mcp add --scope user` are per account). A session keeps its account when continued, after hibernation or a restart. + +### Codex runtime isolation + +Codex shares only known entries: `config.toml`, `AGENTS.md`, `skills`, `prompts`, `rules`, `plugins`, `history.jsonl`, `session_index.jsonl`, `sessions`, `archived_sessions` and `thread-writer-locks`. Other entries stay private, including `auth.json`, all SQLite databases and their WAL, SHM and rollback journals, sockets, and app-server daemon/control state. New runtime names are private by default. Closing SQLite can remove its sidecars; preparing the account again never links those missing files to another home's journals. + +Rollouts and their writer locks use the same canonical directories in `~/.codex`, created if absent. An existing account directory pointing elsewhere is refused rather than replaced or merged. If canonical links cannot be made (including Windows without symlink permission), the account does not start: private writer locks beside shared rollouts would allow competing writers. + +An explicit `CODEX_HOME` in a user's config env wins over the generated account home; the last such entry wins. enowX leaves that explicit home and the unused generated home untouched. The config owner must prepare the explicit home's isolation and any canonical rollout/lock sharing. This does not apply to a team's member's isolated account, which must not share the host user's files. + +### Existing Codex links + +An account home with links outside the shared list is refused before sharing. There is no automatic unlink, database copy, checkpoint, repair or lock removal. A dangling runtime link is refused too. + +Review an old home offline, after all agents and background processes using it have exited normally. Keep a backup and a link/target manifest. Prefer a fresh private runtime and a fresh sign-in. Retain physical databases together with their own journals until their consistency can be established offline; do not copy a live database or combine a database with another home's WAL/SHM. If old links must be retired, quarantine only the links after reviewing their targets; preserve the target files and any physical account files. Preserve the canonical rollout directory and its writer-lock directory as a pair; never delete an active writer lock or introduce private locks for shared rollouts. These steps require a separate reviewed migration, not an account-start side effect. + +Sharing database and journal paths across homes creates a reproducible risk. It does not establish the cause of any historical database corruption. ## API keys as accounts