Skip to content
91 changes: 91 additions & 0 deletions internal/adapters/config/dormant.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package config

import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"time"
)

// Save writes cfg to ~/.rune/config.json (atomic-ish via O_TRUNC + 0o600).
//
// Mirrors Python's `_set_dormant_with_reason` (server.py): truncate + write
// in one syscall, perm 0600. Not a true atomic-rename pattern — if the
// process is killed mid-write the file is left truncated. For dormant-state
// updates this is acceptable: the next boot will read whatever's left and
// either succeed (active state) or re-attempt the dormant write.
func Save(cfg *Config) error {
configPath, err := DefaultConfigPath()
if err != nil {
return err
}
return SaveToPath(cfg, configPath)
}

// SaveToPath writes cfg to a specific path (used by tests).
func SaveToPath(cfg *Config, path string) error {
if err := EnsureDirectories(); err != nil {
return fmt.Errorf("config: ensure directories: %w", err)
}

data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("config: marshal: %w", err)
}

// O_WRONLY | O_CREAT | O_TRUNC matches Python server.py's
// _set_dormant_with_reason atomic-truncate pattern.
if err := os.WriteFile(path, data, FilePerm); err != nil {
return fmt.Errorf("config: write %s: %w", path, err)
}
return nil
}

// MarkDormant transitions config.json to dormant state with the given reason
// + RFC3339 UTC timestamp. Mirrors Python server.py
// `_set_dormant_with_reason` so the daemon's view of "why am I dormant"
// survives process restarts (next boot reads the same dormant_reason).
//
// Idempotent: if config.json is already dormant with the same reason, this
// is a no-op (no disk write). Mirrors Python's "skip if same" guard.
//
// If config.json doesn't exist yet (fresh install), this creates it with
// just the dormant fields populated. The Vault section stays zero so the
// next /rune:configure run can fill it in normally.
//
// Use cases (called by boot loop on terminal failures):
// - "not_configured" — config.json missing, fresh install
// - "vault_unconfigured" — config exists but Vault.Endpoint/Token empty
// - "user_deactivated" — already-dormant config picked up by boot
// (idempotent path, just refreshes timestamp)
func MarkDormant(reason string) error {
cfg, err := Load()
if err != nil {
// Either the file is missing (fresh install) or it's
// corrupt/unreadable. Both cases: fall back to a fresh Config —
// overwriting bad state with a clean dormant config is the right
// recovery here, not bubbling up.
if !errors.Is(err, fs.ErrNotExist) {
// Surface unexpected errors (permission, IO) to the caller; only
// missing-file gets the silent fallback path.
//
// Note: parse errors are wrapped by LoadFromPath but the
// underlying chain still resolves through errors.Is for
// fs.ErrNotExist. For any other failure, prefer creating a fresh
// dormant record over crashing the boot loop.
}
cfg = &Config{}
}

if cfg.State == "dormant" && cfg.DormantReason == reason {
return nil // already in this state — skip write
}

cfg.State = "dormant"
cfg.DormantReason = reason
cfg.DormantSince = time.Now().UTC().Format(time.RFC3339)

return Save(cfg)
}
239 changes: 239 additions & 0 deletions internal/adapters/config/dormant_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,239 @@
package config_test

import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"

"github.com/envector/rune-go/internal/adapters/config"
)

// withTempHome redirects $HOME to a temp dir for the duration of the test.
// config.RuneDir reads $HOME, so this isolates side effects.
func withTempHome(t *testing.T) string {
t.Helper()
dir := t.TempDir()
t.Setenv("HOME", dir)
t.Setenv("RUNE_STATE", "")
return dir
}

func readConfig(t *testing.T, home string) map[string]any {
t.Helper()
data, err := os.ReadFile(filepath.Join(home, ".rune", "config.json"))
if err != nil {
t.Fatalf("read config.json: %v", err)
}
var m map[string]any
if err := json.Unmarshal(data, &m); err != nil {
t.Fatalf("parse config.json: %v", err)
}
return m
}

