From eaea737b5311d0ec321622cf689dfce2ea17844a Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 3 Nov 2026 14:56:09 +0000 Subject: [PATCH] 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 --- .../connector-ui/src/AnswerFields.jsx | 3 +- .../connector-ui/src/ApprovalFields.jsx | 49 ++++++++ .../connector-ui/src/ConnectorPage.jsx | 2 + addons/internal/connector-ui/src/connector.js | 47 ++++++++ addons/telegram/ui/test/connector.test.mjs | 26 ++++- core/http/endpoints/studio/connectors.go | 17 ++- core/http/routes/studio_connectors_test.go | 19 +++ core/schema/chats.go | 23 ++++ core/services/studio/connectors/approval.go | 108 ++++++++++++++++++ .../studio/connectors/approval_test.go | 37 ++++++ .../studio/connectors/connectorstest/suite.go | 13 +++ core/services/studio/connectors/gorm.go | 6 +- core/services/studio/connectors/types.go | 7 ++ core/services/studio/connectorsvc.go | 6 + swagger/docs.go | 44 +++++++ swagger/swagger.json | 44 +++++++ swagger/swagger.yaml | 32 ++++++ 17 files changed, 477 insertions(+), 6 deletions(-) create mode 100644 addons/internal/connector-ui/src/ApprovalFields.jsx create mode 100644 core/services/studio/connectors/approval.go create mode 100644 core/services/studio/connectors/approval_test.go diff --git a/addons/internal/connector-ui/src/AnswerFields.jsx b/addons/internal/connector-ui/src/AnswerFields.jsx index 223fa645e2..468690a28b 100644 --- a/addons/internal/connector-ui/src/AnswerFields.jsx +++ b/addons/internal/connector-ui/src/AnswerFields.jsx @@ -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. diff --git a/addons/internal/connector-ui/src/ApprovalFields.jsx b/addons/internal/connector-ui/src/ApprovalFields.jsx new file mode 100644 index 0000000000..ee7cf72bd9 --- /dev/null +++ b/addons/internal/connector-ui/src/ApprovalFields.jsx @@ -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 ( +
+ {s('approval.title')} +

{s('approval.help')}

+ set({ mode })} + options={APPROVAL_MODES.map((m) => ({ value: m, label: s(`approval.mode.${m}`) }))} /> + {auto && (<> +

{s('approval.limitsHelp')}

+ {APPROVAL_LIMITS.map(({ key, max, fallback }) => ( + + {(p) => set({ [key]: e.target.value })} />} + + ))} + )} +
+ ) +} diff --git a/addons/internal/connector-ui/src/ConnectorPage.jsx b/addons/internal/connector-ui/src/ConnectorPage.jsx index d5b7367bad..dca9e7da83 100644 --- a/addons/internal/connector-ui/src/ConnectorPage.jsx +++ b/addons/internal/connector-ui/src/ConnectorPage.jsx @@ -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 + )}
diff --git a/addons/internal/connector-ui/src/connector.js b/addons/internal/connector-ui/src/connector.js index 2aaef39d39..df3a8530f7 100644 --- a/addons/internal/connector-ui/src/connector.js +++ b/addons/internal/connector-ui/src/connector.js @@ -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) diff --git a/addons/telegram/ui/test/connector.test.mjs b/addons/telegram/ui/test/connector.test.mjs index 39db7683b3..13c96e35c0 100644 --- a/addons/telegram/ui/test/connector.test.mjs +++ b/addons/telegram/ui/test/connector.test.mjs @@ -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) +}) diff --git a/core/http/endpoints/studio/connectors.go b/core/http/endpoints/studio/connectors.go index 379f64b353..20d978fc06 100644 --- a/core/http/endpoints/studio/connectors.go +++ b/core/http/endpoints/studio/connectors.go @@ -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{ diff --git a/core/http/routes/studio_connectors_test.go b/core/http/routes/studio_connectors_test.go index 97712977d4..f9e3f50197 100644 --- a/core/http/routes/studio_connectors_test.go +++ b/core/http/routes/studio_connectors_test.go @@ -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"}}) diff --git a/core/schema/chats.go b/core/schema/chats.go index 9946aa698b..29e15f62fb 100644 --- a/core/schema/chats.go +++ b/core/schema/chats.go @@ -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. diff --git a/core/services/studio/connectors/approval.go b/core/services/studio/connectors/approval.go new file mode 100644 index 0000000000..856149f0bf --- /dev/null +++ b/core/services/studio/connectors/approval.go @@ -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 +} diff --git a/core/services/studio/connectors/approval_test.go b/core/services/studio/connectors/approval_test.go new file mode 100644 index 0000000000..238636f90f --- /dev/null +++ b/core/services/studio/connectors/approval_test.go @@ -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()) + }) +}) diff --git a/core/services/studio/connectors/connectorstest/suite.go b/core/services/studio/connectors/connectorstest/suite.go index 1876418fa1..b8cfe7cae8 100644 --- a/core/services/studio/connectors/connectorstest/suite.go +++ b/core/services/studio/connectors/connectorstest/suite.go @@ -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()) diff --git a/core/services/studio/connectors/gorm.go b/core/services/studio/connectors/gorm.go index 4d2b1fed16..3c2e57cb7a 100644 --- a/core/services/studio/connectors/gorm.go +++ b/core/services/studio/connectors/gorm.go @@ -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 diff --git a/core/services/studio/connectors/types.go b/core/services/studio/connectors/types.go index cd7a4b6568..5906776763 100644 --- a/core/services/studio/connectors/types.go +++ b/core/services/studio/connectors/types.go @@ -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) } diff --git a/core/services/studio/connectorsvc.go b/core/services/studio/connectorsvc.go index 2be7414bbf..c2f18a2182 100644 --- a/core/services/studio/connectorsvc.go +++ b/core/services/studio/connectorsvc.go @@ -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. diff --git a/swagger/docs.go b/swagger/docs.go index 8719ef4abc..a4be0d0da2 100644 --- a/swagger/docs.go +++ b/swagger/docs.go @@ -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", diff --git a/swagger/swagger.json b/swagger/swagger.json index 99e727f886..20db54490c 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -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", diff --git a/swagger/swagger.yaml b/swagger/swagger.yaml index a2ee1e2805..3cb3d6732a 100644 --- a/swagger/swagger.yaml +++ b/swagger/swagger.yaml @@ -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,