eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1package bot
2
3import (
4 "errors"
5 "fmt"
6 "os"
7 "strings"
8 "sync"
9 "time"
10
11 "lchat/integrations"
12 "lchat/internal/e2ee"
13)
14
15// Connected-services capability ops (docs/connected-services.md): the Go port
16// of runtime.mjs's handleConnect / handleOAuthExchange. These are protocol
17// plumbing, not chat behaviour — they run regardless of the bot's recipe or
18// whether it has a model brain, so the runtime routes connect.* payloads here
19// directly (before the recipe Handler ever sees them).
20//
21// The trust story is the whole point: a member seals a time-boxed grant to
22// THIS bot's public key; we open it with our own private key (a grant sealed
23// to a different bot won't open, so we stay silent), act once, and drop the
24// token. The lchat server only ever relays the sealed blobs.
25
26// connectConfig is read from the same env the Node sidecar uses. The broker
27// holds DNSimple's client_secret; unset, the OAuth exchange can't run (but
28// grant-based ops still can, since those carry their own token).
29type connectConfig struct {
30 brokerURL string
31 brokerToken string
32 dnsimple *dnsimpleClient
33 refreshes *refreshGroup // serializes token refreshes per credential
34}
35
36// defaultBrokerURL is the shared connect broker; an instance sets only its
37// per-instance LCHAT_CONNECT_BROKER_TOKEN, so the URL defaults here (matching
38// the Node runtime, which defaults it inline). Override with
39// LCHAT_CONNECT_BROKER_URL for a self-hosted broker.
40const defaultBrokerURL = "https://connect.elevenmessenger.com"
41
42// maxOpResultBytes caps one registry-op result: it must fit inside a single
43// encrypted message (maxBlobBytes, 64 KB ciphertext), and base64 costs 4/3 —
44// 40 KB of result is ~54 KB encrypted, leaving envelope headroom. Manifests
45// keep real results below this via `pick` (a 100-repo GitHub page projects to
46// ~26 KB).
47const maxOpResultBytes = 40 << 10
48
49func loadConnectConfig() *connectConfig {
50 brokerURL := os.Getenv("LCHAT_CONNECT_BROKER_URL")
51 if brokerURL == "" {
52 brokerURL = defaultBrokerURL
53 }
54 return &connectConfig{
55 brokerURL: brokerURL,
56 brokerToken: os.Getenv("LCHAT_CONNECT_BROKER_TOKEN"),
57 dnsimple: newDNSimpleClient(os.Getenv("LCHAT_DNSIMPLE_BASE")),
58 refreshes: newRefreshGroup(),
59 }
60}
61
62// connectPayload is the decrypted shape of a connect.op / connect.oauth message.
63type connectPayload struct {
64 Type string `json:"type"`
65 Service string `json:"service"`
66 Op string `json:"op"`
67 Domain string `json:"domain"`
68 Args map[string]string `json:"args"` // registry-op arguments (integrations.Execute validates them)
69 Grant string `json:"grant"` // sealed to this bot: {service, token, exp, access}
70 RID string `json:"rid"` // request id for a requestData round-trip
71 Code string `json:"code"` // OAuth
72 State string `json:"state"` // OAuth
73 UID string `json:"uid"` // sender's user id (to seal the token back)
74 Name string `json:"name"`
75 Src string `json:"src"` // "chip" = a tapped bot proposal (its failure gets a spoken explanation)
76}
77
78// grant is what a member sealed to the bot for one capability op.
79type grant struct {
80 Service string `json:"service"`
81 Token string `json:"token"`
82 Exp int64 `json:"exp"` // unix millis; 0 = no expiry
83 Access string `json:"access"` // "read" | "write"; empty = legacy grant, read-only
84 // Expiring-token providers (GitHub Apps): the refresh token and the access
85 // token's own expiry ride along so the bot can rotate a stale pair mid-op
86 // and hand the new pair back (sealed to the member) on the result.
87 Refresh string `json:"refresh"`
88 TokenExp int64 `json:"token_expires_at"` // unix millis; 0 = non-expiring
89 // Kind is how the credential was born ("pat" | "oauth"; empty = legacy).
90 // Mixed-scheme providers need it: Linear's pasted keys go in the header
91 // bare, its OAuth tokens as Bearer — ExecuteAs picks per token.
92 Kind string `json:"kind"`
93}
94
95// handleConnect runs a capability op against a sealed grant. It replies ONLY
96// with a non-rendering connect.result (matched by rid) — the DNSimple app
97// renders the data itself, so a capability op is never visible as a bot chat
98// message. A rid-less op (fire-and-forget) gets no reply.
99func (r *Runtime) handleConnect(threadID string, key []byte, p *connectPayload) {
100 var g grant
101 if e2ee.OpenSealed(p.Grant, r.Session.UserPriv, &g) != nil {
102 return // not sealed to us — silently ignore (it isn't ours)
103 }
104 reply := func(result map[string]any) {
105 if p.RID == "" {
106 return
107 }
108 out := map[string]any{"type": "connect.result", "service": p.Service, "op": p.Op, "rid": p.RID, "ts": time.Now().UnixMilli()}
109 for k, v := range result {
110 out[k] = v
111 }
112 if err := r.sendPayload(threadID, key, out); err != nil {
113 r.logf("connect.result: %v", err)
114 }
115 }
116 // A chip is this bot's own proposal, so its failure is answered in the
117 // bot's voice: the raw error still goes back on the result (the instant
118 // notice), and the brain follows with a plain-language explanation in the
119 // same thread. Chip ops only — an app surface (the GitHub app's Refresh)
120 // renders its own inline errors, and a bot message there would be noise.
121 fail := func(msg string) {
122 reply(map[string]any{"error": msg})
123 if p.Src == "chip" {
124 go r.explainConnectError(threadID, key, p, msg)
125 }
126 }
127 if g.Exp != 0 && g.Exp < time.Now().UnixMilli() {
128 reply(map[string]any{"error": "That authorization expired — try again."})
129 return
130 }
131 // Registry-driven path: any service/op pair the integrations registry
132 // declares runs through the pinned executor. Anything else falls through
133 // to the legacy dnsimple-only path below — deliberately including the
134 // legacy op id "list", which the dnsimple manifest does NOT declare (its
135 // op is "domains.list"), so existing DNSimple-app clients keep working.
136 if m := integrations.MustRegistry().Get(p.Service); m != nil {
137 if op := m.Op(p.Op); op != nil {
138 if g.Service != p.Service {
139 reply(map[string]any{"error": "That authorization is for \"" + g.Service + "\", not " + p.Service + "."})
140 return
141 }
142 // A read grant cannot run a write op: the member scoped what they
143 // consented to, and an empty access is a legacy (read) grant.
144 if op.Access == "write" && g.Access != "write" {
145 reply(map[string]any{"error": "That authorization is read-only."})
146 return
147 }
148 // Expiring-token providers: rotate a stale pair before calling
149 // (with a little slack), and hand the fresh pair back on the
150 // result so the member's stored credential stays alive. Rotation
151 // is serialized per credential — providers' refresh tokens are
152 // single-use, so a concurrent second op shares the first refresh
153 // instead of burning a dead token.
154 token := g.Token
155 var renewed *tokenPair
156 if g.Refresh != "" && g.TokenExp != 0 && time.Now().UnixMilli() > g.TokenExp-60_000 {
157 pair, err := r.refreshGrant(&g, p.UID)
158 if err != nil {
159 fail(err.Error())
160 return
161 }
162 token, renewed = pair.Access, pair
163 }
164 data, err := integrations.ExecuteAs(m, p.Op, p.Args, token, g.Kind)
165 // A 401 on an un-refreshed expiring pair means the expiry stamp
166 // lied (revoked-and-reissued, clock skew): rotate once and retry.
167 var se *integrations.StatusError
168 if err != nil && errors.As(err, &se) && se.Code == 401 && g.Refresh != "" && renewed == nil {
169 pair, rerr := r.refreshGrant(&g, p.UID)
170 if rerr == nil {
171 token, renewed = pair.Access, pair
172 data, err = integrations.ExecuteAs(m, p.Op, p.Args, token, g.Kind)
173 }
174 }
175 if err != nil {
176 fail(err.Error())
177 return
178 }
179 // An op result rides inside ONE encrypted message (64 KB blob cap,
180 // hub.go), so cap what one op can push into a thread. Truncating
181 // would hand the app broken JSON — refuse honestly instead; ops
182 // bound their responses with the manifest's `pick` projection.
183 if len(data) > maxOpResultBytes {
184 fail("That result is too large to bring into the chat — narrow the request.")
185 return
186 }
187 out := map[string]any{"data": data}
188 // The renewed pair rides on the result, sealed to the MEMBER (never
189 // stored here) — the requesting device re-stores it, so the next op
190 // starts from the fresh pair.
191 if renewed != nil {
192 if pub := r.senderPublicKey(p.UID); pub != "" {
193 if sealed, err := e2ee.SealForPub(pub, renewed.sealable()); err == nil {
194 out["renewed"] = sealed
195 }
196 }
197 }
198 reply(out)
199 return
200 }
201 }
202 if g.Service != "dnsimple" {
203 reply(map[string]any{"error": "Can't act on \"" + g.Service + "\" yet."})
204 return
205 }
206 if p.Op != "list" {
207 reply(map[string]any{"error": "Unknown action \"" + p.Op + "\"."})
208 return
209 }
210 domains, err := r.connect.dnsimple.listDomains(g.Token)
211 if err != nil {
212 reply(map[string]any{"error": err.Error()})
213 return
214 }
215 reply(map[string]any{"domains": domains})
216}
217
218// handleOAuthExchange finishes the confidential OAuth dance for any service
219// the broker has an app for: run the broker exchange (no secret on this
220// host), seal the token to the MEMBER's public key so only they can open it,
221// hand it straight back, and keep no copy. The service must be a registry
222// service (or legacy dnsimple) — the broker refuses anything it has no app
223// for regardless.
224func (r *Runtime) handleOAuthExchange(threadID string, key []byte, p *connectPayload) {
225 if p.Service == "" {
226 return
227 }
228 name := p.Service
229 if m := integrations.MustRegistry().Get(p.Service); m != nil {
230 name = m.Name
231 } else if p.Service != "dnsimple" {
232 return // not a service we know — stay silent (it isn't ours)
233 }
234 pair, err := exchangeViaBroker(p.Service, p.Code, p.State, r.connect.brokerURL, r.connect.brokerToken)
235 if err != nil {
236 r.logf("connect: %s exchange failed: %v", p.Service, err)
237 r.postReply(threadID, key, Reply{Text: "Couldn't finish connecting " + name + ": " + err.Error()})
238 return
239 }
240 pub := r.senderPublicKey(p.UID)
241 if pub == "" {
242 r.postReply(threadID, key, Reply{Text: "Connected, but I couldn't find your key to hand it back — try again."})
243 return
244 }
245 // The whole pair (access + refresh + expiry, when the provider issues
246 // them) is sealed to the member; a non-expiring provider keeps the legacy
247 // {secret} shape.
248 sealed, err := e2ee.SealForPub(pub, pair.sealable())
249 if err != nil {
250 r.logf("connect: seal token: %v", err)
251 return
252 }
253 if err := r.sendPayload(threadID, key, map[string]any{
254 "type": "connect.token", "service": p.Service, "sealed": sealed, "name": r.Session.Name, "ts": time.Now().UnixMilli(),
255 }); err != nil {
256 r.logf("connect.token: %v", err)
257 }
258}
259
260// explainConnectError has the brain own a failed chip op conversationally:
261// the chip was this bot's proposal, so its failure gets a short plain-language
262// reply in-thread — what went wrong and the concrete fix (the knowledge base
263// carries the decoder for the common cases, e.g. GitHub's 404-means-not-
264// installed). The raw error already went back on the connect.result; a bot
265// with no brain just leaves it at that.
266func (r *Runtime) explainConnectError(threadID string, key []byte, p *connectPayload, errMsg string) {
267 if r.ai == nil {
268 return
269 }
270 resp, err := r.ai.create(anthRequest{
271 Model: r.model,
272 MaxTokens: 1024,
273 System: r.systemBlocks(),
274 Messages: []anthMessage{{Role: "user", Content: "[automatic system note — not written by the member] " +
275 "The member just tapped your \"" + p.Op + "\" chip for " + p.Service + " and the operation FAILED. " +
276 "The raw error (already shown to them as a notice): " + errMsg + " — " +
277 "In one short, friendly message: acknowledge it didn't work, explain the likely cause in plain " +
278 "language, and give the concrete fix. No tool calls, no new chips, no apologising twice."}},
279 })
280 if err != nil {
281 r.logf("connect explain: %v", err)
282 return
283 }
284 if text := strings.TrimSpace(resp.text()); text != "" {
285 r.postReply(threadID, key, Reply{Text: text})
286 }
287}
288
289// refreshGrant rotates the grant's expiring pair via the broker. It refuses
290// to rotate when the fresh pair couldn't be handed back (no resolvable member
291// key) — rotation consumes the stored refresh token, so losing the result
292// would sever the connection outright.
293func (r *Runtime) refreshGrant(g *grant, uid string) (*tokenPair, error) {
294 if uid == "" || r.senderPublicKey(uid) == "" {
295 return nil, fmt.Errorf("that %s connection's token expired and I couldn't hand a fresh one back — reconnect it in Integrations", g.Service)
296 }
297 return r.connect.refreshes.do(g.Refresh, func() (*tokenPair, error) {
298 return refreshViaBroker(g.Service, g.Refresh, r.connect.brokerURL, r.connect.brokerToken)
299 })
300}
301
302// refreshGroup coalesces concurrent refreshes of the SAME credential: refresh
303// tokens are single-use (GitHub rotates one on every call), so two
304// near-simultaneous ops must share one rotation rather than the loser burning
305// a dead token. Nothing is retained once a flight lands — the fresh pair
306// lives only in the results handed to the waiting ops.
307type refreshGroup struct {
308 mu sync.Mutex
309 calls map[string]*refreshCall
310}
311
312type refreshCall struct {
313 done chan struct{}
314 pair *tokenPair
315 err error
316}
317
318func newRefreshGroup() *refreshGroup { return &refreshGroup{calls: map[string]*refreshCall{}} }
319
320func (rg *refreshGroup) do(key string, fn func() (*tokenPair, error)) (*tokenPair, error) {
321 rg.mu.Lock()
322 if c, ok := rg.calls[key]; ok {
323 rg.mu.Unlock()
324 <-c.done
325 return c.pair, c.err
326 }
327 c := &refreshCall{done: make(chan struct{})}
328 rg.calls[key] = c
329 rg.mu.Unlock()
330 c.pair, c.err = fn()
331 close(c.done)
332 rg.mu.Lock()
333 delete(rg.calls, key)
334 rg.mu.Unlock()
335 return c.pair, c.err
336}
337
338// senderPublicKey looks up a roster member's public key by user id (to seal the
339// OAuth token back to them).
340func (r *Runtime) senderPublicKey(uid string) string {
341 if uid == "" {
342 return ""
343 }
344 var rl struct {
345 Roster []struct {
346 UserID string `json:"user_id"`
347 PublicKey string `json:"public_key"`
348 } `json:"roster"`
349 }
350 if err := r.get("/api/roster", &rl); err != nil {
351 r.logf("connect: roster: %v", err)
352 return ""
353 }
354 for _, e := range rl.Roster {
355 if e.UserID == uid {
356 return e.PublicKey
357 }
358 }
359 return ""
360}
361