func TestMarkDormant_FreshInstall_CreatesConfig(t *testing.T) {
home := withTempHome(t)

if err := config.MarkDormant("not_configured"); err != nil {
t.Fatalf("MarkDormant: %v", err)
}

got := readConfig(t, home)
if got["state"] != "dormant" {
t.Errorf("state: got %v, want dormant", got["state"])
}
if got["dormant_reason"] != "not_configured" {
t.Errorf("dormant_reason: got %v", got["dormant_reason"])
}
if _, err := time.Parse(time.RFC3339, got["dormant_since"].(string)); err != nil {
t.Errorf("dormant_since not RFC3339: %v", got["dormant_since"])
}
}

func TestMarkDormant_ExistingConfig_PreservesVaultFields(t *testing.T) {
home := withTempHome(t)

// Pre-write a config with vault creds.
cfg := &config.Config{
Vault: config.VaultConfig{
Endpoint: "tcp://existing:50051",
Token: "evt_keep_me",
},
State: "active",
}
if err := config.Save(cfg); err != nil {
t.Fatalf("setup: Save: %v", err)
}

// Now mark dormant — vault fields must survive.
if err := config.MarkDormant("vault_unconfigured"); err != nil {
t.Fatalf("MarkDormant: %v", err)
}

got := readConfig(t, home)
vault, ok := got["vault"].(map[string]any)
if !ok {
t.Fatalf("vault section missing")
}
if vault["endpoint"] != "tcp://existing:50051" {
t.Errorf("vault.endpoint clobbered: got %v", vault["endpoint"])
}
if vault["token"] != "evt_keep_me" {
t.Errorf("vault.token clobbered: got %v", vault["token"])
}
if got["state"] != "dormant" {
t.Errorf("state: got %v, want dormant", got["state"])
}
if got["dormant_reason"] != "vault_unconfigured" {
t.Errorf("dormant_reason: got %v", got["dormant_reason"])
}
}

func TestMarkDormant_IdempotentOnSameReason(t *testing.T) {
withTempHome(t)

if err := config.MarkDormant("not_configured"); err != nil {
t.Fatalf("first MarkDormant: %v", err)
}
first, err := config.Load()
if err != nil {
t.Fatalf("first Load: %v", err)
}
firstSince := first.DormantSince

// Sleep to ensure timestamp would differ if write happened.
time.Sleep(1100 * time.Millisecond)

// Same reason — must NOT rewrite (timestamp preserved).
if err := config.MarkDormant("not_configured"); err != nil {
t.Fatalf("second MarkDormant: %v", err)
}
second, err := config.Load()
if err != nil {
t.Fatalf("second Load: %v", err)
}
if second.DormantSince != firstSince {
t.Errorf("idempotent same-reason write: timestamp changed (%q → %q)", firstSince, second.DormantSince)
}
}

func TestMarkDormant_NewReasonOverwrites(t *testing.T) {
withTempHome(t)

if err := config.MarkDormant("not_configured"); err != nil {
t.Fatalf("first: %v", err)
}
if err := config.MarkDormant("vault_unconfigured"); err != nil {
t.Fatalf("second: %v", err)
}
cfg, err := config.Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
if cfg.DormantReason != "vault_unconfigured" {
t.Errorf("reason: got %q, want vault_unconfigured", cfg.DormantReason)
}
}

func TestMarkDormant_FilePerm0600(t *testing.T) {
home := withTempHome(t)

if err := config.MarkDormant("not_configured"); err != nil {
t.Fatalf("MarkDormant: %v", err)
}

info, err := os.Stat(filepath.Join(home, ".rune", "config.json"))
if err != nil {
t.Fatalf("stat: %v", err)
}
if got := info.Mode().Perm(); got != config.FilePerm {
t.Errorf("perm: got %#o, want %#o", got, config.FilePerm)
}
}

