| 1 | // Package agents is the workstation bridge behind `lchat agents run` |
| 2 | // (docs/superpowers/specs/2026-08-05-eleven-claude-agents-design.md): an |
| 3 | // ordinary E2EE bot member that owns headless Claude Code sessions on the |
| 4 | // machine it runs on, translating their structured event stream into |
| 5 | // encrypted session.* events in a group thread (the vocabulary pinned by |
| 6 | // test/event-vectors/agents.json and folded by web/static/apps/agents.js), |
| 7 | // and reading members' steering events back off the same thread. |
| 8 | // |
| 9 | // Trust model, in one line: the bridge holds this machine's authority, and |
| 10 | // GROUP MEMBERSHIP IS THE CONSENT MOMENT — everyone in a configured group can |
| 11 | // steer sessions, so the config below is the blast radius, written down in |
| 12 | // one place on the box. |
| 13 | package agents |
| 14 | |
| 15 | import ( |
| 16 | "encoding/json" |
| 17 | "fmt" |
| 18 | "os" |
| 19 | "path/filepath" |
| 20 | "strings" |
| 21 | ) |
| 22 | |
| 23 | // GroupConfig is one group thread's authority grant. |
| 24 | type GroupConfig struct { |
| 25 | // Roots are the directories scoped sessions may live under (each session |
| 26 | // gets a fresh git worktree beside its root). The first root is the |
| 27 | // default a conversational spawn lands in. |
| 28 | Roots []string `json:"roots"` |
| 29 | // Plain opts the group into whole-workstation sessions (cwd = home, no |
| 30 | // pinning). Off by default because it hands every member the entire box. |
| 31 | Plain bool `json:"plain"` |
| 32 | } |
| 33 | |
| 34 | // Config is the bridge's on-disk config — a sidecar-local JSON beside the |
| 35 | // session file, following the recipe-file convention (internal/bot/recipe.go): |
| 36 | // loaded fail-closed at startup, never fetched from the server (the blast |
| 37 | // radius must live on the box it describes). |
| 38 | type Config struct { |
| 39 | Groups map[string]GroupConfig `json:"groups"` |
| 40 | // Command overrides the `claude` binary (tests point this at a stub). |
| 41 | Command string `json:"command,omitempty"` |
| 42 | } |
| 43 | |
| 44 | // LoadConfig reads and validates the bridge config. Fail-closed: a missing or |
| 45 | // broken file is an error, not an empty grant — a bridge with authority over |
| 46 | // nothing is a misconfiguration someone should see, and a truncated file must |
| 47 | // never silently widen or narrow what a group can reach. |
| 48 | func LoadConfig(path string) (*Config, error) { |
| 49 | data, err := os.ReadFile(path) |
| 50 | if err != nil { |
| 51 | return nil, err |
| 52 | } |
| 53 | var cfg Config |
| 54 | if err := json.Unmarshal(data, &cfg); err != nil { |
| 55 | return nil, fmt.Errorf("%s: %v", path, err) |
| 56 | } |
| 57 | if len(cfg.Groups) == 0 { |
| 58 | return nil, fmt.Errorf("%s: no groups configured — the bridge would serve nothing", path) |
| 59 | } |
| 60 | home, _ := os.UserHomeDir() |
| 61 | for tid, g := range cfg.Groups { |
| 62 | if tid == "" { |
| 63 | return nil, fmt.Errorf("%s: empty group thread id", path) |
| 64 | } |
| 65 | if len(g.Roots) == 0 && !g.Plain { |
| 66 | return nil, fmt.Errorf("%s: group %s grants no roots and no plain — it could spawn nothing", path, tid) |
| 67 | } |
| 68 | roots := make([]string, 0, len(g.Roots)) |
| 69 | for _, r := range g.Roots { |
| 70 | r = strings.TrimSpace(r) |
| 71 | if r == "" { |
| 72 | return nil, fmt.Errorf("%s: group %s has an empty root", path, tid) |
| 73 | } |
| 74 | if r == "~" || strings.HasPrefix(r, "~/") { |
| 75 | r = filepath.Join(home, strings.TrimPrefix(strings.TrimPrefix(r, "~"), "/")) |
| 76 | } |
| 77 | abs, err := filepath.Abs(r) |
| 78 | if err != nil { |
| 79 | return nil, fmt.Errorf("%s: group %s root %q: %v", path, tid, r, err) |
| 80 | } |
| 81 | st, err := os.Stat(abs) |
| 82 | if err != nil { |
| 83 | return nil, fmt.Errorf("%s: group %s root %q: %v", path, tid, r, err) |
| 84 | } |
| 85 | if !st.IsDir() { |
| 86 | return nil, fmt.Errorf("%s: group %s root %q is not a directory", path, tid, r) |
| 87 | } |
| 88 | roots = append(roots, abs) |
| 89 | } |
| 90 | g.Roots = roots |
| 91 | cfg.Groups[tid] = g |
| 92 | } |
| 93 | if cfg.Command == "" { |
| 94 | cfg.Command = "claude" |
| 95 | } |
| 96 | return &cfg, nil |
| 97 | } |
| 98 | |