diff --git a/addons/github/manifest.json b/addons/github/manifest.json index aa685a9b44..8f1ef78931 100644 --- a/addons/github/manifest.json +++ b/addons/github/manifest.json @@ -578,6 +578,7 @@ "description": "Let your agents read the repositories you choose, and comment on issues and pull requests if you allow it, with a fine-grained access token.", "senderHint": "Not used yet: this connection does not listen for mentions", "delivery": "pull", + "agentOptional": true, "page": "apps", "roles": [ { diff --git a/addons/github/ui/src/i18n/github.en.json b/addons/github/ui/src/i18n/github.en.json index c53cc4395a..87eb7ed261 100644 --- a/addons/github/ui/src/i18n/github.en.json +++ b/addons/github/ui/src/i18n/github.en.json @@ -15,6 +15,9 @@ "reposMany": "At most 50 repositories.", "agent": "Agent", "agentNone": "Choose an agent", + "agentCredentialsOnly": "No agent (credentials only)", + "agentOptionalHint": "Optional. Without an agent this connection only holds the token, the repository list and the write mode for your chats. It answers no messages and listens for nothing.", + "credentialsOnly": "Credentials only", "agentNoModel": "{{name}} (needs a model)", "test": "Test", "testOk": "Token of {{bot}}.", diff --git a/addons/github/ui/test/github.test.mjs b/addons/github/ui/test/github.test.mjs index 60d8af414a..abe933a46b 100644 --- a/addons/github/ui/test/github.test.mjs +++ b/addons/github/ui/test/github.test.mjs @@ -72,3 +72,11 @@ test('github: every write mode has its words, and the note says the text is publ assert.match(en.writeMode.note.propose, /public/) assert.match(en.writeMode.note.auto, /public/) }) + +test('github: a connection can be saved without an agent, and the page has words for it', () => { + const form = { ...emptyConnector(), name: 'Work', token: 'github_pat_x' } + assert.equal(canSave(form, false, true), false, 'a kind that needs an agent refuses an empty one') + assert.equal(canSave(form, false, true, null, true), true, 'agentOptional lets the connection hold credentials only') + assert.equal(connectorPayload(form, 'github', 'github_pat_x', { enabled: false }).agent_id, '') + for (const key of ['agentCredentialsOnly', 'agentOptionalHint', 'credentialsOnly']) assert.ok(en[key], key) +}) diff --git a/addons/internal/connector-ui/src/ConnectorPage.jsx b/addons/internal/connector-ui/src/ConnectorPage.jsx index 4ab83474cf..eebd4c31d9 100644 --- a/addons/internal/connector-ui/src/ConnectorPage.jsx +++ b/addons/internal/connector-ui/src/ConnectorPage.jsx @@ -98,6 +98,8 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo const [capabilities, setCapabilities] = useState([]) const [accessVersion, setAccessVersion] = useState(0) const kindFiles = kindInfo?.files || null + // A kind that declares agent_optional lets a binding hold credentials only. + const agentOptional = Boolean(kindInfo?.agent_optional) const s = answerT(t) const pairedChanged = useCallback(() => setAccessVersion((v) => v + 1), []) @@ -210,10 +212,10 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo {(p) => setForm({ ...form, name: e.target.value })} />} - + {(p) => ( - setForm({ ...form, agent_id: e.target.value })}> + {agents.map((a) => ( ))} @@ -247,7 +249,7 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo {capabilities.length > 0 && } )}
- +
@@ -271,12 +273,14 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo {c.name} {messaging ? (<> - {t('agentOf', { name: agentName(c.agent_id) })} + {c.credentials_only ? t('credentialsOnly', { defaultValue: 'Credentials only' }) : t('agentOf', { name: agentName(c.agent_id) })} {' ยท '} {t('accepts', { who: c.accept_any ? t('modes.anyone') : (c.allow || []).length === 0 ? t('modes.nobody') : t('modes.listed', { count: c.allow.length }) })} {answerSummary(c, t)} - ) : {t('agentOf', { name: agentName(c.agent_id) })}} + ) : + {c.credentials_only ? t('credentialsOnly', { defaultValue: 'Credentials only' }) : t('agentOf', { name: agentName(c.agent_id) })} + } {RowInfo && } {messaging && capabilities.length > 0 && hasNoCaps(c) && ( allowUsual(c)} /> diff --git a/addons/internal/connector-ui/src/connector.js b/addons/internal/connector-ui/src/connector.js index 3fe6fe5aaa..e13322f9ff 100644 --- a/addons/internal/connector-ui/src/connector.js +++ b/addons/internal/connector-ui/src/connector.js @@ -239,8 +239,8 @@ export function senderLine(s) { // "Accept any message" is only saved after the person confirms the warning. // hasToken says whether the add-on's fields hold a complete secret. -export function canSave(form, confirmed, hasToken = Boolean((form.token || '').trim()), files = null) { - if (!(form.name || '').trim() || !form.agent_id) return false +export function canSave(form, confirmed, hasToken = Boolean((form.token || '').trim()), files = null, agentOptional = false) { + if (!(form.name || '').trim() || (!form.agent_id && !agentOptional)) return false if (!form.id && !hasToken) return false if (form.accept_any && !confirmed) return false if (approvalProblem(form.approval)) return false diff --git a/core/http/endpoints/studio/connectors.go b/core/http/endpoints/studio/connectors.go index 7af3a4e15d..c235596d8a 100644 --- a/core/http/endpoints/studio/connectors.go +++ b/core/http/endpoints/studio/connectors.go @@ -22,7 +22,7 @@ func connectorOut(i studiosvc.ConnectorInfo) schema.ChatsConnector { if allow == nil { allow = []string{} } - 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, + 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, CredentialsOnly: i.CredentialsOnly(), 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, Resources: connectors.ResourceKeys(i.Resources), Channels: channelsOut(i.Channels), WriteMode: i.Writes(), Approval: approvalOut(i.Approval)} @@ -190,7 +190,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), + 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, AgentOptional: k.AgentOptional, ThreadHistory: k.ServesThreadHistory(), Options: optionsOut(k.Options), Files: filesOut(host.KindFileLimits(c.Request().Context(), k)), Roles: rolesOut(k.ConnectorKind), Permissions: permissionsOut(k.ConnectorKind)}) } for _, ci := range contract.ConnectorCapabilities() { diff --git a/core/schema/chats.go b/core/schema/chats.go index 8a4197521e..8ca2ea0b56 100644 --- a/core/schema/chats.go +++ b/core/schema/chats.go @@ -1220,17 +1220,21 @@ type ChatsPushUnsubscribe struct { // ChatsConnector is a connector binding: a bot bound to one of your saved agents. // The bot token is never returned; has_token says whether one is stored. type ChatsConnector struct { - ID string `json:"id"` - Name string `json:"name"` - Kind string `json:"kind"` - Addon string `json:"addon"` - AgentID string `json:"agent_id"` - Enabled bool `json:"enabled"` - AcceptAny bool `json:"accept_any"` - Allow []string `json:"allow"` - NotifyChat string `json:"notify_chat,omitempty"` - HasToken bool `json:"has_token"` - Created time.Time `json:"created"` + ID string `json:"id"` + Name string `json:"name"` + Kind string `json:"kind"` + Addon string `json:"addon"` + // AgentID is empty for a binding of a kind that declares agent_optional. + AgentID string `json:"agent_id"` + // CredentialsOnly is true for a binding without an agent: it holds the + // token, the resource list and the write mode, and never answers messages. + CredentialsOnly bool `json:"credentials_only,omitempty"` + Enabled bool `json:"enabled"` + AcceptAny bool `json:"accept_any"` + Allow []string `json:"allow"` + NotifyChat string `json:"notify_chat,omitempty"` + HasToken bool `json:"has_token"` + Created time.Time `json:"created"` // ReplyMode is what a sender sees while a turn runs: off (typing, then // the answer), progress (a working message that names the tools as they // start) or stream (a working message that shows the answer as it is @@ -1422,6 +1426,9 @@ type ChatsConnectorKind struct { SenderHint string `json:"sender_hint,omitempty"` // Page is the add-on page that adds and edits bindings of this kind. Page string `json:"page,omitempty"` + // AgentOptional is true when a binding of this kind may have no agent. It + // then only holds credentials (token, resource list, write mode). + AgentOptional bool `json:"agent_optional,omitempty"` // ThreadHistory is true when the platform can give the earlier messages // of a thread. The host then reads them whenever the bot is mentioned in a // thread. diff --git a/core/services/studio/connectoraccess.go b/core/services/studio/connectoraccess.go index f9c146b0ab..b176394867 100644 --- a/core/services/studio/connectoraccess.go +++ b/core/services/studio/connectoraccess.go @@ -59,6 +59,8 @@ func Authorize(b connectors.Binding, kind *contract.ConnectorKind, s AccessSubje return Decision{Capability: capability, Reason: fmt.Sprintf(format, a...)} } switch { + case b.CredentialsOnly(): + return deny("this connection has no agent: it holds credentials only") case !contract.KnownCapability(capability): return deny("%q is not a capability", capability) case !s.Allowlisted: diff --git a/core/services/studio/connectoraccess_internal_test.go b/core/services/studio/connectoraccess_internal_test.go index 6b5da26034..bc7104b5e8 100644 --- a/core/services/studio/connectoraccess_internal_test.go +++ b/core/services/studio/connectoraccess_internal_test.go @@ -34,7 +34,7 @@ var _ = Describe("Authorize", func() { // than it may: a capability above the ceiling, one the kind does not // list, and two sensitive ones. binding := connectors.Binding{ - ID: "b1", Kind: "chat", Caps: except(contract.CapFilesUpload), + ID: "b1", Kind: "chat", AgentID: "a1", Caps: except(contract.CapFilesUpload), Grants: []connectors.RoleGrant{{Role: "admin", Caps: []string{ contract.CapMemoryWrite, contract.CapTriggersPropose, contract.CapFilesUpload, contract.CapTalkbackChannels, contract.CapTalkbackNewChan, contract.CapProfileWrite, diff --git a/core/services/studio/connectorgrants.go b/core/services/studio/connectorgrants.go index 5775509523..d5d6bbb4b1 100644 --- a/core/services/studio/connectorgrants.go +++ b/core/services/studio/connectorgrants.go @@ -76,7 +76,7 @@ func agentGrantSwitch(s *Service) CapabilitySwitch { return "" } if b.AgentID == "" { - return grantVerdict(capability, nil, nil, false) + return "this connection has no agent" } if s.d.Agents == nil { return "the agent of this bot cannot be read" diff --git a/core/services/studio/connectorgrants_internal_test.go b/core/services/studio/connectorgrants_internal_test.go index 8f5cd6e535..c3355089f1 100644 --- a/core/services/studio/connectorgrants_internal_test.go +++ b/core/services/studio/connectorgrants_internal_test.go @@ -73,7 +73,7 @@ var _ = Describe("the agent grants at the connector gate", func() { kind := &contract.ConnectorKind{ID: "chat", Roles: []contract.ConnectorRole{{ID: "admin", Title: "Admin"}}, Permissions: []string{contract.CapTriggersPropose, contract.CapTalkbackChannels}} binding := func(caps ...string) connectors.Binding { - return connectors.Binding{ID: "b", Kind: "chat", Caps: caps, Paired: paired, + return connectors.Binding{ID: "b", Kind: "chat", AgentID: "a1", Caps: caps, Paired: paired, Grants: []connectors.RoleGrant{{Role: "admin", Caps: []string{contract.CapTriggersPropose, contract.CapTalkbackChannels}}}} } type row struct { diff --git a/core/services/studio/connectorpairing.go b/core/services/studio/connectorpairing.go index fb3f109598..b894e74334 100644 --- a/core/services/studio/connectorpairing.go +++ b/core/services/studio/connectorpairing.go @@ -69,6 +69,9 @@ func (c *Connectors) StartPairing(ctx context.Context, user, id string) (Pairing if err != nil { return PairingCode{}, err } + if b.CredentialsOnly() { + return PairingCode{}, connectorBad("pairing needs an agent: a connection without an agent receives no messages") + } code, err := newPairingCode() if err != nil { return PairingCode{}, err @@ -181,6 +184,9 @@ func (c *Connectors) addPaired(ctx context.Context, id, senderID string) error { if err != nil { return err } + if b.CredentialsOnly() { + return connectors.ErrForbidden + } 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 diff --git a/core/services/studio/connectorroles_internal_test.go b/core/services/studio/connectorroles_internal_test.go index a6a48085c8..ad713df73f 100644 --- a/core/services/studio/connectorroles_internal_test.go +++ b/core/services/studio/connectorroles_internal_test.go @@ -28,7 +28,7 @@ var _ = Describe("the roles of an inbound message", func() { Expect(err).NotTo(HaveOccurred()) return c } - b := connectors.Binding{ID: "b1", Kind: "chat", AddonID: "one"} + b := connectors.Binding{ID: "b1", Kind: "chat", AddonID: "one", AgentID: "a1"} ctx := context.Background() It("keeps the roles that the kind declares and drops the others", func() { diff --git a/core/services/studio/connectors.go b/core/services/studio/connectors.go index b5672e6241..823450448c 100644 --- a/core/services/studio/connectors.go +++ b/core/services/studio/connectors.go @@ -306,6 +306,10 @@ func (c *Connectors) Sync(ctx context.Context) { // A loop runs only for a binding whose add-on is running and pulls. want := map[string]connectors.Binding{} for _, b := range enabled { + // A binding without an agent has nothing to answer with: no loop. + if b.CredentialsOnly() { + continue + } if k, ok := kindFor(kinds, b); ok && k.Delivery == contract.DeliveryPull { want[b.ID] = b } @@ -360,7 +364,7 @@ func (c *Connectors) poll(ctx context.Context, id string) { poll: for ctx.Err() == nil && !c.closed() { b, err := c.o.Store.GetBinding(ctx, id) - if err != nil || !b.Enabled { + if err != nil || !b.Enabled || b.CredentialsOnly() { return } token, err := c.openToken(ctx, b) @@ -564,7 +568,7 @@ func (c *Connectors) Ingest(ctx context.Context, addonID string, in contract.Con return fmt.Errorf("studio: read a connector binding: %w", err) } // A missing binding and another add-on's binding look the same to the caller. - if err != nil || b.AddonID != addonID || addonID == "" || !b.Enabled { + if err != nil || b.AddonID != addonID || addonID == "" || !b.Enabled || b.CredentialsOnly() { return connectors.ErrForbidden } m := in.ConnectorMessage @@ -691,6 +695,9 @@ func (c *Connectors) windowEnded(ctx context.Context, b connectors.Binding, m co // handle takes one message of a sender. func (c *Connectors) handle(ctx context.Context, b connectors.Binding, token string, m contract.ConnectorMessage) { + if b.CredentialsOnly() { + return // Ingest refuses it first; this keeps any other caller from answering + } senderID := m.SenderID // A message is answered when it is direct, or names the bot in a group. // An entry of the allowlist names the sender or the chat: a listed group @@ -1197,7 +1204,7 @@ func (c *Connectors) Forward(ctx context.Context, n notify.Notification) { } text := strings.TrimSpace(n.Title + "\n" + n.Body) for _, b := range bindings { - if !b.Enabled || b.NotifyChat == "" { + if !b.Enabled || b.NotifyChat == "" || b.CredentialsOnly() { continue } token, err := c.openToken(ctx, b) diff --git a/core/services/studio/connectors/agentoptional_test.go b/core/services/studio/connectors/agentoptional_test.go new file mode 100644 index 0000000000..9743f4eddb --- /dev/null +++ b/core/services/studio/connectors/agentoptional_test.go @@ -0,0 +1,41 @@ +package connectors_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/mudler/LocalAI/core/services/studio/connectors" +) + +var _ = Describe("a binding without an agent", func() { + bare := func() connectors.Binding { return connectors.Binding{Name: "Tokens", Kind: "github"} } + + It("is refused by Validate and by a kind that does not declare agentOptional", func() { + Expect(connectors.Validate(bare())).To(MatchError(connectors.ErrInvalid)) + Expect(connectors.ValidateKind(bare(), false)).To(MatchError(ContainSubstring("agent is required"))) + }) + It("is accepted by a kind that declares agentOptional while it sets nothing an agent needs", func() { + b := bare() + b.WriteMode = connectors.WritePropose + b.Resources = connectors.NewResources([]string{"acme/widgets"}) + Expect(connectors.ValidateKind(b, true)).To(Succeed()) + Expect(b.CredentialsOnly()).To(BeTrue()) + }) + DescribeTable("needs an agent for", + func(mod func(*connectors.Binding)) { + b := bare() + mod(&b) + Expect(connectors.ValidateKind(b, true)).To(MatchError(connectors.ErrInvalid)) + b.AgentID = "a1" + Expect(connectors.ValidateKind(b, true)).To(Succeed()) + }, + Entry("listening", func(b *connectors.Binding) { b.Enabled = true }), + Entry("accepting anyone", func(b *connectors.Binding) { b.AcceptAny = true }), + Entry("an allowlist", func(b *connectors.Binding) { b.Allow = []string{"11"} }), + Entry("a notification chat", func(b *connectors.Binding) { b.NotifyChat = "11" }), + Entry("channels", func(b *connectors.Binding) { b.Channels = []connectors.Channel{{ID: "C1"}} }), + Entry("a capability ceiling", func(b *connectors.Binding) { b.Caps = []string{"memory.write"} }), + Entry("pairing", func(b *connectors.Binding) { b.Paired = []connectors.PairedSender{{SenderID: "11"}} }), + Entry("trigger approval", func(b *connectors.Binding) { b.Approval = connectors.Approval{Mode: connectors.ApprovalAutoReadonly} }), + ) +}) diff --git a/core/services/studio/connectors/types.go b/core/services/studio/connectors/types.go index 74250663c0..2e55d16178 100644 --- a/core/services/studio/connectors/types.go +++ b/core/services/studio/connectors/types.go @@ -130,7 +130,11 @@ type Binding struct { // before connectors became add-ons has none; Normalize gives it the add-on // of its kind. AddonID string - // AgentID is required: a binding never inherits the tools of a chat. + // AgentID is required: a binding never inherits the tools of a chat. The + // one exception is a kind that declares agentOptional (see + // contract.ConnectorKind.AgentOptional): its binding may have no agent. It + // then only holds the credential, the resource list and the write mode, and + // receives and answers nothing (see CredentialsOnly). AgentID string Enabled bool // AcceptAny lets anyone who can message the bot use the agent. Otherwise only @@ -317,8 +321,45 @@ var kindShape = regexp.MustCompile(`^[a-z][a-z0-9-]{0,63}$`) // chatShape is a chat that notifications go to: a numeric ID or a platform ID. var chatShape = regexp.MustCompile(`^(-?[0-9]{1,20}|[A-Z][A-Z0-9]{1,31})$`) -// Validate checks a binding. The error wraps ErrInvalid. -func Validate(b Binding) error { +// CredentialsOnly reports whether the binding has no agent. Such a binding +// holds a credential for the tools of a chat and never receives or answers a +// message. Every reader of AgentID must treat it as unable to listen, answer, +// deliver or be authorized. +func (b Binding) CredentialsOnly() bool { return b.AgentID == "" } + +// needsAgent names the first setting of b that only makes sense when an agent +// answers, or "". A binding without an agent must not set any of them. +func needsAgent(b Binding) string { + switch { + case b.Enabled: + return "listening" + case b.AcceptAny: + return "accepting anyone" + case len(b.Allow) > 0: + return "the allowlist" + case b.NotifyChat != "": + return "the notification chat" + case len(b.Channels) > 0: + return "the talk-back channels" + case len(b.Caps) > 0 || len(b.Grants) > 0: + return "permissions" + case len(b.Paired) > 0: + return "pairing" + case b.Approval != (Approval{}): + return "trigger approval" + } + return "" +} + +// Validate checks a binding of a kind that needs an agent. The error wraps +// ErrInvalid. +func Validate(b Binding) error { return ValidateKind(b, false) } + +// ValidateKind checks a binding. With agentOptional (the kind declares it) a +// binding may have no agent, but only while it sets nothing that needs one: a +// binding that listens, accepts senders, notifies, talks back or carries +// permissions must name an agent. +func ValidateKind(b Binding, agentOptional bool) error { bad := func(format string, a ...any) error { return fmt.Errorf("%w: %s", ErrInvalid, fmt.Sprintf(format, a...)) } @@ -329,7 +370,12 @@ func Validate(b Binding) error { return bad("the kind must be the ID of a connector kind") } if b.AgentID == "" { - return bad("an agent is required: a connector never uses the tools of a chat") + if !agentOptional { + return bad("an agent is required: a connector never uses the tools of a chat") + } + if what := needsAgent(b); what != "" { + return bad("%s needs an agent: a connection without an agent only holds credentials", what) + } } if len(b.Allow) > MaxAllow { return bad("the allowlist has more than %d entries", MaxAllow) diff --git a/core/services/studio/connectors_agentless_test.go b/core/services/studio/connectors_agentless_test.go new file mode 100644 index 0000000000..e9f0f4fc2d --- /dev/null +++ b/core/services/studio/connectors_agentless_test.go @@ -0,0 +1,145 @@ +package studio_test + +import ( + "context" + + "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/core/services/studio/notify" + "github.com/mudler/LocalAI/pkg/addons/contract" + "github.com/mudler/LocalAI/tests/studiobot" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// A kind that declares agentOptional gives bindings that only hold +// credentials. Every place that reads the agent of a binding must treat such a +// binding as unable to receive, answer, deliver or be authorized. +var _ = Describe("a credentials-only connector binding", func() { + ctx := context.Background() + var ( + r *connRig + st connectors.Store + b connectors.Binding + ) + newRig := func(optional bool) { + st = connStore() + bot := newScriptedBot() + addon := studiobot.Telegram(&transport.API{BaseURL: bot.srv.URL, Client: bot.srv.Client()}) + addon.Kind.AgentOptional = optional + r = newConnRigWith(newShared(), st, studiobot.NewTransport(addon), connSay("never"), nil) + r.bot, r.untrusted = bot, true + } + credOnly := func(in studio.ConnectorInput) studio.ConnectorInput { + in.Kind, in.Name, in.Token = "telegram", "Tokens", botToken + return in + } + // stored writes an agentless binding that is switched on, as a store could + // hold it after an edit by hand or a downgrade. + stored := func() connectors.Binding { + got, err := r.conns.CreateConnector(ctx, alice, credOnly(studio.ConnectorInput{Resources: []string{"acme/widgets"}})) + Expect(err).NotTo(HaveOccurred()) + raw, err := st.GetBinding(ctx, got.ID) + Expect(err).NotTo(HaveOccurred()) + raw.Enabled, raw.AcceptAny = true, true + Expect(st.PutBinding(ctx, raw)).To(Succeed()) + return raw + } + + Describe("creating and editing", func() { + It("is refused when the kind does not declare agentOptional", func() { + newRig(false) + _, err := r.conns.CreateConnector(ctx, alice, credOnly(studio.ConnectorInput{})) + Expect(err).To(MatchError(connectors.ErrInvalid)) + Expect(err).To(MatchError(ContainSubstring("agent is required"))) + }) + It("is accepted when the kind declares it, with no agent and listening off", func() { + newRig(true) + got, err := r.conns.CreateConnector(ctx, alice, credOnly(studio.ConnectorInput{Resources: []string{"acme/widgets"}})) + Expect(err).NotTo(HaveOccurred()) + Expect(got.AgentID).To(BeEmpty()) + Expect(got.CredentialsOnly()).To(BeTrue()) + grant, err := r.conns.CredentialFor(ctx, alice, got.AddonID, "acme/widgets") + Expect(err).NotTo(HaveOccurred()) + Expect(grant.Secret).To(Equal(botToken)) + }) + DescribeTable("needs an agent for what only an agent can use", + func(mod func(*studio.ConnectorInput), words string) { + newRig(true) + in := credOnly(studio.ConnectorInput{}) + mod(&in) + _, err := r.conns.CreateConnector(ctx, alice, in) + Expect(err).To(MatchError(connectors.ErrInvalid)) + Expect(err).To(MatchError(ContainSubstring(words))) + }, + Entry("listening", func(in *studio.ConnectorInput) { in.Enabled = true }, "listening needs an agent"), + Entry("accepting anyone", func(in *studio.ConnectorInput) { in.AcceptAny = true }, "accepting anyone"), + Entry("an allowlist", func(in *studio.ConnectorInput) { in.Allow = []string{"11"} }, "the allowlist"), + Entry("a notification chat", func(in *studio.ConnectorInput) { in.NotifyChat = "11" }, "the notification chat"), + Entry("talk-back channels", func(in *studio.ConnectorInput) { + in.Channels = []connectors.Channel{{ID: "C1", Approved: true}} + }, "the talk-back channels"), + Entry("permissions", func(in *studio.ConnectorInput) { in.Caps = []string{contract.CapMemoryWrite} }, "permissions"), + ) + It("cannot be switched on, and an agent can be added later", func() { + newRig(true) + got, err := r.conns.CreateConnector(ctx, alice, credOnly(studio.ConnectorInput{})) + Expect(err).NotTo(HaveOccurred()) + _, err = r.conns.SetConnectorEnabled(ctx, alice, got.ID, true) + Expect(err).To(MatchError(connectors.ErrInvalid)) + on, err := r.conns.UpdateConnector(ctx, alice, got.ID, credOnly(studio.ConnectorInput{AgentID: r.agentID(), Enabled: true, AcceptAny: true})) + Expect(err).NotTo(HaveOccurred()) + Expect(on.CredentialsOnly()).To(BeFalse()) + Expect(on.Enabled).To(BeTrue()) + }) + It("refuses an agent that is not the owner's", func() { + newRig(true) + _, err := r.conns.CreateConnector(ctx, alice, credOnly(studio.ConnectorInput{AgentID: "someone-elses"})) + Expect(err).To(HaveOccurred()) + }) + }) + + Describe("a record that is on without an agent", func() { + BeforeEach(func() { newRig(true); b = stored() }) + + It("receives nothing: Ingest refuses it and no sender is logged", func() { + err := r.conns.Ingest(ctx, b.AddonID, contract.ConnectorInbound{BindingID: b.ID, + ConnectorMessage: contract.ConnectorMessage{Chat: "11", Direct: true, SenderID: "11", Text: "hi"}}) + Expect(err).To(MatchError(connectors.ErrForbidden)) + senders, _ := st.Senders(ctx, b.ID, 10) + Expect(senders).To(BeEmpty()) + chats, _ := r.svc.List(ctx, alice, 10) + Expect(chats).To(BeEmpty()) + }) + It("starts no polling loop", func() { + r.conns.Sync(ctx) + Consistently(func() int { return len(r.bot.answers()) }, "300ms").Should(BeZero()) + }) + It("denies every capability, also for a paired sender with the ceiling set", func() { + b.Caps = contract.ConnectorCapabilityNames() + b.Paired = []connectors.PairedSender{{SenderID: "11", At: b.Created}} + for _, name := range contract.ConnectorCapabilityNames() { + d := studio.Authorize(b, nil, studio.AccessSubject{SenderID: "11", Allowlisted: true}, name, b.Updated, "") + Expect(d.Allowed).To(BeFalse(), name) + Expect(d.Reason).To(ContainSubstring("no agent")) + } + }) + It("gives no pairing code", func() { + _, err := r.conns.StartPairing(ctx, alice, b.ID) + Expect(err).To(MatchError(connectors.ErrInvalid)) + }) + It("is not told a notification", func() { + raw, _ := st.GetBinding(ctx, b.ID) + raw.NotifyChat = "11" + Expect(st.PutBinding(ctx, raw)).To(Succeed()) + r.conns.Forward(ctx, notify.Notification{Principal: alice, Title: "t", Body: "b"}) + Consistently(func() int { return len(r.bot.answers()) }, "300ms").Should(BeZero()) + }) + It("still gives its token to a tool call", func() { + grant, err := r.conns.CredentialFor(ctx, alice, b.AddonID, "acme/widgets") + Expect(err).NotTo(HaveOccurred()) + Expect(grant.Secret).To(Equal(botToken)) + }) + }) +}) diff --git a/core/services/studio/connectorsvc.go b/core/services/studio/connectorsvc.go index 4c809e80c6..d0cbaefd87 100644 --- a/core/services/studio/connectorsvc.go +++ b/core/services/studio/connectorsvc.go @@ -312,6 +312,23 @@ func (c *Connectors) checkAgent(ctx context.Context, owner, agentID string) erro return nil } +// checkAgentIfAny is checkAgent for a binding that has an agent. The binding +// was validated first, so an empty ID here belongs to a kind that declares +// agentOptional. +func (c *Connectors) checkAgentIfAny(ctx context.Context, owner, agentID string) error { + if agentID == "" { + return nil + } + return c.checkAgent(ctx, owner, agentID) +} + +// agentOptional says whether the running kind of the binding lets it have no +// agent. A kind whose add-on does not run cannot say, so it needs an agent. +func (c *Connectors) agentOptional(ctx context.Context, b connectors.Binding) bool { + k := c.kindOf(ctx, b) + return k != nil && k.AgentOptional +} + func (c *Connectors) owned(ctx context.Context, user, id string) (connectors.Binding, error) { b, err := c.o.Store.GetBinding(ctx, id) if err != nil || b.Owner != principal(user) { @@ -333,7 +350,7 @@ func (c *Connectors) CreateConnector(ctx context.Context, user string, in Connec } b := connectors.Binding{ID: newID(), Owner: owner, Kind: kind, AddonID: addon} in.apply(&b) - if err := connectors.Validate(b); err != nil { + if err := connectors.ValidateKind(b, c.agentOptional(ctx, b)); err != nil { return ConnectorInfo{}, err } if err := c.checkOptions(ctx, b); err != nil { @@ -348,7 +365,7 @@ func (c *Connectors) CreateConnector(ctx context.Context, user string, in Connec if strings.TrimSpace(in.Token) == "" { return ConnectorInfo{}, connectorBad("the bot token is required") } - if err := c.checkAgent(ctx, owner, in.AgentID); err != nil { + if err := c.checkAgentIfAny(ctx, owner, in.AgentID); err != nil { return ConnectorInfo{}, err } // Before the write lock: the check asks the platform. @@ -401,7 +418,7 @@ func (c *Connectors) UpdateConnector(ctx context.Context, user, id string, in Co } b := old in.apply(&b) - if err := connectors.Validate(b); err != nil { + if err := connectors.ValidateKind(b, c.agentOptional(ctx, b)); err != nil { return ConnectorInfo{}, err } if err := c.checkOptions(ctx, b); err != nil { @@ -413,7 +430,7 @@ func (c *Connectors) UpdateConnector(ctx context.Context, user, id string, in Co if err := c.checkOwnerLimits(ctx, b, in); err != nil { return ConnectorInfo{}, err } - if err := c.checkAgent(ctx, old.Owner, in.AgentID); err != nil { + if err := c.checkAgentIfAny(ctx, old.Owner, in.AgentID); err != nil { return ConnectorInfo{}, err } if strings.TrimSpace(in.Token) != "" { @@ -441,6 +458,9 @@ func (c *Connectors) SetConnectorEnabled(ctx context.Context, user, id string, o if err != nil { return ConnectorInfo{}, err } + if on && b.CredentialsOnly() { + return ConnectorInfo{}, connectorBad("listening needs an agent: a connection without an agent only holds credentials") + } b.Enabled, b.Updated = on, c.o.Clock().UTC() if err := c.o.Store.PutBinding(ctx, b); err != nil { return ConnectorInfo{}, err diff --git a/docs/content/features/addons.md b/docs/content/features/addons.md index 94bf578b16..94f5a81ac2 100644 --- a/docs/content/features/addons.md +++ b/docs/content/features/addons.md @@ -2282,6 +2282,7 @@ The manifest declares the capability. It needs no tool and no `service` flag: | `kinds[].title`, `description`, `senderHint` | Plain text for people, with the bounds of the UI texts. | | `kinds[].delivery` | `pull`: the host asks for updates (long polling). `push`: the add-on calls the host operation `connector.inbound`. | | `kinds[].page` | Optional. The add-on page that adds and edits bindings; it must be a declared page. The Connectors page links to it. | +| `kinds[].agentOptional` | Optional, manifest `api` 2. `true` lets a binding of the kind have no agent. Such a binding is credentials only: it holds the token, the resource list and the write mode, and the host never delivers a message to it, polls for it, answers from it or authorizes a sender on it. The host refuses an empty agent together with listening, accepting senders, an allowlist, a notification chat, talk-back channels, permissions, pairing or trigger approval. Without the key every binding of the kind needs an agent. | | `kinds[].drafts` | Optional. `true` when the add-on serves the op `draft`. The host then shows the live answer of a direct conversation as a draft instead of a working message. | | `kinds[].previewMs` | Optional. The shortest time between two previews (edits or drafts) of one binding, 250 to 10000 ms. Without it the host waits one second. | | `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. | diff --git a/docs/content/features/chats-connectors.md b/docs/content/features/chats-connectors.md index 46de5a91c6..b8480146df 100644 --- a/docs/content/features/chats-connectors.md +++ b/docs/content/features/chats-connectors.md @@ -140,7 +140,9 @@ The GitHub add-on lets your [saved agents](/features/chats-agents/) read reposit 1. Install the **GitHub** add-on from the add-on gallery. 2. On GitHub, make a **fine-grained personal access token** for only the repositories you want to share. Give it read access to Contents, Issues and Pull requests. To let the agent write, also give read and write access to Issues and Pull requests. Classic tokens are not accepted. -3. On the add-on page, choose **New GitHub connection**. Pick an agent, paste the token and list the repositories, one `owner/name` per line (at most 50). Press **Test** to see whose token it is and which listed repositories it reaches. +3. On the add-on page, choose **New GitHub connection**. Paste the token and list the repositories, one `owner/name` per line (at most 50). Press **Test** to see whose token it is and which listed repositories it reaches. + +The agent field is optional. A connection without an agent shows **Credentials only** in the list: it holds the token, the repository list and the write mode for your chats, and it never answers a message. It cannot be switched on, take an allowlist, send notifications or hold permissions; the server refuses those with a message that asks for an agent. This is the manifest key `agentOptional` of the kind (see [Connector add-ons](/features/addons/)), which the GitHub kind declares; Telegram and Slack bindings always need an agent. Over the API, `agent_id` may be empty for such a kind and the connection reports `credentials_only: true`. The list is checked by LocalAI before the add-on is called, so an agent is refused any repository that is not on it, even if the token could reach it. The token is kept sealed and is never shown again, and no tool result contains it. With several connections, a call uses the first one that lists the repository. diff --git a/pkg/addons/contract/connector.go b/pkg/addons/contract/connector.go index 9a134c28c5..31fa978432 100644 --- a/pkg/addons/contract/connector.go +++ b/pkg/addons/contract/connector.go @@ -117,6 +117,11 @@ type ConnectorKind struct { // Page is the add-on page that adds and edits bindings of this kind. It // must be one of the pages of the add-on's UI. Page string `json:"page,omitempty"` + // AgentOptional lets a binding of this kind have no agent. Such a binding + // is credentials only: it holds the token, the resource list and the write + // mode for the tools of a chat, and the host never delivers a message to it + // or answers one from it. A kind without it needs an agent on every binding. + AgentOptional bool `json:"agentOptional,omitempty"` // Drafts tells that the add-on serves the op draft. The host then shows // a live answer in a direct conversation as a draft, not as a working // message that it edits. diff --git a/pkg/addons/contract/connector_test.go b/pkg/addons/contract/connector_test.go index 1eecf14fc8..48a9a8f457 100644 --- a/pkg/addons/contract/connector_test.go +++ b/pkg/addons/contract/connector_test.go @@ -2,6 +2,7 @@ package contract import ( + "encoding/json" "os" "strings" "unicode/utf8" @@ -37,6 +38,16 @@ var _ = Describe("the connector capability", func() { m.Capabilities.Connector.Kinds[0].PreviewMs = MinPreviewMs Expect(ValidateManifest(m, host())).To(Succeed()) }) + It("accepts agentOptional and keeps it through a JSON round trip", func() { + m.Capabilities.Connector.Kinds[0].AgentOptional = true + Expect(ValidateManifest(m, host())).To(Succeed()) + raw, err := json.Marshal(m.Capabilities.Connector) + Expect(err).NotTo(HaveOccurred()) + Expect(string(raw)).To(ContainSubstring(`"agentOptional":true`)) + m.Capabilities.Connector.Kinds[0].AgentOptional = false + raw, _ = json.Marshal(m.Capabilities.Connector) + Expect(string(raw)).NotTo(ContainSubstring("agentOptional")) + }) It("lets an add-on declare nothing but the connector", func() { m.Capabilities.UI = nil m.Capabilities.Connector.Kinds[0].Page = "" diff --git a/swagger/docs.go b/swagger/docs.go index 4187e0d736..6d6d786627 100644 --- a/swagger/docs.go +++ b/swagger/docs.go @@ -5382,7 +5382,7 @@ const docTemplate = `{ }, "/api/chats/draft/addons": { "get": { - "description": "What GET /api/chats/{id}/addons lists, for a chat that does not exist yet, as GET /api/chats/draft/tools takes it: the add-ons with their default switch and whether the chat could run commands at its first turn.", + "description": "What GET /api/chats/{id}/addons lists, for a chat that does not exist yet, as GET /api/chats/draft/tools takes it: the add-ons with their switch and whether the chat could run commands at its first turn. The draft's current switches may be passed (addons_off, builtins_off, builtins_on: comma-separated lists, as the create request takes them) so the answer is the one the chat would get: with the shell group off, commands is switched_off.", "tags": [ "chats" ], @@ -5399,11 +5399,29 @@ const docTemplate = `{ "description": "a saved agent of yours", "name": "agent_id", "in": "query" + }, + { + "type": "string", + "description": "comma-separated add-on IDs the draft switched off", + "name": "addons_off", + "in": "query" + }, + { + "type": "string", + "description": "comma-separated built-in groups the draft switched off", + "name": "builtins_off", + "in": "query" + }, + { + "type": "string", + "description": "comma-separated opt-in built-in groups the draft switched on", + "name": "builtins_on", + "in": "query" } ], "responses": { "200": { - "description": "The add-ons and their default switches", + "description": "The add-ons and the commands state, with the draft switches applied", "schema": { "$ref": "#/definitions/schema.ChatsAddonList" } @@ -18636,6 +18654,7 @@ const docTemplate = `{ "type": "string" }, "agent_id": { + "description": "AgentID is empty for a binding of a kind that declares agent_optional.", "type": "string" }, "allow": { @@ -18673,6 +18692,10 @@ const docTemplate = `{ "created": { "type": "string" }, + "credentials_only": { + "description": "CredentialsOnly is true for a binding without an agent: it holds the\ntoken, the resource list and the write mode, and never answers messages.", + "type": "boolean" + }, "enabled": { "type": "boolean" }, @@ -18783,6 +18806,13 @@ const docTemplate = `{ "type": "string" } }, + "acts_held": { + "description": "ActsHeld are the act.* names that apply to a run now: Acts within the\ngrants of the agent, plus act.files when the plan names a file it writes.\nReadOnly is true when a run can only read, and ReadOnlyWhy says why:\ndeclared (the proposal said so), binding (a connection in read only mode\nmade it), no_acts (it holds none) or agent (its agent holds none of them).", + "type": "array", + "items": { + "type": "string" + } + }, "agent_id": { "type": "string" }, @@ -18845,6 +18875,12 @@ const docTemplate = `{ "quiet_seconds": { "type": "integer" }, + "read_only": { + "type": "boolean" + }, + "read_only_why": { + "type": "string" + }, "schedule": { "type": "string" }, @@ -19638,6 +19674,7 @@ const docTemplate = `{ "type": "string" }, "agent_id": { + "description": "AgentID is empty for a binding of a kind that declares agent_optional.", "type": "string" }, "allow": { @@ -19675,6 +19712,10 @@ const docTemplate = `{ "created": { "type": "string" }, + "credentials_only": { + "description": "CredentialsOnly is true for a binding without an agent: it holds the\ntoken, the resource list and the write mode, and never answers messages.", + "type": "boolean" + }, "enabled": { "type": "boolean" }, @@ -19956,6 +19997,10 @@ const docTemplate = `{ "addon": { "type": "string" }, + "agent_optional": { + "description": "AgentOptional is true when a binding of this kind may have no agent. It\nthen only holds credentials (token, resource list, write mode).", + "type": "boolean" + }, "description": { "type": "string" }, @@ -22736,6 +22781,13 @@ const docTemplate = `{ "type": "string" } }, + "acts_held": { + "description": "ActsHeld are the act.* names that apply to a run now: Acts within the\ngrants of the agent, plus act.files when the plan names a file it writes.\nReadOnly is true when a run can only read, and ReadOnlyWhy says why:\ndeclared (the proposal said so), binding (a connection in read only mode\nmade it), no_acts (it holds none) or agent (its agent holds none of them).", + "type": "array", + "items": { + "type": "string" + } + }, "agent_id": { "type": "string" }, @@ -22795,6 +22847,12 @@ const docTemplate = `{ "quiet_seconds": { "type": "integer" }, + "read_only": { + "type": "boolean" + }, + "read_only_why": { + "type": "string" + }, "schedule": { "type": "string" }, diff --git a/swagger/swagger.json b/swagger/swagger.json index 890b85b70a..ab7f19abbc 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -5379,7 +5379,7 @@ }, "/api/chats/draft/addons": { "get": { - "description": "What GET /api/chats/{id}/addons lists, for a chat that does not exist yet, as GET /api/chats/draft/tools takes it: the add-ons with their default switch and whether the chat could run commands at its first turn.", + "description": "What GET /api/chats/{id}/addons lists, for a chat that does not exist yet, as GET /api/chats/draft/tools takes it: the add-ons with their switch and whether the chat could run commands at its first turn. The draft's current switches may be passed (addons_off, builtins_off, builtins_on: comma-separated lists, as the create request takes them) so the answer is the one the chat would get: with the shell group off, commands is switched_off.", "tags": [ "chats" ], @@ -5396,11 +5396,29 @@ "description": "a saved agent of yours", "name": "agent_id", "in": "query" + }, + { + "type": "string", + "description": "comma-separated add-on IDs the draft switched off", + "name": "addons_off", + "in": "query" + }, + { + "type": "string", + "description": "comma-separated built-in groups the draft switched off", + "name": "builtins_off", + "in": "query" + }, + { + "type": "string", + "description": "comma-separated opt-in built-in groups the draft switched on", + "name": "builtins_on", + "in": "query" } ], "responses": { "200": { - "description": "The add-ons and their default switches", + "description": "The add-ons and the commands state, with the draft switches applied", "schema": { "$ref": "#/definitions/schema.ChatsAddonList" } @@ -18633,6 +18651,7 @@ "type": "string" }, "agent_id": { + "description": "AgentID is empty for a binding of a kind that declares agent_optional.", "type": "string" }, "allow": { @@ -18670,6 +18689,10 @@ "created": { "type": "string" }, + "credentials_only": { + "description": "CredentialsOnly is true for a binding without an agent: it holds the\ntoken, the resource list and the write mode, and never answers messages.", + "type": "boolean" + }, "enabled": { "type": "boolean" }, @@ -18780,6 +18803,13 @@ "type": "string" } }, + "acts_held": { + "description": "ActsHeld are the act.* names that apply to a run now: Acts within the\ngrants of the agent, plus act.files when the plan names a file it writes.\nReadOnly is true when a run can only read, and ReadOnlyWhy says why:\ndeclared (the proposal said so), binding (a connection in read only mode\nmade it), no_acts (it holds none) or agent (its agent holds none of them).", + "type": "array", + "items": { + "type": "string" + } + }, "agent_id": { "type": "string" }, @@ -18842,6 +18872,12 @@ "quiet_seconds": { "type": "integer" }, + "read_only": { + "type": "boolean" + }, + "read_only_why": { + "type": "string" + }, "schedule": { "type": "string" }, @@ -19635,6 +19671,7 @@ "type": "string" }, "agent_id": { + "description": "AgentID is empty for a binding of a kind that declares agent_optional.", "type": "string" }, "allow": { @@ -19672,6 +19709,10 @@ "created": { "type": "string" }, + "credentials_only": { + "description": "CredentialsOnly is true for a binding without an agent: it holds the\ntoken, the resource list and the write mode, and never answers messages.", + "type": "boolean" + }, "enabled": { "type": "boolean" }, @@ -19953,6 +19994,10 @@ "addon": { "type": "string" }, + "agent_optional": { + "description": "AgentOptional is true when a binding of this kind may have no agent. It\nthen only holds credentials (token, resource list, write mode).", + "type": "boolean" + }, "description": { "type": "string" }, @@ -22733,6 +22778,13 @@ "type": "string" } }, + "acts_held": { + "description": "ActsHeld are the act.* names that apply to a run now: Acts within the\ngrants of the agent, plus act.files when the plan names a file it writes.\nReadOnly is true when a run can only read, and ReadOnlyWhy says why:\ndeclared (the proposal said so), binding (a connection in read only mode\nmade it), no_acts (it holds none) or agent (its agent holds none of them).", + "type": "array", + "items": { + "type": "string" + } + }, "agent_id": { "type": "string" }, @@ -22792,6 +22844,12 @@ "quiet_seconds": { "type": "integer" }, + "read_only": { + "type": "boolean" + }, + "read_only_why": { + "type": "string" + }, "schedule": { "type": "string" }, diff --git a/swagger/swagger.yaml b/swagger/swagger.yaml index 79827e224f..d3f6b33ea9 100644 --- a/swagger/swagger.yaml +++ b/swagger/swagger.yaml @@ -3005,6 +3005,7 @@ definitions: addon: type: string agent_id: + description: AgentID is empty for a binding of a kind that declares agent_optional. type: string allow: items: @@ -3040,6 +3041,11 @@ definitions: type: string created: type: string + credentials_only: + description: |- + CredentialsOnly is true for a binding without an agent: it holds the + token, the resource list and the write mode, and never answers messages. + type: boolean enabled: type: boolean file_receive_max_bytes: @@ -3139,6 +3145,16 @@ definitions: items: type: string type: array + acts_held: + description: |- + ActsHeld are the act.* names that apply to a run now: Acts within the + grants of the agent, plus act.files when the plan names a file it writes. + ReadOnly is true when a run can only read, and ReadOnlyWhy says why: + declared (the proposal said so), binding (a connection in read only mode + made it), no_acts (it holds none) or agent (its agent holds none of them). + items: + type: string + type: array agent_id: type: string cost: @@ -3183,6 +3199,10 @@ definitions: type: string quiet_seconds: type: integer + read_only: + type: boolean + read_only_why: + type: string schedule: type: string source_chat_id: @@ -3840,6 +3860,7 @@ definitions: addon: type: string agent_id: + description: AgentID is empty for a binding of a kind that declares agent_optional. type: string allow: items: @@ -3875,6 +3896,11 @@ definitions: type: string created: type: string + credentials_only: + description: |- + CredentialsOnly is true for a binding without an agent: it holds the + token, the resource list and the write mode, and never answers messages. + type: boolean enabled: type: boolean file_receive_max_bytes: @@ -4091,6 +4117,11 @@ definitions: properties: addon: type: string + agent_optional: + description: |- + AgentOptional is true when a binding of this kind may have no agent. It + then only holds credentials (token, resource list, write mode). + type: boolean description: type: string files: @@ -6161,6 +6192,16 @@ definitions: items: type: string type: array + acts_held: + description: |- + ActsHeld are the act.* names that apply to a run now: Acts within the + grants of the agent, plus act.files when the plan names a file it writes. + ReadOnly is true when a run can only read, and ReadOnlyWhy says why: + declared (the proposal said so), binding (a connection in read only mode + made it), no_acts (it holds none) or agent (its agent holds none of them). + items: + type: string + type: array agent_id: type: string cost: @@ -6203,6 +6244,10 @@ definitions: type: string quiet_seconds: type: integer + read_only: + type: boolean + read_only_why: + type: string schedule: type: string source_chat_id: @@ -15687,7 +15732,10 @@ paths: get: description: 'What GET /api/chats/{id}/addons lists, for a chat that does not exist yet, as GET /api/chats/draft/tools takes it: the add-ons with their - default switch and whether the chat could run commands at its first turn.' + switch and whether the chat could run commands at its first turn. The draft''s + current switches may be passed (addons_off, builtins_off, builtins_on: comma-separated + lists, as the create request takes them) so the answer is the one the chat + would get: with the shell group off, commands is switched_off.' parameters: - description: the model of the new chat in: query @@ -15697,9 +15745,22 @@ paths: in: query name: agent_id type: string + - description: comma-separated add-on IDs the draft switched off + in: query + name: addons_off + type: string + - description: comma-separated built-in groups the draft switched off + in: query + name: builtins_off + type: string + - description: comma-separated opt-in built-in groups the draft switched on + in: query + name: builtins_on + type: string responses: "200": - description: The add-ons and their default switches + description: The add-ons and the commands state, with the draft switches + applied schema: $ref: '#/definitions/schema.ChatsAddonList' "400":