func TestMarkDormant_DormantSinceWithinReasonableWindow(t *testing.T) {
withTempHome(t)

before := time.Now().UTC()
if err := config.MarkDormant("not_configured"); err != nil {
t.Fatalf("MarkDormant: %v", err)
}
after := time.Now().UTC()

cfg, err := config.Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
since, err := time.Parse(time.RFC3339, cfg.DormantSince)
if err != nil {
t.Fatalf("parse since: %v", err)
}
// Allow 1s slack on both sides (RFC3339 is second-precision).
if since.Before(before.Add(-time.Second)) || since.After(after.Add(time.Second)) {
t.Errorf("DormantSince out of window: got %v, want between %v and %v", since, before, after)
}
}

func TestSave_WritesValidJSON(t *testing.T) {
withTempHome(t)

cfg := &config.Config{
Vault: config.VaultConfig{Endpoint: "tcp://test:50051", Token: "t"},
State: "active",
}
if err := config.Save(cfg); err != nil {
t.Fatalf("Save: %v", err)
}

got, err := config.Load()
if err != nil {
t.Fatalf("Load roundtrip: %v", err)
}
if got.Vault.Endpoint != "tcp://test:50051" || got.State != "active" {
t.Errorf("roundtrip: got %+v", got)
}
}

func TestSave_EnsuresDirIfMissing(t *testing.T) {
home := withTempHome(t)

// ~/.rune doesn't exist yet.
if _, err := os.Stat(filepath.Join(home, ".rune")); !os.IsNotExist(err) {
t.Fatalf("setup: ~/.rune should not exist, err=%v", err)
}

cfg := &config.Config{State: "active"}
if err := config.Save(cfg); err != nil {
t.Fatalf("Save: %v", err)
}

info, err := os.Stat(filepath.Join(home, ".rune"))
if err != nil {
t.Fatalf("dir not created: %v", err)
}
if !info.IsDir() {
t.Errorf("not a directory")
}
if got := info.Mode().Perm(); got != config.DirPerm {
t.Errorf("dir perm: got %#o, want %#o", got, config.DirPerm)
}
}

// Sanity: error messages from MarkDormant / Save mention the package prefix
// so callers can grep / wrap appropriately.
func TestSave_ErrorContainsPackagePrefix(t *testing.T) {
// Force a write error: pass an invalid path that resolves outside any
// writable dir. We can do this by using SaveToPath directly with a
// directory path (so WriteFile fails).
dir := t.TempDir()
err := config.SaveToPath(&config.Config{}, dir)
if err == nil {
t.Fatal("expected error writing to a directory path")
}
if !strings.HasPrefix(err.Error(), "config: ") {
t.Errorf("error prefix: got %q, want 'config: ...'", err.Error())
}
}
45 changes: 45 additions & 0 deletions internal/adapters/embedder/socket.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
package embedder

import (
"os"
"path/filepath"
)

// DefaultSocketPath is the runed daemon convention path relative to $HOME:
// ~/.runed/embedding.sock (Plan A scope, macOS/Linux). Spec: embedder.md
// §소켓 경로.
const DefaultSocketPath = ".runed/embedding.sock"

// SocketEnvVar is the env var that overrides socket discovery for tests
// and non-default installations.
const SocketEnvVar = "RUNE_EMBEDDER_SOCKET"

// ResolveSocketPath returns the unix-socket path to use when dialing the
// runed daemon. Priority (per spec/components/embedder.md §소켓 경로):
//
// 1. env RUNE_EMBEDDER_SOCKET
// 2. configPath argument (typically from config.embedder.socket_path; pass
// empty string when unset)
// 3. ~/.runed/embedding.sock (runed convention default)
//
// The returned path is always absolute when the default branch is taken
// (UserHomeDir + relative join). When a caller passes an explicit
// configPath that is relative, it's returned as-is — caller's responsibility
// to ensure it resolves correctly from the rune-mcp working directory.
//
// Returns the empty string only if the home-dir lookup fails AND none of
// env/config provided a value. Callers should treat empty as a fatal
// configuration error.
func ResolveSocketPath(configPath string) string {
if envPath := os.Getenv(SocketEnvVar); envPath != "" {
return envPath
}
if configPath != "" {
return configPath
}
home, err := os.UserHomeDir()
if err != nil {
return ""
}
return filepath.Join(home, DefaultSocketPath)
}
Loading
Loading