feat(connectors): add-on roles and permissions, and sender pairing

A connector kind can now declare the roles it gives to a sender and the
capabilities it can grant to a role. The owner of a binding enables a
ceiling of capabilities and maps roles to them. The host stores these
values and returns them with the binding and the kind list, so an add-on
settings page can show them.

Capability names are a closed list owned by the host. Sensitive names
may be listed by a kind but are never granted to a role; only a paired
sender can use them.

Pairing proves that the owner controls a platform account. The owner
asks for a one-use code (valid ten minutes) and sends it as a direct
message to the bot from an allowlisted account. The host checks the code
before any model turn and never logs it. A binding holds three paired
senders, a pairing ends after 90 days, and five wrong codes in an hour
lock the sender out for an hour.

Contract: connector kinds gain optional roles and permissions, and a
message gains optional roles. They need manifest api 2, which the host
already accepts; protocol is unchanged and manifests that declare
nothing new behave as before. The host drops roles a kind does not
declare.

Enforcement of the capabilities by feature is not part of this change.

Assisted-by: Claude:claude-sonnet-5-5 go swag
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 13:52:56 +00:00
1 parent 3d0f299808
commit 1f481b0747
26 files changed
+2811 -113

No files matched your search

+4
View File
@@ -38,4 +38,8 @@ export const connectorsApi = {
test: (id) => send('POST', `/api/chats/connectors/${enc(id)}/test`, {}),
senders: (id) => call(`/api/chats/connectors/${enc(id)}/senders`),
allow: (id, sender) => send('POST', `/api/chats/connectors/${enc(id)}/senders/${enc(sender)}/allow`, {}),
// Pairing proves that the owner controls a platform account: the code is sent
// to the bot as a direct message from that account.
pair: (id) => send('POST', `/api/chats/connectors/${enc(id)}/pairing`, {}),
unpair: (id, sender) => send('DELETE', `/api/chats/connectors/${enc(id)}/paired/${enc(sender)}`),
}
+2
View File
@@ -147,6 +147,8 @@ var RouteFeatureRegistry = []RouteFeature{
{"POST", "/api/chats/connectors/:id/test", FeatureConnectors},
{"GET", "/api/chats/connectors/:id/senders", FeatureConnectors},
{"POST", "/api/chats/connectors/:id/senders/:sender/allow", FeatureConnectors},
{"POST", "/api/chats/connectors/:id/pairing", FeatureConnectors},
{"DELETE", "/api/chats/connectors/:id/paired/:sender", FeatureConnectors},
{"GET", "/api/chats/mcp-connectors", FeatureChats},
{"POST", "/api/chats/mcp-connectors", FeatureChats},
{"GET", "/api/chats/mcp-connectors/:id", FeatureChats},
+2
View File
@@ -124,6 +124,8 @@ var _ = Describe("Chats feature registration", func() {
"POST /api/chats/connectors/:id/test",
"GET /api/chats/connectors/:id/senders",
"POST /api/chats/connectors/:id/senders/:sender/allow",
"POST /api/chats/connectors/:id/pairing",
"DELETE /api/chats/connectors/:id/paired/:sender",
))
Expect(AgentFeatures).To(ContainElement(FeatureConnectors))
Expect(APIFeatures).NotTo(ContainElement(FeatureConnectors))
File diff suppressed because one or more lines are too long.
+92 -3
View File
@@ -22,12 +22,62 @@ func connectorOut(i studiosvc.ConnectorInfo) schema.ChatsConnector {
if allow == nil {
allow = []string{}
}
return schema.ChatsConnector{ID: i.ID, Name: i.Name, Kind: i.Kind, Addon: i.AddonID, Active: i.Active, Reason: i.Reason, AgentID: i.AgentID, Enabled: i.Enabled, AcceptAny: i.AcceptAny,
return schema.ChatsConnector{Caps: capsOut(i.Caps), Grants: grantsOut(i.Grants), Paired: pairedOut(i.Paired), ID: i.ID, Name: i.Name, Kind: i.Kind, Addon: i.AddonID, Active: i.Active, Reason: i.Reason, AgentID: i.AgentID, Enabled: i.Enabled, AcceptAny: i.AcceptAny,
Allow: allow, NotifyChat: i.NotifyChat, HasToken: i.HasToken, Created: i.Created,
ReplyMode: i.ReplyMode(), Conversation: i.ConversationMode(), WindowMinutes: i.WindowMinutes, SendFiles: i.SendsFiles(), Options: i.Options,
Files: filesOut(i.Files), FileReceiveMaxBytes: i.FileReceiveMax, FileSendMaxBytes: i.FileSendMax}
}
func capsOut(in []string) []string {
if in == nil {
return []string{}
}
return in
}
func grantsOut(in []connectors.RoleGrant) []schema.ChatsConnectorRoleGrant {
out := make([]schema.ChatsConnectorRoleGrant, 0, len(in))
for _, g := range in {
out = append(out, schema.ChatsConnectorRoleGrant{Role: g.Role, Caps: capsOut(g.Caps)})
}
return out
}
func pairedOut(in []connectors.PairedSender) []schema.ChatsConnectorPaired {
out := make([]schema.ChatsConnectorPaired, 0, len(in))
for _, p := range in {
out = append(out, schema.ChatsConnectorPaired{SenderID: p.SenderID, PairedAt: p.At, ExpiresAt: p.At.Add(connectors.PairedTTL)})
}
return out
}
func grantsIn(in []schema.ChatsConnectorRoleGrant) []connectors.RoleGrant {
if in == nil {
return nil
}
out := make([]connectors.RoleGrant, 0, len(in))
for _, g := range in {
out = append(out, connectors.RoleGrant{Role: g.Role, Caps: g.Caps})
}
return out
}
func rolesOut(k contract.ConnectorKind) []schema.ChatsConnectorRole {
var out []schema.ChatsConnectorRole
for _, r := range k.Roles {
out = append(out, schema.ChatsConnectorRole{ID: r.ID, Title: r.Title, Description: r.Description})
}
return out
}
func permissionsOut(k contract.ConnectorKind) []schema.ChatsConnectorPermission {
var out []schema.ChatsConnectorPermission
for _, p := range k.Permissions {
out = append(out, schema.ChatsConnectorPermission{Name: p, Sensitive: contract.SensitiveCapability(p)})
}
return out
}
func filesOut(l studiosvc.FileLimits) schema.ChatsConnectorFiles {
dir := func(e studiosvc.FileLimit, declared, allowed int64) schema.ChatsConnectorFileLimit {
return schema.ChatsConnectorFileLimit{EffectiveBytes: e.Bytes, Layer: string(e.Layer), DeclaredBytes: declared, AllowedBytes: allowed}
@@ -47,7 +97,7 @@ func connectorIn(req schema.ChatsConnectorWrite) studiosvc.ConnectorInput {
return studiosvc.ConnectorInput{Kind: req.Kind, Name: req.Name, Token: req.Token, AgentID: req.AgentID, Enabled: req.Enabled,
AcceptAny: req.AcceptAny, Allow: req.Allow, NotifyChat: req.NotifyChat,
Reply: req.ReplyMode, Conversation: req.Conversation, WindowMinutes: req.WindowMinutes, SendFiles: req.SendFiles, Options: req.Options,
FileReceiveMax: req.FileReceiveMaxBytes, FileSendMax: req.FileSendMaxBytes}
FileReceiveMax: req.FileReceiveMaxBytes, FileSendMax: req.FileSendMaxBytes, Caps: req.Caps, Grants: grantsIn(req.Grants)}
}
var connectorRules = []errRule{
@@ -109,7 +159,7 @@ func (h *Handlers) ConnectorKinds(c echo.Context) error {
out := schema.ChatsConnectorKindList{Kinds: make([]schema.ChatsConnectorKind, 0, len(kinds))}
for _, k := range kinds {
out.Kinds = append(out.Kinds, schema.ChatsConnectorKind{Addon: k.AddonID, Kind: k.ID, Title: k.Title, Description: k.Description, SenderHint: k.SenderHint, Page: k.Page, ThreadHistory: k.ServesThreadHistory(), Options: optionsOut(k.Options),
Files: filesOut(host.KindFileLimits(c.Request().Context(), k))})
Files: filesOut(host.KindFileLimits(c.Request().Context(), k)), Roles: rolesOut(k.ConnectorKind), Permissions: permissionsOut(k.ConnectorKind)})
}
return c.JSON(http.StatusOK, out)
}
@@ -302,6 +352,45 @@ func (h *Handlers) AllowConnectorSender(c echo.Context) error {
return c.JSON(http.StatusOK, connectorOut(i))
}
// StartConnectorPairing makes a pairing code for a binding.
// @Summary Make a pairing code for a connector binding.
// @Description Pairing proves that you control a platform account. Send the code, as a direct message to the bot, from an account on the allowlist, within ten minutes. The code works once, and a new code replaces the old one. A paired sender may use the sensitive capabilities you enabled; a pairing ends after 90 days. At most three senders are paired.
// @Tags chats
// @Param id path string true "the binding's id"
// @Success 200 {object} schema.ChatsConnectorPairing "The code and when it expires"
// @Router /api/chats/connectors/{id}/pairing [post]
func (h *Handlers) StartConnectorPairing(c echo.Context) error {
host, ok := h.connectorHost(c)
if !ok {
return nil
}
p, err := host.StartPairing(c.Request().Context(), userID(c), c.Param("id"))
if err != nil {
return failConnector(c, err)
}
return c.JSON(http.StatusOK, schema.ChatsConnectorPairing{Code: p.Code, Expires: p.Expires})
}
// RemoveConnectorPaired ends the pairing of a sender.
// @Summary End the pairing of a sender of a connector binding.
// @Description The sender can pair again with a new code.
// @Tags chats
// @Param id path string true "the binding's id"
// @Param sender path string true "the sender's id"
// @Success 200 {object} schema.ChatsConnector "The binding"
// @Router /api/chats/connectors/{id}/paired/{sender} [delete]
func (h *Handlers) RemoveConnectorPaired(c echo.Context) error {
host, ok := h.connectorHost(c)
if !ok {
return nil
}
i, err := host.RemovePaired(c.Request().Context(), userID(c), c.Param("id"), c.Param("sender"))
if err != nil {
return failConnector(c, err)
}
return c.JSON(http.StatusOK, connectorOut(i))
}
func optionsOut(in []contract.ConnectorOption) []schema.ChatsConnectorOption {
var out []schema.ChatsConnectorOption
for _, o := range in {
+2
View File
@@ -221,6 +221,8 @@ func RegisterStudioRoutes(e *echo.Echo, svc *studiosvc.Service, models studioend
e.POST(base+"/connectors/:id/test", h.TestConnector)
e.GET(base+"/connectors/:id/senders", h.ConnectorSenders)
e.POST(base+"/connectors/:id/senders/:sender/allow", h.AllowConnectorSender)
e.POST(base+"/connectors/:id/pairing", h.StartConnectorPairing)
e.DELETE(base+"/connectors/:id/paired/:sender", h.RemoveConnectorPaired)
e.GET(base+"/mcp-connectors", h.ListMCPConnectors)
e.POST(base+"/mcp-connectors", h.CreateMCPConnector)
e.GET(base+"/mcp-connectors/:id", h.GetMCPConnector)
@@ -53,6 +53,8 @@ var _ = Describe("Chats connectors endpoints", func() {
Entry("test", http.MethodPost, "/api/chats/connectors/c1/test"),
Entry("senders", http.MethodGet, "/api/chats/connectors/c1/senders"),
Entry("allow", http.MethodPost, "/api/chats/connectors/c1/senders/42/allow"),
Entry("pairing", http.MethodPost, "/api/chats/connectors/c1/pairing"),
Entry("unpair", http.MethodDelete, "/api/chats/connectors/c1/paired/42"),
)
It("is refused to a user without the chats feature", func() {
@@ -259,4 +261,27 @@ var _ = Describe("Chats connectors endpoints", func() {
rec = s.do("alice", http.MethodPut, "/api/chats/connectors/"+id, map[string]any{"name": "Renamed", "agent_id": ag})
Expect(rec.Code).To(Equal(http.StatusOK), "an update that keeps the token is not checked: %s", rec.Body.String())
})
It("keeps the capabilities of a binding, refuses unknown ones and issues a pairing code to its owner only", func() {
ag := agent("alice", "Caps", "test-model")
rec := create("alice", map[string]any{"name": "Caps bot", "token": specToken, "agent_id": ag, "caps": []string{"memory.write", "profile.write"}})
Expect(rec.Code).To(Equal(http.StatusCreated), rec.Body.String())
b := decodeJSON(rec)
id := b["id"].(string)
Expect(b).To(And(HaveKeyWithValue("caps", ConsistOf("memory.write", "profile.write")), HaveKeyWithValue("grants", BeEmpty()), HaveKeyWithValue("paired", BeEmpty())))
// An update that omits the capabilities keeps them; an empty list clears.
rec = s.do("alice", http.MethodPut, "/api/chats/connectors/"+id, map[string]any{"name": "Caps bot", "agent_id": ag})
Expect(decodeJSON(rec)).To(HaveKeyWithValue("caps", HaveLen(2)))
rec = s.do("alice", http.MethodPut, "/api/chats/connectors/"+id, map[string]any{"name": "Caps bot", "agent_id": ag, "caps": []string{}})
Expect(decodeJSON(rec)).To(HaveKeyWithValue("caps", BeEmpty()))
rec = s.do("alice", http.MethodPut, "/api/chats/connectors/"+id, map[string]any{"name": "Caps bot", "agent_id": ag, "caps": []string{"root.everything"}})
Expect(rec.Code).To(Equal(http.StatusBadRequest), rec.Body.String())
rec = s.do("alice", http.MethodPost, "/api/chats/connectors/"+id+"/pairing", nil)
Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String())
Expect(decodeJSON(rec)["code"]).To(MatchRegexp(`^LA-[A-Z0-9]{8}$`))
Expect(s.do("bob", http.MethodPost, "/api/chats/connectors/"+id+"/pairing", nil).Code).To(Equal(http.StatusNotFound))
rec = s.do("alice", http.MethodDelete, "/api/chats/connectors/"+id+"/paired/42", nil)
Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String())
})
})
+56
View File
@@ -1192,6 +1192,48 @@ type ChatsConnector struct {
// zero means you set none and the limit of the kind applies.
FileReceiveMaxBytes int64 `json:"file_receive_max_bytes"`
FileSendMaxBytes int64 `json:"file_send_max_bytes"`
// Caps are the capabilities you enabled for this binding: the ceiling of
// what any sender can use. Grants give roles of the kind some of them.
Caps []string `json:"caps"`
Grants []ChatsConnectorRoleGrant `json:"grants"`
// Paired are the senders you proved with a pairing code. A pairing ends
// at expires_at.
Paired []ChatsConnectorPaired `json:"paired"`
}
// ChatsConnectorRoleGrant gives the capabilities caps to the senders that the
// add-on reports with the role role.
type ChatsConnectorRoleGrant struct {
Role string `json:"role"`
Caps []string `json:"caps"`
}
// ChatsConnectorPaired is a paired sender of a binding.
type ChatsConnectorPaired struct {
SenderID string `json:"sender_id"`
PairedAt time.Time `json:"paired_at"`
ExpiresAt time.Time `json:"expires_at"`
}
// ChatsConnectorPairing is a pairing code. Send it from the account to pair, as
// a direct message to the bot, before it expires.
type ChatsConnectorPairing struct {
Code string `json:"code"`
Expires time.Time `json:"expires"`
}
// ChatsConnectorRole is a role that a kind can give to a sender.
type ChatsConnectorRole struct {
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
}
// ChatsConnectorPermission is a capability that a kind lists. Sensitive ones
// are never granted to a role: only a paired sender can use them.
type ChatsConnectorPermission struct {
Name string `json:"name"`
Sensitive bool `json:"sensitive"`
}
// ChatsConnectorFiles are the file limits of a binding or of a kind, for both
@@ -1242,6 +1284,11 @@ type ChatsConnectorKind struct {
// Files are the limits of a binding of this kind that has no limit of
// your own.
Files ChatsConnectorFiles `json:"files"`
// Roles are the roles the add-on can report for a sender, and
// Permissions the capabilities the kind lists. Grant them to a role with
// the grants of a binding.
Roles []ChatsConnectorRole `json:"roles,omitempty"`
Permissions []ChatsConnectorPermission `json:"permissions,omitempty"`
}
// ChatsConnectorOption is one choice of a connector kind.
@@ -1300,6 +1347,15 @@ type ChatsConnectorWrite struct {
// means none for a new binding and unchanged for an update.
FileReceiveMaxBytes *int64 `json:"file_receive_max_bytes,omitempty"`
FileSendMaxBytes *int64 `json:"file_send_max_bytes,omitempty"`
// Caps are the capabilities you enable for the binding (a closed list,
// see the kind's permissions for the ones it can grant to a role).
// Absent means none for a new binding and unchanged for an update; an
// empty list clears.
Caps []string `json:"caps,omitempty"`
// Grants map roles of the kind to capabilities within caps. A sensitive
// capability is never granted to a role. Absent means unchanged; an empty
// list clears.
Grants []ChatsConnectorRoleGrant `json:"grants,omitempty"`
}
// ChatsConnectorList is the response of the connector list endpoint.
+200
View File
@@ -0,0 +1,200 @@
package studio
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"regexp"
"slices"
"strings"
"time"
"github.com/mudler/LocalAI/core/services/studio/connectors"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
// Sender pairing proves that the owner of a binding controls a platform
// account. The owner asks the host for a code, then sends it as a direct
// message to the bot from that account. The host checks the code before any
// model turn, so the code never reaches a model or the sender log. Pairing
// needs only a stable sender ID, so it works for every connector add-on.
const (
pairingPrefix = "LA-"
pairingChars = 8
// pairingAlphabet has no look-alike characters (0/O, 1/I/L).
pairingAlphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
// pairingAttempts is how many wrong codes a sender may send in an hour
// before the host stops looking at that sender's messages for an hour.
pairingAttempts = 5
pairingWindow = time.Hour
sentPaired = "Paired. This account can now use the sensitive features that you enabled for this bot."
sentPairingBad = "That code is not valid. It may have expired or been used. Ask for a new one."
sentPairingFull = "This bot already has the most paired accounts. Remove one in the settings, then pair again."
sentPairingDirect = "Pairing works in a direct message only."
)
var pairingShape = regexp.MustCompile(`^LA-[A-Z0-9]{8}$`)
// PairingCode is a code for a sender to send to the bot.
type PairingCode struct {
Code string
Expires time.Time
}
func pairingHash(binding, code string) string {
sum := sha256.Sum256([]byte(binding + "\x00" + strings.ToUpper(code)))
return hex.EncodeToString(sum[:])
}
func newPairingCode() (string, error) {
var raw [pairingChars]byte
if _, err := rand.Read(raw[:]); err != nil {
return "", err
}
var b strings.Builder
b.WriteString(pairingPrefix)
for _, x := range raw {
b.WriteByte(pairingAlphabet[int(x)%len(pairingAlphabet)])
}
return b.String(), nil
}
// StartPairing makes a code for a binding of the user. It is valid for
// connectors.PairingCodeTTL and works once. A new code replaces the old one.
func (c *Connectors) StartPairing(ctx context.Context, user, id string) (PairingCode, error) {
b, err := c.owned(ctx, user, id)
if err != nil {
return PairingCode{}, err
}
code, err := newPairingCode()
if err != nil {
return PairingCode{}, err
}
exp := c.o.Clock().UTC().Add(connectors.PairingCodeTTL)
if err := c.o.Store.PutPairing(ctx, b.ID, connectors.Pairing{Hash: pairingHash(b.ID, code), Expires: exp}); err != nil {
return PairingCode{}, err
}
c.audit(b, "", "pairing.code", "issued")
return PairingCode{Code: code, Expires: exp}, nil
}
// RemovePaired ends the pairing of a sender of a binding of the user. It is
// not an error when the sender is not paired.
func (c *Connectors) RemovePaired(ctx context.Context, user, id, senderID string) (ConnectorInfo, error) {
c.wmu.Lock()
defer c.wmu.Unlock()
b, err := c.owned(ctx, user, id)
if err != nil {
return ConnectorInfo{}, err
}
b.Paired = slices.DeleteFunc(b.Paired, func(p connectors.PairedSender) bool { return p.SenderID == senderID })
b.Updated = c.o.Clock().UTC()
if err := c.o.Store.PutBinding(ctx, b); err != nil {
return ConnectorInfo{}, err
}
c.audit(b, senderID, "pairing.remove", "removed")
return c.withState(ctx, info(b)), nil
}
// audit records a pairing decision. It never carries a code.
func (c *Connectors) audit(b connectors.Binding, sender, event, result string) {
c.log.Info("studio: connector audit", "binding", b.ID, "kind", b.Kind, "sender", sender, "event", event, "result", result)
}
// pairingLocked tells whether the sender is locked out: pairingAttempts wrong
// codes in pairingWindow lock the sender for pairingWindow.
func (c *Connectors) pairingLocked(s *senderState) bool {
now := c.o.Clock()
c.smu.Lock()
defer c.smu.Unlock()
return now.Before(s.lockedUntil)
}
// pairingFailed counts a wrong code of a sender.
func (c *Connectors) pairingFailed(s *senderState) {
now := c.o.Clock()
c.smu.Lock()
defer c.smu.Unlock()
kept := s.failures[:0]
for _, t := range s.failures {
if now.Sub(t) < pairingWindow {
kept = append(kept, t)
}
}
s.failures = append(kept, now)
if len(s.failures) >= pairingAttempts {
s.lockedUntil = now.Add(pairingWindow)
s.failures = nil
}
}
// tryPairing handles a message that has the shape of a pairing code. It
// returns true when the message was a pairing attempt: the caller must not
// start a turn for it. The sender is already on the allowlist.
func (c *Connectors) tryPairing(ctx context.Context, b connectors.Binding, m contract.ConnectorMessage, st *senderState, say func(string)) bool {
code := strings.ToUpper(strings.TrimSpace(m.Text))
if !pairingShape.MatchString(code) {
return false
}
if c.pairingLocked(st) {
c.audit(b, m.SenderID, "pairing.attempt", "locked")
return true
}
if !m.Direct {
// A code written in a group is spent in public: it is not tried.
c.audit(b, m.SenderID, "pairing.attempt", "not direct")
c.pairingFailed(st)
say(sentPairingDirect)
return true
}
ok, err := c.o.Store.ConsumePairing(ctx, b.ID, pairingHash(b.ID, code), c.o.Clock())
if err != nil {
c.log.Warn("studio: could not check a pairing code", "binding", b.ID, "error", err)
say(sentUnavailable)
return true
}
if !ok {
c.audit(b, m.SenderID, "pairing.attempt", "refused")
c.pairingFailed(st)
say(sentPairingBad)
return true
}
if err := c.addPaired(ctx, b.ID, m.SenderID); err != nil {
c.audit(b, m.SenderID, "pairing.attempt", err.Error())
say(sentPairingFull)
return true
}
c.audit(b, m.SenderID, "pairing.attempt", "paired")
say(sentPaired)
return true
}
// addPaired stores a pairing, dropping the ones that ended. A sender who pairs
// again renews the pairing.
func (c *Connectors) addPaired(ctx context.Context, id, senderID string) error {
c.wmu.Lock()
defer c.wmu.Unlock()
b, err := c.o.Store.GetBinding(ctx, id)
if err != nil {
return err
}
now := c.o.Clock().UTC()
b.Paired = slices.DeleteFunc(b.Paired, func(p connectors.PairedSender) bool {
return p.SenderID == senderID || now.Sub(p.At) >= connectors.PairedTTL
})
if len(b.Paired) >= connectors.MaxPaired {
return errPairedFull
}
b.Paired = append(b.Paired, connectors.PairedSender{SenderID: senderID, At: now})
b.Updated = now
return c.o.Store.PutBinding(ctx, b)
}
type pairingErr string
func (e pairingErr) Error() string { return string(e) }
const errPairedFull = pairingErr("too many paired senders")
@@ -0,0 +1,48 @@
package studio
import (
"context"
"github.com/mudler/LocalAI/core/services/studio/connectors"
"github.com/mudler/LocalAI/pkg/addons/contract"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
type rolesTransport struct{ roles []contract.ConnectorRole }
func (t rolesTransport) Kinds(context.Context) ([]ConnectorKind, error) {
return []ConnectorKind{{AddonID: "one", ConnectorKind: contract.ConnectorKind{ID: "chat", Title: "Chat", Roles: t.roles}}}, nil
}
func (rolesTransport) Call(context.Context, ConnectorCallScope, contract.ConnectorRequest) (contract.ConnectorResponse, error) {
return contract.ConnectorResponse{}, nil
}
var _ = Describe("the roles of an inbound message", func() {
roles := func(tr ConnectorTransport) *Connectors {
svc, _ := newSkillService(&skillBroker{})
st, err := connectors.NewJSON("")
Expect(err).NotTo(HaveOccurred())
c, err := NewConnectors(svc, ConnectorOptions{Store: st, Transport: tr})
Expect(err).NotTo(HaveOccurred())
return c
}
b := connectors.Binding{ID: "b1", Kind: "chat", AddonID: "one"}
ctx := context.Background()
It("keeps the roles that the kind declares and drops the others", func() {
c := roles(rolesTransport{roles: []contract.ConnectorRole{{ID: "admin", Title: "Admin"}}})
Expect(c.declaredRoles(ctx, b, []string{"admin", "root", "admin"})).To(Equal([]string{"admin"}))
})
It("drops all roles for a kind that declares none", func() {
c := roles(rolesTransport{})
Expect(c.declaredRoles(ctx, b, []string{"admin"})).To(BeEmpty())
})
It("drops all roles for a binding of another add-on's kind", func() {
c := roles(rolesTransport{roles: []contract.ConnectorRole{{ID: "admin", Title: "Admin"}}})
other := b
other.AddonID = "two"
Expect(c.declaredRoles(ctx, other, []string{"admin"})).To(BeEmpty())
})
})
+29
View File
@@ -153,6 +153,10 @@ type senderState struct {
active bool
times []time.Time
lastNotice time.Time
// failures are the times of wrong pairing codes; lockedUntil ends a
// lockout after too many.
failures []time.Time
lockedUntil time.Time
}
// NewConnectors returns the manager. It does nothing until Run or Sync is called.
@@ -559,6 +563,9 @@ func (c *Connectors) Ingest(ctx context.Context, addonID string, in contract.Con
if r := []rune(m.SenderName); len(r) > 200 {
m.SenderName = string(r[:200])
}
// The add-on owns the roles of a sender; the host keeps the ones that the
// kind declares and drops the rest.
m.Roles = c.declaredRoles(ctx, b, m.Roles)
token, err := c.openToken(ctx, b)
if err != nil {
c.disable(b, "its bot token cannot be read")
@@ -568,6 +575,23 @@ func (c *Connectors) Ingest(ctx context.Context, addonID string, in contract.Con
return nil
}
// declaredRoles keeps the role IDs that the kind of the binding declares. A
// kind that is not running, or declares no roles, keeps none.
func (c *Connectors) declaredRoles(ctx context.Context, b connectors.Binding, ids []string) []string {
if len(ids) == 0 {
return nil
}
kinds, err := c.o.Transport.Kinds(ctx)
if err != nil {
return nil
}
k, ok := kindFor(kinds, b)
if !ok {
return nil
}
return k.FilterRoles(ids)
}
// conversation is the key of the chat that a message belongs to. A message in
// a thread of the platform belongs to the thread, so the people who write in
// one thread share one chat; any other message belongs to its sender. The key
@@ -678,6 +702,11 @@ func (c *Connectors) handle(ctx context.Context, b connectors.Binding, token str
case !ok:
return
}
// A pairing code is checked before any model turn and never reaches the
// model or the log.
if c.tryPairing(ctx, b, m, st, say) {
return
}
switch {
case m.Unsupported && len(m.Media) == 0:
say(sentUnsupported)
@@ -113,6 +113,49 @@ func Run(newStore NewStore) {
Expect(byID).To(HaveKeyWithValue("new", "slack-addon"))
}
})
It("keeps the capabilities, grants and paired senders", func() {
s := newStore()
b := binding("b1", "u1", "Bot")
b.Caps = []string{"memory.write", "triggers.run"}
b.Grants = []connectors.RoleGrant{{Role: "admin", Caps: []string{"memory.write"}}}
b.Paired = []connectors.PairedSender{{SenderID: "123", At: t0}}
Expect(s.PutBinding(ctx, b)).To(Succeed())
got, err := s.GetBinding(ctx, "b1")
Expect(err).NotTo(HaveOccurred())
Expect(got.Caps).To(Equal(b.Caps))
Expect(got.Grants).To(Equal(b.Grants))
Expect(got.Paired).To(HaveLen(1))
Expect(got.Paired[0].SenderID).To(Equal("123"))
Expect(got.Paired[0].At.Equal(t0)).To(BeTrue())
// A binding that has none reads as none.
Expect(s.PutBinding(ctx, binding("b2", "u1", "Bare"))).To(Succeed())
bare, _ := s.GetBinding(ctx, "b2")
Expect(bare.Caps).To(BeEmpty())
Expect(bare.Paired).To(BeEmpty())
})
It("uses a pairing code once, before it expires, and only with the right hash", func() {
s := newStore()
Expect(s.PutPairing(ctx, "b1", connectors.Pairing{Hash: "h1", Expires: t0.Add(10 * time.Minute)})).To(Succeed())
ok, err := s.ConsumePairing(ctx, "b1", "wrong", t0)
Expect(err).NotTo(HaveOccurred())
Expect(ok).To(BeFalse())
ok, _ = s.ConsumePairing(ctx, "b1", "h1", t0.Add(time.Minute))
Expect(ok).To(BeTrue())
ok, _ = s.ConsumePairing(ctx, "b1", "h1", t0.Add(time.Minute))
Expect(ok).To(BeFalse(), "a code works once")
Expect(s.PutPairing(ctx, "b1", connectors.Pairing{Hash: "h2", Expires: t0.Add(10 * time.Minute)})).To(Succeed())
ok, _ = s.ConsumePairing(ctx, "b1", "h2", t0.Add(11*time.Minute))
Expect(ok).To(BeFalse(), "an expired code fails")
Expect(s.PutPairing(ctx, "b1", connectors.Pairing{Hash: "h3", Expires: t0.Add(10 * time.Minute)})).To(Succeed())
Expect(s.PutPairing(ctx, "b1", connectors.Pairing{Hash: "h4", Expires: t0.Add(10 * time.Minute)})).To(Succeed())
ok, _ = s.ConsumePairing(ctx, "b1", "h3", t0)
Expect(ok).To(BeFalse(), "a new code replaces the old one")
Expect(s.DeleteBinding(ctx, "b1")).To(Succeed())
ok, _ = s.ConsumePairing(ctx, "b1", "h4", t0)
Expect(ok).To(BeFalse(), "deleting the binding removes its code")
})
It("lists the enabled bindings", func() {
s := newStore()
a, b := binding("a", "u1", "A"), binding("b", "u2", "B")
+48 -6
View File
@@ -37,9 +37,14 @@ type bindingRow struct {
// before them has no limit of the owner.
FileReceiveMax int64
FileSendMax int64
Token []byte
Created time.Time
Updated time.Time
// Caps, Grants and Paired are JSON, added by AutoMigrate; a row stored
// before them has none.
Caps string
Grants string
Paired string
Token []byte
Created time.Time
Updated time.Time
}
func (bindingRow) TableName() string { return "studio_connectors" }
@@ -71,6 +76,14 @@ type chatRow struct {
func (chatRow) TableName() string { return "studio_connector_chats" }
type pairingRow struct {
Binding string `gorm:"primaryKey;size:64"`
Hash string `gorm:"size:64"`
Expires time.Time
}
func (pairingRow) TableName() string { return "studio_connector_pairings" }
type keyRow struct {
Name string `gorm:"primaryKey;size:32"`
Value []byte
@@ -85,7 +98,7 @@ func NewGORM(db *gorm.DB) (Store, error) {
if db == nil {
return nil, errors.New("connectors: nil *gorm.DB")
}
if err := db.AutoMigrate(&bindingRow{}, &stateRow{}, &senderRow{}, &chatRow{}, &keyRow{}); err != nil {
if err := db.AutoMigrate(&bindingRow{}, &stateRow{}, &senderRow{}, &chatRow{}, &keyRow{}, &pairingRow{}); err != nil {
return nil, fmt.Errorf("connectors: migrate: %w", err)
}
return &gormStore{db: db}, nil
@@ -106,6 +119,17 @@ func (r bindingRow) binding() (Binding, error) {
return b, err
}
}
for _, f := range []struct {
raw string
dst any
}{{r.Caps, &b.Caps}, {r.Grants, &b.Grants}, {r.Paired, &b.Paired}} {
if f.raw == "" {
continue
}
if err := json.Unmarshal([]byte(f.raw), f.dst); err != nil {
return b, err
}
}
return Normalize(b), nil
}
@@ -120,7 +144,14 @@ func (s *gormStore) PutBinding(ctx context.Context, b Binding) error {
raw, _ := json.Marshal(b.Options)
options = string(raw)
}
return s.db.WithContext(ctx).Save(&bindingRow{ID: b.ID, Owner: b.Owner, Kind: b.Kind, AddonID: b.AddonID, Name: b.Name, AgentID: b.AgentID, Enabled: b.Enabled,
enc := func(v any, empty bool) string {
if empty {
return ""
}
raw, _ := json.Marshal(v)
return string(raw)
}
return s.db.WithContext(ctx).Save(&bindingRow{Caps: enc(b.Caps, len(b.Caps) == 0), Grants: enc(b.Grants, len(b.Grants) == 0), Paired: enc(b.Paired, len(b.Paired) == 0), ID: b.ID, Owner: b.Owner, Kind: b.Kind, AddonID: b.AddonID, Name: b.Name, AgentID: b.AgentID, Enabled: b.Enabled,
AcceptAny: b.AcceptAny, Allow: allow, NotifyChat: b.NotifyChat, Reply: b.Reply, Conversation: b.Conversation, WindowMinutes: b.WindowMinutes,
FilesOff: b.FilesOff, Options: options, FileReceiveMax: b.FileReceiveMax, FileSendMax: b.FileSendMax,
Token: b.Token, Created: b.Created, Updated: b.Updated}).Error
@@ -168,7 +199,7 @@ func (s *gormStore) EnabledBindings(ctx context.Context) ([]Binding, error) {
func (s *gormStore) DeleteBinding(ctx context.Context, id string) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, m := range []any{&stateRow{}, &senderRow{}, &chatRow{}} {
for _, m := range []any{&stateRow{}, &senderRow{}, &chatRow{}, &pairingRow{}} {
if err := tx.Where("binding = ?", id).Delete(m).Error; err != nil {
return err
}
@@ -274,3 +305,14 @@ func (s *gormStore) EnsureKey(ctx context.Context, generate func() ([]byte, erro
}
return got, nil
}
func (s *gormStore) PutPairing(ctx context.Context, binding string, p Pairing) error {
return s.db.WithContext(ctx).Save(&pairingRow{Binding: binding, Hash: p.Hash, Expires: p.Expires.UTC()}).Error
}
func (s *gormStore) ConsumePairing(ctx context.Context, binding, hash string, now time.Time) (bool, error) {
// An expired code stays until it is replaced or the binding goes; it
// cannot match.
res := s.db.WithContext(ctx).Where("binding = ? AND hash = ? AND expires > ?", binding, hash, now.UTC()).Delete(&pairingRow{})
return res.RowsAffected == 1, res.Error
}
+31 -1
View File
@@ -31,10 +31,11 @@ type jsonData struct {
Offsets map[string]int64 `json:"offsets"`
Senders map[string][]Sender `json:"senders"`
Chats map[string]string `json:"chats"` // binding + "\x00" + sender
Pairings map[string]Pairing `json:"pairings,omitempty"`
}
func newData() jsonData {
return jsonData{Bindings: map[string]Binding{}, Offsets: map[string]int64{}, Senders: map[string][]Sender{}, Chats: map[string]string{}}
return jsonData{Bindings: map[string]Binding{}, Offsets: map[string]int64{}, Senders: map[string][]Sender{}, Chats: map[string]string{}, Pairings: map[string]Pairing{}}
}
// NewJSON returns a Store. With an empty dir it keeps everything in memory.
@@ -60,6 +61,9 @@ func NewJSON(dir string) (Store, error) {
if s.d.Bindings == nil {
s.d = newData()
}
if s.d.Pairings == nil {
s.d.Pairings = map[string]Pairing{}
}
return s, nil
}
@@ -132,6 +136,7 @@ func (s *jsonStore) DeleteBinding(_ context.Context, id string) error {
delete(s.d.Bindings, id)
delete(s.d.Offsets, id)
delete(s.d.Senders, id)
delete(s.d.Pairings, id)
for k := range s.d.Chats {
if len(k) > len(id) && k[:len(id)+1] == id+"\x00" {
delete(s.d.Chats, k)
@@ -236,3 +241,28 @@ func (s *jsonStore) EnsureKey(_ context.Context, generate func() ([]byte, error)
s.key = k
return append([]byte(nil), k...), nil
}
func (s *jsonStore) PutPairing(_ context.Context, binding string, p Pairing) error {
s.mu.Lock()
defer s.mu.Unlock()
s.d.Pairings[binding] = p
return s.save()
}
func (s *jsonStore) ConsumePairing(_ context.Context, binding, hash string, now time.Time) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
p, ok := s.d.Pairings[binding]
if !ok || p.Hash != hash {
return false, nil
}
if !now.Before(p.Expires) {
return false, nil
}
delete(s.d.Pairings, binding)
if err := s.save(); err != nil {
s.d.Pairings[binding] = p
return false, err
}
return true, nil
}
@@ -0,0 +1,52 @@
package connectors_test
import (
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/mudler/LocalAI/core/services/studio/connectors"
)
var _ = Describe("the capabilities of a binding", func() {
base := func() connectors.Binding {
return connectors.Binding{Name: "Bot", Kind: "chat", AgentID: "a1"}
}
It("accepts known capabilities and a grant within them", func() {
b := base()
b.Caps = []string{"memory.write", "profile.write"}
b.Grants = []connectors.RoleGrant{{Role: "admin", Caps: []string{"memory.write"}}}
Expect(connectors.Validate(b)).To(Succeed())
})
It("refuses an unknown or repeated capability", func() {
b := base()
b.Caps = []string{"nope"}
Expect(connectors.Validate(b)).To(MatchError(connectors.ErrInvalid))
b.Caps = []string{"memory.write", "memory.write"}
Expect(connectors.Validate(b)).To(MatchError(connectors.ErrInvalid))
})
It("refuses a grant above the ceiling or of a sensitive capability", func() {
b := base()
b.Caps = []string{"memory.write", "profile.write"}
b.Grants = []connectors.RoleGrant{{Role: "admin", Caps: []string{"files.upload"}}}
Expect(connectors.Validate(b)).To(MatchError(connectors.ErrInvalid))
b.Grants = []connectors.RoleGrant{{Role: "admin", Caps: []string{"profile.write"}}}
Expect(connectors.Validate(b)).To(MatchError(ContainSubstring("sensitive")))
})
It("refuses more than three paired senders", func() {
b := base()
for _, id := range []string{"1", "2", "3", "4"} {
b.Paired = append(b.Paired, connectors.PairedSender{SenderID: id})
}
Expect(connectors.Validate(b)).To(MatchError(connectors.ErrInvalid))
})
It("ends a pairing after 90 days", func() {
at := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
b := base()
b.Paired = []connectors.PairedSender{{SenderID: "1", At: at}}
Expect(b.IsPaired("1", at.Add(89*24*time.Hour))).To(BeTrue())
Expect(b.IsPaired("1", at.Add(91*24*time.Hour))).To(BeFalse())
Expect(b.IsPaired("2", at)).To(BeFalse())
})
})
+105
View File
@@ -14,9 +14,12 @@ import (
"fmt"
"maps"
"regexp"
"slices"
"strings"
"time"
"unicode/utf8"
"github.com/mudler/LocalAI/pkg/addons/contract"
)
// LegacyAddon is the add-on that serves the kinds core carried before
@@ -68,10 +71,23 @@ const (
MaxSendersKept = 200
MaxOptions = 8
MaxOptionRunes = 32
// MaxPaired is how many paired senders a binding holds.
MaxPaired = 3
// MaxGrants is how many roles of a binding have a grant.
MaxGrants = contract.MaxConnectorRoles
// MaxFileLimit is the largest file limit a binding can hold: 1 TiB.
MaxFileLimit int64 = 1 << 40
)
// Pairing limits.
const (
// PairedTTL is how long a pairing lasts. After it, the sender must pair
// again.
PairedTTL = 90 * 24 * time.Hour
// PairingCodeTTL is how long a pairing code can be used.
PairingCodeTTL = 10 * time.Minute
)
// Errors.
var (
ErrNotFound = errors.New("connectors: not found")
@@ -119,6 +135,16 @@ type Binding struct {
// limit of the kind applies. A value can only lower what the kind allows;
// the host takes the smaller one.
FileReceiveMax, FileSendMax int64
// Caps is the ceiling the owner enabled: the capabilities (see
// contract.ConnectorCapabilityNames) that senders of the binding may ever
// use. Empty means none, which is today's behaviour.
Caps []string
// Grants map the roles of the kind to capabilities, each within Caps.
// The kind of the binding decides which roles exist.
Grants []RoleGrant
// Paired are the senders that the owner proved by a pairing code. Only a
// paired sender may use a sensitive capability. At most MaxPaired.
Paired []PairedSender
// Token is the bot token. In a Store it is sealed bytes; the Store returns
// what it was given.
Token []byte
@@ -126,6 +152,26 @@ type Binding struct {
Updated time.Time
}
// RoleGrant gives the capabilities Caps to the senders that have the role Role.
type RoleGrant struct {
Role string `json:"role"`
Caps []string `json:"caps"`
}
// PairedSender is a sender that proved, with a code from the owner, that the
// owner controls the account. The pairing ends PairedTTL after At.
type PairedSender struct {
SenderID string `json:"sender_id"`
At time.Time `json:"at"`
}
// Pairing is a pending pairing code of a binding. The store keeps the hash,
// never the code.
type Pairing struct {
Hash string
Expires time.Time
}
// Sender is a person who messaged a bot.
type Sender struct {
Binding, ID, Name string
@@ -164,6 +210,15 @@ type Store interface {
// EnsureKey returns the sealing key. The first call stores what generate
// makes; every call after it, on any frontend, returns that key.
EnsureKey(ctx context.Context, generate func() ([]byte, error)) ([]byte, error)
// PutPairing stores the pending pairing code of a binding and replaces
// an earlier one.
PutPairing(ctx context.Context, binding string, p Pairing) error
// ConsumePairing removes the pending code of the binding and returns true
// when it has the given hash and has not expired at now. A wrong hash
// leaves the code in place. The check and the removal are one step, so a
// code works once on any frontend.
ConsumePairing(ctx context.Context, binding, hash string, now time.Time) (bool, error)
}
// An entry of the allowlist is a numeric ID (Telegram), an @username, or an
@@ -216,6 +271,9 @@ func Validate(b Binding) error {
default:
return bad("the conversation mode must be %s, %s, %s or %s", ConversationSender, ConversationChat, ConversationWindow, ConversationThread)
}
if err := validateCaps(b); err != nil {
return bad("%v", err)
}
if len(b.Options) > MaxOptions {
return bad("a binding has at most %d options", MaxOptions)
}
@@ -227,6 +285,44 @@ func Validate(b Binding) error {
return nil
}
func validateCaps(b Binding) error {
if len(b.Caps) > len(contract.ConnectorCapabilityNames()) {
return errors.New("too many capabilities")
}
for i, c := range b.Caps {
if !contract.KnownCapability(c) || slices.Contains(b.Caps[:i], c) {
return fmt.Errorf("%q is not a capability, or is repeated", c)
}
}
if len(b.Grants) > MaxGrants {
return fmt.Errorf("a binding grants at most %d roles", MaxGrants)
}
for i, g := range b.Grants {
if g.Role == "" || len(g.Role) > 64 || slices.ContainsFunc(b.Grants[:i], func(o RoleGrant) bool { return o.Role == g.Role }) {
return fmt.Errorf("a grant needs one distinct role")
}
for _, c := range g.Caps {
switch {
case !slices.Contains(b.Caps, c):
return fmt.Errorf("the role %q cannot have %q: the binding does not enable it", g.Role, c)
case contract.SensitiveCapability(c):
return fmt.Errorf("%q is sensitive: it is never granted to a role; pair the sender instead", c)
}
}
}
if len(b.Paired) > MaxPaired {
return fmt.Errorf("a binding has at most %d paired senders", MaxPaired)
}
return nil
}
// IsPaired tells whether the sender holds a pairing that has not ended at now.
func (b Binding) IsPaired(senderID string, now time.Time) bool {
return slices.ContainsFunc(b.Paired, func(p PairedSender) bool {
return p.SenderID == senderID && now.Sub(p.At) < PairedTTL
})
}
// ReplyMode is the reply mode of a binding, with the default filled in.
func (b Binding) ReplyMode() string {
if b.Reply == "" {
@@ -272,5 +368,14 @@ func (b Binding) clone() Binding {
b.Allow = append([]string(nil), b.Allow...)
b.Token = append([]byte(nil), b.Token...)
b.Options = maps.Clone(b.Options)
b.Caps = slices.Clone(b.Caps)
b.Paired = slices.Clone(b.Paired)
if b.Grants != nil {
g := make([]RoleGrant, len(b.Grants))
for i, x := range b.Grants {
g[i] = RoleGrant{Role: x.Role, Caps: slices.Clone(x.Caps)}
}
b.Grants = g
}
return b
}
@@ -0,0 +1,185 @@
package studio_test
import (
"context"
"time"
"github.com/mudler/LocalAI/addons/telegram/transport"
"github.com/mudler/LocalAI/core/services/studio"
"github.com/mudler/LocalAI/core/services/studio/connectors"
"github.com/mudler/LocalAI/pkg/addons/contract"
"github.com/mudler/LocalAI/tests/studiobot"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
// The roles, capabilities and sender pairing of a binding. The kind in these
// specs is a plain connector kind that declares roles; nothing here depends
// on which add-on serves it.
var _ = Describe("capabilities and sender pairing of a connector", func() {
var (
r *connRig
sh *shared
b connectors.Binding
st connectors.Store
)
ctx := context.Background()
dm := func(sender, text string) contract.ConnectorInbound {
return contract.ConnectorInbound{BindingID: b.ID, ConnectorMessage: contract.ConnectorMessage{Chat: sender, Direct: true, SenderID: sender, SenderName: "S" + sender, Text: text}}
}
say := func(sender, text string) {
Expect(r.conns.Ingest(ctx, "telegram", dm(sender, text))).To(Succeed())
}
lastAnswer := func() string {
a := r.bot.answers()
if len(a) == 0 {
return ""
}
return a[len(a)-1]
}
reload := func() connectors.Binding {
got, err := st.GetBinding(ctx, b.ID)
Expect(err).NotTo(HaveOccurred())
return got
}
BeforeEach(func() {
sh = newShared()
st = connStore()
bot := newScriptedBot()
addon := studiobot.Telegram(&transport.API{BaseURL: bot.srv.URL, Client: bot.srv.Client()})
addon.Kind.Roles = []contract.ConnectorRole{{ID: "admin", Title: "Admin"}}
addon.Kind.Permissions = []string{contract.CapMemoryWrite, contract.CapProfileWrite}
r = newConnRigWith(sh, st, studiobot.NewTransport(addon), connSay("model answer"), nil)
r.bot = bot
b = r.bind(studio.ConnectorInput{Allow: []string{"11", "12", "13", "14"}})
})
Describe("the capabilities the owner chooses", func() {
It("stores the ceiling and the grants of a role the kind declares", func() {
in := studio.ConnectorInput{Name: "Support bot", AgentID: b.AgentID, Enabled: true, Allow: b.Allow,
Caps: []string{contract.CapMemoryWrite, contract.CapProfileWrite},
Grants: []connectors.RoleGrant{{Role: "admin", Caps: []string{contract.CapMemoryWrite}}}}
got, err := r.conns.UpdateConnector(ctx, alice, b.ID, in)
Expect(err).NotTo(HaveOccurred())
Expect(got.Caps).To(Equal(in.Caps))
Expect(got.Grants).To(Equal(in.Grants))
// An update that sends none keeps them.
in.Caps, in.Grants = nil, nil
got, err = r.conns.UpdateConnector(ctx, alice, b.ID, in)
Expect(err).NotTo(HaveOccurred())
Expect(got.Caps).To(HaveLen(2))
// An empty list clears them.
in.Caps, in.Grants = []string{}, []connectors.RoleGrant{}
got, err = r.conns.UpdateConnector(ctx, alice, b.ID, in)
Expect(err).NotTo(HaveOccurred())
Expect(got.Caps).To(BeEmpty())
})
DescribeTable("refuses",
func(caps []string, grants []connectors.RoleGrant) {
_, err := r.conns.UpdateConnector(ctx, alice, b.ID, studio.ConnectorInput{Name: "x", AgentID: b.AgentID, Enabled: true, Allow: b.Allow, Caps: caps, Grants: grants})
Expect(err).To(MatchError(connectors.ErrInvalid))
},
Entry("an unknown capability", []string{"root"}, nil),
Entry("a role the kind does not declare", []string{contract.CapMemoryWrite}, []connectors.RoleGrant{{Role: "boss", Caps: []string{contract.CapMemoryWrite}}}),
Entry("a capability the kind does not list", []string{contract.CapFilesUpload}, []connectors.RoleGrant{{Role: "admin", Caps: []string{contract.CapFilesUpload}}}),
Entry("a sensitive grant to a role", []string{contract.CapProfileWrite}, []connectors.RoleGrant{{Role: "admin", Caps: []string{contract.CapProfileWrite}}}),
Entry("a grant above the ceiling", []string{contract.CapMemoryWrite}, []connectors.RoleGrant{{Role: "admin", Caps: []string{contract.CapProfileWrite}}}),
)
})
Describe("pairing", func() {
It("pairs the sender who sends the code in a direct message, and answers without a turn", func() {
pc, err := r.conns.StartPairing(ctx, alice, b.ID)
Expect(err).NotTo(HaveOccurred())
Expect(pc.Code).To(MatchRegexp(`^LA-[A-Z0-9]{8}$`))
Expect(pc.Expires.Sub(sh.clock.Now())).To(Equal(10 * time.Minute))
say("11", pc.Code)
Eventually(lastAnswer, "10s").Should(HavePrefix("Paired"))
got := reload()
Expect(got.IsPaired("11", sh.clock.Now())).To(BeTrue())
Expect(got.IsPaired("12", sh.clock.Now())).To(BeFalse())
chats, _ := r.svc.List(ctx, alice, 20)
Expect(chats).To(BeEmpty(), "the code never starts a turn")
Expect(r.logs.String()).NotTo(ContainSubstring(pc.Code))
})
It("accepts the code once only", func() {
pc, _ := r.conns.StartPairing(ctx, alice, b.ID)
say("11", pc.Code)
Eventually(lastAnswer, "10s").Should(HavePrefix("Paired"))
say("12", pc.Code)
Eventually(lastAnswer, "10s").Should(HavePrefix("That code is not valid"))
Expect(reload().IsPaired("12", sh.clock.Now())).To(BeFalse())
})
It("refuses an expired code and a wrong one", func() {
pc, _ := r.conns.StartPairing(ctx, alice, b.ID)
say("11", "LA-ZZZZZZZZ")
Eventually(lastAnswer, "10s").Should(HavePrefix("That code is not valid"))
sh.clock.Advance(11 * time.Minute)
say("11", pc.Code)
Eventually(func() int { return len(r.bot.answers()) }, "10s").Should(Equal(2))
Expect(reload().Paired).To(BeEmpty())
})
It("does not pair a sender who is not on the allowlist, or in a group", func() {
pc, _ := r.conns.StartPairing(ctx, alice, b.ID)
say("99", pc.Code)
g := dm("11", pc.Code)
g.Direct, g.Mention = false, true
Expect(r.conns.Ingest(ctx, "telegram", g)).To(Succeed())
Eventually(lastAnswer, "10s").Should(HavePrefix("Pairing works in a direct message"))
Expect(reload().Paired).To(BeEmpty())
// The code still works in a direct message: a group attempt did not spend it.
say("11", pc.Code)
Eventually(lastAnswer, "10s").Should(HavePrefix("Paired"))
})
It("locks a sender out after five wrong codes", func() {
pc, _ := r.conns.StartPairing(ctx, alice, b.ID)
sh.clock.Advance(0)
for i := 0; i < 5; i++ {
say("11", "LA-AAAAAAA"+string(rune('A'+i)))
sh.clock.Advance(15 * time.Second) // stays inside the rate limit of the sender
}
n := len(r.bot.answers())
say("11", pc.Code)
Consistently(func() int { return len(r.bot.answers()) }, "300ms").Should(Equal(n), "a locked sender gets no answer")
Expect(reload().Paired).To(BeEmpty())
sh.clock.Advance(61 * time.Minute)
pc, _ = r.conns.StartPairing(ctx, alice, b.ID)
say("11", pc.Code)
Eventually(lastAnswer, "10s").Should(HavePrefix("Paired"))
})
It("holds three paired senders, and a pairing ends after 90 days", func() {
for _, id := range []string{"11", "12", "13"} {
pc, _ := r.conns.StartPairing(ctx, alice, b.ID)
say(id, pc.Code)
Eventually(func() bool { return reload().IsPaired(id, sh.clock.Now()) }, "10s").Should(BeTrue())
}
pc, _ := r.conns.StartPairing(ctx, alice, b.ID)
say("14", pc.Code)
Eventually(lastAnswer, "10s").Should(HavePrefix("This bot already has the most"))
Expect(reload().IsPaired("14", sh.clock.Now())).To(BeFalse())
sh.clock.Advance(91 * 24 * time.Hour)
Expect(reload().IsPaired("11", sh.clock.Now())).To(BeFalse())
pc, _ = r.conns.StartPairing(ctx, alice, b.ID)
say("14", pc.Code)
Eventually(func() bool { return reload().IsPaired("14", sh.clock.Now()) }, "10s").Should(BeTrue())
})
It("lets the owner remove a pairing, and nobody else start one", func() {
pc, _ := r.conns.StartPairing(ctx, alice, b.ID)
say("11", pc.Code)
Eventually(func() int { return len(reload().Paired) }, "10s").Should(Equal(1))
got, err := r.conns.RemovePaired(ctx, alice, b.ID, "11")
Expect(err).NotTo(HaveOccurred())
Expect(got.Paired).To(BeEmpty())
_, err = r.conns.StartPairing(ctx, "bob", b.ID)
Expect(err).To(MatchError(connectors.ErrNotFound))
})
})
})
+46
View File
@@ -45,6 +45,11 @@ type ConnectorInput struct {
// files the bot reads and sends. Nil: new binding none, update unchanged.
// Zero clears the limit. A limit can only lower what the kind allows.
FileReceiveMax, FileSendMax *int64
// Caps are the capabilities the owner enables for the binding, and
// Grants map roles of the kind to capabilities within Caps. Nil: new
// binding none, update unchanged. An empty non-nil slice clears.
Caps []string
Grants []connectors.RoleGrant
}
// apply sets the fields of the input that an owner writes on a binding.
@@ -66,6 +71,12 @@ func (in ConnectorInput) apply(b *connectors.Binding) {
if in.FileSendMax != nil {
b.FileSendMax = *in.FileSendMax
}
if in.Caps != nil {
b.Caps = slices.Clone(in.Caps)
}
if in.Grants != nil {
b.Grants = slices.Clone(in.Grants)
}
}
// ErrTokenRefused means the platform refused the bot token of a binding.
@@ -207,6 +218,35 @@ func (c *Connectors) checkOptions(ctx context.Context, b connectors.Binding) err
return nil
}
// checkPermissions refuses a grant to a role that the kind does not declare,
// or of a capability that the kind does not list as grantable. The generic
// rules (known names, the ceiling, no sensitive grant) are in
// connectors.Validate. A kind whose add-on does not run cannot be checked.
func (c *Connectors) checkPermissions(ctx context.Context, b connectors.Binding) error {
if len(b.Grants) == 0 {
return nil
}
kinds, err := c.o.Transport.Kinds(ctx)
if err != nil {
return nil
}
k, ok := kindFor(kinds, b)
if !ok {
return nil
}
for _, g := range b.Grants {
if _, ok := k.Role(g.Role); !ok {
return connectorBad("the role %q is not one of this kind", g.Role)
}
for _, cp := range g.Caps {
if !k.Grantable(cp) {
return connectorBad("this kind does not let %q go to a role", cp)
}
}
}
return nil
}
func connectorBad(format string, a ...any) error {
return fmt.Errorf("%w: %s", connectors.ErrInvalid, fmt.Sprintf(format, a...))
}
@@ -288,6 +328,9 @@ func (c *Connectors) CreateConnector(ctx context.Context, user string, in Connec
if err := c.checkOptions(ctx, b); err != nil {
return ConnectorInfo{}, err
}
if err := c.checkPermissions(ctx, b); err != nil {
return ConnectorInfo{}, err
}
if err := c.checkOwnerLimits(ctx, b, in); err != nil {
return ConnectorInfo{}, err
}
@@ -353,6 +396,9 @@ func (c *Connectors) UpdateConnector(ctx context.Context, user, id string, in Co
if err := c.checkOptions(ctx, b); err != nil {
return ConnectorInfo{}, err
}
if err := c.checkPermissions(ctx, b); err != nil {
return ConnectorInfo{}, err
}
if err := c.checkOwnerLimits(ctx, b, in); err != nil {
return ConnectorInfo{}, err
}
+2
View File
@@ -2231,6 +2231,8 @@ The manifest declares the capability. It needs no tool and no `service` flag:
| `kinds[].history` | Optional, manifest `api` 2. `{"thread": true, "maxMessages": N}`: the add-on serves the op `history` for threads, and returns at most `maxMessages` (1 to 200; without it the host asks for 30). A kind without it is never asked, so it keeps the behaviour of a platform that cannot give old messages. |
| `kinds[].options` | Optional, manifest `api` 2, at most 4. A choice that each binding of the kind makes: `{"id", "title", "description", "values": [{"id", "title"}], "default"}` with 2 to 8 values and a default among them. The host shows the choice on the binding page, checks the owner's value against the declaration, stores it, and sends the value in effect to the add-on in `options` of every `poll`. What the option means is the add-on's own rule. |
| `kinds[].files` | Optional. The files the kind can receive and send, with a limit for each. Needs manifest `api` 2. See [File abilities and limits](#file-abilities-and-limits). |
| `kinds[].roles` | Optional, manifest `api` 2, at most 8. A role the kind can give to a sender: `{"id", "title", "description"}`, with an `id` in the same form as a kind `id`. The add-on owns the membership (for example, the admins of a workspace) and sends the role IDs of the sender in `roles` of each message, at most 8. The host drops a role the kind does not declare, and all roles for a kind that declares none. The owner of a binding maps each role to capabilities in the host. |
| `kinds[].permissions` | Optional, manifest `api` 2, at most 12. The capabilities the kind can grant to a role, from the host's closed list: `proposals.confirm`, `memory.write`, `files.upload`, `triggers.propose`, `triggers.run`, `talkback.channels` and the sensitive `talkback.new_channel`, `profile.write`, `skills.write`, `pages.write`, `dashboard.write`. An unknown name is refused. A sensitive name may be listed, but it is never granted to a role: only a sender that the owner paired can use it. |
**Outbound: the connector route.** The host calls `POST /connector` on the add-on
with the same bearer credential and incarnation header as every other route and a body
+31
View File
@@ -448,6 +448,37 @@ this with a confirmation. A binding that already has it keeps it on later edits,
and turning it off needs no confirmation. Use it for a public bot whose agent
grants nothing you would mind sharing.
### Capabilities, roles and pairing
Three things sit on top of the allowlist. They work the same for every connector
add-on.
- **Capabilities** (`caps`). The ceiling you enable for a binding, from a closed list
(`proposals.confirm`, `memory.write`, `files.upload`, `triggers.propose`,
`triggers.run`, `talkback.channels`, and the sensitive `talkback.new_channel`,
`profile.write`, `skills.write`, `pages.write`, `dashboard.write`). A binding with
none enabled behaves as before: the allowlist is the only gate.
- **Roles and grants** (`grants`). An add-on can declare roles, such as the admins of
a workspace, and report the roles of each sender. You map a role to capabilities
with `grants: [{"role", "caps"}]`. A grant is within `caps`, within what the kind
lists as `permissions`, and never a sensitive capability. The kind list
(`GET /api/chats/connectors/kinds`) shows each kind's `roles` and `permissions`.
- **Pairing.** A sender you pair may use the sensitive capabilities. Ask for a code
with `POST /api/chats/connectors/<id>/pairing`; send it as a direct message to the
bot from an account on the allowlist. The code looks like `LA-` and eight characters,
works once and lasts ten minutes. The host checks it before any model turn, so
the code never reaches the agent or the sender log. A binding holds at most three
paired senders, and a pairing ends after 90 days. End one earlier with
`DELETE /api/chats/connectors/<id>/paired/<sender>`. A sender who sends five wrong
codes in an hour is ignored for an hour, and every attempt is logged without the
code.
Pairing needs only a stable sender ID, so an add-on without roles supports it too.
The host stores and checks these values and shows them to the add-on pages. The
features that a capability switches on or off (triggers from chat, talk-back, and so
on) read them as they land.
## Notifications in Telegram
Set a notification chat ID on a binding to receive your run notifications there
+18
View File
@@ -145,6 +145,16 @@ type ConnectorKind struct {
// for each direction. Without it, the host moves files inline and caps
// them at MaxConnectorFileBytes. It needs manifest api MinFilesAPI.
Files *ConnectorFiles `json:"files,omitempty"`
// Roles are the roles the kind can give to a sender. The add-on owns
// the membership and sends the role IDs with each message; the owner of
// a binding maps each role to capabilities. It needs manifest api
// MinPermissionsAPI.
Roles []ConnectorRole `json:"roles,omitempty"`
// Permissions are the capabilities (see ConnectorCapabilityNames) that the kind can
// grant to a role. A sensitive name may be listed to explain the pairing
// path, but it is never granted to a role. It needs manifest api
// MinPermissionsAPI.
Permissions []string `json:"permissions,omitempty"`
}
// ConnectorOption is one choice among a few values.
@@ -292,6 +302,9 @@ func validateConnector(c *ConnectorCapability, ui *UI, api VersionRange) error {
if err := k.Files.validate(api); err != nil {
return err
}
if err := validatePermissions(k, api); err != nil {
return err
}
if k.Page != "" {
ok := false
if ui != nil {
@@ -370,6 +383,11 @@ type ConnectorMessage struct {
// is a voice message, which the host transcribes, and the transcript is
// the text of the turn.
Media []string `json:"media,omitempty"`
// Roles are the IDs of the roles (ConnectorKind.Roles) that the sender
// has, at most MaxMessageRoles. The host drops an ID the kind does not
// declare, and the whole field for a kind with no roles. Roles are never
// authority for a sensitive capability.
Roles []string `json:"roles,omitempty"`
}
// ConnectorInbound is the argument object of ConnectorInboundOp. It names the
+143
View File
@@ -0,0 +1,143 @@
// SPDX-License-Identifier: MIT
package contract
import (
"fmt"
"slices"
)
// MinPermissionsAPI is the first manifest API version that knows the roles and
// permissions of a connector kind.
const MinPermissionsAPI uint64 = 2
// Limits of the permissions of a connector kind.
const (
MaxConnectorRoles = 8
MaxConnectorPermissions = 12
// MaxMessageRoles is how many role IDs one ConnectorMessage carries.
MaxMessageRoles = 8
)
// Capability names. The host owns this closed list. The same names are used
// for what an owner enables on a binding, what an owner grants to a role of a
// kind, and what a kind declares that it can grant.
const (
CapProposalsConfirm = "proposals.confirm"
CapMemoryWrite = "memory.write"
CapFilesUpload = "files.upload"
CapTriggersPropose = "triggers.propose"
CapTriggersRun = "triggers.run"
CapTalkbackChannels = "talkback.channels"
CapTalkbackNewChan = "talkback.new_channel"
CapProfileWrite = "profile.write"
CapSkillsWrite = "skills.write"
CapPagesWrite = "pages.write"
CapDashboardWrite = "dashboard.write"
)
// capabilities maps each name to whether it is sensitive. A sensitive
// capability is never granted to a role: only a paired sender can use it.
var capabilities = map[string]bool{
CapProposalsConfirm: false,
CapMemoryWrite: false,
CapFilesUpload: false,
CapTriggersPropose: false,
CapTriggersRun: false,
CapTalkbackChannels: false,
CapTalkbackNewChan: true,
CapProfileWrite: true,
CapSkillsWrite: true,
CapPagesWrite: true,
CapDashboardWrite: true,
}
// KnownCapability reports whether name is in the closed list of capabilities.
func KnownCapability(name string) bool { _, ok := capabilities[name]; return ok }
// SensitiveCapability reports whether name is a sensitive capability.
func SensitiveCapability(name string) bool { return capabilities[name] }
// ConnectorCapabilityNames returns the closed list, sorted.
func ConnectorCapabilityNames() []string {
out := make([]string, 0, len(capabilities))
for n := range capabilities {
out = append(out, n)
}
slices.Sort(out)
return out
}
// ConnectorRole is a role that a kind can give to a sender, such as the admin
// of a workspace. The add-on decides who has which role and sends the role IDs
// with each message; the owner decides what each role may do.
type ConnectorRole struct {
// ID names the role in ConnectorMessage.Roles. It has the shape of an
// add-on namespace ID.
ID string `json:"id"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
}
// Role returns the role of the kind with the given ID.
func (k ConnectorKind) Role(id string) (ConnectorRole, bool) {
for _, r := range k.Roles {
if r.ID == id {
return r, true
}
}
return ConnectorRole{}, false
}
// Grantable reports whether the kind lists the capability and the capability
// may go to a role: it is known and not sensitive.
func (k ConnectorKind) Grantable(capability string) bool {
return KnownCapability(capability) && !SensitiveCapability(capability) && slices.Contains(k.Permissions, capability)
}
// FilterRoles keeps the role IDs of a message that the kind declares, without
// repeats and at most MaxMessageRoles. A kind that declares no roles keeps none.
func (k ConnectorKind) FilterRoles(ids []string) []string {
var out []string
for _, id := range ids {
if len(out) == MaxMessageRoles {
break
}
if _, ok := k.Role(id); ok && !slices.Contains(out, id) {
out = append(out, id)
}
}
return out
}
func validatePermissions(k ConnectorKind, api VersionRange) error {
if len(k.Roles) == 0 && len(k.Permissions) == 0 {
return nil
}
if api.Max < MinPermissionsAPI {
return fmt.Errorf("connector roles and permissions need api %d", MinPermissionsAPI)
}
if len(k.Roles) > MaxConnectorRoles {
return fmt.Errorf("a connector kind has at most %d roles", MaxConnectorRoles)
}
seen := map[string]bool{}
for _, r := range k.Roles {
if !namespace.MatchString(r.ID) || seen[r.ID] || !plainText(r.Title, MaxTitleChars) || (r.Description != "" && !plainText(r.Description, MaxDescriptionChars)) {
return fmt.Errorf("invalid or repeated connector role")
}
seen[r.ID] = true
}
if len(k.Permissions) > MaxConnectorPermissions {
return fmt.Errorf("a connector kind lists at most %d permissions", MaxConnectorPermissions)
}
listed := map[string]bool{}
for _, p := range k.Permissions {
if !KnownCapability(p) {
return fmt.Errorf("unknown connector permission %q", p)
}
if listed[p] {
return fmt.Errorf("repeated connector permission %q", p)
}
listed[p] = true
}
return nil
}
+83
View File
@@ -0,0 +1,83 @@
// SPDX-License-Identifier: MIT
package contract
import (
"os"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("the roles and permissions of a connector kind", func() {
var m Manifest
BeforeEach(func() {
b, err := os.ReadFile("../../../tests/addons/fixtures/contracts/page.json")
Expect(err).NotTo(HaveOccurred())
m, err = ParseManifest(b)
Expect(err).NotTo(HaveOccurred())
m.API = VersionRange{Min: 1, Max: 2}
m.Capabilities.UI = nil
m.Capabilities.Connector = &ConnectorCapability{Kinds: []ConnectorKind{{ID: "chat", Title: "Chat", Delivery: DeliveryPull}}}
})
host := func() HostCompatibility {
h := compatible()
h.API, h.APIMin = HostAPIVersion, HostAPIMin
h.SDK = HostSDKVersion
return h
}
kind := func() *ConnectorKind { return &m.Capabilities.Connector.Kinds[0] }
It("accepts roles and known permissions", func() {
kind().Roles = []ConnectorRole{{ID: "admin", Title: "Admin", Description: "Workspace admin"}}
kind().Permissions = []string{CapProposalsConfirm, CapMemoryWrite, CapProfileWrite}
Expect(ValidateManifest(m, host())).To(Succeed())
})
It("accepts a kind that declares neither, on api 1", func() {
m.API = VersionRange{Min: 1, Max: 1}
Expect(ValidateManifest(m, host())).To(Succeed())
})
It("needs api 2", func() {
m.API = VersionRange{Min: 1, Max: 1}
kind().Permissions = []string{CapMemoryWrite}
Expect(ValidateManifest(m, host())).To(MatchError(ContainSubstring("api 2")))
})
It("refuses an unknown or repeated permission", func() {
kind().Permissions = []string{"root.everything"}
Expect(ValidateManifest(m, host())).NotTo(Succeed())
kind().Permissions = []string{CapMemoryWrite, CapMemoryWrite}
Expect(ValidateManifest(m, host())).NotTo(Succeed())
})
It("refuses too many roles or permissions, a bad role ID and a repeated role", func() {
kind().Roles = nil
for i := 0; i <= MaxConnectorRoles; i++ {
kind().Roles = append(kind().Roles, ConnectorRole{ID: "r" + string(rune('a'+i)), Title: "R"})
}
Expect(ValidateManifest(m, host())).NotTo(Succeed())
kind().Roles = []ConnectorRole{{ID: "Bad Id", Title: "R"}}
Expect(ValidateManifest(m, host())).NotTo(Succeed())
kind().Roles = []ConnectorRole{{ID: "a", Title: "A"}, {ID: "a", Title: "B"}}
Expect(ValidateManifest(m, host())).NotTo(Succeed())
kind().Roles = nil
kind().Permissions = ConnectorCapabilityNames()
Expect(ValidateManifest(m, host())).To(Succeed()) // the whole list fits
kind().Permissions = append(kind().Permissions, "x.1", "x.2")
Expect(ValidateManifest(m, host())).NotTo(Succeed())
})
It("never grants a sensitive capability to a role", func() {
kind().Permissions = []string{CapProfileWrite, CapMemoryWrite}
Expect(kind().Grantable(CapProfileWrite)).To(BeFalse())
Expect(kind().Grantable(CapMemoryWrite)).To(BeTrue())
Expect(kind().Grantable(CapFilesUpload)).To(BeFalse(), "not listed by the kind")
})
It("keeps only declared, distinct roles of a message", func() {
kind().Roles = []ConnectorRole{{ID: "admin", Title: "Admin"}}
Expect(kind().FilterRoles([]string{"admin", "root", "admin"})).To(Equal([]string{"admin"}))
Expect((ConnectorKind{}).FilterRoles([]string{"admin"})).To(BeEmpty())
})
})
+554 -32
View File
@@ -3660,6 +3660,12 @@ const docTemplate = `{
"description": "with chat, sub-agent runs are left out and the limit counts chats",
"name": "kind",
"in": "query"
},
{
"type": "string",
"description": "only chats of one origin, with sub-agent runs left out and the limit counting the chats kept: person (a person started it), automated (anything else) or one origin kind (trigger, connector, job, page). Without it, all chats",
"name": "origin",
"in": "query"
}
],
"responses": {
@@ -3708,7 +3714,7 @@ const docTemplate = `{
}
},
"409": {
"description": "The saved agent's folder is missing or not chosen (agent_folder_missing)",
"description": "The saved agent's folder is missing or not chosen (agent_folder_missing), or its complete file access exceeds the folder limit (agent_folder_limit)",
"schema": {
"$ref": "#/definitions/schema.ErrorResponse"
}
@@ -3855,7 +3861,7 @@ const docTemplate = `{
"tags": [
"chats"
],
"summary": "Read the settings of the Sandbox network (administrators).",
"summary": "Read the settings of the network of commands (administrators).",
"responses": {
"200": {
"description": "The settings in force",
@@ -3868,11 +3874,11 @@ const docTemplate = `{
},
"/api/chats/admin/egress/events": {
"get": {
"description": "Every request a command made through the Sandbox proxy, newest first: who, which chat, host, port, kind (connect or http), verdict (allow, block, or done when an allowed connection ended, with its address, bytes and duration) and reason.",
"description": "Every request a command made through the proxy of the add-on, newest first: who, which chat, host, port, kind (connect or http), verdict (allow, block, or done when an allowed connection ended, with its address, bytes and duration) and reason.",
"tags": [
"chats"
],
"summary": "Read the audit of the Sandbox network requests (administrators).",
"summary": "Read the audit of the network of commands requests (administrators).",
"parameters": [
{
"type": "integer",
@@ -4690,7 +4696,7 @@ const docTemplate = `{
},
"/api/chats/agents/{id}/export": {
"get": {
"description": "Returns the definition as a JSON file (version, kind localai-agent, agent). It holds no ID, owner or time. An agent with a folder is version 2 and carries folder {path, mode}, the folder's path in your files now (empty when the folder is missing or not chosen); a server that knows only version 1 refuses it. An agent without a folder is version 1. POST /api/chats/agents/import reads it. This replaces the old /api/agents/{name}/export.",
"description": "Returns the definition as a JSON file (version, kind localai-agent, agent). It holds no ID, owner or time. An agent with a folder is version 2 and carries folder {path, mode}, the folder's home-relative path now (empty when the folder is missing, not chosen, or outside your home, even if readable); importing an empty path preserves the scope as unset with a warning; a server that knows only version 1 refuses it. An agent without a folder is version 1. POST /api/chats/agents/import reads it. This replaces the old /api/agents/{name}/export.",
"tags": [
"chats"
],
@@ -5153,6 +5159,65 @@ const docTemplate = `{
}
}
},
"/api/chats/connectors/{id}/paired/{sender}": {
"delete": {
"description": "The sender can pair again with a new code.",
"tags": [
"chats"
],
"summary": "End the pairing of a sender of a connector binding.",
"parameters": [
{
"type": "string",
"description": "the binding's id",
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "the sender's id",
"name": "sender",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "The binding",
"schema": {
"$ref": "#/definitions/schema.ChatsConnector"
}
}
}
}
},
"/api/chats/connectors/{id}/pairing": {
"post": {
"description": "Pairing proves that you control a platform account. Send the code, as a direct message to the bot, from an account on the allowlist, within ten minutes. The code works once, and a new code replaces the old one. A paired sender may use the sensitive capabilities you enabled; a pairing ends after 90 days. At most three senders are paired.",
"tags": [
"chats"
],
"summary": "Make a pairing code for a connector binding.",
"parameters": [
{
"type": "string",
"description": "the binding's id",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "The code and when it expires",
"schema": {
"$ref": "#/definitions/schema.ChatsConnectorPairing"
}
}
}
}
},
"/api/chats/connectors/{id}/senders": {
"get": {
"description": "The log of people who wrote to the bot, newest first, with whether they were accepted. A message from a sender who is not accepted is ignored, never answered.",
@@ -8497,11 +8562,11 @@ const docTemplate = `{
},
"/api/chats/{id}/egress": {
"get": {
"description": "The preset of the chat (off, registries, all), the administrator's ceiling, the preset in force (the lower of the two), whether it applies (false when commands run on this machine, applies_reason host, or no Sandbox serves bash, none), addon (the add-on that runs the commands and draws the network controls of the chat, from its network-policy host control; absent when none does), the package registries the preset registries admits, the chat's allow and deny rules (above_limit when the ceiling no longer admits one) and the cards that asked. A sub-agent chat shows its parent's. Another user's chat is 404.",
"description": "The preset of the chat (off, registries, all), the administrator's ceiling, the preset in force (the lower of the two), whether it applies (false when commands run on this machine, applies_reason host, or no add-on serves bash, none), addon (the add-on that runs the commands and draws the network controls of the chat, from its network-policy host control; absent when none does), the package registries the preset registries admits, the chat's allow and deny rules (above_limit when the ceiling no longer admits one) and the cards that asked. A sub-agent chat shows its parent's. Another user's chat is 404.",
"tags": [
"chats"
],
"summary": "Read the network of a chat's Sandbox commands.",
"summary": "Read the network of a chat's commands.",
"parameters": [
{
"type": "string",
@@ -8531,7 +8596,7 @@ const docTemplate = `{
"tags": [
"chats"
],
"summary": "Set the network preset of a chat's Sandbox commands.",
"summary": "Set the network preset of a chat's commands.",
"parameters": [
{
"type": "string",
@@ -8578,7 +8643,7 @@ const docTemplate = `{
"tags": [
"chats"
],
"summary": "Answer a network card of a chat's Sandbox commands.",
"summary": "Answer a network card of a chat's commands.",
"parameters": [
{
"type": "string",
@@ -8630,7 +8695,7 @@ const docTemplate = `{
},
"/api/chats/{id}/egress/rules/{host}/{port}": {
"delete": {
"description": "Removes the rule that allows or denies one host and port for the chat's Sandbox commands. A host that was denied asks again; one that was allowed follows the preset again.",
"description": "Removes the rule that allows or denies one host and port for the chat's commands. A host that was denied asks again; one that was allowed follows the preset again.",
"tags": [
"chats"
],
@@ -9579,7 +9644,7 @@ const docTemplate = `{
}
},
"409": {
"description": "A turn is running for a command, or the saved agent's folder is missing (agent_folder_missing)",
"description": "A turn is running for a command, or the saved agent's folder is missing (agent_folder_missing), or its complete file access exceeds the folder limit (agent_folder_limit)",
"schema": {
"$ref": "#/definitions/schema.ErrorResponse"
}
@@ -14816,6 +14881,9 @@ const docTemplate = `{
"canStop": {
"type": "boolean"
},
"hostOperations": {
"$ref": "#/definitions/hostops.RequestSummary"
},
"managementUrl": {
"description": "ManagementURL opens the management view of the addon.",
"type": "string",
@@ -14885,6 +14953,14 @@ const docTemplate = `{
"type": "string",
"example": "notes"
},
"network": {
"description": "Network is the network policy the add-on declares, present only while\nthe host draws its network-policy control (see contract.EntitledControls).",
"allOf": [
{
"$ref": "#/definitions/apps.NetworkView"
}
]
},
"shadows": {
"description": "Shadows lists the built-in chat tools the add-on replaces (bash, say).",
"type": "array",
@@ -15015,6 +15091,32 @@ const docTemplate = `{
}
}
},
"apps.HomeCommandView": {
"type": "object",
"properties": {
"alias": {
"type": "string"
},
"icon": {
"type": "string"
},
"id": {
"type": "string"
},
"label": {
"type": "string"
},
"labels": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"page": {
"type": "string"
}
}
},
"apps.List": {
"type": "object",
"properties": {
@@ -15026,6 +15128,31 @@ const docTemplate = `{
}
}
},
"apps.NetworkView": {
"type": "object",
"properties": {
"kind": {
"description": "Kind is the declared kind of network policy.",
"type": "string",
"enum": [
"egress-check"
],
"example": "egress-check"
},
"presets": {
"description": "Presets are the titles the add-on gives the host's network presets.",
"type": "array",
"items": {
"$ref": "#/definitions/contract.NetworkPreset"
}
},
"title": {
"description": "Title names the network in text: the declared title, else the add-on's.",
"type": "string",
"example": "Example provider"
}
}
},
"apps.SlotView": {
"type": "object",
"properties": {
@@ -15118,6 +15245,20 @@ const docTemplate = `{
"type": "string",
"example": "/api/addons/notes/assets/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef/ui.js"
},
"homeCommands": {
"description": "HomeCommands contains only resolved navigation metadata for this app.",
"type": "array",
"items": {
"$ref": "#/definitions/apps.HomeCommandView"
}
},
"modelActions": {
"description": "ModelActions contains only declared destinations for this installed UI.",
"type": "array",
"items": {
"$ref": "#/definitions/contract.ModelAction"
}
},
"navigation": {
"type": "array",
"items": {
@@ -15517,6 +15658,31 @@ const docTemplate = `{
}
}
},
"contract.ModelAction": {
"type": "object",
"properties": {
"capability": {
"type": "string"
},
"page": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"contract.NetworkPreset": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"title": {
"type": "string"
}
}
},
"contract.OutputDescription": {
"type": "object",
"properties": {
@@ -15651,11 +15817,11 @@ const docTemplate = `{
"type": "object",
"properties": {
"name": {
"description": "Name is the skill's name and the name of its folder. A plain skill is\nnamed after the add-on: the add-on ID, or the ID and a dash and more\n(office-quickstart), so two add-ons never ship one plain name. A skill\nthat replaces a built-in one has the built-in's name (see Shadows).",
"description": "Name is the skill's name and the name of its folder. A plain skill is\nnamed after the add-on: the add-on ID, or the ID and a dash and more\n(office-quickstart), so two add-ons never ship one plain name. A skill\nfor a reserved document slot has the slot's name (see Shadows).",
"type": "string"
},
"shadows": {
"description": "Shadows names the built-in skill this skill replaces. It must equal Name\nand be one of ReplaceableSkills. It repeats the name on purpose: the\nreplacement is an explicit act, which the install warning and the check\nread from the manifest without opening the package. Empty: a plain\nadd-on skill.",
"description": "Shadows names the document-provider slot this skill supplies. It must equal Name\nand be one of ReplaceableSkills. It repeats the name on purpose: the\nreplacement is an explicit act, which the install warning and the check\nread from the manifest without opening the package. Empty: a plain\nadd-on skill.",
"type": "string"
}
}
@@ -16970,6 +17136,50 @@ const docTemplate = `{
}
}
},
"hostops.RequestSummary": {
"type": "object",
"properties": {
"granted": {
"type": "array",
"items": {
"type": "string"
}
},
"grantedCount": {
"type": "integer"
},
"missingOptional": {
"type": "array",
"items": {
"type": "string"
}
},
"missingRequired": {
"type": "array",
"items": {
"type": "string"
}
},
"optional": {
"type": "array",
"items": {
"type": "string"
}
},
"optionalCount": {
"type": "integer"
},
"required": {
"type": "array",
"items": {
"type": "string"
}
},
"requiredCount": {
"type": "integer"
}
}
},
"library.Info": {
"type": "object",
"properties": {
@@ -18192,6 +18402,9 @@ const docTemplate = `{
"commands_reason": {
"description": "CommandsReason is why commands cannot run, when Commands is unavailable.",
"type": "string"
},
"execution_status": {
"$ref": "#/definitions/schema.ChatsExecutionStatus"
}
}
},
@@ -18289,6 +18502,13 @@ const docTemplate = `{
"type": "string"
}
},
"caps": {
"description": "Caps are the capabilities you enabled for this binding: the ceiling of\nwhat any sender can use. Grants give roles of the kind some of them.",
"type": "array",
"items": {
"type": "string"
}
},
"conversation": {
"description": "Conversation says which chat a message belongs to: sender (one chat\nper sender and per platform thread), chat (one chat per group, channel\nor direct chat), window (sender, and a new chat after window_minutes\nwithout a message) or thread (a new chat per message; a reply to an\nanswer goes on in its chat).",
"type": "string"
@@ -18299,6 +18519,27 @@ const docTemplate = `{
"enabled": {
"type": "boolean"
},
"file_receive_max_bytes": {
"description": "FileReceiveMaxBytes and FileSendMaxBytes are your limits for this bot;\nzero means you set none and the limit of the kind applies.",
"type": "integer"
},
"file_send_max_bytes": {
"type": "integer"
},
"files": {
"description": "Files are the limits of the files the bot reads and sends, with the\nlimit that you set for this bot.",
"allOf": [
{
"$ref": "#/definitions/schema.ChatsConnectorFiles"
}
]
},
"grants": {
"type": "array",
"items": {
"$ref": "#/definitions/schema.ChatsConnectorRoleGrant"
}
},
"has_token": {
"type": "boolean"
},
@@ -18314,9 +18555,23 @@ const docTemplate = `{
"notify_chat": {
"type": "string"
},
"options": {
"description": "Options are the choices made for the options of the kind, by option id.\nAn option that is absent has its default (see ChatsConnectorKind).",
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"owner": {
"type": "string"
},
"paired": {
"description": "Paired are the senders you proved with a pairing code. A pairing ends\nat expires_at.",
"type": "array",
"items": {
"$ref": "#/definitions/schema.ChatsConnectorPaired"
}
},
"reason": {
"description": "Reason says in plain words why the binding is not active.",
"type": "string"
@@ -18527,7 +18782,7 @@ const docTemplate = `{
"type": "string"
},
"path": {
"description": "Path is the folder's path in your files now (\"/\" is all of them). It\nis absent when the folder is missing or not chosen.",
"description": "Path is home-relative (\"/\" is the whole home), or /shared/\u003cname\u003e/...\nfor a followed readable shared folder. It is absent when missing,\nnot chosen, or readable outside these public locations; a readable\nfolder without a public path still has state ok.",
"type": "string"
},
"state": {
@@ -18686,7 +18941,7 @@ const docTemplate = `{
"type": "string"
},
"folder": {
"description": "Folder chooses the one folder the agent's chats work in. A write\nwithout it keeps the folder the agent has; {\"path\": \"\"} clears it. In\nan export file it is the folder's path and mode when the file was made.",
"description": "Folder chooses the one folder the agent's chats work in. A write\nwithout it keeps the folder the agent has; {\"path\": \"\"} clears it. In\nan export file it is the home-relative path and mode when the file was\nmade. A missing, unchosen or non-home folder has an empty export path;\nimport preserves its mode as unset and warns instead of clearing scope.",
"allOf": [
{
"$ref": "#/definitions/schema.ChatsAgentFolderWrite"
@@ -19093,6 +19348,13 @@ const docTemplate = `{
"type": "string"
}
},
"caps": {
"description": "Caps are the capabilities you enabled for this binding: the ceiling of\nwhat any sender can use. Grants give roles of the kind some of them.",
"type": "array",
"items": {
"type": "string"
}
},
"conversation": {
"description": "Conversation says which chat a message belongs to: sender (one chat\nper sender and per platform thread), chat (one chat per group, channel\nor direct chat), window (sender, and a new chat after window_minutes\nwithout a message) or thread (a new chat per message; a reply to an\nanswer goes on in its chat).",
"type": "string"
@@ -19103,6 +19365,27 @@ const docTemplate = `{
"enabled": {
"type": "boolean"
},
"file_receive_max_bytes": {
"description": "FileReceiveMaxBytes and FileSendMaxBytes are your limits for this bot;\nzero means you set none and the limit of the kind applies.",
"type": "integer"
},
"file_send_max_bytes": {
"type": "integer"
},
"files": {
"description": "Files are the limits of the files the bot reads and sends, with the\nlimit that you set for this bot.",
"allOf": [
{
"$ref": "#/definitions/schema.ChatsConnectorFiles"
}
]
},
"grants": {
"type": "array",
"items": {
"$ref": "#/definitions/schema.ChatsConnectorRoleGrant"
}
},
"has_token": {
"type": "boolean"
},
@@ -19118,17 +19401,24 @@ const docTemplate = `{
"notify_chat": {
"type": "string"
},
"reason": {
"description": "Reason says in plain words why the binding is not active.",
"type": "string"
},
"options": {
"description": "Options are the choices made for the options of the kind, by option id. An option that is absent has its default (see ChatsConnectorKind).",
"description": "Options are the choices made for the options of the kind, by option id.\nAn option that is absent has its default (see ChatsConnectorKind).",
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"paired": {
"description": "Paired are the senders you proved with a pairing code. A pairing ends\nat expires_at.",
"type": "array",
"items": {
"$ref": "#/definitions/schema.ChatsConnectorPaired"
}
},
"reason": {
"description": "Reason says in plain words why the binding is not active.",
"type": "string"
},
"reply_mode": {
"description": "ReplyMode is what a sender sees while a turn runs: off (typing, then\nthe answer), progress (a working message that names the tools as they\nstart) or stream (a working message that shows the answer as it is\nwritten). The answer replaces the working message.",
"type": "string"
@@ -19142,6 +19432,56 @@ const docTemplate = `{
}
}
},
"schema.ChatsConnectorFileLimit": {
"type": "object",
"properties": {
"allowed_bytes": {
"description": "AllowedBytes is the most you may set for a bot.",
"type": "integer"
},
"declared_bytes": {
"description": "DeclaredBytes is the ability the kind declares, or zero.",
"type": "integer"
},
"effective_bytes": {
"description": "EffectiveBytes is the largest file that moves now: the smallest of the\nhost ceiling, the declared ability, your limit and the inline cap.",
"type": "integer"
},
"layer": {
"description": "Layer names the one that sets EffectiveBytes: owner (your limit for\nthis bot), addon (the ability the kind declares), host (the ceiling of\nthe server) or inline (the cap of the inline path).",
"type": "string"
}
}
},
"schema.ChatsConnectorFiles": {
"type": "object",
"properties": {
"host_ceiling_bytes": {
"description": "HostCeilingBytes is the ceiling of the server for a connector file, set\nby the add-on owner; no limit above it applies.",
"type": "integer"
},
"inline_bytes": {
"description": "InlineBytes is the cap of the inline path. Until the add-on can stream\na file, nothing larger moves, whatever the other limits are.",
"type": "integer"
},
"receive": {
"description": "Receive is the limit of the files that people send to the bot.",
"allOf": [
{
"$ref": "#/definitions/schema.ChatsConnectorFileLimit"
}
]
},
"send": {
"description": "Send is the limit of the files that the bot sends.",
"allOf": [
{
"$ref": "#/definitions/schema.ChatsConnectorFileLimit"
}
]
}
}
},
"schema.ChatsConnectorKind": {
"type": "object",
"properties": {
@@ -19151,6 +19491,14 @@ const docTemplate = `{
"description": {
"type": "string"
},
"files": {
"description": "Files are the limits of a binding of this kind that has no limit of\nyour own.",
"allOf": [
{
"$ref": "#/definitions/schema.ChatsConnectorFiles"
}
]
},
"kind": {
"type": "string"
},
@@ -19165,6 +19513,19 @@ const docTemplate = `{
"description": "Page is the add-on page that adds and edits bindings of this kind.",
"type": "string"
},
"permissions": {
"type": "array",
"items": {
"$ref": "#/definitions/schema.ChatsConnectorPermission"
}
},
"roles": {
"description": "Roles are the roles the add-on can report for a sender, and\nPermissions the capabilities the kind lists. Grant them to a role with\nthe grants of a binding.",
"type": "array",
"items": {
"$ref": "#/definitions/schema.ChatsConnectorRole"
}
},
"sender_hint": {
"type": "string"
},
@@ -19177,6 +19538,28 @@ const docTemplate = `{
}
}
},
"schema.ChatsConnectorKindList": {
"type": "object",
"properties": {
"kinds": {
"type": "array",
"items": {
"$ref": "#/definitions/schema.ChatsConnectorKind"
}
}
}
},
"schema.ChatsConnectorList": {
"type": "object",
"properties": {
"connectors": {
"type": "array",
"items": {
"$ref": "#/definitions/schema.ChatsConnector"
}
}
}
},
"schema.ChatsConnectorOption": {
"type": "object",
"properties": {
@@ -19211,25 +19594,67 @@ const docTemplate = `{
}
}
},
"schema.ChatsConnectorKindList": {
"schema.ChatsConnectorPaired": {
"type": "object",
"properties": {
"kinds": {
"type": "array",
"items": {
"$ref": "#/definitions/schema.ChatsConnectorKind"
}
"expires_at": {
"type": "string"
},
"paired_at": {
"type": "string"
},
"sender_id": {
"type": "string"
}
}
},
"schema.ChatsConnectorList": {
"schema.ChatsConnectorPairing": {
"type": "object",
"properties": {
"connectors": {
"code": {
"type": "string"
},
"expires": {
"type": "string"
}
}
},
"schema.ChatsConnectorPermission": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"sensitive": {
"type": "boolean"
}
}
},
"schema.ChatsConnectorRole": {
"type": "object",
"properties": {
"description": {
"type": "string"
},
"id": {
"type": "string"
},
"title": {
"type": "string"
}
}
},
"schema.ChatsConnectorRoleGrant": {
"type": "object",
"properties": {
"caps": {
"type": "array",
"items": {
"$ref": "#/definitions/schema.ChatsConnector"
"type": "string"
}
},
"role": {
"type": "string"
}
}
},
@@ -19293,6 +19718,13 @@ const docTemplate = `{
"type": "string"
}
},
"caps": {
"description": "Caps are the capabilities you enable for the binding (a closed list,\nsee the kind's permissions for the ones it can grant to a role).\nAbsent means none for a new binding and unchanged for an update; an\nempty list clears.",
"type": "array",
"items": {
"type": "string"
}
},
"confirm_accept_any": {
"description": "ConfirmAcceptAny must be the exact text \"accept any message\" in the\nrequest that turns accept_any on (a create with accept_any, or an update\nthat changes it from off to on). Anyone who can message the bot then uses\nthe agent's tools as you, so the server asks you to say you mean it.",
"type": "string"
@@ -19304,6 +19736,20 @@ const docTemplate = `{
"enabled": {
"type": "boolean"
},
"file_receive_max_bytes": {
"description": "FileReceiveMaxBytes and FileSendMaxBytes limit the files the bot reads\nand sends, in bytes. A limit can only lower the limit of the kind, which\nthe kind list shows as allowed_bytes. Zero removes your limit. Absent\nmeans none for a new binding and unchanged for an update.",
"type": "integer"
},
"file_send_max_bytes": {
"type": "integer"
},
"grants": {
"description": "Grants map roles of the kind to capabilities within caps. A sensitive\ncapability is never granted to a role. Absent means unchanged; an empty\nlist clears.",
"type": "array",
"items": {
"$ref": "#/definitions/schema.ChatsConnectorRoleGrant"
}
},
"kind": {
"description": "Kind is the platform, such as telegram. It is used to create a binding and\nignored by an update. Empty means telegram.",
"type": "string"
@@ -19315,7 +19761,7 @@ const docTemplate = `{
"type": "string"
},
"options": {
"description": "Options are the choices for the options of the kind, by option id. Absent means the defaults for a new binding and unchanged for an update.",
"description": "Options are the choices for the options of the kind, by option id. Absent\nmeans the defaults for a new binding and unchanged for an update.",
"type": "object",
"additionalProperties": {
"type": "string"
@@ -19643,7 +20089,7 @@ const docTemplate = `{
}
},
"applies": {
"description": "Applies is false when commands run on this machine (applies_reason\nhost) or no Sandbox serves bash (none).",
"description": "Applies is true when the add-on that runs the commands reports that it\nenforces the network policy. It is false when commands run on this\nmachine (applies_reason host), or no Sandbox serves bash or the add-on\ndoes not report enforcement (none).",
"type": "boolean"
},
"applies_reason": {
@@ -19663,6 +20109,14 @@ const docTemplate = `{
"description": "the lower of the two",
"type": "string"
},
"network": {
"description": "Network names the network of addon in text and gives the preset titles\nthe add-on declares. Set with addon.",
"allOf": [
{
"$ref": "#/definitions/schema.ChatsEgressNetwork"
}
]
},
"preset": {
"description": "off, registries, all",
"type": "string"
@@ -19717,6 +20171,14 @@ const docTemplate = `{
"isolation": {
"type": "string"
},
"network": {
"description": "Network names the network of that add-on in text. Set with sandbox.",
"allOf": [
{
"$ref": "#/definitions/schema.ChatsEgressNetwork"
}
]
},
"ports": {
"type": "array",
"items": {
@@ -19730,7 +20192,7 @@ const docTemplate = `{
}
},
"sandbox": {
"description": "Sandbox is the add-on that runs commands, empty when none serves;\nIsolation is isolated, host, or empty when unknown.",
"description": "Sandbox is the add-on that runs commands, empty when none serves (the\nname of the field is kept for existing clients); Isolation is isolated,\nhost, or empty when unknown.",
"type": "string"
}
}
@@ -19842,6 +20304,41 @@ const docTemplate = `{
}
}
},
"schema.ChatsEgressNetwork": {
"type": "object",
"properties": {
"presets": {
"description": "Presets are the titles the add-on gives the host's presets; a preset\nthat is not listed keeps the host's title.",
"type": "array",
"items": {
"$ref": "#/definitions/schema.ChatsEgressNetworkPreset"
}
},
"title": {
"description": "Title names the network in text: the declared title, else the title of\nthe commands tool.",
"type": "string",
"example": "Sandbox"
}
}
},
"schema.ChatsEgressNetworkPreset": {
"type": "object",
"properties": {
"id": {
"type": "string",
"enum": [
"off",
"registries",
"all"
],
"example": "off"
},
"title": {
"type": "string",
"example": "Closed"
}
}
},
"schema.ChatsEgressPreset": {
"type": "object",
"properties": {
@@ -19955,6 +20452,31 @@ const docTemplate = `{
}
}
},
"schema.ChatsExecutionStatus": {
"type": "object",
"properties": {
"explanation": {
"type": "string"
},
"generation": {
"type": "string"
},
"incarnation": {
"type": "string"
},
"isolation": {
"type": "string"
},
"readiness": {
"type": "string"
},
"unknown_reason": {
"description": "UnknownReason says why the status is unknown: owner_unreachable,\nnot_reported, stale, invalid_status, not_selected, no_answer,\nreported_unknown or draft (the chat does not exist yet). Absent when the status is known.",
"type": "string",
"example": "owner_unreachable"
}
}
},
"schema.ChatsExport": {
"type": "object",
"properties": {
+554 -32
View File
@@ -3657,6 +3657,12 @@
"description": "with chat, sub-agent runs are left out and the limit counts chats",
"name": "kind",
"in": "query"
},
{
"type": "string",
"description": "only chats of one origin, with sub-agent runs left out and the limit counting the chats kept: person (a person started it), automated (anything else) or one origin kind (trigger, connector, job, page). Without it, all chats",
"name": "origin",
"in": "query"
}
],
"responses": {
@@ -3705,7 +3711,7 @@
}
},
"409": {
"description": "The saved agent's folder is missing or not chosen (agent_folder_missing)",
"description": "The saved agent's folder is missing or not chosen (agent_folder_missing), or its complete file access exceeds the folder limit (agent_folder_limit)",
"schema": {
"$ref": "#/definitions/schema.ErrorResponse"
}
@@ -3852,7 +3858,7 @@
"tags": [
"chats"
],
"summary": "Read the settings of the Sandbox network (administrators).",
"summary": "Read the settings of the network of commands (administrators).",
"responses": {
"200": {
"description": "The settings in force",
@@ -3865,11 +3871,11 @@
},
"/api/chats/admin/egress/events": {
"get": {
"description": "Every request a command made through the Sandbox proxy, newest first: who, which chat, host, port, kind (connect or http), verdict (allow, block, or done when an allowed connection ended, with its address, bytes and duration) and reason.",
"description": "Every request a command made through the proxy of the add-on, newest first: who, which chat, host, port, kind (connect or http), verdict (allow, block, or done when an allowed connection ended, with its address, bytes and duration) and reason.",
"tags": [
"chats"
],
"summary": "Read the audit of the Sandbox network requests (administrators).",
"summary": "Read the audit of the network of commands requests (administrators).",
"parameters": [
{
"type": "integer",
@@ -4687,7 +4693,7 @@
},
"/api/chats/agents/{id}/export": {
"get": {
"description": "Returns the definition as a JSON file (version, kind localai-agent, agent). It holds no ID, owner or time. An agent with a folder is version 2 and carries folder {path, mode}, the folder's path in your files now (empty when the folder is missing or not chosen); a server that knows only version 1 refuses it. An agent without a folder is version 1. POST /api/chats/agents/import reads it. This replaces the old /api/agents/{name}/export.",
"description": "Returns the definition as a JSON file (version, kind localai-agent, agent). It holds no ID, owner or time. An agent with a folder is version 2 and carries folder {path, mode}, the folder's home-relative path now (empty when the folder is missing, not chosen, or outside your home, even if readable); importing an empty path preserves the scope as unset with a warning; a server that knows only version 1 refuses it. An agent without a folder is version 1. POST /api/chats/agents/import reads it. This replaces the old /api/agents/{name}/export.",
"tags": [
"chats"
],
@@ -5150,6 +5156,65 @@
}
}
},
"/api/chats/connectors/{id}/paired/{sender}": {
"delete": {
"description": "The sender can pair again with a new code.",
"tags": [
"chats"
],
"summary": "End the pairing of a sender of a connector binding.",
"parameters": [
{
"type": "string",
"description": "the binding's id",
"name": "id",
"in": "path",
"required": true
},
{
"type": "string",
"description": "the sender's id",
"name": "sender",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "The binding",
"schema": {
"$ref": "#/definitions/schema.ChatsConnector"
}
}
}
}
},
"/api/chats/connectors/{id}/pairing": {
"post": {
"description": "Pairing proves that you control a platform account. Send the code, as a direct message to the bot, from an account on the allowlist, within ten minutes. The code works once, and a new code replaces the old one. A paired sender may use the sensitive capabilities you enabled; a pairing ends after 90 days. At most three senders are paired.",
"tags": [
"chats"
],
"summary": "Make a pairing code for a connector binding.",
"parameters": [
{
"type": "string",
"description": "the binding's id",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "The code and when it expires",
"schema": {
"$ref": "#/definitions/schema.ChatsConnectorPairing"
}
}
}
}
},
"/api/chats/connectors/{id}/senders": {
"get": {
"description": "The log of people who wrote to the bot, newest first, with whether they were accepted. A message from a sender who is not accepted is ignored, never answered.",
@@ -8494,11 +8559,11 @@
},
"/api/chats/{id}/egress": {
"get": {
"description": "The preset of the chat (off, registries, all), the administrator's ceiling, the preset in force (the lower of the two), whether it applies (false when commands run on this machine, applies_reason host, or no Sandbox serves bash, none), addon (the add-on that runs the commands and draws the network controls of the chat, from its network-policy host control; absent when none does), the package registries the preset registries admits, the chat's allow and deny rules (above_limit when the ceiling no longer admits one) and the cards that asked. A sub-agent chat shows its parent's. Another user's chat is 404.",
"description": "The preset of the chat (off, registries, all), the administrator's ceiling, the preset in force (the lower of the two), whether it applies (false when commands run on this machine, applies_reason host, or no add-on serves bash, none), addon (the add-on that runs the commands and draws the network controls of the chat, from its network-policy host control; absent when none does), the package registries the preset registries admits, the chat's allow and deny rules (above_limit when the ceiling no longer admits one) and the cards that asked. A sub-agent chat shows its parent's. Another user's chat is 404.",
"tags": [
"chats"
],
"summary": "Read the network of a chat's Sandbox commands.",
"summary": "Read the network of a chat's commands.",
"parameters": [
{
"type": "string",
@@ -8528,7 +8593,7 @@
"tags": [
"chats"
],
"summary": "Set the network preset of a chat's Sandbox commands.",
"summary": "Set the network preset of a chat's commands.",
"parameters": [
{
"type": "string",
@@ -8575,7 +8640,7 @@
"tags": [
"chats"
],
"summary": "Answer a network card of a chat's Sandbox commands.",
"summary": "Answer a network card of a chat's commands.",
"parameters": [
{
"type": "string",
@@ -8627,7 +8692,7 @@
},
"/api/chats/{id}/egress/rules/{host}/{port}": {
"delete": {
"description": "Removes the rule that allows or denies one host and port for the chat's Sandbox commands. A host that was denied asks again; one that was allowed follows the preset again.",
"description": "Removes the rule that allows or denies one host and port for the chat's commands. A host that was denied asks again; one that was allowed follows the preset again.",
"tags": [
"chats"
],
@@ -9576,7 +9641,7 @@
}
},
"409": {
"description": "A turn is running for a command, or the saved agent's folder is missing (agent_folder_missing)",
"description": "A turn is running for a command, or the saved agent's folder is missing (agent_folder_missing), or its complete file access exceeds the folder limit (agent_folder_limit)",
"schema": {
"$ref": "#/definitions/schema.ErrorResponse"
}
@@ -14813,6 +14878,9 @@
"canStop": {
"type": "boolean"
},
"hostOperations": {
"$ref": "#/definitions/hostops.RequestSummary"
},
"managementUrl": {
"description": "ManagementURL opens the management view of the addon.",
"type": "string",
@@ -14882,6 +14950,14 @@
"type": "string",
"example": "notes"
},
"network": {
"description": "Network is the network policy the add-on declares, present only while\nthe host draws its network-policy control (see contract.EntitledControls).",
"allOf": [
{
"$ref": "#/definitions/apps.NetworkView"
}
]
},
"shadows": {
"description": "Shadows lists the built-in chat tools the add-on replaces (bash, say).",
"type": "array",
@@ -15012,6 +15088,32 @@
}
}
},
"apps.HomeCommandView": {
"type": "object",
"properties": {
"alias": {
"type": "string"
},
"icon": {
"type": "string"
},
"id": {
"type": "string"
},
"label": {
"type": "string"
},
"labels": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"page": {
"type": "string"
}
}
},
"apps.List": {
"type": "object",
"properties": {
@@ -15023,6 +15125,31 @@
}
}
},
"apps.NetworkView": {
"type": "object",
"properties": {
"kind": {
"description": "Kind is the declared kind of network policy.",
"type": "string",
"enum": [
"egress-check"
],
"example": "egress-check"
},
"presets": {
"description": "Presets are the titles the add-on gives the host's network presets.",
"type": "array",
"items": {
"$ref": "#/definitions/contract.NetworkPreset"
}
},
"title": {
"description": "Title names the network in text: the declared title, else the add-on's.",
"type": "string",
"example": "Example provider"
}
}
},
"apps.SlotView": {
"type": "object",
"properties": {
@@ -15115,6 +15242,20 @@
"type": "string",
"example": "/api/addons/notes/assets/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef/ui.js"
},
"homeCommands": {
"description": "HomeCommands contains only resolved navigation metadata for this app.",
"type": "array",
"items": {
"$ref": "#/definitions/apps.HomeCommandView"
}
},
"modelActions": {
"description": "ModelActions contains only declared destinations for this installed UI.",
"type": "array",
"items": {
"$ref": "#/definitions/contract.ModelAction"
}
},
"navigation": {
"type": "array",
"items": {
@@ -15514,6 +15655,31 @@
}
}
},
"contract.ModelAction": {
"type": "object",
"properties": {
"capability": {
"type": "string"
},
"page": {
"type": "string"
},
"path": {
"type": "string"
}
}
},
"contract.NetworkPreset": {
"type": "object",
"properties": {
"id": {
"type": "string"
},
"title": {
"type": "string"
}
}
},
"contract.OutputDescription": {
"type": "object",
"properties": {
@@ -15648,11 +15814,11 @@
"type": "object",
"properties": {
"name": {
"description": "Name is the skill's name and the name of its folder. A plain skill is\nnamed after the add-on: the add-on ID, or the ID and a dash and more\n(office-quickstart), so two add-ons never ship one plain name. A skill\nthat replaces a built-in one has the built-in's name (see Shadows).",
"description": "Name is the skill's name and the name of its folder. A plain skill is\nnamed after the add-on: the add-on ID, or the ID and a dash and more\n(office-quickstart), so two add-ons never ship one plain name. A skill\nfor a reserved document slot has the slot's name (see Shadows).",
"type": "string"
},
"shadows": {
"description": "Shadows names the built-in skill this skill replaces. It must equal Name\nand be one of ReplaceableSkills. It repeats the name on purpose: the\nreplacement is an explicit act, which the install warning and the check\nread from the manifest without opening the package. Empty: a plain\nadd-on skill.",
"description": "Shadows names the document-provider slot this skill supplies. It must equal Name\nand be one of ReplaceableSkills. It repeats the name on purpose: the\nreplacement is an explicit act, which the install warning and the check\nread from the manifest without opening the package. Empty: a plain\nadd-on skill.",
"type": "string"
}
}
@@ -16967,6 +17133,50 @@
}
}
},
"hostops.RequestSummary": {
"type": "object",
"properties": {
"granted": {
"type": "array",
"items": {
"type": "string"
}
},
"grantedCount": {
"type": "integer"
},
"missingOptional": {
"type": "array",
"items": {
"type": "string"
}
},
"missingRequired": {
"type": "array",
"items": {
"type": "string"
}
},
"optional": {
"type": "array",
"items": {
"type": "string"
}
},
"optionalCount": {
"type": "integer"
},
"required": {
"type": "array",
"items": {
"type": "string"
}
},
"requiredCount": {
"type": "integer"
}
}
},
"library.Info": {
"type": "object",
"properties": {
@@ -18189,6 +18399,9 @@
"commands_reason": {
"description": "CommandsReason is why commands cannot run, when Commands is unavailable.",
"type": "string"
},
"execution_status": {
"$ref": "#/definitions/schema.ChatsExecutionStatus"
}
}
},
@@ -18286,6 +18499,13 @@
"type": "string"
}
},
"caps": {
"description": "Caps are the capabilities you enabled for this binding: the ceiling of\nwhat any sender can use. Grants give roles of the kind some of them.",
"type": "array",
"items": {
"type": "string"
}
},
"conversation": {
"description": "Conversation says which chat a message belongs to: sender (one chat\nper sender and per platform thread), chat (one chat per group, channel\nor direct chat), window (sender, and a new chat after window_minutes\nwithout a message) or thread (a new chat per message; a reply to an\nanswer goes on in its chat).",
"type": "string"
@@ -18296,6 +18516,27 @@
"enabled": {
"type": "boolean"
},
"file_receive_max_bytes": {
"description": "FileReceiveMaxBytes and FileSendMaxBytes are your limits for this bot;\nzero means you set none and the limit of the kind applies.",
"type": "integer"
},
"file_send_max_bytes": {
"type": "integer"
},
"files": {
"description": "Files are the limits of the files the bot reads and sends, with the\nlimit that you set for this bot.",
"allOf": [
{
"$ref": "#/definitions/schema.ChatsConnectorFiles"
}
]
},
"grants": {
"type": "array",
"items": {
"$ref": "#/definitions/schema.ChatsConnectorRoleGrant"
}
},
"has_token": {
"type": "boolean"
},
@@ -18311,9 +18552,23 @@
"notify_chat": {
"type": "string"
},
"options": {
"description": "Options are the choices made for the options of the kind, by option id.\nAn option that is absent has its default (see ChatsConnectorKind).",
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"owner": {
"type": "string"
},
"paired": {
"description": "Paired are the senders you proved with a pairing code. A pairing ends\nat expires_at.",
"type": "array",
"items": {
"$ref": "#/definitions/schema.ChatsConnectorPaired"
}
},
"reason": {
"description": "Reason says in plain words why the binding is not active.",
"type": "string"
@@ -18524,7 +18779,7 @@
"type": "string"
},
"path": {
"description": "Path is the folder's path in your files now (\"/\" is all of them). It\nis absent when the folder is missing or not chosen.",
"description": "Path is home-relative (\"/\" is the whole home), or /shared/\u003cname\u003e/...\nfor a followed readable shared folder. It is absent when missing,\nnot chosen, or readable outside these public locations; a readable\nfolder without a public path still has state ok.",
"type": "string"
},
"state": {
@@ -18683,7 +18938,7 @@
"type": "string"
},
"folder": {
"description": "Folder chooses the one folder the agent's chats work in. A write\nwithout it keeps the folder the agent has; {\"path\": \"\"} clears it. In\nan export file it is the folder's path and mode when the file was made.",
"description": "Folder chooses the one folder the agent's chats work in. A write\nwithout it keeps the folder the agent has; {\"path\": \"\"} clears it. In\nan export file it is the home-relative path and mode when the file was\nmade. A missing, unchosen or non-home folder has an empty export path;\nimport preserves its mode as unset and warns instead of clearing scope.",
"allOf": [
{
"$ref": "#/definitions/schema.ChatsAgentFolderWrite"
@@ -19090,6 +19345,13 @@
"type": "string"
}
},
"caps": {
"description": "Caps are the capabilities you enabled for this binding: the ceiling of\nwhat any sender can use. Grants give roles of the kind some of them.",
"type": "array",
"items": {
"type": "string"
}
},
"conversation": {
"description": "Conversation says which chat a message belongs to: sender (one chat\nper sender and per platform thread), chat (one chat per group, channel\nor direct chat), window (sender, and a new chat after window_minutes\nwithout a message) or thread (a new chat per message; a reply to an\nanswer goes on in its chat).",
"type": "string"
@@ -19100,6 +19362,27 @@
"enabled": {
"type": "boolean"
},
"file_receive_max_bytes": {
"description": "FileReceiveMaxBytes and FileSendMaxBytes are your limits for this bot;\nzero means you set none and the limit of the kind applies.",
"type": "integer"
},
"file_send_max_bytes": {
"type": "integer"
},
"files": {
"description": "Files are the limits of the files the bot reads and sends, with the\nlimit that you set for this bot.",
"allOf": [
{
"$ref": "#/definitions/schema.ChatsConnectorFiles"
}
]
},
"grants": {
"type": "array",
"items": {
"$ref": "#/definitions/schema.ChatsConnectorRoleGrant"
}
},
"has_token": {
"type": "boolean"
},
@@ -19115,17 +19398,24 @@
"notify_chat": {
"type": "string"
},
"reason": {
"description": "Reason says in plain words why the binding is not active.",
"type": "string"
},
"options": {
"description": "Options are the choices made for the options of the kind, by option id. An option that is absent has its default (see ChatsConnectorKind).",
"description": "Options are the choices made for the options of the kind, by option id.\nAn option that is absent has its default (see ChatsConnectorKind).",
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"paired": {
"description": "Paired are the senders you proved with a pairing code. A pairing ends\nat expires_at.",
"type": "array",
"items": {
"$ref": "#/definitions/schema.ChatsConnectorPaired"
}
},
"reason": {
"description": "Reason says in plain words why the binding is not active.",
"type": "string"
},
"reply_mode": {
"description": "ReplyMode is what a sender sees while a turn runs: off (typing, then\nthe answer), progress (a working message that names the tools as they\nstart) or stream (a working message that shows the answer as it is\nwritten). The answer replaces the working message.",
"type": "string"
@@ -19139,6 +19429,56 @@
}
}
},
"schema.ChatsConnectorFileLimit": {
"type": "object",
"properties": {
"allowed_bytes": {
"description": "AllowedBytes is the most you may set for a bot.",
"type": "integer"
},
"declared_bytes": {
"description": "DeclaredBytes is the ability the kind declares, or zero.",
"type": "integer"
},
"effective_bytes": {
"description": "EffectiveBytes is the largest file that moves now: the smallest of the\nhost ceiling, the declared ability, your limit and the inline cap.",
"type": "integer"
},
"layer": {
"description": "Layer names the one that sets EffectiveBytes: owner (your limit for\nthis bot), addon (the ability the kind declares), host (the ceiling of\nthe server) or inline (the cap of the inline path).",
"type": "string"
}
}
},
"schema.ChatsConnectorFiles": {
"type": "object",
"properties": {
"host_ceiling_bytes": {
"description": "HostCeilingBytes is the ceiling of the server for a connector file, set\nby the add-on owner; no limit above it applies.",
"type": "integer"
},
"inline_bytes": {
"description": "InlineBytes is the cap of the inline path. Until the add-on can stream\na file, nothing larger moves, whatever the other limits are.",
"type": "integer"
},
"receive": {
"description": "Receive is the limit of the files that people send to the bot.",
"allOf": [
{
"$ref": "#/definitions/schema.ChatsConnectorFileLimit"
}
]
},
"send": {
"description": "Send is the limit of the files that the bot sends.",
"allOf": [
{
"$ref": "#/definitions/schema.ChatsConnectorFileLimit"
}
]
}
}
},
"schema.ChatsConnectorKind": {
"type": "object",
"properties": {
@@ -19148,6 +19488,14 @@
"description": {
"type": "string"
},
"files": {
"description": "Files are the limits of a binding of this kind that has no limit of\nyour own.",
"allOf": [
{
"$ref": "#/definitions/schema.ChatsConnectorFiles"
}
]
},
"kind": {
"type": "string"
},
@@ -19162,6 +19510,19 @@
"description": "Page is the add-on page that adds and edits bindings of this kind.",
"type": "string"
},
"permissions": {
"type": "array",
"items": {
"$ref": "#/definitions/schema.ChatsConnectorPermission"
}
},
"roles": {
"description": "Roles are the roles the add-on can report for a sender, and\nPermissions the capabilities the kind lists. Grant them to a role with\nthe grants of a binding.",
"type": "array",
"items": {
"$ref": "#/definitions/schema.ChatsConnectorRole"
}
},
"sender_hint": {
"type": "string"
},
@@ -19174,6 +19535,28 @@
}
}
},
"schema.ChatsConnectorKindList": {
"type": "object",
"properties": {
"kinds": {
"type": "array",
"items": {
"$ref": "#/definitions/schema.ChatsConnectorKind"
}
}
}
},
"schema.ChatsConnectorList": {
"type": "object",
"properties": {
"connectors": {
"type": "array",
"items": {
"$ref": "#/definitions/schema.ChatsConnector"
}
}
}
},
"schema.ChatsConnectorOption": {
"type": "object",
"properties": {
@@ -19208,25 +19591,67 @@
}
}
},
"schema.ChatsConnectorKindList": {
"schema.ChatsConnectorPaired": {
"type": "object",
"properties": {
"kinds": {
"type": "array",
"items": {
"$ref": "#/definitions/schema.ChatsConnectorKind"
}
"expires_at": {
"type": "string"
},
"paired_at": {
"type": "string"
},
"sender_id": {
"type": "string"
}
}
},
"schema.ChatsConnectorList": {
"schema.ChatsConnectorPairing": {
"type": "object",
"properties": {
"connectors": {
"code": {
"type": "string"
},
"expires": {
"type": "string"
}
}
},
"schema.ChatsConnectorPermission": {
"type": "object",
"properties": {
"name": {
"type": "string"
},
"sensitive": {
"type": "boolean"
}
}
},
"schema.ChatsConnectorRole": {
"type": "object",
"properties": {
"description": {
"type": "string"
},
"id": {
"type": "string"
},
"title": {
"type": "string"
}
}
},
"schema.ChatsConnectorRoleGrant": {
"type": "object",
"properties": {
"caps": {
"type": "array",
"items": {
"$ref": "#/definitions/schema.ChatsConnector"
"type": "string"
}
},
"role": {
"type": "string"
}
}
},
@@ -19290,6 +19715,13 @@
"type": "string"
}
},
"caps": {
"description": "Caps are the capabilities you enable for the binding (a closed list,\nsee the kind's permissions for the ones it can grant to a role).\nAbsent means none for a new binding and unchanged for an update; an\nempty list clears.",
"type": "array",
"items": {
"type": "string"
}
},
"confirm_accept_any": {
"description": "ConfirmAcceptAny must be the exact text \"accept any message\" in the\nrequest that turns accept_any on (a create with accept_any, or an update\nthat changes it from off to on). Anyone who can message the bot then uses\nthe agent's tools as you, so the server asks you to say you mean it.",
"type": "string"
@@ -19301,6 +19733,20 @@
"enabled": {
"type": "boolean"
},
"file_receive_max_bytes": {
"description": "FileReceiveMaxBytes and FileSendMaxBytes limit the files the bot reads\nand sends, in bytes. A limit can only lower the limit of the kind, which\nthe kind list shows as allowed_bytes. Zero removes your limit. Absent\nmeans none for a new binding and unchanged for an update.",
"type": "integer"
},
"file_send_max_bytes": {
"type": "integer"
},
"grants": {
"description": "Grants map roles of the kind to capabilities within caps. A sensitive\ncapability is never granted to a role. Absent means unchanged; an empty\nlist clears.",
"type": "array",
"items": {
"$ref": "#/definitions/schema.ChatsConnectorRoleGrant"
}
},
"kind": {
"description": "Kind is the platform, such as telegram. It is used to create a binding and\nignored by an update. Empty means telegram.",
"type": "string"
@@ -19312,7 +19758,7 @@
"type": "string"
},
"options": {
"description": "Options are the choices for the options of the kind, by option id. Absent means the defaults for a new binding and unchanged for an update.",
"description": "Options are the choices for the options of the kind, by option id. Absent\nmeans the defaults for a new binding and unchanged for an update.",
"type": "object",
"additionalProperties": {
"type": "string"
@@ -19640,7 +20086,7 @@
}
},
"applies": {
"description": "Applies is false when commands run on this machine (applies_reason\nhost) or no Sandbox serves bash (none).",
"description": "Applies is true when the add-on that runs the commands reports that it\nenforces the network policy. It is false when commands run on this\nmachine (applies_reason host), or no Sandbox serves bash or the add-on\ndoes not report enforcement (none).",
"type": "boolean"
},
"applies_reason": {
@@ -19660,6 +20106,14 @@
"description": "the lower of the two",
"type": "string"
},
"network": {
"description": "Network names the network of addon in text and gives the preset titles\nthe add-on declares. Set with addon.",
"allOf": [
{
"$ref": "#/definitions/schema.ChatsEgressNetwork"
}
]
},
"preset": {
"description": "off, registries, all",
"type": "string"
@@ -19714,6 +20168,14 @@
"isolation": {
"type": "string"
},
"network": {
"description": "Network names the network of that add-on in text. Set with sandbox.",
"allOf": [
{
"$ref": "#/definitions/schema.ChatsEgressNetwork"
}
]
},
"ports": {
"type": "array",
"items": {
@@ -19727,7 +20189,7 @@
}
},
"sandbox": {
"description": "Sandbox is the add-on that runs commands, empty when none serves;\nIsolation is isolated, host, or empty when unknown.",
"description": "Sandbox is the add-on that runs commands, empty when none serves (the\nname of the field is kept for existing clients); Isolation is isolated,\nhost, or empty when unknown.",
"type": "string"
}
}
@@ -19839,6 +20301,41 @@
}
}
},
"schema.ChatsEgressNetwork": {
"type": "object",
"properties": {
"presets": {
"description": "Presets are the titles the add-on gives the host's presets; a preset\nthat is not listed keeps the host's title.",
"type": "array",
"items": {
"$ref": "#/definitions/schema.ChatsEgressNetworkPreset"
}
},
"title": {
"description": "Title names the network in text: the declared title, else the title of\nthe commands tool.",
"type": "string",
"example": "Sandbox"
}
}
},
"schema.ChatsEgressNetworkPreset": {
"type": "object",
"properties": {
"id": {
"type": "string",
"enum": [
"off",
"registries",
"all"
],
"example": "off"
},
"title": {
"type": "string",
"example": "Closed"
}
}
},
"schema.ChatsEgressPreset": {
"type": "object",
"properties": {
@@ -19952,6 +20449,31 @@
}
}
},
"schema.ChatsExecutionStatus": {
"type": "object",
"properties": {
"explanation": {
"type": "string"
},
"generation": {
"type": "string"
},
"incarnation": {
"type": "string"
},
"isolation": {
"type": "string"
},
"readiness": {
"type": "string"
},
"unknown_reason": {
"description": "UnknownReason says why the status is unknown: owner_unreachable,\nnot_reported, stale, invalid_status, not_selected, no_answer,\nreported_unknown or draft (the chat does not exist yet). Absent when the status is known.",
"type": "string",
"example": "owner_unreachable"
}
}
},
"schema.ChatsExport": {
"type": "object",
"properties": {
+455 -38
View File
@@ -166,6 +166,8 @@ definitions:
type: boolean
canStop:
type: boolean
hostOperations:
$ref: '#/definitions/hostops.RequestSummary'
managementUrl:
description: ManagementURL opens the management view of the addon.
example: /app/addons?manage=notes
@@ -231,6 +233,12 @@ definitions:
name:
example: notes
type: string
network:
allOf:
- $ref: '#/definitions/apps.NetworkView'
description: |-
Network is the network policy the add-on declares, present only while
the host draws its network-policy control (see contract.EntitledControls).
shadows:
description: Shadows lists the built-in chat tools the add-on replaces (bash,
say).
@@ -351,6 +359,23 @@ definitions:
example: summarize
type: string
type: object
apps.HomeCommandView:
properties:
alias:
type: string
icon:
type: string
id:
type: string
label:
type: string
labels:
additionalProperties:
type: string
type: object
page:
type: string
type: object
apps.List:
properties:
apps:
@@ -358,6 +383,25 @@ definitions:
$ref: '#/definitions/apps.App'
type: array
type: object
apps.NetworkView:
properties:
kind:
description: Kind is the declared kind of network policy.
enum:
- egress-check
example: egress-check
type: string
presets:
description: Presets are the titles the add-on gives the host's network presets.
items:
$ref: '#/definitions/contract.NetworkPreset'
type: array
title:
description: 'Title names the network in text: the declared title, else the
add-on''s.'
example: Example provider
type: string
type: object
apps.SlotView:
properties:
component:
@@ -441,6 +485,18 @@ definitions:
package digest, so a new package is a new URL.
example: /api/addons/notes/assets/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef/ui.js
type: string
homeCommands:
description: HomeCommands contains only resolved navigation metadata for this
app.
items:
$ref: '#/definitions/apps.HomeCommandView'
type: array
modelActions:
description: ModelActions contains only declared destinations for this installed
UI.
items:
$ref: '#/definitions/contract.ModelAction'
type: array
navigation:
items:
type: string
@@ -743,6 +799,22 @@ definitions:
type: string
type: array
type: object
contract.ModelAction:
properties:
capability:
type: string
page:
type: string
path:
type: string
type: object
contract.NetworkPreset:
properties:
id:
type: string
title:
type: string
type: object
contract.OutputDescription:
properties:
handle:
@@ -840,11 +912,11 @@ definitions:
Name is the skill's name and the name of its folder. A plain skill is
named after the add-on: the add-on ID, or the ID and a dash and more
(office-quickstart), so two add-ons never ship one plain name. A skill
that replaces a built-in one has the built-in's name (see Shadows).
for a reserved document slot has the slot's name (see Shadows).
type: string
shadows:
description: |-
Shadows names the built-in skill this skill replaces. It must equal Name
Shadows names the document-provider slot this skill supplies. It must equal Name
and be one of ReplaceableSkills. It repeats the name on purpose: the
replacement is an explicit act, which the install warning and the check
read from the manifest without opening the package. Empty: a plain
@@ -1868,6 +1940,35 @@ definitions:
example: 4f1c
type: string
type: object
hostops.RequestSummary:
properties:
granted:
items:
type: string
type: array
grantedCount:
type: integer
missingOptional:
items:
type: string
type: array
missingRequired:
items:
type: string
type: array
optional:
items:
type: string
type: array
optionalCount:
type: integer
required:
items:
type: string
type: array
requiredCount:
type: integer
type: object
library.Info:
properties:
authReference:
@@ -2753,6 +2854,8 @@ definitions:
commands_reason:
description: CommandsReason is why commands cannot run, when Commands is unavailable.
type: string
execution_status:
$ref: '#/definitions/schema.ChatsExecutionStatus'
type: object
schema.ChatsAddonSwitches:
properties:
@@ -2818,6 +2921,13 @@ definitions:
items:
type: string
type: array
caps:
description: |-
Caps are the capabilities you enabled for this binding: the ceiling of
what any sender can use. Grants give roles of the kind some of them.
items:
type: string
type: array
conversation:
description: |-
Conversation says which chat a message belongs to: sender (one chat
@@ -2830,6 +2940,23 @@ definitions:
type: string
enabled:
type: boolean
file_receive_max_bytes:
description: |-
FileReceiveMaxBytes and FileSendMaxBytes are your limits for this bot;
zero means you set none and the limit of the kind applies.
type: integer
file_send_max_bytes:
type: integer
files:
allOf:
- $ref: '#/definitions/schema.ChatsConnectorFiles'
description: |-
Files are the limits of the files the bot reads and sends, with the
limit that you set for this bot.
grants:
items:
$ref: '#/definitions/schema.ChatsConnectorRoleGrant'
type: array
has_token:
type: boolean
id:
@@ -2840,8 +2967,22 @@ definitions:
type: string
notify_chat:
type: string
options:
additionalProperties:
type: string
description: |-
Options are the choices made for the options of the kind, by option id.
An option that is absent has its default (see ChatsConnectorKind).
type: object
owner:
type: string
paired:
description: |-
Paired are the senders you proved with a pairing code. A pairing ends
at expires_at.
items:
$ref: '#/definitions/schema.ChatsConnectorPaired'
type: array
reason:
description: Reason says in plain words why the binding is not active.
type: string
@@ -3000,8 +3141,10 @@ definitions:
type: string
path:
description: |-
Path is the folder's path in your files now ("/" is all of them). It
is absent when the folder is missing or not chosen.
Path is home-relative ("/" is the whole home), or /shared/<name>/...
for a followed readable shared folder. It is absent when missing,
not chosen, or readable outside these public locations; a readable
folder without a public path still has state ok.
type: string
state:
description: |-
@@ -3128,7 +3271,9 @@ definitions:
description: |-
Folder chooses the one folder the agent's chats work in. A write
without it keeps the folder the agent has; {"path": ""} clears it. In
an export file it is the folder's path and mode when the file was made.
an export file it is the home-relative path and mode when the file was
made. A missing, unchosen or non-home folder has an empty export path;
import preserves its mode as unset and warns instead of clearing scope.
instructions:
type: string
mcp_servers:
@@ -3460,6 +3605,13 @@ definitions:
items:
type: string
type: array
caps:
description: |-
Caps are the capabilities you enabled for this binding: the ceiling of
what any sender can use. Grants give roles of the kind some of them.
items:
type: string
type: array
conversation:
description: |-
Conversation says which chat a message belongs to: sender (one chat
@@ -3472,6 +3624,23 @@ definitions:
type: string
enabled:
type: boolean
file_receive_max_bytes:
description: |-
FileReceiveMaxBytes and FileSendMaxBytes are your limits for this bot;
zero means you set none and the limit of the kind applies.
type: integer
file_send_max_bytes:
type: integer
files:
allOf:
- $ref: '#/definitions/schema.ChatsConnectorFiles'
description: |-
Files are the limits of the files the bot reads and sends, with the
limit that you set for this bot.
grants:
items:
$ref: '#/definitions/schema.ChatsConnectorRoleGrant'
type: array
has_token:
type: boolean
id:
@@ -3482,9 +3651,6 @@ definitions:
type: string
notify_chat:
type: string
reason:
description: Reason says in plain words why the binding is not active.
type: string
options:
additionalProperties:
type: string
@@ -3492,6 +3658,16 @@ definitions:
Options are the choices made for the options of the kind, by option id.
An option that is absent has its default (see ChatsConnectorKind).
type: object
paired:
description: |-
Paired are the senders you proved with a pairing code. A pairing ends
at expires_at.
items:
$ref: '#/definitions/schema.ChatsConnectorPaired'
type: array
reason:
description: Reason says in plain words why the binding is not active.
type: string
reply_mode:
description: |-
ReplyMode is what a sender sees while a turn runs: off (typing, then
@@ -3507,12 +3683,59 @@ definitions:
window_minutes:
type: integer
type: object
schema.ChatsConnectorFileLimit:
properties:
allowed_bytes:
description: AllowedBytes is the most you may set for a bot.
type: integer
declared_bytes:
description: DeclaredBytes is the ability the kind declares, or zero.
type: integer
effective_bytes:
description: |-
EffectiveBytes is the largest file that moves now: the smallest of the
host ceiling, the declared ability, your limit and the inline cap.
type: integer
layer:
description: |-
Layer names the one that sets EffectiveBytes: owner (your limit for
this bot), addon (the ability the kind declares), host (the ceiling of
the server) or inline (the cap of the inline path).
type: string
type: object
schema.ChatsConnectorFiles:
properties:
host_ceiling_bytes:
description: |-
HostCeilingBytes is the ceiling of the server for a connector file, set
by the add-on owner; no limit above it applies.
type: integer
inline_bytes:
description: |-
InlineBytes is the cap of the inline path. Until the add-on can stream
a file, nothing larger moves, whatever the other limits are.
type: integer
receive:
allOf:
- $ref: '#/definitions/schema.ChatsConnectorFileLimit'
description: Receive is the limit of the files that people send to the bot.
send:
allOf:
- $ref: '#/definitions/schema.ChatsConnectorFileLimit'
description: Send is the limit of the files that the bot sends.
type: object
schema.ChatsConnectorKind:
properties:
addon:
type: string
description:
type: string
files:
allOf:
- $ref: '#/definitions/schema.ChatsConnectorFiles'
description: |-
Files are the limits of a binding of this kind that has no limit of
your own.
kind:
type: string
options:
@@ -3525,6 +3748,18 @@ definitions:
description: Page is the add-on page that adds and edits bindings of this
kind.
type: string
permissions:
items:
$ref: '#/definitions/schema.ChatsConnectorPermission'
type: array
roles:
description: |-
Roles are the roles the add-on can report for a sender, and
Permissions the capabilities the kind lists. Grant them to a role with
the grants of a binding.
items:
$ref: '#/definitions/schema.ChatsConnectorRole'
type: array
sender_hint:
type: string
thread_history:
@@ -3536,6 +3771,20 @@ definitions:
title:
type: string
type: object
schema.ChatsConnectorKindList:
properties:
kinds:
items:
$ref: '#/definitions/schema.ChatsConnectorKind'
type: array
type: object
schema.ChatsConnectorList:
properties:
connectors:
items:
$ref: '#/definitions/schema.ChatsConnector'
type: array
type: object
schema.ChatsConnectorOption:
properties:
default:
@@ -3558,19 +3807,46 @@ definitions:
title:
type: string
type: object
schema.ChatsConnectorKindList:
schema.ChatsConnectorPaired:
properties:
kinds:
items:
$ref: '#/definitions/schema.ChatsConnectorKind'
type: array
expires_at:
type: string
paired_at:
type: string
sender_id:
type: string
type: object
schema.ChatsConnectorList:
schema.ChatsConnectorPairing:
properties:
connectors:
code:
type: string
expires:
type: string
type: object
schema.ChatsConnectorPermission:
properties:
name:
type: string
sensitive:
type: boolean
type: object
schema.ChatsConnectorRole:
properties:
description:
type: string
id:
type: string
title:
type: string
type: object
schema.ChatsConnectorRoleGrant:
properties:
caps:
items:
$ref: '#/definitions/schema.ChatsConnector'
type: string
type: array
role:
type: string
type: object
schema.ChatsConnectorSender:
properties:
@@ -3611,6 +3887,15 @@ definitions:
items:
type: string
type: array
caps:
description: |-
Caps are the capabilities you enable for the binding (a closed list,
see the kind's permissions for the ones it can grant to a role).
Absent means none for a new binding and unchanged for an update; an
empty list clears.
items:
type: string
type: array
confirm_accept_any:
description: |-
ConfirmAcceptAny must be the exact text "accept any message" in the
@@ -3623,6 +3908,23 @@ definitions:
type: string
enabled:
type: boolean
file_receive_max_bytes:
description: |-
FileReceiveMaxBytes and FileSendMaxBytes limit the files the bot reads
and sends, in bytes. A limit can only lower the limit of the kind, which
the kind list shows as allowed_bytes. Zero removes your limit. Absent
means none for a new binding and unchanged for an update.
type: integer
file_send_max_bytes:
type: integer
grants:
description: |-
Grants map roles of the kind to capabilities within caps. A sensitive
capability is never granted to a role. Absent means unchanged; an empty
list clears.
items:
$ref: '#/definitions/schema.ChatsConnectorRoleGrant'
type: array
kind:
description: |-
Kind is the platform, such as telegram. It is used to create a binding and
@@ -3894,8 +4196,10 @@ definitions:
type: array
applies:
description: |-
Applies is false when commands run on this machine (applies_reason
host) or no Sandbox serves bash (none).
Applies is true when the add-on that runs the commands reports that it
enforces the network policy. It is false when commands run on this
machine (applies_reason host), or no Sandbox serves bash or the add-on
does not report enforcement (none).
type: boolean
applies_reason:
type: string
@@ -3909,6 +4213,12 @@ definitions:
effective:
description: the lower of the two
type: string
network:
allOf:
- $ref: '#/definitions/schema.ChatsEgressNetwork'
description: |-
Network names the network of addon in text and gives the preset titles
the add-on declares. Set with addon.
preset:
description: off, registries, all
type: string
@@ -3945,6 +4255,10 @@ definitions:
type: array
isolation:
type: string
network:
allOf:
- $ref: '#/definitions/schema.ChatsEgressNetwork'
description: Network names the network of that add-on in text. Set with sandbox.
ports:
items:
type: integer
@@ -3955,8 +4269,9 @@ definitions:
type: array
sandbox:
description: |-
Sandbox is the add-on that runs commands, empty when none serves;
Isolation is isolated, host, or empty when unknown.
Sandbox is the add-on that runs commands, empty when none serves (the
name of the field is kept for existing clients); Isolation is isolated,
host, or empty when unknown.
type: string
type: object
schema.ChatsEgressCard:
@@ -4032,6 +4347,35 @@ definitions:
$ref: '#/definitions/schema.ChatsEgressEvent'
type: array
type: object
schema.ChatsEgressNetwork:
properties:
presets:
description: |-
Presets are the titles the add-on gives the host's presets; a preset
that is not listed keeps the host's title.
items:
$ref: '#/definitions/schema.ChatsEgressNetworkPreset'
type: array
title:
description: |-
Title names the network in text: the declared title, else the title of
the commands tool.
example: Sandbox
type: string
type: object
schema.ChatsEgressNetworkPreset:
properties:
id:
enum:
- "off"
- registries
- all
example: "off"
type: string
title:
example: Closed
type: string
type: object
schema.ChatsEgressPreset:
properties:
preset:
@@ -4113,6 +4457,26 @@ definitions:
- $ref: '#/definitions/schema.ChatsUsage'
description: 'Usage is set on usage events: what one model request used.'
type: object
schema.ChatsExecutionStatus:
properties:
explanation:
type: string
generation:
type: string
incarnation:
type: string
isolation:
type: string
readiness:
type: string
unknown_reason:
description: |-
UnknownReason says why the status is unknown: owner_unreachable,
not_reported, stale, invalid_status, not_selected, no_answer,
reported_unknown or draft (the chat does not exist yet). Absent when the status is known.
example: owner_unreachable
type: string
type: object
schema.ChatsExport:
properties:
created:
@@ -12261,6 +12625,13 @@ paths:
in: query
name: kind
type: string
- description: 'only chats of one origin, with sub-agent runs left out and the
limit counting the chats kept: person (a person started it), automated (anything
else) or one origin kind (trigger, connector, job, page). Without it, all
chats'
in: query
name: origin
type: string
responses:
"200":
description: Conversations, including sub-agent runs unless kind=chat
@@ -12295,7 +12666,8 @@ paths:
schema:
$ref: '#/definitions/schema.ErrorResponse'
"409":
description: The saved agent's folder is missing or not chosen (agent_folder_missing)
description: The saved agent's folder is missing or not chosen (agent_folder_missing),
or its complete file access exceeds the folder limit (agent_folder_limit)
schema:
$ref: '#/definitions/schema.ErrorResponse'
summary: Start a chat.
@@ -12621,7 +12993,7 @@ paths:
get:
description: The preset of the chat (off, registries, all), the administrator's
ceiling, the preset in force (the lower of the two), whether it applies (false
when commands run on this machine, applies_reason host, or no Sandbox serves
when commands run on this machine, applies_reason host, or no add-on serves
bash, none), addon (the add-on that runs the commands and draws the network
controls of the chat, from its network-policy host control; absent when none
does), the package registries the preset registries admits, the chat's allow
@@ -12643,7 +13015,7 @@ paths:
description: No such conversation, or it is another user's
schema:
$ref: '#/definitions/schema.ErrorResponse'
summary: Read the network of a chat's Sandbox commands.
summary: Read the network of a chat's commands.
tags:
- chats
put:
@@ -12676,7 +13048,7 @@ paths:
description: No such conversation, or it is another user's
schema:
$ref: '#/definitions/schema.ErrorResponse'
summary: Set the network preset of a chat's Sandbox commands.
summary: Set the network preset of a chat's commands.
tags:
- chats
/api/chats/{id}/egress/consent:
@@ -12723,14 +13095,14 @@ paths:
the same answer again is 204
schema:
$ref: '#/definitions/schema.ErrorResponse'
summary: Answer a network card of a chat's Sandbox commands.
summary: Answer a network card of a chat's commands.
tags:
- chats
/api/chats/{id}/egress/rules/{host}/{port}:
delete:
description: Removes the rule that allows or denies one host and port for the
chat's Sandbox commands. A host that was denied asks again; one that was allowed
follows the preset again.
chat's commands. A host that was denied asks again; one that was allowed follows
the preset again.
parameters:
- description: conversation id
in: path
@@ -13433,7 +13805,8 @@ paths:
$ref: '#/definitions/schema.ErrorResponse'
"409":
description: A turn is running for a command, or the saved agent's folder
is missing (agent_folder_missing)
is missing (agent_folder_missing), or its complete file access exceeds
the folder limit (agent_folder_limit)
schema:
$ref: '#/definitions/schema.ErrorResponse'
summary: Send a message to a chat.
@@ -13765,15 +14138,15 @@ paths:
description: The settings in force
schema:
$ref: '#/definitions/schema.ChatsEgressAdmin'
summary: Read the settings of the Sandbox network (administrators).
summary: Read the settings of the network of commands (administrators).
tags:
- chats
/api/chats/admin/egress/events:
get:
description: 'Every request a command made through the Sandbox proxy, newest
first: who, which chat, host, port, kind (connect or http), verdict (allow,
block, or done when an allowed connection ended, with its address, bytes and
duration) and reason.'
description: 'Every request a command made through the proxy of the add-on,
newest first: who, which chat, host, port, kind (connect or http), verdict
(allow, block, or done when an allowed connection ended, with its address,
bytes and duration) and reason.'
parameters:
- description: How many lines (default 100, at most 500)
in: query
@@ -13800,7 +14173,7 @@ paths:
description: The newest lines first
schema:
$ref: '#/definitions/schema.ChatsEgressEventList'
summary: Read the audit of the Sandbox network requests (administrators).
summary: Read the audit of the network of commands requests (administrators).
tags:
- chats
/api/chats/admin/host-exec/events:
@@ -14296,10 +14669,11 @@ paths:
get:
description: Returns the definition as a JSON file (version, kind localai-agent,
agent). It holds no ID, owner or time. An agent with a folder is version 2
and carries folder {path, mode}, the folder's path in your files now (empty
when the folder is missing or not chosen); a server that knows only version
1 refuses it. An agent without a folder is version 1. POST /api/chats/agents/import
reads it. This replaces the old /api/agents/{name}/export.
and carries folder {path, mode}, the folder's home-relative path now (empty
when the folder is missing, not chosen, or outside your home, even if readable);
importing an empty path preserves the scope as unset with a warning; a server
that knows only version 1 refuses it. An agent without a folder is version
1. POST /api/chats/agents/import reads it. This replaces the old /api/agents/{name}/export.
parameters:
- description: the agent's id
in: path
@@ -14681,6 +15055,49 @@ paths:
summary: Switch a connector binding on.
tags:
- chats
/api/chats/connectors/{id}/paired/{sender}:
delete:
description: The sender can pair again with a new code.
parameters:
- description: the binding's id
in: path
name: id
required: true
type: string
- description: the sender's id
in: path
name: sender
required: true
type: string
responses:
"200":
description: The binding
schema:
$ref: '#/definitions/schema.ChatsConnector'
summary: End the pairing of a sender of a connector binding.
tags:
- chats
/api/chats/connectors/{id}/pairing:
post:
description: Pairing proves that you control a platform account. Send the code,
as a direct message to the bot, from an account on the allowlist, within ten
minutes. The code works once, and a new code replaces the old one. A paired
sender may use the sensitive capabilities you enabled; a pairing ends after
90 days. At most three senders are paired.
parameters:
- description: the binding's id
in: path
name: id
required: true
type: string
responses:
"200":
description: The code and when it expires
schema:
$ref: '#/definitions/schema.ChatsConnectorPairing'
summary: Make a pairing code for a connector binding.
tags:
- chats
/api/chats/connectors/{id}/senders:
get:
description: The log of people who wrote to the bot, newest first, with whether