eleven / messenger Public

bot: sidecar self-redeems agent invites on first boot (#806) (#864)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell a0dd81532869f78889bda2e8b7f37cab88f76958 parent dda6365
3 files changed, +229 −11
  • botcmd.go +40 −11
  • internal/bot/provision.go +32 −0
  • internal/bot/provision_test.go +157 −0
diff --git a/botcmd.go b/botcmd.go
index c837c09..152c2db 100644
--- a/botcmd.go
+++ b/botcmd.go
@@ -59,33 +59,62 @@ func runBotRun(args []string) {
if *base == "" {
die("bot run: set -base (or LCHAT_BASE) to your instance URL")
}
- paths := fs.Args()
- if len(paths) == 0 {
+ // Each identity pairs a session path with the one-time invite that can
+ // mint it: a fresh sidecar self-provisions on first boot (#806) instead of
+ // crash-looping until an operator runs `bot provision` by hand — the
+ // behavior add-bot and the activator co-spawn were written to rely on.
+ type botIdentity struct {
+ path string
+ invite string // one-time agent invite; consulted only when the session is absent
+ name string // identity name stamped into a self-provisioned session
+ }
+ var ids []botIdentity
+ if args := fs.Args(); len(args) > 0 {
+ for _, p := range args {
+ ids = append(ids, botIdentity{path: p})
+ }
+ if len(ids) == 1 {
+ // A lone hand-run session may still self-provision, matching the
+ // Node reference sidecar (it redeems LCHAT_INVITE on first boot).
+ ids[0].invite = os.Getenv("LCHAT_INVITE")
+ ids[0].name = envOr("LCHAT_AGENT_NAME", "Claude")
+ }
+ } else {
// No sessions on the command line: source them from the env the sidecar
// unit provides, running whichever identities are set — so the generic
// lchat-bot@.service template needs no per-host args, and a full pair or an
// Eleven-only box both work. Falls back to a lone session.json for a
// hand-run bot with nothing set. (LCHAT_ANNOUNCER_SESSION stays listed so
// a pre-retirement EnvironmentFile still resolves; the session is then
- // skipped by name below.)
- for _, ev := range []string{"LCHAT_SESSION", "LCHAT_ELEVEN_SESSION", "LCHAT_ANNOUNCER_SESSION"} {
- if p := strings.TrimSpace(os.Getenv(ev)); p != "" {
- paths = append(paths, p)
+ // skipped by name below — and never self-provisioned: the identity is
+ // retired.)
+ for _, src := range []struct{ session, invite, name string }{
+ {"LCHAT_SESSION", "LCHAT_INVITE", "Claude"},
+ {"LCHAT_ELEVEN_SESSION", "LCHAT_ELEVEN_INVITE", "Eleven"},
+ {"LCHAT_ANNOUNCER_SESSION", "", "Announcer"},
+ } {
+ if p := strings.TrimSpace(os.Getenv(src.session)); p != "" {
+ var invite string
+ if src.invite != "" {
+ invite = os.Getenv(src.invite)
+ }
+ ids = append(ids, botIdentity{path: p, invite: invite, name: src.name})
}
}
- if len(paths) == 0 {
- paths = []string{"session.json"}
+ if len(ids) == 0 {
+ ids = []botIdentity{{path: "session.json", invite: os.Getenv("LCHAT_INVITE"), name: envOr("LCHAT_AGENT_NAME", "Claude")}}
}
}
var engines []*bot.Engine
- for _, path := range paths {
- sess, err := bot.LoadSession(path)
+ for _, id := range ids {
+ sess, err := bot.EnsureSession(strings.TrimRight(*base, "/"), id.path, id.invite, id.name, log.Printf)
if err != nil {
die("bot run: %v", err)
}
if sess == nil {
- die("bot run: no session at %s — provision it first (lchat bot provision)", path)
+ die("bot run: no session at %s — provision it first (lchat bot provision, or set the matching invite env var)", id.path)
}
+ path := id.path
if sess.Name == "Announcer" {
// The Announcer is retired (the server announces its own changelog —
// announce.go). Running this identity under the default message recipe
diff --git a/internal/bot/provision.go b/internal/bot/provision.go
index 132284f..581a4dd 100644
--- a/internal/bot/provision.go
+++ b/internal/bot/provision.go
@@ -68,6 +68,38 @@ func Provision(base, fragment, name string) (*Session, error) {
return &Session{Token: claim.Token, Name: name, UserPub: kp.PublicKey, UserPriv: kp.PrivateKey}, nil
}
+// EnsureSession loads the session at path, self-provisioning it first when
+// the file is absent and an invite is available. This is what makes a freshly
+// staged sidecar zero-touch (#806): add-bot — and the activator's co-spawn on
+// a hibernating host — hand the process one-time invites in its environment,
+// and the first boot redeems them and writes the session, so keys are born on
+// the machine that runs the bot rather than copied to it. A missing session
+// with no invite still returns (nil, nil): "not provisioned" stays a normal
+// state the caller reports.
+func EnsureSession(base, path, invite, name string, logf func(string, ...any)) (*Session, error) {
+ sess, err := LoadSession(path)
+ if err != nil || sess != nil {
+ return sess, err
+ }
+ invite = strings.TrimSpace(invite)
+ if invite == "" {
+ return nil, nil
+ }
+ if logf != nil {
+ logf("bot: no session at %s — provisioning %q from its invite", path, name)
+ }
+ sess, err = Provision(base, invite, name)
+ if err != nil {
+ return nil, fmt.Errorf("bot: provision %q: %w", name, err)
+ }
+ // Persist before connecting: the invite is one-time, so a session held
+ // only in memory would be unrecoverable after the next restart.
+ if err := sess.Save(path); err != nil {
+ return nil, fmt.Errorf("bot: save session %s: %w", path, err)
+ }
+ return sess, nil
+}
+
func getJSON(client *http.Client, url string, out any) error {
resp, err := client.Get(url)
if err != nil {
diff --git a/internal/bot/provision_test.go b/internal/bot/provision_test.go
new file mode 100644
index 0000000..8fb29b9
--- /dev/null
+++ b/internal/bot/provision_test.go
@@ -0,0 +1,157 @@
+package bot
+
+import (
+ "crypto/rand"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "sync/atomic"
+ "testing"
+
+ "lchat/internal/e2ee"
+)
+
+// fakeInviteServer implements the three endpoints Provision walks, the same
+// wire shape agent/provision.mjs pins. It hands out one invite (the given
+// fragment) and records how many claims it saw.
+func fakeInviteServer(t *testing.T, fragment string, claims *atomic.Int32) *httptest.Server {
+ t.Helper()
+ inv, err := e2ee.DeriveInvite(fragment)
+ if err != nil {
+ t.Fatalf("derive invite: %v", err)
+ }
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /api/key-invite/"+inv.ID, func(w http.ResponseWriter, r *http.Request) {
+ json.NewEncoder(w).Encode(map[string]any{"ok": true, "wrapped": ""})
+ })
+ mux.HandleFunc("POST /api/key-invite/"+inv.ID+"/claim", func(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ ClaimSecret string `json:"claim_secret"`
+ }
+ json.NewDecoder(r.Body).Decode(&body)
+ if body.ClaimSecret != inv.ClaimSecret {
+ json.NewEncoder(w).Encode(map[string]any{"ok": false})
+ return
+ }
+ claims.Add(1)
+ json.NewEncoder(w).Encode(map[string]any{"ok": true, "token": "tok-ensure-test"})
+ })
+ mux.HandleFunc("POST /api/pubkey", func(w http.ResponseWriter, r *http.Request) {
+ var body struct {
+ Token string `json:"token"`
+ PublicKey string `json:"public_key"`
+ }
+ json.NewDecoder(r.Body).Decode(&body)
+ if body.Token == "" || body.PublicKey == "" {
+ http.Error(w, "missing", http.StatusBadRequest)
+ return
+ }
+ json.NewEncoder(w).Encode(map[string]any{"ok": true})
+ })
+ return httptest.NewServer(mux)
+}
+
+func testFragment(t *testing.T) string {
+ t.Helper()
+ raw := make([]byte, 32)
+ if _, err := rand.Read(raw); err != nil {
+ t.Fatal(err)
+ }
+ return e2ee.B64uEncode(raw)
+}
+
+// The #806 shape: session file absent + invite present → redeem, write the
+// session, and hand it back — so a fresh add-bot'd sidecar boots zero-touch.
+func TestEnsureSessionProvisionsFromInvite(t *testing.T) {
+ fragment := testFragment(t)
+ var claims atomic.Int32
+ srv := fakeInviteServer(t, fragment, &claims)
+ defer srv.Close()
+
+ path := filepath.Join(t.TempDir(), "eleven-session.json")
+ sess, err := EnsureSession(srv.URL, path, fragment, "Eleven", t.Logf)
+ if err != nil {
+ t.Fatalf("EnsureSession: %v", err)
+ }
+ if sess == nil {
+ t.Fatal("EnsureSession returned nil session despite an invite")
+ }
+ if sess.Name != "Eleven" || sess.Token != "tok-ensure-test" || sess.UserPriv == "" {
+ t.Fatalf("unexpected session: name=%q token=%q priv-empty=%v", sess.Name, sess.Token, sess.UserPriv == "")
+ }
+ if claims.Load() != 1 {
+ t.Fatalf("expected exactly one claim, got %d", claims.Load())
+ }
+ if _, err := os.Stat(path); err != nil {
+ t.Fatalf("session file not persisted: %v", err)
+ }
+
+ // Second boot: the persisted session wins; the (now spent) invite is never
+ // re-redeemed. The server is closed to prove no network is touched.
+ srv.Close()
+ again, err := EnsureSession(srv.URL, path, fragment, "Eleven", nil)
+ if err != nil {
+ t.Fatalf("EnsureSession reload: %v", err)
+ }
+ if again == nil || again.Token != "tok-ensure-test" {
+ t.Fatalf("reload did not return the persisted session: %+v", again)
+ }
+}
+
+// No session and no invite stays the normal "not provisioned" state — the
+// caller keeps its clear operator-facing error.
+func TestEnsureSessionMissingWithoutInvite(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "session.json")
+ sess, err := EnsureSession("http://unused.invalid", path, " ", "Claude", nil)
+ if err != nil {
+ t.Fatalf("EnsureSession: %v", err)
+ }
+ if sess != nil {
+ t.Fatalf("expected nil session, got %+v", sess)
+ }
+}
+
+// An existing session short-circuits: the invite is ignored and nothing dials
+// out (the base URL is unresolvable on purpose).
+func TestEnsureSessionPrefersExistingFile(t *testing.T) {
+ path := filepath.Join(t.TempDir(), "session.json")
+ existing := &Session{Token: "tok-existing", Name: "Eleven", UserPub: "pub", UserPriv: "priv"}
+ if err := existing.Save(path); err != nil {
+ t.Fatal(err)
+ }
+ sess, err := EnsureSession("http://unused.invalid", path, testFragment(t), "Eleven", nil)
+ if err != nil {
+ t.Fatalf("EnsureSession: %v", err)
+ }
+ if sess == nil || sess.Token != "tok-existing" {
+ t.Fatalf("expected the existing session, got %+v", sess)
+ }
+}
+
+// A spent invite (the crash-loop-after-burn case) fails with a provision
+// error rather than looping silently.
+func TestEnsureSessionSpentInvite(t *testing.T) {
+ fragment := testFragment(t)
+ inv, err := e2ee.DeriveInvite(fragment)
+ if err != nil {
+ t.Fatal(err)
+ }
+ mux := http.NewServeMux()
+ mux.HandleFunc("GET /api/key-invite/"+inv.ID, func(w http.ResponseWriter, r *http.Request) {
+ json.NewEncoder(w).Encode(map[string]any{"ok": false})
+ })
+ srv := httptest.NewServer(mux)
+ defer srv.Close()
+
+ path := filepath.Join(t.TempDir(), "session.json")
+ _, err = EnsureSession(srv.URL, path, fragment, "Claude", nil)
+ if err == nil || !strings.Contains(err.Error(), "invite is used or invalid") {
+ t.Fatalf("expected spent-invite error, got %v", err)
+ }
+ if _, statErr := os.Stat(path); !os.IsNotExist(statErr) {
+ t.Fatalf("no session file should be written on failure; stat: %v", statErr)
+ }
+}