diff --git a/internal/adapters/config/dormant.go b/internal/adapters/config/dormant.go new file mode 100644 index 00000000..7860bd90 --- /dev/null +++ b/internal/adapters/config/dormant.go @@ -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) +} diff --git a/internal/adapters/config/dormant_test.go b/internal/adapters/config/dormant_test.go new file mode 100644 index 00000000..8587c399 --- /dev/null +++ b/internal/adapters/config/dormant_test.go @@ -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()) + } +} diff --git a/internal/adapters/embedder/socket.go b/internal/adapters/embedder/socket.go new file mode 100644 index 00000000..8f1e9cf2 --- /dev/null +++ b/internal/adapters/embedder/socket.go @@ -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) +} diff --git a/internal/adapters/embedder/socket_test.go b/internal/adapters/embedder/socket_test.go new file mode 100644 index 00000000..3a3377bd --- /dev/null +++ b/internal/adapters/embedder/socket_test.go @@ -0,0 +1,59 @@ +package embedder_test + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/envector/rune-go/internal/adapters/embedder" +) + +func TestResolveSocketPath_EnvVarWins(t *testing.T) { + t.Setenv(embedder.SocketEnvVar, "/tmp/custom.sock") + + got := embedder.ResolveSocketPath("/from/config.sock") + if got != "/tmp/custom.sock" { + t.Errorf("env override should win: got %q, want /tmp/custom.sock", got) + } +} + +func TestResolveSocketPath_ConfigUsedWhenEnvUnset(t *testing.T) { + t.Setenv(embedder.SocketEnvVar, "") + + got := embedder.ResolveSocketPath("/from/config.sock") + if got != "/from/config.sock" { + t.Errorf("config path: got %q, want /from/config.sock", got) + } +} + +func TestResolveSocketPath_DefaultWhenNothingProvided(t *testing.T) { + t.Setenv(embedder.SocketEnvVar, "") + t.Setenv("HOME", "/test/home") + + got := embedder.ResolveSocketPath("") + want := filepath.Join("/test/home", embedder.DefaultSocketPath) + if got != want { + t.Errorf("default: got %q, want %q", got, want) + } +} + +func TestResolveSocketPath_DefaultEndsInRunedDir(t *testing.T) { + t.Setenv(embedder.SocketEnvVar, "") + t.Setenv("HOME", "/h") + + got := embedder.ResolveSocketPath("") + if !strings.HasSuffix(got, ".runed/embedding.sock") { + t.Errorf("default path should end in .runed/embedding.sock, got %q", got) + } +} + +func TestResolveSocketPath_EmptyConfigStringFalsThrough(t *testing.T) { + // Explicit empty config string should fall through to env lookup, not be + // treated as a valid path itself. + t.Setenv(embedder.SocketEnvVar, "/env/wins.sock") + + got := embedder.ResolveSocketPath("") + if got != "/env/wins.sock" { + t.Errorf("env should still win when config is empty: got %q", got) + } +} diff --git a/internal/adapters/keymanager/keys.go b/internal/adapters/keymanager/keys.go new file mode 100644 index 00000000..7c5a1864 --- /dev/null +++ b/internal/adapters/keymanager/keys.go @@ -0,0 +1,69 @@ +// Package keymanager persists FHE key material received from Vault to the +// local rune directory so the envector SDK can load it via OpenKeysFromFile. +// +// Format note: EncKey.json carries a libevi key envelope (provider_meta + +// entries — see third_party/evi/include/km/KeyEnvelope.hpp). envector-go-sdk +// (our envector adapter) and pyenvector are both libevi wrappers and produce +// / consume this same on-disk format. The Vault server generated the key via +// envector-go-sdk's GenerateKeys (which calls libevi's evi_km_wrap_enc_key) +// and forwards the file content verbatim through GetAgentManifest's +// manifest_json. When we load it back, envector-go-sdk's OpenKeysFromFile +// invokes evi_km_unwrap_enc_key — which expects the same envelope shape on +// disk. We must persist bundle.EncKey byte-identical; any re-encoding or +// re-wrapping will be rejected by the cgo unwrap. +package keymanager + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/envector/rune-go/internal/adapters/config" +) + +// SaveEncKey writes the EncKey envelope received from Vault verbatim to +// ~/.rune/keys//EncKey.json (perm 0600). The directory is created +// with perm 0700 if missing. +// +// encKey is the byte content of the original pyenvector EncKey.json file +// (manifest_json field "EncKey.json" carries this as a string). Do NOT +// re-encode, base64-wrap, or otherwise transform — the cgo unwrap on the +// envector side parses the original envelope shape and any modification +// breaks it. +// +// Empty encKey is treated as a no-op (caller responsibility to validate). +func SaveEncKey(keyID string, encKey []byte) error { + if len(encKey) == 0 { + return nil + } + + keyDir, err := KeyDir(keyID) + if err != nil { + return err + } + if err := os.MkdirAll(keyDir, config.DirPerm); err != nil { + return fmt.Errorf("keymanager: mkdir %s: %w", keyDir, err) + } + // MkdirAll honors umask — explicitly enforce 0700 in case umask masked it. + if err := os.Chmod(keyDir, config.DirPerm); err != nil { + return fmt.Errorf("keymanager: chmod %s: %w", keyDir, err) + } + + encPath := filepath.Join(keyDir, "EncKey.json") + if err := os.WriteFile(encPath, encKey, config.FilePerm); err != nil { + return fmt.Errorf("keymanager: write EncKey.json: %w", err) + } + return nil +} + +// KeyDir returns the per-key directory path that envector SDK's +// OpenKeysFromFile expects as WithKeyPath: ~/.rune/keys//. This is +// the directory containing EncKey.json — envector resolves the file +// directly via filepath.Join(keyDir, "EncKey.json"). +func KeyDir(keyID string) (string, error) { + runedir, err := config.RuneDir() + if err != nil { + return "", err + } + return filepath.Join(runedir, "keys", keyID), nil +} diff --git a/internal/adapters/keymanager/keys_test.go b/internal/adapters/keymanager/keys_test.go new file mode 100644 index 00000000..7807ed39 --- /dev/null +++ b/internal/adapters/keymanager/keys_test.go @@ -0,0 +1,132 @@ +package keymanager_test + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/envector/rune-go/internal/adapters/config" + "github.com/envector/rune-go/internal/adapters/keymanager" +) + +// withTempHome redirects $HOME to a t.TempDir for the duration of t. +// config.RuneDir() reads $HOME, so this isolates filesystem side effects. +func withTempHome(t *testing.T) string { + t.Helper() + dir := t.TempDir() + t.Setenv("HOME", dir) + return dir +} + +func TestSaveEncKey_WritesBytesVerbatim(t *testing.T) { + home := withTempHome(t) + + // Simulate the exact bytes Vault would forward as manifest_json["EncKey.json"]: + // a pyenvector KeyEnvelope JSON string. Use a representative payload that + // includes the structural fields libevi expects (provider_meta + entries), + // without committing to specific values for unit-test purposes. + encKey := []byte(`{ + "provider_meta": {"name":"test","format_version":"1"}, + "entries": [{"role":"EncKey","key_data":"AAAA","metadata":{"parameter":{"Q":1,"P":1,"DB_SCALE_FACTOR":1.0,"QUERY_SCALE_FACTOR":1.0,"preset":"FGb"},"eval_mode":"rmp"}}] +}`) + + if err := keymanager.SaveEncKey("key-test", encKey); err != nil { + t.Fatalf("SaveEncKey: %v", err) + } + + // Cross-check: file should be byte-identical to what we passed in. + got, err := os.ReadFile(filepath.Join(home, ".rune", "keys", "key-test", "EncKey.json")) + if err != nil { + t.Fatalf("read written file: %v", err) + } + if !bytes.Equal(got, encKey) { + t.Errorf("file contents differ from input — keymanager must NOT transform encKey.\nwant=%q\n got=%q", encKey, got) + } +} + +func TestSaveEncKey_EmptyIsNoop(t *testing.T) { + home := withTempHome(t) + + if err := keymanager.SaveEncKey("key-empty", nil); err != nil { + t.Errorf("nil encKey: got error %v, want nil (no-op)", err) + } + if err := keymanager.SaveEncKey("key-empty", []byte{}); err != nil { + t.Errorf("empty encKey: got error %v, want nil (no-op)", err) + } + + // Should not have created the keys/key-empty directory either, since + // the no-op fast-paths before MkdirAll. + if _, err := os.Stat(filepath.Join(home, ".rune", "keys", "key-empty")); !os.IsNotExist(err) { + t.Errorf("empty payload should not create keys dir; stat err=%v", err) + } +} + +func TestSaveEncKey_FilePerm0600(t *testing.T) { + home := withTempHome(t) + + if err := keymanager.SaveEncKey("perm-test", []byte("x")); err != nil { + t.Fatalf("SaveEncKey: %v", err) + } + + encPath := filepath.Join(home, ".rune", "keys", "perm-test", "EncKey.json") + info, err := os.Stat(encPath) + if err != nil { + t.Fatalf("stat: %v", err) + } + if got := info.Mode().Perm(); got != config.FilePerm { + t.Errorf("file perm: got %#o, want %#o", got, config.FilePerm) + } +} + +func TestSaveEncKey_DirPerm0700(t *testing.T) { + home := withTempHome(t) + + if err := keymanager.SaveEncKey("dir-perm", []byte("x")); err != nil { + t.Fatalf("SaveEncKey: %v", err) + } + + keyDir := filepath.Join(home, ".rune", "keys", "dir-perm") + info, err := os.Stat(keyDir) + if err != nil { + t.Fatalf("stat keyDir: %v", err) + } + if got := info.Mode().Perm(); got != config.DirPerm { + t.Errorf("dir perm: got %#o, want %#o", got, config.DirPerm) + } +} + +func TestKeyDir_ReturnsExpectedPath(t *testing.T) { + home := withTempHome(t) + + got, err := keymanager.KeyDir("my-key") + if err != nil { + t.Fatalf("KeyDir: %v", err) + } + want := filepath.Join(home, ".rune", "keys", "my-key") + if got != want { + t.Errorf("KeyDir: got %q, want %q", got, want) + } +} + +func TestSaveEncKey_OverwritesExisting(t *testing.T) { + withTempHome(t) + + // First write + if err := keymanager.SaveEncKey("over", []byte("old")); err != nil { + t.Fatalf("first SaveEncKey: %v", err) + } + // Overwrite + if err := keymanager.SaveEncKey("over", []byte("new content")); err != nil { + t.Fatalf("second SaveEncKey: %v", err) + } + + dir, _ := keymanager.KeyDir("over") + got, err := os.ReadFile(filepath.Join(dir, "EncKey.json")) + if err != nil { + t.Fatalf("read: %v", err) + } + if string(got) != "new content" { + t.Errorf("overwrite: got %q, want %q", got, "new content") + } +} diff --git a/internal/lifecycle/boot.go b/internal/lifecycle/boot.go index 84438865..67c8b165 100644 --- a/internal/lifecycle/boot.go +++ b/internal/lifecycle/boot.go @@ -16,10 +16,30 @@ package lifecycle import ( "context" + "fmt" + "log/slog" + "os" "sync/atomic" "time" + + "github.com/envector/rune-go/internal/adapters/config" + "github.com/envector/rune-go/internal/adapters/embedder" + "github.com/envector/rune-go/internal/adapters/envector" + "github.com/envector/rune-go/internal/adapters/keymanager" + "github.com/envector/rune-go/internal/adapters/vault" ) +// BootAdapterInjector decouples lifecycle from mcp.Deps to break the +// adapter ↔ handler import cycle. The boot loop pushes adapter clients + +// per-token Vault bundle metadata through this interface; the concrete +// implementation (mcp.Deps) propagates them onto the 3 service structs. +type BootAdapterInjector interface { + InjectVault(client vault.Client) + InjectEmbedder(client embedder.Client) + InjectEnvector(client envector.Client) + ApplyVaultBundle(bundle *vault.Bundle) +} + // State — atomic-safe enum. type State int32 @@ -68,6 +88,17 @@ func (m *Manager) SetState(s State) { m.state.Store(int32(s)) } +// LastError reports the most recent transient failure recorded by the boot +// loop (empty string when none). Used by diagnostics tools. +func (m *Manager) LastError() string { + v := m.lastError.Load() + if v == nil { + return "" + } + s, _ := v.(string) + return s +} + // BootBackoffs — Python server.py Vault retry schedule. // Total to cap: 1s → 2s → 5s → 15s → 30s → 60s (then loop at 60s). var BootBackoffs = []time.Duration{ @@ -79,18 +110,255 @@ var BootBackoffs = []time.Duration{ 60 * time.Second, } -// RunBootLoop — background goroutine. Calls Vault.GetPublicKey with exp backoff -// until success, then SetState(Active). Stays alive for entire process to -// re-enter waiting_for_vault if Vault dies. +// DefaultKeyDim is the FHE slot dimension matching the Qwen3-Embedding-0.6B +// production deployment (spec/components/embedder.md §불변 계약). The Vault +// manifest does not currently carry a dim field; once embedder.Info is +// available end-to-end, the boot loop should source dim from there instead. +const DefaultKeyDim = 1024 + +// bootResult is the outcome of one bootOnce attempt. +type bootResult int + +const ( + // bootRetry — transient failure (Vault unreachable, network blip, partial + // init error). Caller should backoff and try again. + bootRetry bootResult = iota + + // bootActive — full success: Vault dialed, manifest parsed, keys persisted, + // adapters wired, services injected. Caller should set StateActive and exit. + bootActive + + // bootDormant — terminal: config missing, config.State="dormant", or vault + // endpoint/token unconfigured. Retrying won't help — only /rune:configure + // (or a process restart) will. Caller should set StateDormant and exit; + // service.LifecycleService.ReloadPipelines is responsible for re-spawning + // RunBootLoop after the user fixes config. + bootDormant +) + +// RunBootLoop drives the boot sequence per spec/components/rune-mcp.md §부팅 +// 시퀀스. It runs to completion (Active or Dormant terminal) then returns. +// Re-init after dormant↔active transitions is the responsibility of +// service.LifecycleService.ReloadPipelines (which spawns a fresh RunBootLoop +// goroutine). // -// TODO: -// 1. state = Starting -// 2. loop: call deps.vault.GetPublicKey -// success → cache keys (disk + memory), init envector, state = Active, return -// fail → state = WaitingForVault, log, sleep backoff[min(attempt, len-1)], retry -// 3. every attempt=20, log "persistent failure — check config" -func RunBootLoop(ctx context.Context, m *Manager /*, deps *Deps*/) { - // TODO: bit-identical to rune-mcp.md runBootLoop pseudocode - _ = ctx - _ = m +// Failure modes: +// - config.json missing → terminal Dormant (await /rune:configure) +// - config.State="dormant" → terminal Dormant (user explicit) +// - vault endpoint/token empty → terminal Dormant (await /rune:configure) +// - vault dial / GetAgentManifest → state=WaitingForVault, exp backoff retry +// - keymanager / embedder / envector init → exp backoff retry (might be +// transient — daemon down, etc.) +// - other config error (parse fail) → exp backoff retry (user might be editing) +// - ctx cancellation → return immediately +// +// Every attempt that fails after a successful Vault dial closes the partial +// adapter conns it created (vault, embedder, envector) before retrying so +// gRPC connections do not leak across retries. +func RunBootLoop(ctx context.Context, m *Manager, deps BootAdapterInjector) { + m.SetState(StateStarting) + + attempt := 0 + for { + if ctx.Err() != nil { + return + } + + switch bootOnce(ctx, m, deps) { + case bootActive: + m.SetState(StateActive) + m.lastError.Store("") + m.attempts.Store(int32(attempt)) + slog.Info("boot: pipelines initialized and active") + return + + case bootDormant: + // State + lastError already set inside bootOnce. + m.attempts.Store(int32(attempt)) + slog.Info("boot: dormant — awaiting /rune:configure or /rune:reload_pipelines", + "reason", m.LastError()) + return + + case bootRetry: + if attempt > 0 && attempt%20 == 0 { + slog.Error("boot: persistent failure — check config or network", + "attempt", attempt, + "last_error", m.LastError()) + } + sleepBackoff(ctx, attempt) + attempt++ + } + } +} + +// bootOnce runs one boot attempt. Returns: +// - bootActive on full success +// - bootDormant on terminal config-side failures (caller should not retry) +// - bootRetry on transient failures (caller backs off and retries) +// +// On any failure path, state + lastError are updated. On post-Vault-dial +// failures the partially-constructed adapter conns are closed before return +// to avoid gRPC connection leak. +func bootOnce(ctx context.Context, m *Manager, deps BootAdapterInjector) bootResult { + cfg, err := config.Load() + if err != nil { + if os.IsNotExist(err) { + // Fresh install — config.json not provisioned. Retrying won't help; + // user must run /rune:configure first. Persist the dormant state + // to config.json so the next boot picks up the same reason + // (Python parity: server.py _set_dormant_with_reason). + m.SetState(StateDormant) + m.lastError.Store("config.json not found — run /rune:configure to set up") + if dErr := config.MarkDormant("not_configured"); dErr != nil { + slog.Warn("boot: failed to persist dormant state to config.json", "err", dErr) + } + slog.Warn("boot: config.json not found — entering dormant", + "hint", "run /rune:configure") + return bootDormant + } + // Other config errors (JSON parse, permission denied, etc.) — could be + // transient (user editing the file). Retry. + m.lastError.Store(fmt.Sprintf("config load: %v", err)) + slog.Error("boot: failed to load config", "err", err) + return bootRetry + } + + // Anything other than config.State == "active" is treated as dormant: + // - "dormant" — user explicitly deactivated (or a previous boot + // persisted dormant via MarkDormant) + // - "" — fresh install or hand-edited config without state + // - other / unknown — corrupted config + // + // Python parity: server.py:L1544 — `if rune_config.state != "active": + // return result`. Strict check covers all non-active values uniformly. + // /rune:activate transitions config.State back to "active" and re-spawns + // RunBootLoop. + if cfg.State != "active" { + m.SetState(StateDormant) + + var reason string + switch cfg.State { + case "dormant": + reason = cfg.DormantReason + if reason == "" { + reason = "user_deactivated" + } + case "": + reason = "not_configured" + default: + reason = "invalid_state" + } + + m.lastError.Store("dormant: " + reason) + if dErr := config.MarkDormant(reason); dErr != nil { + slog.Warn("boot: failed to persist dormant state to config.json", "err", dErr) + } + slog.Info("boot: state != active — staying dormant", + "config.state", cfg.State, + "reason", reason) + return bootDormant + } + + if cfg.Vault.Endpoint == "" || cfg.Vault.Token == "" { + // Config exists but Vault credentials are missing. Same UX as missing + // config — user must run /rune:configure. No retry. Persist to disk + // so the next boot picks up the same dormant_reason. + m.SetState(StateDormant) + m.lastError.Store("vault endpoint or token missing in config — run /rune:configure") + if dErr := config.MarkDormant("vault_unconfigured"); dErr != nil { + slog.Warn("boot: failed to persist dormant state to config.json", "err", dErr) + } + slog.Warn("boot: vault endpoint/token missing — entering dormant", + "hint", "run /rune:configure") + return bootDormant + } + + vaultClient, err := vault.NewClient(cfg.Vault.Endpoint, cfg.Vault.Token, vault.ClientOpts{ + CACertPath: cfg.Vault.CACert, + TLSDisable: cfg.Vault.TLSDisable, + }) + if err != nil { + m.SetState(StateWaitingForVault) + m.lastError.Store(fmt.Sprintf("vault dial: %v", err)) + slog.Error("boot: failed to connect to vault", "err", err) + return bootRetry + } + + bundle, err := vaultClient.GetAgentManifest(ctx) + if err != nil { + m.SetState(StateWaitingForVault) + m.lastError.Store(fmt.Sprintf("vault get manifest: %v", err)) + slog.Warn("boot: waiting for vault...", "err", err) + _ = vaultClient.Close() + return bootRetry + } + + if err := keymanager.SaveEncKey(bundle.KeyID, bundle.EncKey); err != nil { + m.lastError.Store(fmt.Sprintf("save EncKey: %v", err)) + slog.Error("boot: failed to save keys to disk", "err", err) + _ = vaultClient.Close() + return bootRetry + } + + embedderClient, err := embedder.New(embedder.ResolveSocketPath("")) + if err != nil { + m.lastError.Store(fmt.Sprintf("embedder dial: %v", err)) + slog.Error("boot: failed to connect to embedder", "err", err) + _ = vaultClient.Close() + return bootRetry + } + + keyDir, err := keymanager.KeyDir(bundle.KeyID) + if err != nil { + m.lastError.Store(fmt.Sprintf("resolve key dir: %v", err)) + slog.Error("boot: failed to resolve key dir", "err", err) + _ = vaultClient.Close() + _ = embedderClient.Close() + return bootRetry + } + + envectorClient, err := envector.NewClient(envector.ClientConfig{ + Endpoint: bundle.EnvectorEndpoint, + APIKey: bundle.EnvectorAPIKey, + KeyPath: keyDir, + KeyID: bundle.KeyID, + KeyDim: DefaultKeyDim, + IndexName: bundle.IndexName, + }) + if err != nil { + m.lastError.Store(fmt.Sprintf("envector new client: %v", err)) + slog.Error("boot: failed to connect to envector", "err", err) + _ = vaultClient.Close() + _ = embedderClient.Close() + return bootRetry + } + + if err := envectorClient.OpenIndex(ctx); err != nil { + m.lastError.Store(fmt.Sprintf("envector open index: %v", err)) + slog.Error("boot: envector index activation failed", "err", err) + _ = vaultClient.Close() + _ = embedderClient.Close() + _ = envectorClient.Close() + return bootRetry + } + + deps.InjectVault(vaultClient) + deps.InjectEmbedder(embedderClient) + deps.InjectEnvector(envectorClient) + deps.ApplyVaultBundle(bundle) + + return bootActive +} + +// sleepBackoff sleeps for BootBackoffs[min(attempt, len-1)] but returns +// early if ctx is cancelled. +func sleepBackoff(ctx context.Context, attempt int) { + idx := attempt + if idx >= len(BootBackoffs) { + idx = len(BootBackoffs) - 1 + } + select { + case <-time.After(BootBackoffs[idx]): + case <-ctx.Done(): + } } diff --git a/internal/mcp/apply_bundle_test.go b/internal/mcp/apply_bundle_test.go new file mode 100644 index 00000000..119b4bcb --- /dev/null +++ b/internal/mcp/apply_bundle_test.go @@ -0,0 +1,109 @@ +package mcp_test + +import ( + "bytes" + "testing" + + "github.com/envector/rune-go/internal/adapters/vault" + "github.com/envector/rune-go/internal/mcp" + "github.com/envector/rune-go/internal/service" +) + +func newDepsForApply() *mcp.Deps { + return &mcp.Deps{ + Capture: service.NewCaptureService(), + Recall: service.NewRecallService(), + Lifecycle: service.NewLifecycleService(), + } +} + +func TestApplyVaultBundle_PropagatesToCapture(t *testing.T) { + d := newDepsForApply() + bundle := &vault.Bundle{ + AgentID: "agent_test", + AgentDEK: bytes.Repeat([]byte{0xAB}, 32), + IndexName: "team-index", + KeyID: "key_xyz", + EncKey: []byte("non-empty"), + } + + d.ApplyVaultBundle(bundle) + + if d.Capture.AgentID != "agent_test" { + t.Errorf("Capture.AgentID: got %q", d.Capture.AgentID) + } + if !bytes.Equal(d.Capture.AgentDEK, bundle.AgentDEK) { + t.Errorf("Capture.AgentDEK: got %v", d.Capture.AgentDEK) + } + if d.Capture.IndexName != "team-index" { + t.Errorf("Capture.IndexName: got %q", d.Capture.IndexName) + } +} + +func TestApplyVaultBundle_PropagatesToRecall(t *testing.T) { + d := newDepsForApply() + bundle := &vault.Bundle{IndexName: "ix"} + + d.ApplyVaultBundle(bundle) + + if d.Recall.IndexName != "ix" { + t.Errorf("Recall.IndexName: got %q, want ix", d.Recall.IndexName) + } +} + +func TestApplyVaultBundle_PropagatesToLifecycle(t *testing.T) { + d := newDepsForApply() + bundle := &vault.Bundle{ + IndexName: "ix", + KeyID: "key_z", + AgentDEK: bytes.Repeat([]byte{0x01}, 32), + EncKey: []byte("foo"), + } + + d.ApplyVaultBundle(bundle) + + if d.Lifecycle.IndexName != "ix" { + t.Errorf("Lifecycle.IndexName: got %q", d.Lifecycle.IndexName) + } + if d.Lifecycle.KeyID != "key_z" { + t.Errorf("Lifecycle.KeyID: got %q", d.Lifecycle.KeyID) + } + if !bytes.Equal(d.Lifecycle.AgentDEK, bundle.AgentDEK) { + t.Errorf("Lifecycle.AgentDEK mismatch") + } + if !d.Lifecycle.EncKeyLoaded { + t.Error("Lifecycle.EncKeyLoaded: got false, want true (EncKey present)") + } +} + +func TestApplyVaultBundle_EncKeyLoadedFalseWhenEmpty(t *testing.T) { + d := newDepsForApply() + d.ApplyVaultBundle(&vault.Bundle{EncKey: nil}) + + if d.Lifecycle.EncKeyLoaded { + t.Error("EncKeyLoaded with nil EncKey: got true, want false") + } +} + +func TestApplyVaultBundle_NilBundleNoOp(t *testing.T) { + d := newDepsForApply() + d.Capture.AgentID = "preexisting" + + // Should not panic, should not modify state. + d.ApplyVaultBundle(nil) + + if d.Capture.AgentID != "preexisting" { + t.Errorf("nil bundle should be no-op, but Capture.AgentID changed to %q", d.Capture.AgentID) + } +} + +func TestApplyVaultBundle_NilServicesNoOp(t *testing.T) { + // All service pointers nil → ApplyVaultBundle must not panic. + defer func() { + if r := recover(); r != nil { + t.Errorf("nil services panicked: %v", r) + } + }() + d := &mcp.Deps{} // no services + d.ApplyVaultBundle(&vault.Bundle{AgentID: "x"}) +} diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index 08c263e4..605e063d 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -21,7 +21,11 @@ import ( sdkmcp "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/envector/rune-go/internal/adapters/embedder" + "github.com/envector/rune-go/internal/adapters/envector" + "github.com/envector/rune-go/internal/adapters/vault" "github.com/envector/rune-go/internal/domain" + "github.com/envector/rune-go/internal/lifecycle" "github.com/envector/rune-go/internal/service" ) @@ -35,13 +39,65 @@ import ( // Future fields (commented as a contract sketch — to be activated as the // owning adapter PR lands): // -// Vault vault.Client -// Envector envector.Client -// Embedder embedder.Client -// CaptureLog *logio.CaptureLog -// State *lifecycle.Manager -// Cfg *config.Config -type Deps struct{} +type Deps struct { + Vault vault.Client + Envector envector.Client + Embedder embedder.Client + State *lifecycle.Manager + + Capture *service.CaptureService + Recall *service.RecallService + Lifecycle *service.LifecycleService +} + +func (d *Deps) InjectVault(client vault.Client) { + d.Vault = client + if d.Capture != nil { d.Capture.Vault = client } + if d.Recall != nil { d.Recall.Vault = client } + if d.Lifecycle != nil { d.Lifecycle.Vault = client } +} + +func (d *Deps) InjectEmbedder(client embedder.Client) { + d.Embedder = client + if d.Capture != nil { d.Capture.Embedder = client } + if d.Recall != nil { d.Recall.Embedder = client } + if d.Lifecycle != nil { d.Lifecycle.Embedder = client } +} + +func (d *Deps) InjectEnvector(client envector.Client) { + d.Envector = client + if d.Capture != nil { d.Capture.Envector = client } + if d.Recall != nil { d.Recall.Envector = client } + if d.Lifecycle != nil { d.Lifecycle.Envector = client } +} + +// ApplyVaultBundle propagates per-bundle metadata (AgentID / AgentDEK / +// IndexName / KeyID) to the three services that need them. Called by the +// boot loop after Vault.GetAgentManifest succeeds. +// +// Without this call, capture's AES envelope sealing fails (empty AgentDEK) +// and recall / lifecycle diagnostics surface zero-value IndexName. Adapter +// client injection (InjectVault/InjectEmbedder/InjectEnvector) handles the +// gRPC connections; this method handles the per-token metadata. +func (d *Deps) ApplyVaultBundle(b *vault.Bundle) { + if b == nil { + return + } + if d.Capture != nil { + d.Capture.AgentID = b.AgentID + d.Capture.AgentDEK = b.AgentDEK + d.Capture.IndexName = b.IndexName + } + if d.Recall != nil { + d.Recall.IndexName = b.IndexName + } + if d.Lifecycle != nil { + d.Lifecycle.IndexName = b.IndexName + d.Lifecycle.KeyID = b.KeyID + d.Lifecycle.AgentDEK = b.AgentDEK + d.Lifecycle.EncKeyLoaded = len(b.EncKey) > 0 + } +} // emptyArgs — input type for tools that take no arguments. type emptyArgs struct{}