diff --git a/README.md b/README.md index 42ba54c..c16ed53 100644 --- a/README.md +++ b/README.md @@ -53,20 +53,22 @@ No Docker daemon is on the session path. | Step | What runs | | --- | --- | -| `make image` | Docker, to build the guest disk | +| `make image` | Docker, to pack the golden disk | | `abox` / `--probe-vm` | `abox` + `abox-vmm` + libkrun VM | -`~/.abox/images/abox-guest.raw` is the **base guest disk** -(Alpine + git + `abox-guest`). Think of it like the microVMs golden hard-drive image. Sessions do not boot that file read/write. -On start, ABox clones it (APFS copy-on-write when available) to: +Guest storage is files on the Mac, not a mount of your repo. Three files matter: -`~/.abox/sessions//root.raw` +| File | Role | Attached to the running VM? | +| --- | --- | --- | +| `~/.abox/images/abox-guest.raw` | **Golden image.** Alpine + git + patch + `abox-guest`. Clean template from `make image`. | No. Never opened read/write by a session. | +| `~/.abox/sessions//root.raw` | **Session disk.** Clone of the golden image (APFS copy-on-write when available, else a full copy). This is the writable microVM hard drive (`/dev/vda`). Repo snapshot, `/work/repo`, guest Git, and anything the agent writes land here. | Yes. | +| `~/.abox/sessions//config.raw` | **Sealed config.** ~1 MiB, mode `0400`, read-only. Session id, capability, model, API keys. Guest reads it as `/dev/vdb`. Not an OS image and not cloned from the golden disk. | Yes (read-only). | -The microVM attaches that session disk. Repo snapshot and agent work land -there. The cache image stays the clean template for the next session. +`~/.abox/sessions//root.raw` is a copy of the golden image/template to use within a running instance of ABox so a user can write to it, prompt, etc... The guest boots that file as its writable disk. Prompts, tools, /work/repo, patches, run_command all land there. The golden abox-guest.raw stays a clean template. -Each session also gets a small read-only `config.raw` (session id, -capability, model, keys). That is not the OS disk. +The VM boots **only** the session clone, not the golden file. Destroy a session directory and that run’s guest files are gone; the golden image stays clean for the next `abox`. `make image-update` patches `/usr/local/bin/abox-guest` on an existing golden disk; `make image` rebuilds the golden disk from scratch. + +`abox --resume` does **not** clone the golden image again. It boots the existing `root.raw` for that session and the guest reloads conversation state from `/var/lib/abox/context.json` on that disk. The host git tree is not re-copied (that would overwrite guest work). ## Quickstart @@ -90,6 +92,7 @@ abox - `/provider` sets Grok, OpenAI, or Anthropic API keys - `/mcp` lists configured Streamable HTTP MCP servers and accepts a Bearer token (`abox mcp login` for OAuth) +- `abox --resume` reopens the latest session for this repo (same `root.raw` and LLM conversation). `abox --resume ` picks a session. Plain `abox` still starts a new session. - `ctrl+c` quits - The agent runs only inside the guest (MicroVM) diff --git a/cmd/abox-guest/main.go b/cmd/abox-guest/main.go index 8edb96d..8561b35 100644 --- a/cmd/abox-guest/main.go +++ b/cmd/abox-guest/main.go @@ -53,7 +53,10 @@ func run() error { fmt.Fprintf(os.Stderr, "abox-guest: mcp: %v\n", err) } defer mcpMgr.Close() - loop := &agent.Loop{Model: agent.ModelFromGuest(cfg.Model), Repo: repo, MCP: mcpMgr} + loop := &agent.Loop{Model: agent.ModelFromGuest(cfg.Model), Repo: repo, MCP: mcpMgr, ContextFile: agent.DefaultContextFile} + if err := loop.LoadContext(); err != nil { + fmt.Fprintf(os.Stderr, "abox-guest: context: %v\n", err) + } conn, err := dialVsock(cfg.VsockPort) if err != nil { return fmt.Errorf("vsock: %w", err) @@ -113,8 +116,10 @@ func handleTurn(conn net.Conn, loop *agent.Loop, req protocol.Frame) error { _ = protocol.WriteFrame(conn, protocol.Frame{ID: req.ID, Method: "agent_event", Params: raw}) } if err := loop.Turn(context.Background(), p.Text); err != nil { + _ = loop.SaveContext() return protocol.WriteFrame(conn, protocol.Frame{ID: req.ID, Error: &protocol.Error{Code: "agent", Message: err.Error()}}) } + _ = loop.SaveContext() ok, _ := protocol.EncodeParams(map[string]bool{"ok": true}) return protocol.WriteFrame(conn, protocol.Frame{ID: req.ID, Result: ok}) } @@ -251,6 +256,7 @@ func handle(loop *agent.Loop, repo tools.Repo, mcpMgr *guestmcp.Manager, archive _ = execSetTime(t) out.Result, _ = protocol.EncodeParams(map[string]bool{"ok": true}) case "shutdown": + _ = loop.SaveContext() out.Result, _ = protocol.EncodeParams(map[string]bool{"ok": true}) default: err = fmt.Errorf("unknown method %q", req.Method) @@ -321,6 +327,7 @@ func prepMounts() { _ = os.MkdirAll("/dev", 0o755) _ = os.MkdirAll("/tmp", 0o1777) _ = os.MkdirAll("/work/repo", 0o755) + _ = os.MkdirAll("/var/lib/abox", 0o755) _ = mountIfNeeded("proc", "/proc") _ = mountIfNeeded("sysfs", "/sys") _ = mountIfNeeded("devtmpfs", "/dev") diff --git a/cmd/abox/main.go b/cmd/abox/main.go index 584f865..0ae54f9 100644 --- a/cmd/abox/main.go +++ b/cmd/abox/main.go @@ -37,6 +37,7 @@ func run() error { prompt := fs.String("prompt", "", "prompt for exec mode") modelName := fs.String("model", "", "configured model profile name") probeVM := fs.Bool("probe-vm", false, "boot the guest and list files; no model call") + resume := fs.Bool("resume", false, "resume a previous session for this repository (same root.raw and conversation)") args := os.Args[1:] execMode := false if len(args) > 0 && args[0] == "exec" { @@ -70,21 +71,41 @@ func run() error { return err } - sess, err := session.Create(wd, "pending") - if err != nil { - return err - } - snap, err := repository.OpenForSession(wd, filepath.Join(sess.Dir, "host-tree")) - if err != nil { - return err - } - sess.RepoRoot = snap.Root - sess.HEAD = snap.HEAD - if err := sess.WriteMeta(); err != nil { - return err + resumeID := "" + if *resume { + if extra := fs.Args(); len(extra) > 0 { + resumeID = extra[0] + } } - if snap.Ephemeral { - fmt.Fprintf(os.Stderr, "abox: no clean committed worktree; using an ephemeral snapshot. host git is unchanged.\n") + + var sess *session.Session + var snap repository.Snapshot + if *resume { + loaded, err := loadResumeSession(wd, resumeID) + if err != nil { + return err + } + sess = loaded + fmt.Fprintf(os.Stderr, "abox: resuming session %s\n", sess.ID) + } else { + created, err := session.Create(wd, "pending") + if err != nil { + return err + } + sess = created + opened, err := repository.OpenForSession(wd, filepath.Join(sess.Dir, "host-tree")) + if err != nil { + return err + } + snap = opened + sess.RepoRoot = snap.Root + sess.HEAD = snap.HEAD + if err := sess.WriteMeta(); err != nil { + return err + } + if snap.Ephemeral { + fmt.Fprintf(os.Stderr, "abox: no clean committed worktree; using an ephemeral snapshot. host git is unchanged.\n") + } } var sb *runtime.Sandbox @@ -97,7 +118,7 @@ func run() error { if err != nil { return err } - if err := runtime.Prepare(sess, image, sel, currentSecrets(cfg), mcpServers); err != nil { + if err := runtime.Prepare(sess, image, sel, currentSecrets(cfg), mcpServers, *resume); err != nil { if execMode { return err } @@ -124,12 +145,14 @@ func run() error { sb = started vmState = "ready" defer sb.Stop() - archive, err := repository.ArchiveHEAD(snap.Root) - if err != nil { - return err - } - if err := sb.TransferArchive(context.Background(), archive); err != nil { - fmt.Fprintf(os.Stderr, "abox: repo transfer: %v\n", err) + if !*resume { + archive, err := repository.ArchiveHEAD(snap.Root) + if err != nil { + return err + } + if err := sb.TransferArchive(context.Background(), archive); err != nil { + fmt.Fprintf(os.Stderr, "abox: repo transfer: %v\n", err) + } } } } @@ -169,6 +192,21 @@ func runExec(sb *runtime.Sandbox, prompt string) error { }) } +func loadResumeSession(wd, id string) (*session.Session, error) { + if id != "" { + return session.Load(id) + } + abs, err := filepath.Abs(wd) + if err != nil { + abs = wd + } + roots := []string{abs} + if top, err := repository.TopLevel(wd); err == nil { + roots = append(roots, top) + } + return session.LatestForRepo(roots...) +} + func currentSecrets(cfg config.File) map[string]string { out := map[string]string{} for _, name := range []string{"XAI_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY"} { diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 9725656..c890277 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -4,6 +4,8 @@ import ( "context" "encoding/json" "fmt" + "os" + "path/filepath" "strings" "time" @@ -19,12 +21,18 @@ type MCPClient interface { Call(ctx context.Context, server, tool string, args json.RawMessage) (string, error) } +const ( + DefaultContextFile = "/var/lib/abox/context.json" + maxContextBytes = 2 << 20 +) + type Loop struct { - Model config.Model - Repo tools.Repo - MCP MCPClient - Messages []provider.Message - OnEvent func(protocol.AgentEvent) + Model config.Model + Repo tools.Repo + MCP MCPClient + Messages []provider.Message + ContextFile string + OnEvent func(protocol.AgentEvent) } func BuiltinTools() []provider.ToolSchema { @@ -67,6 +75,7 @@ func (l *Loop) Turn(ctx context.Context, user string) error { events, err := provider.Stream(ctx, l.Model, l.Messages, l.allTools()) if err != nil { l.emit(protocol.AgentEvent{Kind: "error", Err: err.Error()}) + _ = l.SaveContext() return err } var text string @@ -80,6 +89,7 @@ func (l *Loop) Turn(ctx context.Context, user string) error { tool = ev case "error": l.emit(protocol.AgentEvent{Kind: "error", Err: ev.Err.Error()}) + _ = l.SaveContext() return ev.Err } } @@ -88,6 +98,7 @@ func (l *Loop) Turn(ctx context.Context, user string) error { l.Messages = append(l.Messages, provider.Message{Role: "assistant", Content: text}) } l.emit(protocol.AgentEvent{Kind: "done"}) + _ = l.SaveContext() return nil } l.Messages = append(l.Messages, provider.Message{ @@ -109,9 +120,87 @@ func (l *Loop) Turn(ctx context.Context, user string) error { ToolResult: result, }) } + _ = l.SaveContext() return fmt.Errorf("turn limit reached") } +func (l *Loop) contextPath() string { + if l.ContextFile != "" { + return l.ContextFile + } + return DefaultContextFile +} + +func (l *Loop) SaveContext() error { + return SaveMessages(l.contextPath(), l.Messages) +} + +func (l *Loop) LoadContext() error { + msgs, err := LoadMessages(l.contextPath()) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return err + } + l.Messages = msgs + return nil +} + +func SaveMessages(path string, msgs []provider.Message) error { + if path == "" { + return fmt.Errorf("empty context path") + } + trimmed := trimMessages(msgs, maxContextBytes) + data, err := json.MarshalIndent(trimmed, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return err + } + return os.Rename(tmp, path) +} + +func LoadMessages(path string) ([]provider.Message, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + var msgs []provider.Message + if err := json.Unmarshal(data, &msgs); err != nil { + return nil, err + } + return msgs, nil +} + +func trimMessages(msgs []provider.Message, max int) []provider.Message { + if max <= 0 { + return nil + } + var out []provider.Message + var size int + for i := len(msgs) - 1; i >= 0; i-- { + b, err := json.Marshal(msgs[i]) + if err != nil { + continue + } + if size+len(b) > max && len(out) > 0 { + break + } + out = append(out, msgs[i]) + size += len(b) + } + for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 { + out[i], out[j] = out[j], out[i] + } + return out +} + func (l *Loop) allTools() []provider.ToolSchema { tools := BuiltinTools() if l.MCP == nil { diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 10502eb..46206ed 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -3,6 +3,7 @@ package agent import ( "context" "encoding/json" + "path/filepath" "strings" "testing" @@ -25,6 +26,51 @@ func TestTurnRequiresGuestRepo(t *testing.T) { } } +func TestSaveLoadMessagesRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "context.json") + l := &Loop{ + ContextFile: path, + Messages: []provider.Message{ + {Role: "user", Content: "hi"}, + {Role: "assistant", Content: "hello"}, + {Role: "tool", ToolID: "1", ToolResult: "ok"}, + }, + } + if err := l.SaveContext(); err != nil { + t.Fatal(err) + } + l2 := &Loop{ContextFile: path} + if err := l2.LoadContext(); err != nil { + t.Fatal(err) + } + if len(l2.Messages) != 3 || l2.Messages[0].Content != "hi" || l2.Messages[2].ToolResult != "ok" { + t.Fatalf("%#v", l2.Messages) + } +} + +func TestLoadContextMissingFile(t *testing.T) { + l := &Loop{ContextFile: filepath.Join(t.TempDir(), "missing.json")} + if err := l.LoadContext(); err != nil { + t.Fatal(err) + } + if l.Messages != nil { + t.Fatalf("%#v", l.Messages) + } +} + +func TestTrimMessagesKeepsNewest(t *testing.T) { + msgs := []provider.Message{ + {Role: "user", Content: strings.Repeat("a", 200)}, + {Role: "user", Content: strings.Repeat("b", 200)}, + {Role: "user", Content: "tail"}, + } + got := trimMessages(msgs, 80) + if len(got) == 0 || got[len(got)-1].Content != "tail" { + t.Fatalf("%#v", got) + } +} + func TestExecUnknownTool(t *testing.T) { l := &Loop{} _, err := l.execTool(context.Background(), provider.Event{ToolName: "host_shell"}) diff --git a/internal/repository/repository.go b/internal/repository/repository.go index 9269675..b659965 100644 --- a/internal/repository/repository.go +++ b/internal/repository/repository.go @@ -87,6 +87,10 @@ func ArchiveHEAD(root string) ([]byte, error) { return stdout.Bytes(), nil } +func TopLevel(start string) (string, error) { + return gitOutput(start, "rev-parse", "--show-toplevel") +} + func gitOutput(dir string, args ...string) (string, error) { cmd := exec.Command("git", args...) cmd.Dir = dir diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index dbea576..4c6cbae 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -37,15 +37,21 @@ type VMMConfig struct { ConsoleLog string `json:"console_log"` } -func Prepare(sess *session.Session, imagePath string, model config.Model, secrets map[string]string, mcpServers []config.MCPServer) error { +func Prepare(sess *session.Session, imagePath string, model config.Model, secrets map[string]string, mcpServers []config.MCPServer, resume bool) error { if imagePath == "" { imagePath = filepath.Join(config.ImageDir(), "abox-guest.raw") } - if _, err := os.Stat(imagePath); err != nil { - return fmt.Errorf("guest image missing at %s (run: make image)", imagePath) - } - if err := cloneFile(imagePath, sess.RootDisk()); err != nil { - return fmt.Errorf("clone session disk: %w", err) + if resume { + if _, err := os.Stat(sess.RootDisk()); err != nil { + return fmt.Errorf("resume: session disk missing at %s", sess.RootDisk()) + } + } else { + if _, err := os.Stat(imagePath); err != nil { + return fmt.Errorf("guest image missing at %s (run: make image)", imagePath) + } + if err := cloneFile(imagePath, sess.RootDisk()); err != nil { + return fmt.Errorf("clone session disk: %w", err) + } } if err := sess.WriteGuestConfig(model, secrets, mcpServers); err != nil { return err @@ -79,6 +85,7 @@ func Start(ctx context.Context, sess *session.Session, vmmPath string, vcpu int, if vmmPath == "" { return nil, fmt.Errorf("abox-vmm not found; build with make build") } + _ = os.Remove(sess.RPCSocket()) ln, err := net.Listen("unix", sess.RPCSocket()) if err != nil { return nil, fmt.Errorf("listen rpc: %w", err) diff --git a/internal/runtime/runtime_test.go b/internal/runtime/runtime_test.go index 89713ba..4941af0 100644 --- a/internal/runtime/runtime_test.go +++ b/internal/runtime/runtime_test.go @@ -4,6 +4,9 @@ import ( "os" "path/filepath" "testing" + + "github.com/AdminTurnedDevOps/ABox/internal/config" + "github.com/AdminTurnedDevOps/ABox/internal/session" ) func TestCloneFileCopiesContents(t *testing.T) { @@ -24,3 +27,33 @@ func TestCloneFileCopiesContents(t *testing.T) { t.Fatalf("got %q", got) } } + +func TestPrepareResumeDoesNotClobberRoot(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + s, err := session.Create("/repo", "head") + if err != nil { + t.Fatal(err) + } + original := []byte("session-disk-bytes") + if err := os.WriteFile(s.RootDisk(), original, 0o600); err != nil { + t.Fatal(err) + } + golden := filepath.Join(t.TempDir(), "golden.raw") + if err := os.WriteFile(golden, []byte("GOLDEN"), 0o600); err != nil { + t.Fatal(err) + } + err = Prepare(s, golden, config.Model{Name: "grok", Provider: "xai", Model: "grok-4"}, nil, nil, true) + if err != nil { + t.Fatal(err) + } + got, err := os.ReadFile(s.RootDisk()) + if err != nil { + t.Fatal(err) + } + if string(got) != string(original) { + t.Fatalf("root.raw clobbered: %q", got) + } + if _, err := os.Stat(s.ConfigDisk()); err != nil { + t.Fatal(err) + } +} diff --git a/internal/session/session.go b/internal/session/session.go index d8b5d5d..b078daf 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -52,6 +52,80 @@ func Create(repoRoot, head string) (*Session, error) { return s, nil } +func Load(id string) (*Session, error) { + if id == "" { + return nil, fmt.Errorf("empty session id") + } + dir := filepath.Join(config.SessionRoot(), id) + data, err := os.ReadFile(filepath.Join(dir, "session.json")) + if err != nil { + return nil, fmt.Errorf("session %s: %w", id, err) + } + var s Session + if err := json.Unmarshal(data, &s); err != nil { + return nil, fmt.Errorf("session %s: %w", id, err) + } + s.Dir = dir + s.ID = id + if _, err := os.Stat(s.RootDisk()); err != nil { + return nil, fmt.Errorf("session %s: missing root.raw", id) + } + return &s, nil +} + +// LatestForRepo returns the newest session whose RepoRoot matches any of roots +// and that still has a root.raw disk. +func LatestForRepo(roots ...string) (*Session, error) { + want := map[string]struct{}{} + for _, r := range roots { + if r == "" { + continue + } + abs, err := filepath.Abs(r) + if err != nil { + abs = filepath.Clean(r) + } + want[abs] = struct{}{} + want[filepath.Clean(r)] = struct{}{} + } + if len(want) == 0 { + return nil, fmt.Errorf("no repository root to match") + } + entries, err := os.ReadDir(config.SessionRoot()) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("no sessions to resume") + } + return nil, err + } + var best *Session + for _, e := range entries { + if !e.IsDir() { + continue + } + s, err := Load(e.Name()) + if err != nil { + continue + } + root := s.RepoRoot + if abs, err := filepath.Abs(root); err == nil { + root = abs + } + if _, ok := want[root]; !ok { + if _, ok := want[filepath.Clean(s.RepoRoot)]; !ok { + continue + } + } + if best == nil || s.Created.After(best.Created) { + best = s + } + } + if best == nil { + return nil, fmt.Errorf("no session to resume for this repository") + } + return best, nil +} + func (s *Session) WriteMeta() error { data, err := json.MarshalIndent(s, "", " ") if err != nil { diff --git a/internal/session/session_test.go b/internal/session/session_test.go index 2ecbce9..6b4d4f5 100644 --- a/internal/session/session_test.go +++ b/internal/session/session_test.go @@ -2,8 +2,10 @@ package session import ( "os" + "path/filepath" "strings" "testing" + "time" "github.com/AdminTurnedDevOps/ABox/internal/config" ) @@ -29,3 +31,80 @@ func TestWriteGuestConfigIncludesMCP(t *testing.T) { t.Fatalf("guest config missing mcp: %s", body) } } + +func TestLoadRequiresRootRaw(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + s, err := Create("/repo/a", "h1") + if err != nil { + t.Fatal(err) + } + if _, err := Load(s.ID); err == nil { + t.Fatal("expected missing root.raw error") + } + if err := os.WriteFile(s.RootDisk(), []byte("disk"), 0o600); err != nil { + t.Fatal(err) + } + got, err := Load(s.ID) + if err != nil { + t.Fatal(err) + } + if got.ID != s.ID || got.RepoRoot != "/repo/a" { + t.Fatalf("%#v", got) + } +} + +func TestLatestForRepoPicksNewestMatching(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + old, err := Create("/repo/app", "h1") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(old.RootDisk(), []byte("a"), 0o600); err != nil { + t.Fatal(err) + } + time.Sleep(5 * time.Millisecond) + other, err := Create("/repo/other", "h2") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(other.RootDisk(), []byte("b"), 0o600); err != nil { + t.Fatal(err) + } + time.Sleep(5 * time.Millisecond) + newer, err := Create("/repo/app", "h3") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(newer.RootDisk(), []byte("c"), 0o600); err != nil { + t.Fatal(err) + } + got, err := LatestForRepo("/repo/app") + if err != nil { + t.Fatal(err) + } + if got.ID != newer.ID { + t.Fatalf("got %s want %s", got.ID, newer.ID) + } +} + +func TestLatestForRepoSkipsMissingDisk(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + s, err := Create("/repo/app", "h1") + if err != nil { + t.Fatal(err) + } + _ = s + if _, err := LatestForRepo("/repo/app"); err == nil { + t.Fatal("expected error when root.raw missing") + } +} + +func TestLatestForRepoNone(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + if err := os.MkdirAll(config.SessionRoot(), 0o700); err != nil { + t.Fatal(err) + } + if _, err := LatestForRepo(filepath.Join(t.TempDir(), "nope")); err == nil { + t.Fatal("expected no session error") + } +}