bot: sidecar self-redeems agent invites on first boot (#806) (#864)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+229
−11
botcmd.go+40 −11internal/bot/provision.go+32 −0internal/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) |
| + } |
| +} |