feat(connectors): let the owner choose how chat triggers are approved

A connector binding gets an approval setting: ask, auto or auto_readonly,
with four limits for the two automatic modes (live triggers, minutes
between runs of a recurring trigger, runs a day, lifetime in days). The
setting is generic, so every connector add-on can show it, and the
shared connector page of the add-ons now draws it. A binding that sets
nothing asks, as before.

This change stores, validates and exposes the setting. The next change
acts on it.

Assisted-by: Claude:claude-sonnet-5-5 go swag node
This commit is contained in:
Ettore Di Giacinto committed 2026-11-03 14:59:30 +00:00
1 parent 24c837e453
commit eaea737b53
17 files changed
+477 -6

No files matched your search

@@ -4,6 +4,7 @@
// files of a turn go out. The words are the same on every platform, so they
// live here as defaults; an add-on can still change one in its namespace.
import { Field, Hint, Select, Switch, TextInput } from './controls'
import { APPROVAL_TEXT } from './ApprovalFields'
import { FILES_TEXT } from './FilesFields'
import { CONVERSATIONS, MAX_WINDOW_MINUTES, REPLY_MODES } from './connector'
@@ -44,7 +45,7 @@ export const ANSWER_TEXT = {
// text reads a string of the add-on's namespace, with the default above.
export function answerT(t) {
return (key, opts = {}) => t(key, { defaultValue: ANSWER_TEXT[key] ?? FILES_TEXT[key], ...opts })
return (key, opts = {}) => t(key, { defaultValue: ANSWER_TEXT[key] ?? FILES_TEXT[key] ?? APPROVAL_TEXT[key], ...opts })
}
// answerSummary says in a few words how a binding answers, for its row.
@@ -0,0 +1,49 @@
/* eslint-disable no-unused-vars -- components used only inside JSX look unused to this config, which has no eslint-plugin-react */
// How the triggers that the agent proposes in a chat are approved. The same on
// every platform, so the words live here as defaults, like the other shared
// fields. The server enforces the limits; the page only bounds what is typed.
import { Field, RadioCards, TextInput } from './controls'
import { APPROVAL_LIMITS, APPROVAL_MODES, approvalProblem } from './connector'
export const APPROVAL_TEXT = {
'approval.title': 'Timers and triggers from the chat',
'approval.help': 'When the agent sets up something that runs later ("in 10 minutes", "every day"), every run is one model run.',
'approval.mode.ask': 'Ask me: the bot proposes it in the chat and the person answers yes or no',
'approval.mode.auto': 'Set it up at once and tell the person how to cancel it',
'approval.mode.auto_readonly': 'At once when the runs only read and report; otherwise ask',
'approval.limitsHelp': 'These limits bound what is set up without asking. Leave a field empty for its default. Anything over a limit is asked instead.',
'approval.max_pending': 'Most timers and triggers at once',
'approval.min_gap_minutes': 'Fewest minutes between runs of a recurring trigger',
'approval.max_runs_per_day': 'Most runs a day, all together',
'approval.max_lifetime_days': 'Longest lifetime, in days',
'approval.invalid': 'Use a whole number from 1 to {{max}}, or leave it empty.',
'approval.default': 'Default: {{value}}.',
'approval.summary': 'Triggers: {{mode}}',
'approval.short.ask': 'asks first',
'approval.short.auto': 'set up at once',
'approval.short.auto_readonly': 'at once if read-only',
}
export default function ApprovalFields({ form, setForm, s }) {
const a = form.approval
const set = (patch) => setForm({ ...form, approval: { ...a, ...patch } })
const auto = a.mode !== 'ask'
const bad = approvalProblem(a)
return (
<fieldset className="cn-group" data-testid="connector-approval">
<legend>{s('approval.title')}</legend>
<p className="dk-hint">{s('approval.help')}</p>
<RadioCards name="approval" legend={s('approval.title')} value={a.mode} onChange={(mode) => set({ mode })}
options={APPROVAL_MODES.map((m) => ({ value: m, label: s(`approval.mode.${m}`) }))} />
{auto && (<>
<p className="dk-hint">{s('approval.limitsHelp')}</p>
{APPROVAL_LIMITS.map(({ key, max, fallback }) => (
<Field key={key} label={s(`approval.${key}`)} hint={s('approval.default', { value: fallback })} error={bad === key ? s('approval.invalid', { max }) : undefined}>
{(p) => <TextInput {...p} type="number" inputMode="numeric" min="1" max={max} step="1" data-testid={`connector-approval-${key}`}
value={a[key]} onChange={(e) => set({ [key]: e.target.value })} />}
</Field>
))}
</>)}
</fieldset>
)
}
@@ -9,6 +9,7 @@ import { useTranslation } from 'react-i18next'
import ConfirmDialog from '../../host-compat/src/components/ConfirmDialog'
import Icon from '../../host-compat/src/components/Icon'
import AnswerFields, { answerSummary, answerT } from './AnswerFields'
import ApprovalFields from './ApprovalFields'
import FilesFields from './FilesFields'
import { Check, Field, Notice, RadioCards, Select, Switch, TextInput } from './controls'
import NameList from './NameList'
@@ -215,6 +216,7 @@ export default function ConnectorPage({ kind, ns, testId, TokenFields = SingleTo
</Field>
<AnswerFields form={form} setForm={setForm} t={t} kindInfo={kindInfo} />
<FilesFields form={form} setForm={setForm} kind={kindFiles} saved={form.files} s={answerT(t)} />
<ApprovalFields form={form} setForm={setForm} s={answerT(t)} />
</>)}
<div className="cn-actions">
<button type="submit" className="dk-btn dk-btn--primary" aria-busy={busy ? 'true' : undefined} disabled={busy || !canSave(form, confirmed, Boolean(tokenOf(form).trim()), kindFiles) || (extraValid ? !extraValid(form) : false)}>{t('save')}</button>
@@ -6,12 +6,56 @@ export const REPLY_MODES = ['off', 'progress', 'stream']
export const CONVERSATIONS = ['sender', 'chat', 'window', 'thread']
export const MAX_WINDOW_MINUTES = 7 * 24 * 60
// How the triggers that the agent proposes in a chat are approved, and the
// limits of the two modes that make one without asking. The server has the
// same names, defaults and bounds (connectors.Approval).
export const APPROVAL_MODES = ['ask', 'auto', 'auto_readonly']
export const APPROVAL_LIMITS = [
{ key: 'max_pending', max: 50, fallback: 5 },
{ key: 'min_gap_minutes', max: 1440, fallback: 60 },
{ key: 'max_runs_per_day', max: 200, fallback: 10 },
{ key: 'max_lifetime_days', max: 365, fallback: 30 },
]
export function emptyApproval() {
return { mode: 'ask', max_pending: '', min_gap_minutes: '', max_runs_per_day: '', max_lifetime_days: '' }
}
// The approval of a saved binding as a form: a limit of zero is the default
// and shows empty.
export function approvalForm(a) {
const f = { ...emptyApproval(), mode: a?.mode || 'ask' }
for (const { key } of APPROVAL_LIMITS) f[key] = a?.[key] > 0 ? String(a[key]) : ''
return f
}
// Why a typed limit cannot be saved: not a whole number from 1 to its bound.
// Empty is fine (the default).
export function approvalProblem(a) {
if (!a) return ''
if (!APPROVAL_MODES.includes(a.mode)) return 'mode'
for (const { key, max } of APPROVAL_LIMITS) {
const v = String(a[key] ?? '').trim()
if (v === '') continue
const n = Number(v)
if (!Number.isInteger(n) || n < 1 || n > max) return key
}
return ''
}
export function approvalPayload(a) {
const body = { mode: APPROVAL_MODES.includes(a?.mode) ? a.mode : 'ask' }
for (const { key } of APPROVAL_LIMITS) body[key] = Number(String(a?.[key] ?? '').trim()) || 0
return body
}
export function emptyConnector() {
return {
id: '', name: '', token: '', agent_id: '', enabled: true, accept_any: false, allow: [], notify_chat: '',
reply_mode: 'progress', conversation: 'sender', window_minutes: 60, send_files: true,
options: {},
file_receive_mib: '', file_send_mib: '',
approval: emptyApproval(),
}
}
@@ -33,6 +77,7 @@ export function connectorForm(c) {
return {
...emptyConnector(), ...c, token: '',
file_receive_mib: bytesToMib(c.file_receive_max_bytes), file_send_mib: bytesToMib(c.file_send_max_bytes),
approval: approvalForm(c.approval),
}
}
@@ -86,6 +131,7 @@ export function connectorPayload(form, kind, token = form.token, extra = {}) {
options: Object.fromEntries(Object.entries(form.options || {}).filter(([, v]) => v)),
file_receive_max_bytes: mibToBytes(form.file_receive_mib),
file_send_max_bytes: mibToBytes(form.file_send_mib),
approval: approvalPayload(form.approval),
}
if (body.conversation === 'window') body.window_minutes = Number(form.window_minutes) || 0
const secret = (token || '').trim()
@@ -113,6 +159,7 @@ export function canSave(form, confirmed, hasToken = Boolean((form.token || '').t
if (!(form.name || '').trim() || !form.agent_id) return false
if (!form.id && !hasToken) return false
if (form.accept_any && !confirmed) return false
if (approvalProblem(form.approval)) return false
if (fileLimitProblem(form.file_receive_mib, files?.receive?.allowed_bytes) || fileLimitProblem(form.file_send_mib, files?.send?.allowed_bytes)) return false
if (form.conversation === 'window') {
const m = Number(form.window_minutes)
+25 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { bytesToMib, canSave, connectorForm, connectorPayload, emptyConnector, fileLimitProblem, formatBytes, mibToBytes, modeText, previewLimit, senderLine } from '../../../internal/connector-ui/src/connector.js'
import { approvalForm, approvalPayload, approvalProblem, bytesToMib, canSave, connectorForm, connectorPayload, emptyConnector, fileLimitProblem, formatBytes, mibToBytes, modeText, previewLimit, senderLine } from '../../../internal/connector-ui/src/connector.js'
test('chat connectors: the token goes only when it was typed', () => {
const create = connectorPayload({ ...emptyConnector(), name: ' Bot ', token: ' 1:abc ', agent_id: 'a1', allow: ['11', ' 11', '@ada_l', ''] }, 'telegram')
@@ -132,3 +132,27 @@ test('chat connectors: sizes are written in KiB and MiB', () => {
test('chat connectors: a saved limit reads back to the same number of bytes', () => {
for (const bytes of [1, 1000, 100 * 1024, 256 * 1024, 5 * 1024 * 1024 + 7]) assert.equal(mibToBytes(bytesToMib(bytes)), bytes)
})
test('chat connectors: the approval of chat triggers asks by default and sends its limits as numbers', () => {
const base = { ...emptyConnector(), name: 'B', agent_id: 'a' }
assert.deepEqual(connectorPayload(base, 'telegram').approval, { mode: 'ask', max_pending: 0, min_gap_minutes: 0, max_runs_per_day: 0, max_lifetime_days: 0 })
const auto = connectorPayload({ ...base, approval: { mode: 'auto', max_pending: '3', min_gap_minutes: '', max_runs_per_day: ' 8 ', max_lifetime_days: '14' } }, 'telegram')
assert.deepEqual(auto.approval, { mode: 'auto', max_pending: 3, min_gap_minutes: 0, max_runs_per_day: 8, max_lifetime_days: 14 })
assert.equal(connectorPayload({ ...base, approval: { mode: 'sometimes' } }, 'telegram').approval.mode, 'ask', 'an unknown mode falls back to ask')
})
test('chat connectors: a saved approval reads back, with a zero limit shown as empty', () => {
assert.deepEqual(approvalForm({ mode: 'auto_readonly', max_pending: 4, min_gap_minutes: 0 }), { mode: 'auto_readonly', max_pending: '4', min_gap_minutes: '', max_runs_per_day: '', max_lifetime_days: '' })
assert.equal(connectorForm({ id: 'b' }).approval.mode, 'ask')
})
test('chat connectors: an approval limit is a whole number within its bound, or empty', () => {
assert.equal(approvalProblem({ mode: 'auto', max_pending: '5' }), '')
assert.equal(approvalProblem({ mode: 'auto', max_pending: '51' }), 'max_pending')
assert.equal(approvalProblem({ mode: 'auto', max_runs_per_day: '2.5' }), 'max_runs_per_day')
assert.equal(approvalProblem({ mode: 'auto', max_lifetime_days: '0' }), 'max_lifetime_days')
assert.equal(approvalProblem({ mode: 'bad' }), 'mode')
const ok = { ...emptyConnector(), name: 'B', agent_id: 'a', token: '1:x' }
assert.equal(canSave({ ...ok, approval: { mode: 'auto', max_pending: '500' } }, false), false)
assert.equal(canSave(ok, false), true)
})
+15 -2
View File
@@ -25,7 +25,20 @@ func connectorOut(i studiosvc.ConnectorInfo) schema.ChatsConnector {
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, Resources: connectors.ResourceKeys(i.Resources), Channels: channelsOut(i.Channels), WriteMode: i.Writes()}
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)}
}
func approvalOut(a connectors.Approval) schema.ChatsConnectorApproval {
return schema.ChatsConnectorApproval{Mode: a.ModeOrDefault(), MaxPending: a.MaxPending, MinGapMinutes: a.MinGapMinutes,
MaxRunsPerDay: a.MaxRunsPerDay, MaxLifetimeDays: a.MaxLifetimeDays}
}
func approvalIn(a *schema.ChatsConnectorApproval) *connectors.Approval {
if a == nil {
return nil
}
return &connectors.Approval{Mode: a.Mode, MaxPending: a.MaxPending, MinGapMinutes: a.MinGapMinutes,
MaxRunsPerDay: a.MaxRunsPerDay, MaxLifetimeDays: a.MaxLifetimeDays}
}
func capsOut(in []string) []string {
@@ -116,7 +129,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, Caps: req.Caps, Grants: grantsIn(req.Grants), Resources: req.Resources, Channels: channelsIn(req.Channels), WriteMode: req.WriteMode}
FileReceiveMax: req.FileReceiveMaxBytes, FileSendMax: req.FileSendMaxBytes, Caps: req.Caps, Grants: grantsIn(req.Grants), Resources: req.Resources, Channels: channelsIn(req.Channels), WriteMode: req.WriteMode, Approval: approvalIn(req.Approval)}
}
var connectorRules = []errRule{
@@ -263,6 +263,25 @@ var _ = Describe("Chats connectors endpoints", func() {
Expect(rec.Code).To(Equal(http.StatusOK), "an update that keeps the token is not checked: %s", rec.Body.String())
})
It("keeps the approval of chat triggers: ask by default, kept on an update that omits it, refused when out of bounds", func() {
ag := agent("alice", "Approval", "test-model")
rec := create("alice", map[string]any{"name": "Approval bot", "token": specToken, "agent_id": ag})
Expect(rec.Code).To(Equal(http.StatusCreated), rec.Body.String())
b := decodeJSON(rec)
id := b["id"].(string)
Expect(b).To(HaveKeyWithValue("approval", HaveKeyWithValue("mode", "ask")))
rec = s.do("alice", http.MethodPut, "/api/chats/connectors/"+id, map[string]any{"name": "Approval bot", "agent_id": ag,
"approval": map[string]any{"mode": "auto", "max_pending": 3, "max_runs_per_day": 6}})
Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String())
Expect(decodeJSON(rec)["approval"]).To(And(HaveKeyWithValue("mode", "auto"), HaveKeyWithValue("max_pending", BeNumerically("==", 3)), HaveKeyWithValue("max_runs_per_day", BeNumerically("==", 6))))
rec = s.do("alice", http.MethodPut, "/api/chats/connectors/"+id, map[string]any{"name": "Approval bot", "agent_id": ag})
Expect(decodeJSON(rec)["approval"]).To(HaveKeyWithValue("mode", "auto"))
for _, bad := range []map[string]any{{"mode": "always"}, {"mode": "auto", "max_pending": 51}, {"mode": "auto", "max_lifetime_days": -1}} {
rec = s.do("alice", http.MethodPut, "/api/chats/connectors/"+id, map[string]any{"name": "Approval bot", "agent_id": ag, "approval": bad})
Expect(rec.Code).To(Equal(http.StatusBadRequest), 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"}})
+23
View File
@@ -1256,6 +1256,25 @@ type ChatsConnector struct {
// Paired are the senders you proved with a pairing code. A pairing ends
// at expires_at.
Paired []ChatsConnectorPaired `json:"paired"`
// Approval is what the host does with a trigger that the agent proposes
// from a chat of this binding.
Approval ChatsConnectorApproval `json:"approval"`
}
// ChatsConnectorApproval is the owner's choice for the triggers that the agent
// proposes from a chat of a binding. mode is ask (the person answers the
// proposal in the chat in plain words; the default), auto (the trigger is
// made at once and the person is told how to cancel it) or auto_readonly (a
// trigger whose runs only read and report is made at once; any other
// proposal asks). The limits bound the modes that make a trigger without
// asking; a zero limit means its default (5 live triggers, one hour between
// runs of a recurring trigger, 10 runs a day, 30 days).
type ChatsConnectorApproval struct {
Mode string `json:"mode"`
MaxPending int `json:"max_pending"`
MinGapMinutes int `json:"min_gap_minutes"`
MaxRunsPerDay int `json:"max_runs_per_day"`
MaxLifetimeDays int `json:"max_lifetime_days"`
}
// ChatsConnectorRoleGrant gives the capabilities caps to the senders that the
@@ -1464,6 +1483,10 @@ type ChatsConnectorWrite struct {
// WriteMode is off, propose or auto (see the connector record). Absent
// means off for a new binding and unchanged for an update.
WriteMode *string `json:"write_mode,omitempty" enums:"off,propose,auto"`
// Approval sets how triggers that the agent proposes from a chat of the
// binding are approved, and the limits of the automatic modes. Absent
// means ask for a new binding and unchanged for an update.
Approval *ChatsConnectorApproval `json:"approval,omitempty"`
}
// ChatsConnectorChannel is a chat of the platform where the agent may write.
+108
View File
@@ -0,0 +1,108 @@
package connectors
import (
"fmt"
)
// The approval modes of a binding: what the host does with a trigger that the
// agent proposes from a chat of the binding. The names are the same for every
// connector add-on.
const (
// ApprovalAsk posts the proposal in the chat and waits for a plain answer
// from the person. It is the default.
ApprovalAsk = "ask"
// ApprovalAuto creates the trigger at once and tells the person how to
// cancel it, within the limits of the owner.
ApprovalAuto = "auto"
// ApprovalAutoReadonly creates at once a trigger whose runs only read and
// report; any other proposal still asks.
ApprovalAutoReadonly = "auto_readonly"
)
// Defaults and bounds of the limits that apply when a mode creates a trigger
// without asking. A zero field of Approval means the default.
const (
DefaultMaxPending = 5
MaxMaxPending = 50
DefaultMinGapMinutes = 60
MaxMinGapMinutes = 24 * 60
DefaultMaxRunsPerDay = 10
MaxMaxRunsPerDay = 200
DefaultMaxLifetimeDays = 30
MaxMaxLifetimeDays = 365
)
// Approval is the choice of the owner for the triggers that an agent proposes
// from a chat of one binding, and the limits that bound the modes which create
// a trigger without asking. The zero value is ApprovalAsk with the default
// limits.
type Approval struct {
// Mode is one of the Approval constants; empty means ApprovalAsk.
Mode string `json:"mode,omitempty"`
// MaxPending is how many triggers made without asking may be live at once
// for the binding, timers and recurring ones together.
MaxPending int `json:"max_pending,omitempty"`
// MinGapMinutes is the shortest time between two runs of a recurring
// trigger made without asking.
MinGapMinutes int `json:"min_gap_minutes,omitempty"`
// MaxRunsPerDay is how many runs a day all the live triggers made without
// asking may have together.
MaxRunsPerDay int `json:"max_runs_per_day,omitempty"`
// MaxLifetimeDays is how far ahead a trigger made without asking may run,
// and how long a recurring one lives before it is removed.
MaxLifetimeDays int `json:"max_lifetime_days,omitempty"`
}
// ModeOrDefault gives Mode, or ApprovalAsk when it is empty.
func (a Approval) ModeOrDefault() string {
if a.Mode == "" {
return ApprovalAsk
}
return a.Mode
}
// Auto says whether the mode may create a trigger without asking.
func (a Approval) Auto() bool {
m := a.ModeOrDefault()
return m == ApprovalAuto || m == ApprovalAutoReadonly
}
// Limits are the limits of an Approval with the defaults filled in.
type Limits struct {
MaxPending, MinGapMinutes, MaxRunsPerDay, MaxLifetimeDays int
}
// Limits gives the limits with the default for each field that is zero.
func (a Approval) Limits() Limits {
pick := func(v, def int) int {
if v <= 0 {
return def
}
return v
}
return Limits{
MaxPending: pick(a.MaxPending, DefaultMaxPending),
MinGapMinutes: pick(a.MinGapMinutes, DefaultMinGapMinutes),
MaxRunsPerDay: pick(a.MaxRunsPerDay, DefaultMaxRunsPerDay),
MaxLifetimeDays: pick(a.MaxLifetimeDays, DefaultMaxLifetimeDays),
}
}
// Validate checks the mode and the bounds of the limits.
func (a Approval) Validate() error {
switch a.Mode {
case "", ApprovalAsk, ApprovalAuto, ApprovalAutoReadonly:
default:
return fmt.Errorf("the approval mode must be %s, %s or %s", ApprovalAsk, ApprovalAuto, ApprovalAutoReadonly)
}
for _, f := range []struct {
name string
v, max int
}{{"max pending", a.MaxPending, MaxMaxPending}, {"minimum gap in minutes", a.MinGapMinutes, MaxMinGapMinutes},
{"runs per day", a.MaxRunsPerDay, MaxMaxRunsPerDay}, {"lifetime in days", a.MaxLifetimeDays, MaxMaxLifetimeDays}} {
if f.v < 0 || f.v > f.max {
return fmt.Errorf("the approval limit %s must be 0 to %d", f.name, f.max)
}
}
return nil
}
@@ -0,0 +1,37 @@
package connectors_test
import (
"github.com/mudler/LocalAI/core/services/studio/connectors"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("the approval of a binding", func() {
It("asks by default, with the default limits", func() {
var a connectors.Approval
Expect(a.ModeOrDefault()).To(Equal(connectors.ApprovalAsk))
Expect(a.Auto()).To(BeFalse())
Expect(a.Limits()).To(Equal(connectors.Limits{MaxPending: 5, MinGapMinutes: 60, MaxRunsPerDay: 10, MaxLifetimeDays: 30}))
Expect(a.Validate()).To(Succeed())
})
It("knows which modes create without asking", func() {
Expect(connectors.Approval{Mode: connectors.ApprovalAuto}.Auto()).To(BeTrue())
Expect(connectors.Approval{Mode: connectors.ApprovalAutoReadonly}.Auto()).To(BeTrue())
Expect(connectors.Approval{Mode: connectors.ApprovalAsk}.Auto()).To(BeFalse())
})
It("keeps the limits that the owner set", func() {
a := connectors.Approval{Mode: connectors.ApprovalAuto, MaxPending: 2, MinGapMinutes: 120, MaxRunsPerDay: 4, MaxLifetimeDays: 7}
Expect(a.Limits()).To(Equal(connectors.Limits{MaxPending: 2, MinGapMinutes: 120, MaxRunsPerDay: 4, MaxLifetimeDays: 7}))
})
It("refuses an unknown mode and a limit out of bounds", func() {
Expect(connectors.Approval{Mode: "always"}.Validate()).NotTo(Succeed())
Expect(connectors.Approval{MaxPending: -1}.Validate()).NotTo(Succeed())
Expect(connectors.Approval{MaxPending: connectors.MaxMaxPending + 1}.Validate()).NotTo(Succeed())
Expect(connectors.Approval{MaxLifetimeDays: connectors.MaxMaxLifetimeDays + 1}.Validate()).NotTo(Succeed())
Expect(connectors.Approval{MaxRunsPerDay: connectors.MaxMaxRunsPerDay + 1}.Validate()).NotTo(Succeed())
Expect(connectors.Approval{MinGapMinutes: connectors.MaxMinGapMinutes + 1}.Validate()).NotTo(Succeed())
})
})
@@ -160,6 +160,19 @@ func Run(newStore NewStore) {
Expect(bare.Paired).To(BeEmpty())
})
It("keeps the approval mode and limits", func() {
s := newStore()
b := binding("b1", "u1", "Bot")
b.Approval = connectors.Approval{Mode: connectors.ApprovalAuto, MaxPending: 3, MaxRunsPerDay: 7}
Expect(s.PutBinding(ctx, b)).To(Succeed())
got, err := s.GetBinding(ctx, "b1")
Expect(err).NotTo(HaveOccurred())
Expect(got.Approval).To(Equal(b.Approval))
Expect(s.PutBinding(ctx, binding("b2", "u1", "Bare"))).To(Succeed())
bare, _ := s.GetBinding(ctx, "b2")
Expect(bare.Approval.ModeOrDefault()).To(Equal(connectors.ApprovalAsk))
})
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())
+4 -2
View File
@@ -39,6 +39,8 @@ type bindingRow struct {
Channels string
// WriteMode is added by AutoMigrate; a row stored before it reads as off.
WriteMode string `gorm:"size:16"`
// Approval is JSON, added by AutoMigrate; a row stored before it asks.
Approval string
// The columns of the file limits are added by AutoMigrate; a row stored
// before them has no limit of the owner.
FileReceiveMax int64
@@ -128,7 +130,7 @@ func (r bindingRow) binding() (Binding, error) {
for _, f := range []struct {
raw string
dst any
}{{r.Caps, &b.Caps}, {r.Grants, &b.Grants}, {r.Paired, &b.Paired}, {r.Resources, &b.Resources}, {r.Channels, &b.Channels}} {
}{{r.Caps, &b.Caps}, {r.Grants, &b.Grants}, {r.Paired, &b.Paired}, {r.Resources, &b.Resources}, {r.Channels, &b.Channels}, {r.Approval, &b.Approval}} {
if f.raw == "" {
continue
}
@@ -157,7 +159,7 @@ func (s *gormStore) PutBinding(ctx context.Context, b Binding) error {
raw, _ := json.Marshal(v)
return string(raw)
}
return s.db.WithContext(ctx).Save(&bindingRow{WriteMode: b.WriteMode, Resources: enc(b.Resources, len(b.Resources) == 0), Channels: enc(b.Channels, len(b.Channels) == 0), 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,
return s.db.WithContext(ctx).Save(&bindingRow{Approval: enc(b.Approval, b.Approval == Approval{}), WriteMode: b.WriteMode, Resources: enc(b.Resources, len(b.Resources) == 0), Channels: enc(b.Channels, len(b.Channels) == 0), 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
+7
View File
@@ -192,6 +192,10 @@ type Binding struct {
// WriteMode is one of WriteOff, WritePropose and WriteAuto. Empty means
// WriteOff.
WriteMode string
// Approval is what the host does with a trigger that the agent proposes
// from a chat of the binding, and the limits of the modes that create one
// without asking. The zero value asks.
Approval Approval
// Token is the bot token. In a Store it is sealed bytes; the Store returns
// what it was given.
Token []byte
@@ -359,6 +363,9 @@ func Validate(b Binding) error {
if !ValidWriteMode(b.WriteMode) {
return bad("the write mode must be %s, %s or %s", WriteOff, WritePropose, WriteAuto)
}
if err := b.Approval.Validate(); err != nil {
return bad("%v", err)
}
if len(b.Options) > MaxOptions {
return bad("a binding has at most %d options", MaxOptions)
}
+6
View File
@@ -62,6 +62,9 @@ type ConnectorInput struct {
// WriteMode is one of connectors.WriteOff, WritePropose and WriteAuto. Nil:
// new binding off, update unchanged.
WriteMode *string
// Approval is the owner's choice for the triggers the agent proposes from
// a chat of the binding. Nil: new binding asks, update unchanged.
Approval *connectors.Approval
}
// apply sets the fields of the input that an owner writes on a binding.
@@ -98,6 +101,9 @@ func (in ConnectorInput) apply(b *connectors.Binding) {
if in.WriteMode != nil {
b.WriteMode = *in.WriteMode
}
if in.Approval != nil {
b.Approval = *in.Approval
}
}
// ErrTokenRefused means the platform refused the bot token of a binding.
+44
View File
@@ -18642,6 +18642,14 @@ const docTemplate = `{
"type": "string"
}
},
"approval": {
"description": "Approval is what the host does with a trigger that the agent proposes\nfrom a chat of this binding.",
"allOf": [
{
"$ref": "#/definitions/schema.ChatsConnectorApproval"
}
]
},
"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",
@@ -19629,6 +19637,14 @@ const docTemplate = `{
"type": "string"
}
},
"approval": {
"description": "Approval is what the host does with a trigger that the agent proposes\nfrom a chat of this binding.",
"allOf": [
{
"$ref": "#/definitions/schema.ChatsConnectorApproval"
}
]
},
"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",
@@ -19837,6 +19853,26 @@ const docTemplate = `{
}
}
},
"schema.ChatsConnectorApproval": {
"type": "object",
"properties": {
"max_lifetime_days": {
"type": "integer"
},
"max_pending": {
"type": "integer"
},
"max_runs_per_day": {
"type": "integer"
},
"min_gap_minutes": {
"type": "integer"
},
"mode": {
"type": "string"
}
}
},
"schema.ChatsConnectorFileLimit": {
"type": "object",
"properties": {
@@ -20123,6 +20159,14 @@ const docTemplate = `{
"type": "string"
}
},
"approval": {
"description": "Approval sets how triggers that the agent proposes from a chat of the\nbinding are approved, and the limits of the automatic modes. Absent\nmeans ask for a new binding and unchanged for an update.",
"allOf": [
{
"$ref": "#/definitions/schema.ChatsConnectorApproval"
}
]
},
"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",
+44
View File
@@ -18639,6 +18639,14 @@
"type": "string"
}
},
"approval": {
"description": "Approval is what the host does with a trigger that the agent proposes\nfrom a chat of this binding.",
"allOf": [
{
"$ref": "#/definitions/schema.ChatsConnectorApproval"
}
]
},
"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",
@@ -19626,6 +19634,14 @@
"type": "string"
}
},
"approval": {
"description": "Approval is what the host does with a trigger that the agent proposes\nfrom a chat of this binding.",
"allOf": [
{
"$ref": "#/definitions/schema.ChatsConnectorApproval"
}
]
},
"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",
@@ -19834,6 +19850,26 @@
}
}
},
"schema.ChatsConnectorApproval": {
"type": "object",
"properties": {
"max_lifetime_days": {
"type": "integer"
},
"max_pending": {
"type": "integer"
},
"max_runs_per_day": {
"type": "integer"
},
"min_gap_minutes": {
"type": "integer"
},
"mode": {
"type": "string"
}
}
},
"schema.ChatsConnectorFileLimit": {
"type": "object",
"properties": {
@@ -20120,6 +20156,14 @@
"type": "string"
}
},
"approval": {
"description": "Approval sets how triggers that the agent proposes from a chat of the\nbinding are approved, and the limits of the automatic modes. Absent\nmeans ask for a new binding and unchanged for an update.",
"allOf": [
{
"$ref": "#/definitions/schema.ChatsConnectorApproval"
}
]
},
"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",
+32
View File
@@ -3026,6 +3026,12 @@ definitions:
items:
type: string
type: array
approval:
allOf:
- $ref: '#/definitions/schema.ChatsConnectorApproval'
description: |-
Approval is what the host does with a trigger that the agent proposes
from a chat of this binding.
caps:
description: |-
Caps are the capabilities you enabled for this binding: the ceiling of
@@ -3847,6 +3853,12 @@ definitions:
items:
type: string
type: array
approval:
allOf:
- $ref: '#/definitions/schema.ChatsConnectorApproval'
description: |-
Approval is what the host does with a trigger that the agent proposes
from a chat of this binding.
caps:
description: |-
Caps are the capabilities you enabled for this binding: the ceiling of
@@ -4017,6 +4029,19 @@ definitions:
label:
type: string
type: object
schema.ChatsConnectorApproval:
properties:
max_lifetime_days:
type: integer
max_pending:
type: integer
max_runs_per_day:
type: integer
min_gap_minutes:
type: integer
mode:
type: string
type: object
schema.ChatsConnectorFileLimit:
properties:
allowed_bytes:
@@ -4221,6 +4246,13 @@ definitions:
items:
type: string
type: array
approval:
allOf:
- $ref: '#/definitions/schema.ChatsConnectorApproval'
description: |-
Approval sets how triggers that the agent proposes from a chat of the
binding are approved, and the limits of the automatic modes. Absent
means ask for a new binding and unchanged for an update.
caps:
description: |-
Caps are the capabilities you enable for the binding (a closed list,