diff --git a/addons/internal/connector-ui/src/api.js b/addons/internal/connector-ui/src/api.js index fadd339051..4213e0b459 100644 --- a/addons/internal/connector-ui/src/api.js +++ b/addons/internal/connector-ui/src/api.js @@ -38,4 +38,8 @@ export const connectorsApi = { test: (id) => send('POST', `/api/chats/connectors/${enc(id)}/test`, {}), senders: (id) => call(`/api/chats/connectors/${enc(id)}/senders`), allow: (id, sender) => send('POST', `/api/chats/connectors/${enc(id)}/senders/${enc(sender)}/allow`, {}), + // Pairing proves that the owner controls a platform account: the code is sent + // to the bot as a direct message from that account. + pair: (id) => send('POST', `/api/chats/connectors/${enc(id)}/pairing`, {}), + unpair: (id, sender) => send('DELETE', `/api/chats/connectors/${enc(id)}/paired/${enc(sender)}`), } diff --git a/core/http/auth/features.go b/core/http/auth/features.go index 72d8174137..eb3a423b4e 100644 --- a/core/http/auth/features.go +++ b/core/http/auth/features.go @@ -147,6 +147,8 @@ var RouteFeatureRegistry = []RouteFeature{ {"POST", "/api/chats/connectors/:id/test", FeatureConnectors}, {"GET", "/api/chats/connectors/:id/senders", FeatureConnectors}, {"POST", "/api/chats/connectors/:id/senders/:sender/allow", FeatureConnectors}, + {"POST", "/api/chats/connectors/:id/pairing", FeatureConnectors}, + {"DELETE", "/api/chats/connectors/:id/paired/:sender", FeatureConnectors}, {"GET", "/api/chats/mcp-connectors", FeatureChats}, {"POST", "/api/chats/mcp-connectors", FeatureChats}, {"GET", "/api/chats/mcp-connectors/:id", FeatureChats}, diff --git a/core/http/auth/features_chats_test.go b/core/http/auth/features_chats_test.go index cb9128dbda..671f4ac383 100644 --- a/core/http/auth/features_chats_test.go +++ b/core/http/auth/features_chats_test.go @@ -124,6 +124,8 @@ var _ = Describe("Chats feature registration", func() { "POST /api/chats/connectors/:id/test", "GET /api/chats/connectors/:id/senders", "POST /api/chats/connectors/:id/senders/:sender/allow", + "POST /api/chats/connectors/:id/pairing", + "DELETE /api/chats/connectors/:id/paired/:sender", )) Expect(AgentFeatures).To(ContainElement(FeatureConnectors)) Expect(APIFeatures).NotTo(ContainElement(FeatureConnectors)) diff --git a/core/http/endpoints/localai/api_instructions.go b/core/http/endpoints/localai/api_instructions.go index 31464a54a8..f202c4b82c 100644 --- a/core/http/endpoints/localai/api_instructions.go +++ b/core/http/endpoints/localai/api_instructions.go @@ -28,7 +28,7 @@ var instructionDefs = []instructionDef{ {Name: "agent-jobs", Description: "Agent tasks and jobs: saved prompt templates run in the background, with webhooks", Tags: []string{"agent-jobs"}, Intro: "A task is a prompt template ({{.name}} parameters) run by a model or a saved agent (agent_id); a job is one run of it, a background chat of its owner with origin job. POST /api/agent/tasks creates a task {name, model, agent_id, prompt, enabled, webhooks [{url, method, headers, payload_template}]}; GET, PUT and DELETE /api/agent/tasks/{id} read, replace and delete it; header values are never returned (sent back as [hidden] they keep their value). POST /api/agent/jobs/execute {task_id, parameters, images, videos, audios, files} or POST /api/agent/tasks/{name}/execute {parameters} starts a job and answers 201 {job_id, status, url} at once; attachments are base64 data URIs, an address is never fetched. GET /api/agent/jobs/{id} gives status (pending, running, completed, failed, cancelled), result, error, traces and the webhook delivery; GET /api/agent/jobs lists newest first (task_id, status, limit); POST .../jobs/{id}/cancel stops one, POST .../jobs/{id}/rerun runs it again, DELETE removes it with its chat. cron and multimedia_sources on a task are refused: schedule a saved agent with a trigger. A webhook to a private address is refused unless an administrator allows it. Needs the mcp_jobs feature (off by default); ?all_users=true gives an administrator a read-only view of every user's tasks and jobs."}, {Name: "collections", Description: "Collections: the knowledge base of documents the chats can search, with files and followed URLs as sources", Tags: []string{"collections"}, Intro: "A collection is a set of files the server cuts into passages and embeds. GET and POST /api/collections list and create (POST {name}) the collections of the user; POST /api/collections/{name}/upload (multipart field file) adds a file; GET .../entries, .../entries/{entry} and .../entries-raw/{entry} read them; POST .../search {query, max_results} returns the best passages (a number above the passages stored returns all of them); DELETE .../entry/delete {entry} removes one; POST .../reset removes every entry and keeps the collection and its sources; GET, POST and DELETE .../sources follow a URL. Names with special characters must be URL-encoded. Each user sees only their own collections. The routes need the collections feature (off by default for a user); the service can be turned off with LOCALAI_DISABLE_COLLECTIONS. A saved agent can search a collection when it is granted in the agent's collections list. The deprecated path /api/agents/collections serves the same store with a Deprecation header."}, {Name: "workspace", Description: "A live feed of the changes to your files, whoever made them", Tags: []string{"workspace"}, Intro: "GET /api/workspace/events streams server-sent events: a change event {revision, paths, kinds} when files in your home folder change, from any client, chat, trigger, connector or frontend. Paths are inside your home, without a leading slash; kinds are the kinds of the change sets (user, agent, upload, undo). Changes close together come as one event, about once a second at most. A resync event {revision} means some changes were left out: read again what you show. The event id is the revision; send it back as Last-Event-ID or ?after= to resume, otherwise the stream starts from now. A comment line comes every 25 seconds. You see only paths you may read. At most 8 streams per user (429 after). Needs the chats feature; 409 files_off when the server has no files."}, - {Name: "chats", Description: "Chats the server keeps: steering, sub-agents and live events", Tags: []string{"chats"}, Intro: "POST /api/chats starts a chat with a model; PATCH /api/chats/{id} {title, model, settings} renames it (1 to 80 characters), moves it to another model (history stays) or sets its settings {system_prompt, temperature, top_p, top_k, reasoning_effort, context_size}, which apply from the next turn (a new title or model needs an idle chat: 409 otherwise). POST .../{id}/regenerate answers the last message again, POST .../{id}/messages/{seq}/edit {text} replaces one of your messages and runs again from it (later lines are removed), POST .../{id}/fork {from_seq, title} copies a chat up to a line into a new chat, GET /api/chats/search?q= finds chats by title or text, GET .../{id}/export?format=markdown|json returns a chat as a file and POST /api/chats/import {chat, model} stores a JSON export as a new chat. Events also carry usage {used, limit, completion, tokens_per_second, model, node, prompt_ms} after each model request, for a context meter (node only when the server exposes the node header, prompt_ms only when the backend reports timings). Sub-agents: GET /api/chats/{id}/agents lists the sub-agent runs of a chat with running, last_turn and the background limit (from the shared store), GET .../{id}/agents/{agent_id}/messages reads one run's transcript and POST .../{id}/agents/{agent_id}/stop stops it; sub_agent_started and sub_agent_done events mark them in the parent's stream. A chat that PUT .../{id}/builtins {on: [\"saved_agents\"]} switched on (off by default; needs the agents feature and the chats_saved_agents setting) also gets list_agents and the agent argument of spawn_agent (a saved agent's id or name): the sub-agent then runs with that agent's instructions, model and grants. POST .../{id}/messages sends a message: with no turn running it starts one; with a turn running it waits (started false, pending_id) and reaches the model, together with any others and in order, at the model's next request. GET .../{id} lists the waiting messages; PATCH and DELETE .../{id}/pending/{pid} edit or cancel one until it is delivered (409 after). After a stop or a failed turn the waiting messages stay; POST .../{id}/send-now starts a turn from them and POST .../{id}/stop ends a running turn. Goals: PUT .../{id}/goal {text} sets a goal for the chat (it replaces the one it had, returned as replaced) and starts a turn on it when none runs; while the goal is active every turn's prompt carries it, the model gets goal_done {summary} and goal_blocked {reason}, and when a turn ends by itself the server starts the next one, until the goal is done or blocked, you stop it (POST .../{id}/stop pauses the goal), or chats_goal_max_turns (20 by default) automatic turns or chats_goal_max_steps (400) model steps pause it. Your messages still steer it: they go first. GET .../{id}/goal reads it {text, state active|paused|done|blocked, paused_by user|turn_limit|step_limit|error|interrupted, note, turns, max_turns, steps, max_steps}, DELETE .../{id}/goal clears it and POST .../{id}/goal/resume resumes a paused or blocked one with new counts; GET .../{id} carries it as goal, a goal event says it changed, and the transcript has goal lines (role goal, goal_event set|replaced|continue|paused|resumed|done|blocked|cleared). A chat that a trigger, a connector, a job or a page started, and a sub-agent run, cannot have a goal. A turn whose tool loop reaches the step limit (the runtime setting chats_max_iterations, 40 by default, or the model's agent.max_iterations) ends with last_turn paused after a paused event {steps}: the model answered from what it had, and a new message (for example \"Continue\") goes on. GET .../{id}/messages reads the transcript (a user line has text, the words typed, and attachments [{path, name, size, mime, version, missing}], the files it carried, with path inside your home for the files API; a waiting message has the same two fields; the model still gets the paths of the files with the message) and GET .../{id}/events streams live server-sent events, so read the transcript first. A chat is named after its first message. A conversation belongs to its user; another user's id is always 404. POST /api/chats/import/browser takes the chats the old chat page kept in the browser (the value of localStorage localai_chats_data) and stores them as yours; a chat that is there already is skipped. Needs the chats feature, which is on by default. Saved agents need the agents feature, triggers the triggers feature and connectors the connectors feature; all three are off by default for a user and an administrator grants them (an administrator has them all). The server can switch the chats off (LOCALAI_DISABLE_CHATS: every /api/chats path then answers 404 chats_off), and the triggers or the connectors alone (LOCALAI_DISABLE_TRIGGERS, LOCALAI_DISABLE_CONNECTORS: their routes answer 409 triggers_off or connectors_off); GET /api/features says what runs. Your files: GET /api/chats/files?path= lists a folder of your workspace (paths are inside your home, \"/\"), GET and PUT /api/chats/files/content?path= download and upload a file (a second upload that does not name the version it started from keeps both files and reports conflict_of), POST .../files/folders and .../files/move create and rename, DELETE .../files?path= moves to the Trash, GET .../files/trash lists deletions (each with files, folders and bytes, and totals for the whole Trash) and POST .../files/undo {rev} restores one, DELETE .../files/trash empties the Trash for good and DELETE .../files/trash/{rev} deletes one entry for good (neither can be undone; they touch only your own Trash and the agent tools never do this), GET .../files/history lists changes, GET .../files/search?q= searches names, text files and the text of PDF, Word, Excel and PowerPoint files (a document hit has document, page, sheet or slide; indexing counts documents still being read), and GET .../files/preview?path= reads a .xlsx, .xlsm, .csv, .tsv, .docx or .pptx file of up to 20 MiB for a preview (kind table: sheets of at most 200 rows by 30 columns with total_rows and total_cols; kind document: headings, list items, paragraphs and tables {type table, rows}; kind slides: titles, text and notes; 415 for another type). POST .../{id}/messages also takes multipart/form-data (text and files) to attach files (with a turn running the files are saved at once and the message waits with their paths): images go to a vision model inside the message (image_url parts; other models get the path and describe_image), videos to a model that takes video, and the text of PDF, Word, Excel, PowerPoint and EPUB files is added to the message (the model reads the rest with read_document). Skills: GET /api/chats/skills?chat= lists the skills of the five layers (folder, personal, shared, addon, builtin; an addon skill names its add-on in addon and addon_title) with enabled and shadowed, POST creates one {layer personal|shared, name, description, body} (shared needs an administrator), GET PUT DELETE .../skills/{layer}/{name} read, change and delete (to the Trash), PUT /api/chats/{id}/skills {off} switches skills off for one chat. Well-known files: GET and PUT /api/chats/wellknown/{name} (persona, user, shortcuts, home, ui, or widget-data with ?path=) read and change the files of your home that set the assistant's persona, your profile, the composer shortcuts, the Home widgets and the look. A read is validated against the JSON Schema at .../wellknown/{name}/schema and falls back to the last valid version (fallback, errors); a write {values, replace} merges the keys it sends (null removes one), takes the version you read in If-Match (409 with current when the file changed) and is refused with errors when the result is invalid; POST .../wellknown/{name}/revert restores the previous valid version as a new change, or the version {rev} that GET .../wellknown/{name}/versions lists (newest first, with who wrote it and in which chat). A checkpoint line of a transcript carries rev, the change set of the turn, and lists the paths it changed. Add-ons: GET /api/chats/{id}/addons lists the add-ons whose tools you may use, each with its switch for that chat, and PUT /api/chats/{id}/addons {off: [ids]} switches the tools of add-ons off for one chat. Commands: a chat runs commands with bash, served by an add-on that runs commands (isolated) or, when no add-on serves it, by the built-in bash directly on this machine with no isolation and with its network; the add-on list says which (commands_provider, commands_isolation, commands_consent, commands_fallback). With chats_host_exec ask, each command waits: a host_exec_consent event carries {ids: [id], text: command} and POST /api/chats/{id}/host-exec/consent {id, decision: once|deny} answers it (once needs an administrator). Administrators: GET /api/chats/admin/tools lists the built-in tools an add-on may replace (Settings > Tools; change them with POST /api/settings chats_host_exec and chats_tool_providers) and GET /api/chats/admin/host-exec/events?limit= reads the audit of the commands run on this machine. Network of the isolated commands: the guest has no network device and reaches HTTP and HTTPS only through a proxy that asks the server about each connection. GET /api/chats/{id}/egress returns {preset, ceiling, effective, applies, applies_reason, addon, registries, rules, cards} (applies is false when commands run on this machine, which has the server's network; addon is the add-on that runs the commands and draws the chat's network controls), PUT .../{id}/egress {preset: off|registries|all} sets the chat's preset (400 egress_above_ceiling above the administrator's chats_egress_ceiling), DELETE .../{id}/egress/rules/{host}/{port} removes an allow or deny rule, an egress_consent event {ids: [card id], text: host:port} asks for a host the chat does not allow (the command waits up to 60 seconds) and POST .../{id}/egress/consent {id, decision: once|chat|deny} answers it (allowing a host inside your network needs an administrator), and egress_card events say that the chat's network changed. Administrators: GET /api/chats/admin/egress reads the chats_egress_* settings in force, the registries and the counts of the last 24 hours, and GET /api/chats/admin/egress/events?limit=&user=&chat=&verdict=&host= reads the audit. Saved agents: GET and POST /api/chats/agents, GET PUT DELETE .../agents/{id}: an agent is {name, description, instructions, model, mcp_servers, addons, collections, skills, network, folder}; folder {path, mode read|write} gives it one folder of your files (\"/\" is all of them; not LocalAI, Chats or a shared folder; 400 otherwise), kept by the folder itself so a rename or move is followed, shown back as {path, name, mode, state ok|missing|unset}; a write without folder keeps it and folder {path: \"\"} clears it; network (off, registries or all; off by default) is the network of the isolated commands of its trigger and connector runs, capped by chats_egress_ceiling, which never show a card; the lists are exact grants (an empty list grants nothing) and a chat gets only what you may use at that moment. POST /api/chats {agent_id} starts a chat with one (the agent's model replaces model when it has one). GET .../agents/{id}/export returns the definition as a JSON file (version 1, kind localai-agent; version 2 with folder {path, mode} for an agent with a folder, which a server that knows only version 1 refuses), POST .../agents/import takes that file (JSON body or multipart file field) and saves a new agent (a taken name gets a number, an unavailable model is dropped, a folder you do not have leaves the agent unset until you choose one; warnings say so), GET .../agents/templates lists starter agents and POST .../agents/templates/{id}/create saves one, GET .../agents/{id}/runs lists the chats that ran with the agent (yours, trigger runs, connector runs) with last_turn and counts. Inbox: GET /api/chats/notifications?unread=1 lists the notifications of your trigger runs (kind run_ended, run_failed or needs_attention) with the unread count, GET .../notifications/count counts them, POST .../notifications/read {ids} or {all: true} marks them read, DELETE .../notifications {ids} removes some, or {all: true} empties your inbox ({all: true, read_only: true} removes only the read ones; the answer has deleted and the unread count left), the list also gives total (everything kept, since limit, at most 500, can cut the list), and GET .../notifications/events streams new ones as server-sent events plus a notifications_changed event when notifications were read or deleted (from any tab or frontend), after which a client reads the list again. A user only ever reaches their own notifications. Opening a chat (GET /api/chats/{id}) marks its notifications read. Triggers: GET and POST /api/chats/triggers, GET PUT DELETE .../triggers/{id}, POST .../{id}/run (start now), .../{id}/enable and .../{id}/disable, GET .../{id}/runs, GET .../triggers/preview?when=&tz= (next times of a schedule). A trigger is {name, prompt, kind schedule|file, schedule + time_zone, or folder + quiet_seconds, daily_cap, enabled, agent_id, model, source_chat_id}: it starts a chat as you by itself, on a schedule (\"daily 08:00\", \"weekdays 09:30\", \"weekly mon,thu 09:30\", \"monthly 15 08:00\", \"once 2026-12-24 18:00\" or five cron fields) or when a file arrives in the folder; a chat it starts has origin trigger. A trigger also has plan {reads, writes, widget} (what a run reads, the file it rewrites and the Home widget that shows it), cost {estimated_tokens, average_tokens, runs, max_runs_per_day, remote} and last_done, and each run its tokens; a file trigger waits min_gap_seconds (60 by default) after a run before a file that changed again starts another. The four trigger tools (propose_trigger, list_triggers, update_trigger, delete_trigger) are the built-in tool group triggers: on in a chat a person starts, switched together with PUT .../{id}/builtins {off: [\"triggers\"]}, listed in GET .../{id}/tools and GET .../draft/tools with usable and reason (not usable in a run a trigger or connector started, for a user without the triggers feature; not listed when the server runs no triggers), and never given to a sub-agent. A model can propose a trigger in a chat (propose_trigger; a schedule from a chat runs at most once an hour); GET .../triggers/proposals?chat= lists them, PUT .../triggers/proposals/{id} edits one (it stays a proposal), POST .../triggers/proposals/{id}/confirm {time_zone} turns one into a trigger that is on, DELETE dismisses it (the answer is kept: GET .../triggers/proposals/{id} returns {state pending|accepted|declined|undone, trigger, answered_at, can_undo, undo_blocked}, POST .../undo takes an accepted one back unless the trigger ran or was edited since (409 says why) and POST .../reopen restores a declined or undone one); the chat carries a proposal as a card {kind trigger_proposal, id} on the tool line (tool_end event and transcript) and as a trigger_proposed event. A model can also ask to change a trigger of yours (list_triggers gives each a ref; update_trigger edits, pauses or resumes, delete_trigger deletes): an edit or a delete waits for you, and a pause or a resume applies at once; the chat carries it as a card {kind trigger_change, id}. GET .../triggers/changes/{id} returns {id, op update|delete|pause|resume, state pending|applied|dismissed|undone, trigger_id, before, after, fields, answered_at, stale, can_undo, undo_blocked, last_run}, POST .../changes/{id}/apply makes it (409 when the trigger changed since), DELETE dismisses it and POST .../changes/{id}/undo puts the trigger back as it was, for a day after it applied (a deleted trigger comes back with its id, without its run history). Proposed changes: a model asks to change your memory or an agent profile with propose_change {kind memory|profile, scope user|agent|channel, target, content, summary} (in the built-in tool group changes; revert_change {id} undoes an applied one). Nothing is written until you confirm. GET /api/chats/changes?status= lists the proposals (id, kind, scope, target, summary, content, source {kind, id, chat}, status pending|applied|dismissed|expired|reverted|stale, rev), GET .../changes/{id} reads one, POST .../changes/{id}/confirm writes it as one change set, or with a body {content} your own text in its place (409 when the file changed since, the proposal expired after 24 hours or it was answered), POST .../changes/{id}/dismiss answers no, POST .../changes/{id}/undo restores the file (409 when it was edited since) and GET .../changes/audit lists who proposed, confirmed or reverted what, with a hash and never the text. GET /api/chats/context-files lists the notes the host puts in the prompt (limits, and groups of {source, scope, key, max_bytes, max_lines, files}) for the Memory tab of Your settings; it only reads. At most 10 proposals wait per user and 5 are made per hour per agent. A chat that runs a saved agent cannot propose until the agent is granted it. Browser push: GET /api/chats/push/key returns the server's VAPID public key, POST /api/chats/push/subscribe takes a browser PushSubscription {endpoint, keys: {p256dh, auth}} (an https endpoint on a public address) and DELETE .../push/subscribe {endpoint} removes it; a notification is then pushed with its title and link only. These answer 409 push_off when the server keeps nothing across a restart. Conversion: at start the server converts the old chat page, agents, tasks, skills and Telegram connectors; GET /api/chats/conversion lists what happened to your items (kind, name, status converted|skipped|failed|dropped, reason) and gives an administrator the totals. Converted triggers and connectors are switched off. Connectors: GET /api/chats/connectors/kinds lists the platforms that an installed add-on serves (each kind comes with the add-on that serves it), GET and POST /api/chats/connectors, GET PUT DELETE .../connectors/{id}, POST .../{id}/enable, .../disable and .../test (asks the platform who the bot is), GET .../{id}/senders (who wrote to the bot) and POST .../{id}/senders/{sender}/allow. A binding is {kind (the platform, telegram when empty; set at create only), name, token (write-only, sealed, never returned; has_token says it is stored), agent_id (required: an agent of yours that names a model), enabled, accept_any, allow [chat IDs, user IDs or @usernames], notify_chat}: a person who messages the bot talks to that agent in a conversation of their own, as you, with only the agent's grants. The allowlist is empty at first, so nobody is accepted; a sender who is not accepted is ignored and logged. accept_any lets anyone who can message the bot use the agent; the create or update that turns it on must also send confirm_accept_any: \"accept any message\" (else 400). Files and skills answer 409 with the reason when the server has no data path. Administrators: GET /api/chats/admin/agents, .../admin/triggers and .../admin/connectors list what every user has, each with its owner and never a token or instructions; GET .../admin/triggers/{id}/runs lists the runs of a trigger, POST .../admin/triggers/{id}/disable and .../admin/connectors/{id}/disable switch one off, and DELETE .../admin/agents/{id}, .../admin/triggers/{id} and .../admin/connectors/{id} remove one. No MCP tool maps to these routes: they are the user's own chat and files, not administration. GET POST /api/chats/mcp-connectors, GET PUT DELETE /api/chats/mcp-connectors/{id} and POST .../enable, .../disable, .../test manage your MCP connectors: MCP servers you define (transport http, or stdio for administrators outside distributed mode), stored per user, with token, headers and env sealed and never returned; a test answers 200 with ok and the tool names or the error. Addresses on private networks are refused at connect time unless an administrator sets mcp_connectors_allow_private. A chat with no agent gets your enabled connectors; a saved agent names one as connector: in mcp_servers. GET /api/chats/{id}/tools lists the built-in tool groups, add-ons, model MCP servers and connectors of a chat with their switches. The built-in groups are on until PUT /api/chats/{id}/builtins {off: [...]} switches them off for that chat: shell (bash, whoever serves it; commands then says switched_off), web, files (the file tools and read_document), media (describe_image and transcribe_audio), collections, subagents (spawn_agent) and triggers; load_skill and the goal and loop tools have no switch, and a sub-agent follows its parent's switches; PUT /api/chats/{id}/mcp {off: [server name or connector:]} switches MCP servers and connectors off for that chat. GET /api/chats/{id}/mcp/prompts and .../mcp/resources list the prompts and resources of the servers the chat can use now; POST .../mcp/prompts {server, name, arguments} and POST .../mcp/resources/read {server, uri} return their text (403 mcp_refused for a server the chat cannot use); POST .../{id}/messages takes context: [{server, name, arguments} or {server, uri}] to add them to a message. MCP Apps: a tool that declares an interactive page (_meta.ui.resourceUri) gives its tool_end event and its transcript line an app {server, tool, resource_uri, visibility}; the transcript line also holds the call input and result. GET /api/chats/{id}/mcp-apps/resource?server=&uri= reads the ui:// page (html, mime_type, meta with csp, permissions and prefersBorder) for a server the chat may use now and a resource a tool of that server declares, and POST /api/chats/{id}/mcp-apps/call {server, tool, arguments} runs a tool of the same server whose visibility includes app, for the page to call; both answer 403 mcp_app_refused for anything else and need the MCP feature. Loops repeat a prompt inside a chat: POST .../{id}/loops {interval, prompt} starts one (interval such as 5m, at least chats_loop_min_interval; empty for a self-paced loop whose model calls schedule_next or loop_done after each run), GET .../{id}/loops lists them (also the loops field of GET /api/chats/{id}), PUT .../{id}/loops/{loopId} {state: paused|active} pauses, resumes or confirms one (the first loop of a chat is pending until confirmed), DELETE .../{id}/loops/{loopId} or DELETE .../{id}/loops stops them; a loops event tells other tabs, and a user line a loop sent carries loop {id, run}. A loop runs in its own chat and as often as every minute; a trigger starts a new chat at most once an hour."}, + {Name: "chats", Description: "Chats the server keeps: steering, sub-agents and live events", Tags: []string{"chats"}, Intro: "POST /api/chats starts a chat with a model; PATCH /api/chats/{id} {title, model, settings} renames it (1 to 80 characters), moves it to another model (history stays) or sets its settings {system_prompt, temperature, top_p, top_k, reasoning_effort, context_size}, which apply from the next turn (a new title or model needs an idle chat: 409 otherwise). POST .../{id}/regenerate answers the last message again, POST .../{id}/messages/{seq}/edit {text} replaces one of your messages and runs again from it (later lines are removed), POST .../{id}/fork {from_seq, title} copies a chat up to a line into a new chat, GET /api/chats/search?q= finds chats by title or text, GET .../{id}/export?format=markdown|json returns a chat as a file and POST /api/chats/import {chat, model} stores a JSON export as a new chat. Events also carry usage {used, limit, completion, tokens_per_second, model, node, prompt_ms} after each model request, for a context meter (node only when the server exposes the node header, prompt_ms only when the backend reports timings). Sub-agents: GET /api/chats/{id}/agents lists the sub-agent runs of a chat with running, last_turn and the background limit (from the shared store), GET .../{id}/agents/{agent_id}/messages reads one run's transcript and POST .../{id}/agents/{agent_id}/stop stops it; sub_agent_started and sub_agent_done events mark them in the parent's stream. A chat that PUT .../{id}/builtins {on: [\"saved_agents\"]} switched on (off by default; needs the agents feature and the chats_saved_agents setting) also gets list_agents and the agent argument of spawn_agent (a saved agent's id or name): the sub-agent then runs with that agent's instructions, model and grants. POST .../{id}/messages sends a message: with no turn running it starts one; with a turn running it waits (started false, pending_id) and reaches the model, together with any others and in order, at the model's next request. GET .../{id} lists the waiting messages; PATCH and DELETE .../{id}/pending/{pid} edit or cancel one until it is delivered (409 after). After a stop or a failed turn the waiting messages stay; POST .../{id}/send-now starts a turn from them and POST .../{id}/stop ends a running turn. Goals: PUT .../{id}/goal {text} sets a goal for the chat (it replaces the one it had, returned as replaced) and starts a turn on it when none runs; while the goal is active every turn's prompt carries it, the model gets goal_done {summary} and goal_blocked {reason}, and when a turn ends by itself the server starts the next one, until the goal is done or blocked, you stop it (POST .../{id}/stop pauses the goal), or chats_goal_max_turns (20 by default) automatic turns or chats_goal_max_steps (400) model steps pause it. Your messages still steer it: they go first. GET .../{id}/goal reads it {text, state active|paused|done|blocked, paused_by user|turn_limit|step_limit|error|interrupted, note, turns, max_turns, steps, max_steps}, DELETE .../{id}/goal clears it and POST .../{id}/goal/resume resumes a paused or blocked one with new counts; GET .../{id} carries it as goal, a goal event says it changed, and the transcript has goal lines (role goal, goal_event set|replaced|continue|paused|resumed|done|blocked|cleared). A chat that a trigger, a connector, a job or a page started, and a sub-agent run, cannot have a goal. A turn whose tool loop reaches the step limit (the runtime setting chats_max_iterations, 40 by default, or the model's agent.max_iterations) ends with last_turn paused after a paused event {steps}: the model answered from what it had, and a new message (for example \"Continue\") goes on. GET .../{id}/messages reads the transcript (a user line has text, the words typed, and attachments [{path, name, size, mime, version, missing}], the files it carried, with path inside your home for the files API; a waiting message has the same two fields; the model still gets the paths of the files with the message) and GET .../{id}/events streams live server-sent events, so read the transcript first. A chat is named after its first message. A conversation belongs to its user; another user's id is always 404. POST /api/chats/import/browser takes the chats the old chat page kept in the browser (the value of localStorage localai_chats_data) and stores them as yours; a chat that is there already is skipped. Needs the chats feature, which is on by default. Saved agents need the agents feature, triggers the triggers feature and connectors the connectors feature; all three are off by default for a user and an administrator grants them (an administrator has them all). The server can switch the chats off (LOCALAI_DISABLE_CHATS: every /api/chats path then answers 404 chats_off), and the triggers or the connectors alone (LOCALAI_DISABLE_TRIGGERS, LOCALAI_DISABLE_CONNECTORS: their routes answer 409 triggers_off or connectors_off); GET /api/features says what runs. Your files: GET /api/chats/files?path= lists a folder of your workspace (paths are inside your home, \"/\"), GET and PUT /api/chats/files/content?path= download and upload a file (a second upload that does not name the version it started from keeps both files and reports conflict_of), POST .../files/folders and .../files/move create and rename, DELETE .../files?path= moves to the Trash, GET .../files/trash lists deletions (each with files, folders and bytes, and totals for the whole Trash) and POST .../files/undo {rev} restores one, DELETE .../files/trash empties the Trash for good and DELETE .../files/trash/{rev} deletes one entry for good (neither can be undone; they touch only your own Trash and the agent tools never do this), GET .../files/history lists changes, GET .../files/search?q= searches names, text files and the text of PDF, Word, Excel and PowerPoint files (a document hit has document, page, sheet or slide; indexing counts documents still being read), and GET .../files/preview?path= reads a .xlsx, .xlsm, .csv, .tsv, .docx or .pptx file of up to 20 MiB for a preview (kind table: sheets of at most 200 rows by 30 columns with total_rows and total_cols; kind document: headings, list items, paragraphs and tables {type table, rows}; kind slides: titles, text and notes; 415 for another type). POST .../{id}/messages also takes multipart/form-data (text and files) to attach files (with a turn running the files are saved at once and the message waits with their paths): images go to a vision model inside the message (image_url parts; other models get the path and describe_image), videos to a model that takes video, and the text of PDF, Word, Excel, PowerPoint and EPUB files is added to the message (the model reads the rest with read_document). Skills: GET /api/chats/skills?chat= lists the skills of the five layers (folder, personal, shared, addon, builtin; an addon skill names its add-on in addon and addon_title) with enabled and shadowed, POST creates one {layer personal|shared, name, description, body} (shared needs an administrator), GET PUT DELETE .../skills/{layer}/{name} read, change and delete (to the Trash), PUT /api/chats/{id}/skills {off} switches skills off for one chat. Well-known files: GET and PUT /api/chats/wellknown/{name} (persona, user, shortcuts, home, ui, or widget-data with ?path=) read and change the files of your home that set the assistant's persona, your profile, the composer shortcuts, the Home widgets and the look. A read is validated against the JSON Schema at .../wellknown/{name}/schema and falls back to the last valid version (fallback, errors); a write {values, replace} merges the keys it sends (null removes one), takes the version you read in If-Match (409 with current when the file changed) and is refused with errors when the result is invalid; POST .../wellknown/{name}/revert restores the previous valid version as a new change, or the version {rev} that GET .../wellknown/{name}/versions lists (newest first, with who wrote it and in which chat). A checkpoint line of a transcript carries rev, the change set of the turn, and lists the paths it changed. Add-ons: GET /api/chats/{id}/addons lists the add-ons whose tools you may use, each with its switch for that chat, and PUT /api/chats/{id}/addons {off: [ids]} switches the tools of add-ons off for one chat. Commands: a chat runs commands with bash, served by an add-on that runs commands (isolated) or, when no add-on serves it, by the built-in bash directly on this machine with no isolation and with its network; the add-on list says which (commands_provider, commands_isolation, commands_consent, commands_fallback). With chats_host_exec ask, each command waits: a host_exec_consent event carries {ids: [id], text: command} and POST /api/chats/{id}/host-exec/consent {id, decision: once|deny} answers it (once needs an administrator). Administrators: GET /api/chats/admin/tools lists the built-in tools an add-on may replace (Settings > Tools; change them with POST /api/settings chats_host_exec and chats_tool_providers) and GET /api/chats/admin/host-exec/events?limit= reads the audit of the commands run on this machine. Network of the isolated commands: the guest has no network device and reaches HTTP and HTTPS only through a proxy that asks the server about each connection. GET /api/chats/{id}/egress returns {preset, ceiling, effective, applies, applies_reason, addon, registries, rules, cards} (applies is false when commands run on this machine, which has the server's network; addon is the add-on that runs the commands and draws the chat's network controls), PUT .../{id}/egress {preset: off|registries|all} sets the chat's preset (400 egress_above_ceiling above the administrator's chats_egress_ceiling), DELETE .../{id}/egress/rules/{host}/{port} removes an allow or deny rule, an egress_consent event {ids: [card id], text: host:port} asks for a host the chat does not allow (the command waits up to 60 seconds) and POST .../{id}/egress/consent {id, decision: once|chat|deny} answers it (allowing a host inside your network needs an administrator), and egress_card events say that the chat's network changed. Administrators: GET /api/chats/admin/egress reads the chats_egress_* settings in force, the registries and the counts of the last 24 hours, and GET /api/chats/admin/egress/events?limit=&user=&chat=&verdict=&host= reads the audit. Saved agents: GET and POST /api/chats/agents, GET PUT DELETE .../agents/{id}: an agent is {name, description, instructions, model, mcp_servers, addons, collections, skills, network, folder}; folder {path, mode read|write} gives it one folder of your files (\"/\" is all of them; not LocalAI, Chats or a shared folder; 400 otherwise), kept by the folder itself so a rename or move is followed, shown back as {path, name, mode, state ok|missing|unset}; a write without folder keeps it and folder {path: \"\"} clears it; network (off, registries or all; off by default) is the network of the isolated commands of its trigger and connector runs, capped by chats_egress_ceiling, which never show a card; the lists are exact grants (an empty list grants nothing) and a chat gets only what you may use at that moment. POST /api/chats {agent_id} starts a chat with one (the agent's model replaces model when it has one). GET .../agents/{id}/export returns the definition as a JSON file (version 1, kind localai-agent; version 2 with folder {path, mode} for an agent with a folder, which a server that knows only version 1 refuses), POST .../agents/import takes that file (JSON body or multipart file field) and saves a new agent (a taken name gets a number, an unavailable model is dropped, a folder you do not have leaves the agent unset until you choose one; warnings say so), GET .../agents/templates lists starter agents and POST .../agents/templates/{id}/create saves one, GET .../agents/{id}/runs lists the chats that ran with the agent (yours, trigger runs, connector runs) with last_turn and counts. Inbox: GET /api/chats/notifications?unread=1 lists the notifications of your trigger runs (kind run_ended, run_failed or needs_attention) with the unread count, GET .../notifications/count counts them, POST .../notifications/read {ids} or {all: true} marks them read, DELETE .../notifications {ids} removes some, or {all: true} empties your inbox ({all: true, read_only: true} removes only the read ones; the answer has deleted and the unread count left), the list also gives total (everything kept, since limit, at most 500, can cut the list), and GET .../notifications/events streams new ones as server-sent events plus a notifications_changed event when notifications were read or deleted (from any tab or frontend), after which a client reads the list again. A user only ever reaches their own notifications. Opening a chat (GET /api/chats/{id}) marks its notifications read. Triggers: GET and POST /api/chats/triggers, GET PUT DELETE .../triggers/{id}, POST .../{id}/run (start now), .../{id}/enable and .../{id}/disable, GET .../{id}/runs, GET .../triggers/preview?when=&tz= (next times of a schedule). A trigger is {name, prompt, kind schedule|file, schedule + time_zone, or folder + quiet_seconds, daily_cap, enabled, agent_id, model, source_chat_id}: it starts a chat as you by itself, on a schedule (\"daily 08:00\", \"weekdays 09:30\", \"weekly mon,thu 09:30\", \"monthly 15 08:00\", \"once 2026-12-24 18:00\" or five cron fields) or when a file arrives in the folder; a chat it starts has origin trigger. A trigger also has plan {reads, writes, widget} (what a run reads, the file it rewrites and the Home widget that shows it), cost {estimated_tokens, average_tokens, runs, max_runs_per_day, remote} and last_done, and each run its tokens; a file trigger waits min_gap_seconds (60 by default) after a run before a file that changed again starts another. The four trigger tools (propose_trigger, list_triggers, update_trigger, delete_trigger) are the built-in tool group triggers: on in a chat a person starts, switched together with PUT .../{id}/builtins {off: [\"triggers\"]}, listed in GET .../{id}/tools and GET .../draft/tools with usable and reason (not usable in a run a trigger or connector started, for a user without the triggers feature; not listed when the server runs no triggers), and never given to a sub-agent. A model can propose a trigger in a chat (propose_trigger; a schedule from a chat runs at most once an hour); GET .../triggers/proposals?chat= lists them, PUT .../triggers/proposals/{id} edits one (it stays a proposal), POST .../triggers/proposals/{id}/confirm {time_zone} turns one into a trigger that is on, DELETE dismisses it (the answer is kept: GET .../triggers/proposals/{id} returns {state pending|accepted|declined|undone, trigger, answered_at, can_undo, undo_blocked}, POST .../undo takes an accepted one back unless the trigger ran or was edited since (409 says why) and POST .../reopen restores a declined or undone one); the chat carries a proposal as a card {kind trigger_proposal, id} on the tool line (tool_end event and transcript) and as a trigger_proposed event. A model can also ask to change a trigger of yours (list_triggers gives each a ref; update_trigger edits, pauses or resumes, delete_trigger deletes): an edit or a delete waits for you, and a pause or a resume applies at once; the chat carries it as a card {kind trigger_change, id}. GET .../triggers/changes/{id} returns {id, op update|delete|pause|resume, state pending|applied|dismissed|undone, trigger_id, before, after, fields, answered_at, stale, can_undo, undo_blocked, last_run}, POST .../changes/{id}/apply makes it (409 when the trigger changed since), DELETE dismisses it and POST .../changes/{id}/undo puts the trigger back as it was, for a day after it applied (a deleted trigger comes back with its id, without its run history). Proposed changes: a model asks to change your memory or an agent profile with propose_change {kind memory|profile, scope user|agent|channel, target, content, summary} (in the built-in tool group changes; revert_change {id} undoes an applied one). Nothing is written until you confirm. GET /api/chats/changes?status= lists the proposals (id, kind, scope, target, summary, content, source {kind, id, chat}, status pending|applied|dismissed|expired|reverted|stale, rev), GET .../changes/{id} reads one, POST .../changes/{id}/confirm writes it as one change set, or with a body {content} your own text in its place (409 when the file changed since, the proposal expired after 24 hours or it was answered), POST .../changes/{id}/dismiss answers no, POST .../changes/{id}/undo restores the file (409 when it was edited since) and GET .../changes/audit lists who proposed, confirmed or reverted what, with a hash and never the text. GET /api/chats/context-files lists the notes the host puts in the prompt (limits, and groups of {source, scope, key, max_bytes, max_lines, files}) for the Memory tab of Your settings; it only reads. At most 10 proposals wait per user and 5 are made per hour per agent. A chat that runs a saved agent cannot propose until the agent is granted it. Browser push: GET /api/chats/push/key returns the server's VAPID public key, POST /api/chats/push/subscribe takes a browser PushSubscription {endpoint, keys: {p256dh, auth}} (an https endpoint on a public address) and DELETE .../push/subscribe {endpoint} removes it; a notification is then pushed with its title and link only. These answer 409 push_off when the server keeps nothing across a restart. Conversion: at start the server converts the old chat page, agents, tasks, skills and Telegram connectors; GET /api/chats/conversion lists what happened to your items (kind, name, status converted|skipped|failed|dropped, reason) and gives an administrator the totals. Converted triggers and connectors are switched off. Connectors: GET /api/chats/connectors/kinds lists the platforms that an installed add-on serves (each kind comes with the add-on that serves it), GET and POST /api/chats/connectors, GET PUT DELETE .../connectors/{id}, POST .../{id}/enable, .../disable and .../test (asks the platform who the bot is), GET .../{id}/senders (who wrote to the bot) and POST .../{id}/senders/{sender}/allow. POST .../{id}/pairing makes a one-use pairing code (valid ten minutes) that you send as a direct message to the bot from an allowlisted account to pair it (a pairing lasts 90 days, at most three senders), and DELETE .../{id}/paired/{sender} ends one. A binding also has caps (the capabilities you enabled, from a closed list), grants [{role, caps}] (roles that the kind declares in roles and permissions of the kinds list; never a sensitive capability) and paired. A binding is {kind (the platform, telegram when empty; set at create only), name, token (write-only, sealed, never returned; has_token says it is stored), agent_id (required: an agent of yours that names a model), enabled, accept_any, allow [chat IDs, user IDs or @usernames], notify_chat}: a person who messages the bot talks to that agent in a conversation of their own, as you, with only the agent's grants. The allowlist is empty at first, so nobody is accepted; a sender who is not accepted is ignored and logged. accept_any lets anyone who can message the bot use the agent; the create or update that turns it on must also send confirm_accept_any: \"accept any message\" (else 400). Files and skills answer 409 with the reason when the server has no data path. Administrators: GET /api/chats/admin/agents, .../admin/triggers and .../admin/connectors list what every user has, each with its owner and never a token or instructions; GET .../admin/triggers/{id}/runs lists the runs of a trigger, POST .../admin/triggers/{id}/disable and .../admin/connectors/{id}/disable switch one off, and DELETE .../admin/agents/{id}, .../admin/triggers/{id} and .../admin/connectors/{id} remove one. No MCP tool maps to these routes: they are the user's own chat and files, not administration. GET POST /api/chats/mcp-connectors, GET PUT DELETE /api/chats/mcp-connectors/{id} and POST .../enable, .../disable, .../test manage your MCP connectors: MCP servers you define (transport http, or stdio for administrators outside distributed mode), stored per user, with token, headers and env sealed and never returned; a test answers 200 with ok and the tool names or the error. Addresses on private networks are refused at connect time unless an administrator sets mcp_connectors_allow_private. A chat with no agent gets your enabled connectors; a saved agent names one as connector: in mcp_servers. GET /api/chats/{id}/tools lists the built-in tool groups, add-ons, model MCP servers and connectors of a chat with their switches. The built-in groups are on until PUT /api/chats/{id}/builtins {off: [...]} switches them off for that chat: shell (bash, whoever serves it; commands then says switched_off), web, files (the file tools and read_document), media (describe_image and transcribe_audio), collections, subagents (spawn_agent) and triggers; load_skill and the goal and loop tools have no switch, and a sub-agent follows its parent's switches; PUT /api/chats/{id}/mcp {off: [server name or connector:]} switches MCP servers and connectors off for that chat. GET /api/chats/{id}/mcp/prompts and .../mcp/resources list the prompts and resources of the servers the chat can use now; POST .../mcp/prompts {server, name, arguments} and POST .../mcp/resources/read {server, uri} return their text (403 mcp_refused for a server the chat cannot use); POST .../{id}/messages takes context: [{server, name, arguments} or {server, uri}] to add them to a message. MCP Apps: a tool that declares an interactive page (_meta.ui.resourceUri) gives its tool_end event and its transcript line an app {server, tool, resource_uri, visibility}; the transcript line also holds the call input and result. GET /api/chats/{id}/mcp-apps/resource?server=&uri= reads the ui:// page (html, mime_type, meta with csp, permissions and prefersBorder) for a server the chat may use now and a resource a tool of that server declares, and POST /api/chats/{id}/mcp-apps/call {server, tool, arguments} runs a tool of the same server whose visibility includes app, for the page to call; both answer 403 mcp_app_refused for anything else and need the MCP feature. Loops repeat a prompt inside a chat: POST .../{id}/loops {interval, prompt} starts one (interval such as 5m, at least chats_loop_min_interval; empty for a self-paced loop whose model calls schedule_next or loop_done after each run), GET .../{id}/loops lists them (also the loops field of GET /api/chats/{id}), PUT .../{id}/loops/{loopId} {state: paused|active} pauses, resumes or confirms one (the first loop of a chat is pending until confirmed), DELETE .../{id}/loops/{loopId} or DELETE .../{id}/loops stops them; a loops event tells other tabs, and a user line a loop sent carries loop {id, run}. A loop runs in its own chat and as often as every minute; a trigger starts a new chat at most once an hour."}, {Name: "pages", Description: "Pages: HTML pages in your workspace that the UI shows in a sandboxed frame", Tags: []string{"pages"}, Intro: "A page is the folder pages// of your home (name ^[a-z0-9][a-z0-9-]{0,63}$) with an index.html, an optional page.json {title, icon, description, widget: {size small|medium|large, title}} and its data files; a page with a widget block can be a tile on Home (a widget {type: page, page: , folder?: } in LocalAI/home.json, at most 3), where it runs with localai.page.mode \"widget\". GET /api/chats/pages lists them (source own, author you or assistant, updated_at, updated_by); GET /api/chats/pages/{name} returns the page with its HTML (at most 1 MiB). The bridge of a page: GET /api/chats/pages/{name}/files?dir=, GET and PUT /api/chats/pages/{name}/file (text files of at most 1 MiB, paths relative to the page's folder; a page writes data files only, code and document files such as .js, .css, .html, index and page.json are refused with 403; writes are rate limited; GET .../file?path=&encoding=base64 reads an image of the folder as base64, which the UI puts into the page), and POST /api/chats/pages/{name}/chat {text, chat_id, model} to put a message in a chat after the person confirmed it. GET /api/chats/page-starters and POST /api/chats/page-starters/{id}/copy copy a starter page. Write a page with the files API or from a chat. Shared pages: GET /api/chats/shared-pages lists the pages of the folders an administrator shared with you (folder, folder_id, author_name, hash, code_files, consent none, allowed or changed; the hash covers index.html, page.json and code_files, the scripts, style sheets and modules the page names, so a change to any of them asks again; enabled false when an administrator turned shared pages off). GET /api/chats/shared-pages/{folder_id}/{name} returns one with its HTML and your consent; PUT .../consent {hash, once} allows that version (once gives a visit token), DELETE .../consent revokes it. The bridge of a shared page is .../files, .../file and .../chat as for your own pages, each with the header X-LocalAI-Page-Hash (the version that runs) and X-LocalAI-Page-Visit for a visit allowed once; without a consent it answers 403 page_not_allowed, a read of a file of code by a frame of an older version answers 409, and a read-only grant cannot write. Needs the chats and pages features (pages is off by default for users who are not administrators)."}, {Name: "addons", Description: "Owner-routed addon lifecycle and response relays", Tags: []string{"Addons"}, Intro: "POST /api/addons/dispatch submits typed lifecycle operations or reads actor-bound operation-status. POST /api/addons/relay serves status and coarse lifecycle progress on the receiving frontend. Named authentication and the addons feature are required; lifecycle operations require administration. Preserve operationId after outcome_unknown and poll status, never submit a new mutation. An add-on that refuses a request as invalid answers 400 invalid_request from every route, never an outage: nothing was applied, so correct the request instead of repeating it. Resource and output routes deny access until host admission and resolution providers are installed. Data and schema mutations are stored proposals: POST /api/addons/proposals previews and stores one (nothing is applied), GET /api/addons/proposals/{id} reads it, GET /api/addons/proposals?state=pending lists the caller's own pending proposals (newest first, at most 32; where a proposal made through the OpenAI-compatible chat route, an API client or an MCP session waits for the proposer's own browser session; the chats of /api/chats, their agents and their triggers apply a change at once because turning the add-on on for the chat is the consent, and are not proposals), POST .../cancel withdraws one and POST .../reconcile resolves an unknown outcome by receipt lookup, and when no receipt can be found POST .../resolve lets the resource owner's own browser session abandon it with a reason (same confirmation-surface rules as confirm; nothing is applied again). The owner role finds what to resolve with GET /api/addons/resources/{resourceId}/proposals?state=unresolved (same rules, X-LocalAI-Confirm equals the resource ID): it lists the proposal ID, proposer, effects, digest and age of every unresolved proposal on the resource, never its arguments. A write blocked by an unresolved outcome is denied (403) and, for the owner role only, names it as outcome_blocked with blockedBy. POST .../confirm applies exactly the stored operation once and is accepted only from the proposer's own browser session (same-origin fetch, X-LocalAI-Confirm header equal to the proposal ID, digest of what was shown); API keys, MCP sessions and tool or agent calls can propose but never confirm, and no MCP tool maps to confirm. Sharing and database deletion are never proposals. A read that returns a file result gives private artifact IDs, never addon handles: GET /api/addons/artifacts/{id} returns the transfer state, name, size and expiry, and GET /api/addons/artifacts/{id}/download streams the complete file as an attachment. Only the requesting principal can read or download an artifact, an unknown, foreign or expired ID is the same 403, access to every source resource is checked again at completion and while streaming, and a disabled addon denies downloads until it is enabled again. Only GET /backends/library/updates has an MCP tool, list_backend_library_updates; no MCP tool maps to the other routes. The Apps surface: GET /api/addons/apps lists the installed, enabled addons any caller with the addons feature may use, with title, description, icon URL, version, generation, state (running, starting, failed or stopped; an observation separate from enabled), a main page path only when the manifest declares a valid one, and a bounded tool summary; administrators also see disabled addons and per-app controls. GET /api/addons/apps/{id}/icon?generation=N serves the verified icon of a visible app. POST /api/addons/apps/{id}/start and .../stop are administrator-only, take {operationId, generation} with the generation that was listed, deny a stale generation and keep the lifecycle rules (preserve operationId after outcome_unknown). No MCP tool maps to the Apps routes. The listed ui entry of an app gives the same-origin URLs of its UI code: GET /api/addons/{id}/assets/{digest}/{path} streams one declared file (the entry module or a style sheet) of the current installed package, relayed from the owner, with Cache-Control private, max-age=31536000, immutable and nosniff. The digest is the package sha256 in lower-case hex; a file the manifest does not declare, a traversal, another addon, an older package and a disabled or uninstalled addon are all one 403, never cached. A credential in the URL is never accepted; the browser uses its same-origin session. No MCP tool maps to the asset route. Management (administrators only, through POST /api/addons/dispatch): the reads catalog-search, catalog-details, catalog-sources and addons-installed return catalog entries, one resolved entry, the configured libraries with their last fetch state and the installed addons with verified package facts, health, retained generations and storage; catalog-source-save stores or removes a library by reference only (a secret in place of a reference is denied) and catalog-source-refresh fetches one again; upgrade installs a newer release of an installed addon. Removing a library never touches installed addons. The installed view has an update object computed by the owner's update check (state, candidate version and digest, check and last-success time, reasons, redacted error class); an add-on the owner cannot answer for has none, and a failed check is never up-to-date. No MCP tool maps to these actions. UI-only resource management (never tools; no MCP tool maps to any of it): GET /api/addons/resources?addonId=tables lists the resources the caller owns or that were shared with the caller, with the caller's own role, the display name, the owner's display name for a shared one and, for the owner role only, the number of people it is shared with and the revision. POST /api/addons/resources creates one by the person's own action (operationId makes it idempotent; the arguments are the add-on's create tool arguments), POST .../{resourceId}/rename and .../delete rename and delete it (the owner role only; generation and revision must be the listed ones or the call is 409 stale; delete needs confirm equal to the display name, removes the data and every grant and never uninstalls the add-on), GET .../{resourceId}/grants, PUT/DELETE .../grants/{userId} and GET .../{resourceId}/users?q= list, set (viewer or editor, whole resource) and remove grants and search the people it can be shared with (immutable account ID and display name only; local-owner mode answers 409 sharing_disabled). All of them accept only the owner's own browser session with the X-LocalAI-Confirm header equal to the resource ID (the operation ID for a creation); API keys are refused. POST .../{resourceId}/apply changes the data or the schema by the person's own action with no card, through a write or schema tool the add-on declares ({operationId, generation, revision, tool, arguments} with the revision the caller read; editors write rows, only the owner changes the schema; 409 stale when the generation or revision moved; the same operationId with the same request is one change; reads, creations, rename, delete and undeclared tools are refused). POST .../resources/{resourceId}/changes/{changeId}/undo ({operationId, generation}) undoes one change an add-on tool made at once from a chat, an agent or a trigger, by the change set the add-on kept for it; the change ID is the one the tool result named (undoable true, changeId, undo). It is the person's own action in a browser tab and accepts only the browser session with X-LocalAI-Confirm equal to the resource ID: an API key, an MCP session or a tool call is refused, so a model can never undo work. The role is the one the change needed (editors undo data changes, only the owner role undoes a schema change or a creation). It is applied against the revision the add-on just reported: when the resource changed after the change the answer is 409 stale with the words of what changed and nothing is overwritten. The other refusals are typed: not_found (404), expired (410, after 7 days), already_undone (409) and cannot_undo (409, for example a job that finished); the same operationId with the same change is one undo. GET .../resources/{resourceId}/operations/{operationId}?kind=rename|delete|apply|undo reads the receipt of a rename, a deletion, a change or an undo after outcome_unknown, which carries the original operationId. GET/PUT/DELETE /api/addons/conversations/{conversationId}/selection/{addonId} keep the resource a conversation works on for the authenticated user; a chat tool call that names no resourceId uses it, authorized again by the owner on every call, and with none selected the tool answers an error result with code select_resource. Data recovery (administrators only, never tools; no MCP tool maps to any of it): GET /api/addons/{id}/recovery returns the recovery state of an add-on's data (ready, upgrading, restoring, needs-recovery or purging), its backups newest first with size, reason, mode and last verification result, the retention policy and the receipt of the last restore. POST /api/addons/{id}/recovery/backup makes a verified, consistent backup (the add-on is stopped while its SQLite files are copied with VACUUM INTO, admission is blocked and pending proposals are invalidated; nothing is left behind on failure; 503 insufficient_storage when it does not fit), .../verify reads one completely, .../restore restores one (the add-on must not be running, confirm is the add-on ID, mapping names the account that takes the data of a backup made in local-owner mode, the answer carries warnings and no share is given back that was removed since the backup or whose account is gone; 409 needs_recovery, addon_running, backup_corrupt, incompatible_format, identity_changed, mapping_required or downgrade_unsupported), .../delete-backup removes one (never the only verified copy, 409 sole_recovery_copy) and .../prune applies the retention policy. POST /api/addons/{id}/purge removes retained data by explicit scope (data, backups, recoveryCopies) with confirm equal to the add-on ID, for an add-on that is uninstalled or disabled; it is not a database deletion or an uninstall. Every mutation accepts only the administrator's own browser session (same-origin fetch, X-LocalAI-Confirm equal to the operationId); a repeated operationId returns the recorded result and never runs again; after outcome_unknown read the state and keep the operationId. An upgrade of an add-on that has data is backed up, migrated under exclusive ownership and published only when healthy; a failed migration that changes the data format leaves the add-on in needs-recovery with the previous package and the verified backup retained. Updates (never tools; no MCP tool maps to any of it): GET /api/addons/{id}/update reads the owner's update status of an installed add-on for every caller with the addons feature who may see it (administrators any installed add-on, others an enabled one; the same 403 otherwise). The state is unknown, up-to-date, update-available, incompatible (reasons: platform, host-version, api-version, protocol-version, sdk-version, data-format), check-failed (a redacted error class and lastSuccess, the time the library was last read; never shown as up-to-date) or integrity-error (an immutable version was listed again with another digest; never an update). A moving channel (development, a branch, latest) compares resolved digests, so a changed digest with the same version label is an update; an immutable version selects the highest compatible greater version of the same major. The library and the channel the add-on came from never change by themselves. Callers who are not administrators get the state, versions and times only. The route never uses the network. POST /api/addons/{id}/update/check (administrators, own browser session, X-LocalAI-Confirm equal to the operationId) fetches the library again and returns the status; it is rate limited and answers throttled true when it did not fetch, and it applies nothing. POST /api/addons/{id}/upgrade (administrators, own browser session) is the only request that applies an update: it carries operationId, expectedDigest (the installed package the caller saw) and candidateDigest (the candidate the check resolved), and the owner re-resolves, re-checks compatibility and refuses before it records anything with 409 stale_installed, candidate_changed, incompatible, integrity_error or upgrade_conflict (the same transition under another operationId); 503 check_failed when the library cannot be read. A failure keeps the last good generation or enters the recovery state; the previous installation is never deleted. The apps list marks an app update: update-pending from the owner's cached check only and never reaches a library."}, {Name: "library-sources", Description: "Library sources of add-ons, backends and models", Tags: []string{"library"}, Intro: "Administrators only. GET /api/library/sources lists every source of the add-on, backend and model kinds, shipped defaults and removed defaults (tombstones) included, with kind, id, name, url, mirrors, enabled, default, removed, suspended, a conflict message and the last fetch state. POST /api/library/sources adds a source of your own ({kind: addon|backend|model, url, id, name, format: library|gallery}); the same kind and URL twice is a 409. POST /api/library/sources/{id}/enable and /disable turn one on or off (enabling a removed default brings it back). PUT /api/library/sources/{id} replaces the mirrors of a model or backend source ({mirrors: [..]}); POST also takes mirrors. Each mirror is an http(s), github:, oci:// or file:// address, at most 8. DELETE /api/library/sources/{id} removes a source; a shipped default stays as a tombstone so it does not return at the next start. The routes work on the store that `local-ai library` edits. On a frontend (--addons-role=frontend) the routes forward to the add-on owner, which is the only writer; they answer 503 when the owner is not reachable or runs on another platform. `GET /backends/library/updates` and `POST /backends/library/install` forward the same way. The runtime setting official_libraries (default on) turns every shipped default off at once: they stay listed with suspended true. No MCP tool maps to these routes."}, diff --git a/core/http/endpoints/studio/connectors.go b/core/http/endpoints/studio/connectors.go index 173bae2b65..e124d4d3b0 100644 --- a/core/http/endpoints/studio/connectors.go +++ b/core/http/endpoints/studio/connectors.go @@ -22,12 +22,62 @@ func connectorOut(i studiosvc.ConnectorInfo) schema.ChatsConnector { if allow == nil { allow = []string{} } - return schema.ChatsConnector{ID: i.ID, Name: i.Name, Kind: i.Kind, Addon: i.AddonID, Active: i.Active, Reason: i.Reason, AgentID: i.AgentID, Enabled: i.Enabled, AcceptAny: i.AcceptAny, + return schema.ChatsConnector{Caps: capsOut(i.Caps), Grants: grantsOut(i.Grants), Paired: pairedOut(i.Paired), ID: i.ID, Name: i.Name, Kind: i.Kind, Addon: i.AddonID, Active: i.Active, Reason: i.Reason, AgentID: i.AgentID, Enabled: i.Enabled, AcceptAny: i.AcceptAny, Allow: allow, NotifyChat: i.NotifyChat, HasToken: i.HasToken, Created: i.Created, ReplyMode: i.ReplyMode(), Conversation: i.ConversationMode(), WindowMinutes: i.WindowMinutes, SendFiles: i.SendsFiles(), Options: i.Options, Files: filesOut(i.Files), FileReceiveMaxBytes: i.FileReceiveMax, FileSendMaxBytes: i.FileSendMax} } +func capsOut(in []string) []string { + if in == nil { + return []string{} + } + return in +} + +func grantsOut(in []connectors.RoleGrant) []schema.ChatsConnectorRoleGrant { + out := make([]schema.ChatsConnectorRoleGrant, 0, len(in)) + for _, g := range in { + out = append(out, schema.ChatsConnectorRoleGrant{Role: g.Role, Caps: capsOut(g.Caps)}) + } + return out +} + +func pairedOut(in []connectors.PairedSender) []schema.ChatsConnectorPaired { + out := make([]schema.ChatsConnectorPaired, 0, len(in)) + for _, p := range in { + out = append(out, schema.ChatsConnectorPaired{SenderID: p.SenderID, PairedAt: p.At, ExpiresAt: p.At.Add(connectors.PairedTTL)}) + } + return out +} + +func grantsIn(in []schema.ChatsConnectorRoleGrant) []connectors.RoleGrant { + if in == nil { + return nil + } + out := make([]connectors.RoleGrant, 0, len(in)) + for _, g := range in { + out = append(out, connectors.RoleGrant{Role: g.Role, Caps: g.Caps}) + } + return out +} + +func rolesOut(k contract.ConnectorKind) []schema.ChatsConnectorRole { + var out []schema.ChatsConnectorRole + for _, r := range k.Roles { + out = append(out, schema.ChatsConnectorRole{ID: r.ID, Title: r.Title, Description: r.Description}) + } + return out +} + +func permissionsOut(k contract.ConnectorKind) []schema.ChatsConnectorPermission { + var out []schema.ChatsConnectorPermission + for _, p := range k.Permissions { + out = append(out, schema.ChatsConnectorPermission{Name: p, Sensitive: contract.SensitiveCapability(p)}) + } + return out +} + func filesOut(l studiosvc.FileLimits) schema.ChatsConnectorFiles { dir := func(e studiosvc.FileLimit, declared, allowed int64) schema.ChatsConnectorFileLimit { return schema.ChatsConnectorFileLimit{EffectiveBytes: e.Bytes, Layer: string(e.Layer), DeclaredBytes: declared, AllowedBytes: allowed} @@ -47,7 +97,7 @@ func connectorIn(req schema.ChatsConnectorWrite) studiosvc.ConnectorInput { return studiosvc.ConnectorInput{Kind: req.Kind, Name: req.Name, Token: req.Token, AgentID: req.AgentID, Enabled: req.Enabled, AcceptAny: req.AcceptAny, Allow: req.Allow, NotifyChat: req.NotifyChat, Reply: req.ReplyMode, Conversation: req.Conversation, WindowMinutes: req.WindowMinutes, SendFiles: req.SendFiles, Options: req.Options, - FileReceiveMax: req.FileReceiveMaxBytes, FileSendMax: req.FileSendMaxBytes} + FileReceiveMax: req.FileReceiveMaxBytes, FileSendMax: req.FileSendMaxBytes, Caps: req.Caps, Grants: grantsIn(req.Grants)} } var connectorRules = []errRule{ @@ -109,7 +159,7 @@ func (h *Handlers) ConnectorKinds(c echo.Context) error { out := schema.ChatsConnectorKindList{Kinds: make([]schema.ChatsConnectorKind, 0, len(kinds))} for _, k := range kinds { out.Kinds = append(out.Kinds, schema.ChatsConnectorKind{Addon: k.AddonID, Kind: k.ID, Title: k.Title, Description: k.Description, SenderHint: k.SenderHint, Page: k.Page, ThreadHistory: k.ServesThreadHistory(), Options: optionsOut(k.Options), - Files: filesOut(host.KindFileLimits(c.Request().Context(), k))}) + Files: filesOut(host.KindFileLimits(c.Request().Context(), k)), Roles: rolesOut(k.ConnectorKind), Permissions: permissionsOut(k.ConnectorKind)}) } return c.JSON(http.StatusOK, out) } @@ -302,6 +352,45 @@ func (h *Handlers) AllowConnectorSender(c echo.Context) error { return c.JSON(http.StatusOK, connectorOut(i)) } +// StartConnectorPairing makes a pairing code for a binding. +// @Summary Make a pairing code for a connector binding. +// @Description Pairing proves that you control a platform account. Send the code, as a direct message to the bot, from an account on the allowlist, within ten minutes. The code works once, and a new code replaces the old one. A paired sender may use the sensitive capabilities you enabled; a pairing ends after 90 days. At most three senders are paired. +// @Tags chats +// @Param id path string true "the binding's id" +// @Success 200 {object} schema.ChatsConnectorPairing "The code and when it expires" +// @Router /api/chats/connectors/{id}/pairing [post] +func (h *Handlers) StartConnectorPairing(c echo.Context) error { + host, ok := h.connectorHost(c) + if !ok { + return nil + } + p, err := host.StartPairing(c.Request().Context(), userID(c), c.Param("id")) + if err != nil { + return failConnector(c, err) + } + return c.JSON(http.StatusOK, schema.ChatsConnectorPairing{Code: p.Code, Expires: p.Expires}) +} + +// RemoveConnectorPaired ends the pairing of a sender. +// @Summary End the pairing of a sender of a connector binding. +// @Description The sender can pair again with a new code. +// @Tags chats +// @Param id path string true "the binding's id" +// @Param sender path string true "the sender's id" +// @Success 200 {object} schema.ChatsConnector "The binding" +// @Router /api/chats/connectors/{id}/paired/{sender} [delete] +func (h *Handlers) RemoveConnectorPaired(c echo.Context) error { + host, ok := h.connectorHost(c) + if !ok { + return nil + } + i, err := host.RemovePaired(c.Request().Context(), userID(c), c.Param("id"), c.Param("sender")) + if err != nil { + return failConnector(c, err) + } + return c.JSON(http.StatusOK, connectorOut(i)) +} + func optionsOut(in []contract.ConnectorOption) []schema.ChatsConnectorOption { var out []schema.ChatsConnectorOption for _, o := range in { diff --git a/core/http/routes/studio.go b/core/http/routes/studio.go index c6e3292c96..ab494dfc0a 100644 --- a/core/http/routes/studio.go +++ b/core/http/routes/studio.go @@ -221,6 +221,8 @@ func RegisterStudioRoutes(e *echo.Echo, svc *studiosvc.Service, models studioend e.POST(base+"/connectors/:id/test", h.TestConnector) e.GET(base+"/connectors/:id/senders", h.ConnectorSenders) e.POST(base+"/connectors/:id/senders/:sender/allow", h.AllowConnectorSender) + e.POST(base+"/connectors/:id/pairing", h.StartConnectorPairing) + e.DELETE(base+"/connectors/:id/paired/:sender", h.RemoveConnectorPaired) e.GET(base+"/mcp-connectors", h.ListMCPConnectors) e.POST(base+"/mcp-connectors", h.CreateMCPConnector) e.GET(base+"/mcp-connectors/:id", h.GetMCPConnector) diff --git a/core/http/routes/studio_connectors_test.go b/core/http/routes/studio_connectors_test.go index 32f16d8c6e..373a6c9f05 100644 --- a/core/http/routes/studio_connectors_test.go +++ b/core/http/routes/studio_connectors_test.go @@ -53,6 +53,8 @@ var _ = Describe("Chats connectors endpoints", func() { Entry("test", http.MethodPost, "/api/chats/connectors/c1/test"), Entry("senders", http.MethodGet, "/api/chats/connectors/c1/senders"), Entry("allow", http.MethodPost, "/api/chats/connectors/c1/senders/42/allow"), + Entry("pairing", http.MethodPost, "/api/chats/connectors/c1/pairing"), + Entry("unpair", http.MethodDelete, "/api/chats/connectors/c1/paired/42"), ) It("is refused to a user without the chats feature", func() { @@ -259,4 +261,27 @@ var _ = Describe("Chats connectors endpoints", func() { rec = s.do("alice", http.MethodPut, "/api/chats/connectors/"+id, map[string]any{"name": "Renamed", "agent_id": ag}) Expect(rec.Code).To(Equal(http.StatusOK), "an update that keeps the token is not checked: %s", rec.Body.String()) }) + + It("keeps the capabilities of a binding, refuses unknown ones and issues a pairing code to its owner only", func() { + ag := agent("alice", "Caps", "test-model") + rec := create("alice", map[string]any{"name": "Caps bot", "token": specToken, "agent_id": ag, "caps": []string{"memory.write", "profile.write"}}) + Expect(rec.Code).To(Equal(http.StatusCreated), rec.Body.String()) + b := decodeJSON(rec) + id := b["id"].(string) + Expect(b).To(And(HaveKeyWithValue("caps", ConsistOf("memory.write", "profile.write")), HaveKeyWithValue("grants", BeEmpty()), HaveKeyWithValue("paired", BeEmpty()))) + // An update that omits the capabilities keeps them; an empty list clears. + rec = s.do("alice", http.MethodPut, "/api/chats/connectors/"+id, map[string]any{"name": "Caps bot", "agent_id": ag}) + Expect(decodeJSON(rec)).To(HaveKeyWithValue("caps", HaveLen(2))) + rec = s.do("alice", http.MethodPut, "/api/chats/connectors/"+id, map[string]any{"name": "Caps bot", "agent_id": ag, "caps": []string{}}) + Expect(decodeJSON(rec)).To(HaveKeyWithValue("caps", BeEmpty())) + rec = s.do("alice", http.MethodPut, "/api/chats/connectors/"+id, map[string]any{"name": "Caps bot", "agent_id": ag, "caps": []string{"root.everything"}}) + Expect(rec.Code).To(Equal(http.StatusBadRequest), rec.Body.String()) + + rec = s.do("alice", http.MethodPost, "/api/chats/connectors/"+id+"/pairing", nil) + Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String()) + Expect(decodeJSON(rec)["code"]).To(MatchRegexp(`^LA-[A-Z0-9]{8}$`)) + Expect(s.do("bob", http.MethodPost, "/api/chats/connectors/"+id+"/pairing", nil).Code).To(Equal(http.StatusNotFound)) + rec = s.do("alice", http.MethodDelete, "/api/chats/connectors/"+id+"/paired/42", nil) + Expect(rec.Code).To(Equal(http.StatusOK), rec.Body.String()) + }) }) diff --git a/core/schema/chats.go b/core/schema/chats.go index b983c3fe06..ff8ac1846d 100644 --- a/core/schema/chats.go +++ b/core/schema/chats.go @@ -1192,6 +1192,48 @@ type ChatsConnector struct { // zero means you set none and the limit of the kind applies. FileReceiveMaxBytes int64 `json:"file_receive_max_bytes"` FileSendMaxBytes int64 `json:"file_send_max_bytes"` + // Caps are the capabilities you enabled for this binding: the ceiling of + // what any sender can use. Grants give roles of the kind some of them. + Caps []string `json:"caps"` + Grants []ChatsConnectorRoleGrant `json:"grants"` + // Paired are the senders you proved with a pairing code. A pairing ends + // at expires_at. + Paired []ChatsConnectorPaired `json:"paired"` +} + +// ChatsConnectorRoleGrant gives the capabilities caps to the senders that the +// add-on reports with the role role. +type ChatsConnectorRoleGrant struct { + Role string `json:"role"` + Caps []string `json:"caps"` +} + +// ChatsConnectorPaired is a paired sender of a binding. +type ChatsConnectorPaired struct { + SenderID string `json:"sender_id"` + PairedAt time.Time `json:"paired_at"` + ExpiresAt time.Time `json:"expires_at"` +} + +// ChatsConnectorPairing is a pairing code. Send it from the account to pair, as +// a direct message to the bot, before it expires. +type ChatsConnectorPairing struct { + Code string `json:"code"` + Expires time.Time `json:"expires"` +} + +// ChatsConnectorRole is a role that a kind can give to a sender. +type ChatsConnectorRole struct { + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description,omitempty"` +} + +// ChatsConnectorPermission is a capability that a kind lists. Sensitive ones +// are never granted to a role: only a paired sender can use them. +type ChatsConnectorPermission struct { + Name string `json:"name"` + Sensitive bool `json:"sensitive"` } // ChatsConnectorFiles are the file limits of a binding or of a kind, for both @@ -1242,6 +1284,11 @@ type ChatsConnectorKind struct { // Files are the limits of a binding of this kind that has no limit of // your own. Files ChatsConnectorFiles `json:"files"` + // Roles are the roles the add-on can report for a sender, and + // Permissions the capabilities the kind lists. Grant them to a role with + // the grants of a binding. + Roles []ChatsConnectorRole `json:"roles,omitempty"` + Permissions []ChatsConnectorPermission `json:"permissions,omitempty"` } // ChatsConnectorOption is one choice of a connector kind. @@ -1300,6 +1347,15 @@ type ChatsConnectorWrite struct { // means none for a new binding and unchanged for an update. FileReceiveMaxBytes *int64 `json:"file_receive_max_bytes,omitempty"` FileSendMaxBytes *int64 `json:"file_send_max_bytes,omitempty"` + // Caps are the capabilities you enable for the binding (a closed list, + // see the kind's permissions for the ones it can grant to a role). + // Absent means none for a new binding and unchanged for an update; an + // empty list clears. + Caps []string `json:"caps,omitempty"` + // Grants map roles of the kind to capabilities within caps. A sensitive + // capability is never granted to a role. Absent means unchanged; an empty + // list clears. + Grants []ChatsConnectorRoleGrant `json:"grants,omitempty"` } // ChatsConnectorList is the response of the connector list endpoint. diff --git a/core/services/studio/connectorpairing.go b/core/services/studio/connectorpairing.go new file mode 100644 index 0000000000..fb3f109598 --- /dev/null +++ b/core/services/studio/connectorpairing.go @@ -0,0 +1,200 @@ +package studio + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "regexp" + "slices" + "strings" + "time" + + "github.com/mudler/LocalAI/core/services/studio/connectors" + "github.com/mudler/LocalAI/pkg/addons/contract" +) + +// Sender pairing proves that the owner of a binding controls a platform +// account. The owner asks the host for a code, then sends it as a direct +// message to the bot from that account. The host checks the code before any +// model turn, so the code never reaches a model or the sender log. Pairing +// needs only a stable sender ID, so it works for every connector add-on. + +const ( + pairingPrefix = "LA-" + pairingChars = 8 + // pairingAlphabet has no look-alike characters (0/O, 1/I/L). + pairingAlphabet = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" + // pairingAttempts is how many wrong codes a sender may send in an hour + // before the host stops looking at that sender's messages for an hour. + pairingAttempts = 5 + pairingWindow = time.Hour + + sentPaired = "Paired. This account can now use the sensitive features that you enabled for this bot." + sentPairingBad = "That code is not valid. It may have expired or been used. Ask for a new one." + sentPairingFull = "This bot already has the most paired accounts. Remove one in the settings, then pair again." + sentPairingDirect = "Pairing works in a direct message only." +) + +var pairingShape = regexp.MustCompile(`^LA-[A-Z0-9]{8}$`) + +// PairingCode is a code for a sender to send to the bot. +type PairingCode struct { + Code string + Expires time.Time +} + +func pairingHash(binding, code string) string { + sum := sha256.Sum256([]byte(binding + "\x00" + strings.ToUpper(code))) + return hex.EncodeToString(sum[:]) +} + +func newPairingCode() (string, error) { + var raw [pairingChars]byte + if _, err := rand.Read(raw[:]); err != nil { + return "", err + } + var b strings.Builder + b.WriteString(pairingPrefix) + for _, x := range raw { + b.WriteByte(pairingAlphabet[int(x)%len(pairingAlphabet)]) + } + return b.String(), nil +} + +// StartPairing makes a code for a binding of the user. It is valid for +// connectors.PairingCodeTTL and works once. A new code replaces the old one. +func (c *Connectors) StartPairing(ctx context.Context, user, id string) (PairingCode, error) { + b, err := c.owned(ctx, user, id) + if err != nil { + return PairingCode{}, err + } + code, err := newPairingCode() + if err != nil { + return PairingCode{}, err + } + exp := c.o.Clock().UTC().Add(connectors.PairingCodeTTL) + if err := c.o.Store.PutPairing(ctx, b.ID, connectors.Pairing{Hash: pairingHash(b.ID, code), Expires: exp}); err != nil { + return PairingCode{}, err + } + c.audit(b, "", "pairing.code", "issued") + return PairingCode{Code: code, Expires: exp}, nil +} + +// RemovePaired ends the pairing of a sender of a binding of the user. It is +// not an error when the sender is not paired. +func (c *Connectors) RemovePaired(ctx context.Context, user, id, senderID string) (ConnectorInfo, error) { + c.wmu.Lock() + defer c.wmu.Unlock() + b, err := c.owned(ctx, user, id) + if err != nil { + return ConnectorInfo{}, err + } + b.Paired = slices.DeleteFunc(b.Paired, func(p connectors.PairedSender) bool { return p.SenderID == senderID }) + b.Updated = c.o.Clock().UTC() + if err := c.o.Store.PutBinding(ctx, b); err != nil { + return ConnectorInfo{}, err + } + c.audit(b, senderID, "pairing.remove", "removed") + return c.withState(ctx, info(b)), nil +} + +// audit records a pairing decision. It never carries a code. +func (c *Connectors) audit(b connectors.Binding, sender, event, result string) { + c.log.Info("studio: connector audit", "binding", b.ID, "kind", b.Kind, "sender", sender, "event", event, "result", result) +} + +// pairingLocked tells whether the sender is locked out: pairingAttempts wrong +// codes in pairingWindow lock the sender for pairingWindow. +func (c *Connectors) pairingLocked(s *senderState) bool { + now := c.o.Clock() + c.smu.Lock() + defer c.smu.Unlock() + return now.Before(s.lockedUntil) +} + +// pairingFailed counts a wrong code of a sender. +func (c *Connectors) pairingFailed(s *senderState) { + now := c.o.Clock() + c.smu.Lock() + defer c.smu.Unlock() + kept := s.failures[:0] + for _, t := range s.failures { + if now.Sub(t) < pairingWindow { + kept = append(kept, t) + } + } + s.failures = append(kept, now) + if len(s.failures) >= pairingAttempts { + s.lockedUntil = now.Add(pairingWindow) + s.failures = nil + } +} + +// tryPairing handles a message that has the shape of a pairing code. It +// returns true when the message was a pairing attempt: the caller must not +// start a turn for it. The sender is already on the allowlist. +func (c *Connectors) tryPairing(ctx context.Context, b connectors.Binding, m contract.ConnectorMessage, st *senderState, say func(string)) bool { + code := strings.ToUpper(strings.TrimSpace(m.Text)) + if !pairingShape.MatchString(code) { + return false + } + if c.pairingLocked(st) { + c.audit(b, m.SenderID, "pairing.attempt", "locked") + return true + } + if !m.Direct { + // A code written in a group is spent in public: it is not tried. + c.audit(b, m.SenderID, "pairing.attempt", "not direct") + c.pairingFailed(st) + say(sentPairingDirect) + return true + } + ok, err := c.o.Store.ConsumePairing(ctx, b.ID, pairingHash(b.ID, code), c.o.Clock()) + if err != nil { + c.log.Warn("studio: could not check a pairing code", "binding", b.ID, "error", err) + say(sentUnavailable) + return true + } + if !ok { + c.audit(b, m.SenderID, "pairing.attempt", "refused") + c.pairingFailed(st) + say(sentPairingBad) + return true + } + if err := c.addPaired(ctx, b.ID, m.SenderID); err != nil { + c.audit(b, m.SenderID, "pairing.attempt", err.Error()) + say(sentPairingFull) + return true + } + c.audit(b, m.SenderID, "pairing.attempt", "paired") + say(sentPaired) + return true +} + +// addPaired stores a pairing, dropping the ones that ended. A sender who pairs +// again renews the pairing. +func (c *Connectors) addPaired(ctx context.Context, id, senderID string) error { + c.wmu.Lock() + defer c.wmu.Unlock() + b, err := c.o.Store.GetBinding(ctx, id) + if err != nil { + return err + } + now := c.o.Clock().UTC() + b.Paired = slices.DeleteFunc(b.Paired, func(p connectors.PairedSender) bool { + return p.SenderID == senderID || now.Sub(p.At) >= connectors.PairedTTL + }) + if len(b.Paired) >= connectors.MaxPaired { + return errPairedFull + } + b.Paired = append(b.Paired, connectors.PairedSender{SenderID: senderID, At: now}) + b.Updated = now + return c.o.Store.PutBinding(ctx, b) +} + +type pairingErr string + +func (e pairingErr) Error() string { return string(e) } + +const errPairedFull = pairingErr("too many paired senders") diff --git a/core/services/studio/connectorroles_internal_test.go b/core/services/studio/connectorroles_internal_test.go new file mode 100644 index 0000000000..a6a48085c8 --- /dev/null +++ b/core/services/studio/connectorroles_internal_test.go @@ -0,0 +1,48 @@ +package studio + +import ( + "context" + + "github.com/mudler/LocalAI/core/services/studio/connectors" + "github.com/mudler/LocalAI/pkg/addons/contract" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type rolesTransport struct{ roles []contract.ConnectorRole } + +func (t rolesTransport) Kinds(context.Context) ([]ConnectorKind, error) { + return []ConnectorKind{{AddonID: "one", ConnectorKind: contract.ConnectorKind{ID: "chat", Title: "Chat", Roles: t.roles}}}, nil +} + +func (rolesTransport) Call(context.Context, ConnectorCallScope, contract.ConnectorRequest) (contract.ConnectorResponse, error) { + return contract.ConnectorResponse{}, nil +} + +var _ = Describe("the roles of an inbound message", func() { + roles := func(tr ConnectorTransport) *Connectors { + svc, _ := newSkillService(&skillBroker{}) + st, err := connectors.NewJSON("") + Expect(err).NotTo(HaveOccurred()) + c, err := NewConnectors(svc, ConnectorOptions{Store: st, Transport: tr}) + Expect(err).NotTo(HaveOccurred()) + return c + } + b := connectors.Binding{ID: "b1", Kind: "chat", AddonID: "one"} + ctx := context.Background() + + It("keeps the roles that the kind declares and drops the others", func() { + c := roles(rolesTransport{roles: []contract.ConnectorRole{{ID: "admin", Title: "Admin"}}}) + Expect(c.declaredRoles(ctx, b, []string{"admin", "root", "admin"})).To(Equal([]string{"admin"})) + }) + It("drops all roles for a kind that declares none", func() { + c := roles(rolesTransport{}) + Expect(c.declaredRoles(ctx, b, []string{"admin"})).To(BeEmpty()) + }) + It("drops all roles for a binding of another add-on's kind", func() { + c := roles(rolesTransport{roles: []contract.ConnectorRole{{ID: "admin", Title: "Admin"}}}) + other := b + other.AddonID = "two" + Expect(c.declaredRoles(ctx, other, []string{"admin"})).To(BeEmpty()) + }) +}) diff --git a/core/services/studio/connectors.go b/core/services/studio/connectors.go index 0094fc747c..0877410cfb 100644 --- a/core/services/studio/connectors.go +++ b/core/services/studio/connectors.go @@ -153,6 +153,10 @@ type senderState struct { active bool times []time.Time lastNotice time.Time + // failures are the times of wrong pairing codes; lockedUntil ends a + // lockout after too many. + failures []time.Time + lockedUntil time.Time } // NewConnectors returns the manager. It does nothing until Run or Sync is called. @@ -559,6 +563,9 @@ func (c *Connectors) Ingest(ctx context.Context, addonID string, in contract.Con if r := []rune(m.SenderName); len(r) > 200 { m.SenderName = string(r[:200]) } + // The add-on owns the roles of a sender; the host keeps the ones that the + // kind declares and drops the rest. + m.Roles = c.declaredRoles(ctx, b, m.Roles) token, err := c.openToken(ctx, b) if err != nil { c.disable(b, "its bot token cannot be read") @@ -568,6 +575,23 @@ func (c *Connectors) Ingest(ctx context.Context, addonID string, in contract.Con return nil } +// declaredRoles keeps the role IDs that the kind of the binding declares. A +// kind that is not running, or declares no roles, keeps none. +func (c *Connectors) declaredRoles(ctx context.Context, b connectors.Binding, ids []string) []string { + if len(ids) == 0 { + return nil + } + kinds, err := c.o.Transport.Kinds(ctx) + if err != nil { + return nil + } + k, ok := kindFor(kinds, b) + if !ok { + return nil + } + return k.FilterRoles(ids) +} + // conversation is the key of the chat that a message belongs to. A message in // a thread of the platform belongs to the thread, so the people who write in // one thread share one chat; any other message belongs to its sender. The key @@ -678,6 +702,11 @@ func (c *Connectors) handle(ctx context.Context, b connectors.Binding, token str case !ok: return } + // A pairing code is checked before any model turn and never reaches the + // model or the log. + if c.tryPairing(ctx, b, m, st, say) { + return + } switch { case m.Unsupported && len(m.Media) == 0: say(sentUnsupported) diff --git a/core/services/studio/connectors/connectorstest/suite.go b/core/services/studio/connectors/connectorstest/suite.go index 0ba7631170..7bb737525d 100644 --- a/core/services/studio/connectors/connectorstest/suite.go +++ b/core/services/studio/connectors/connectorstest/suite.go @@ -113,6 +113,49 @@ func Run(newStore NewStore) { Expect(byID).To(HaveKeyWithValue("new", "slack-addon")) } }) + It("keeps the capabilities, grants and paired senders", func() { + s := newStore() + b := binding("b1", "u1", "Bot") + b.Caps = []string{"memory.write", "triggers.run"} + b.Grants = []connectors.RoleGrant{{Role: "admin", Caps: []string{"memory.write"}}} + b.Paired = []connectors.PairedSender{{SenderID: "123", At: t0}} + Expect(s.PutBinding(ctx, b)).To(Succeed()) + got, err := s.GetBinding(ctx, "b1") + Expect(err).NotTo(HaveOccurred()) + Expect(got.Caps).To(Equal(b.Caps)) + Expect(got.Grants).To(Equal(b.Grants)) + Expect(got.Paired).To(HaveLen(1)) + Expect(got.Paired[0].SenderID).To(Equal("123")) + Expect(got.Paired[0].At.Equal(t0)).To(BeTrue()) + // A binding that has none reads as none. + Expect(s.PutBinding(ctx, binding("b2", "u1", "Bare"))).To(Succeed()) + bare, _ := s.GetBinding(ctx, "b2") + Expect(bare.Caps).To(BeEmpty()) + Expect(bare.Paired).To(BeEmpty()) + }) + + It("uses a pairing code once, before it expires, and only with the right hash", func() { + s := newStore() + Expect(s.PutPairing(ctx, "b1", connectors.Pairing{Hash: "h1", Expires: t0.Add(10 * time.Minute)})).To(Succeed()) + ok, err := s.ConsumePairing(ctx, "b1", "wrong", t0) + Expect(err).NotTo(HaveOccurred()) + Expect(ok).To(BeFalse()) + ok, _ = s.ConsumePairing(ctx, "b1", "h1", t0.Add(time.Minute)) + Expect(ok).To(BeTrue()) + ok, _ = s.ConsumePairing(ctx, "b1", "h1", t0.Add(time.Minute)) + Expect(ok).To(BeFalse(), "a code works once") + Expect(s.PutPairing(ctx, "b1", connectors.Pairing{Hash: "h2", Expires: t0.Add(10 * time.Minute)})).To(Succeed()) + ok, _ = s.ConsumePairing(ctx, "b1", "h2", t0.Add(11*time.Minute)) + Expect(ok).To(BeFalse(), "an expired code fails") + Expect(s.PutPairing(ctx, "b1", connectors.Pairing{Hash: "h3", Expires: t0.Add(10 * time.Minute)})).To(Succeed()) + Expect(s.PutPairing(ctx, "b1", connectors.Pairing{Hash: "h4", Expires: t0.Add(10 * time.Minute)})).To(Succeed()) + ok, _ = s.ConsumePairing(ctx, "b1", "h3", t0) + Expect(ok).To(BeFalse(), "a new code replaces the old one") + Expect(s.DeleteBinding(ctx, "b1")).To(Succeed()) + ok, _ = s.ConsumePairing(ctx, "b1", "h4", t0) + Expect(ok).To(BeFalse(), "deleting the binding removes its code") + }) + It("lists the enabled bindings", func() { s := newStore() a, b := binding("a", "u1", "A"), binding("b", "u2", "B") diff --git a/core/services/studio/connectors/gorm.go b/core/services/studio/connectors/gorm.go index abb94650a3..8d34a6a3f2 100644 --- a/core/services/studio/connectors/gorm.go +++ b/core/services/studio/connectors/gorm.go @@ -37,9 +37,14 @@ type bindingRow struct { // before them has no limit of the owner. FileReceiveMax int64 FileSendMax int64 - Token []byte - Created time.Time - Updated time.Time + // Caps, Grants and Paired are JSON, added by AutoMigrate; a row stored + // before them has none. + Caps string + Grants string + Paired string + Token []byte + Created time.Time + Updated time.Time } func (bindingRow) TableName() string { return "studio_connectors" } @@ -71,6 +76,14 @@ type chatRow struct { func (chatRow) TableName() string { return "studio_connector_chats" } +type pairingRow struct { + Binding string `gorm:"primaryKey;size:64"` + Hash string `gorm:"size:64"` + Expires time.Time +} + +func (pairingRow) TableName() string { return "studio_connector_pairings" } + type keyRow struct { Name string `gorm:"primaryKey;size:32"` Value []byte @@ -85,7 +98,7 @@ func NewGORM(db *gorm.DB) (Store, error) { if db == nil { return nil, errors.New("connectors: nil *gorm.DB") } - if err := db.AutoMigrate(&bindingRow{}, &stateRow{}, &senderRow{}, &chatRow{}, &keyRow{}); err != nil { + if err := db.AutoMigrate(&bindingRow{}, &stateRow{}, &senderRow{}, &chatRow{}, &keyRow{}, &pairingRow{}); err != nil { return nil, fmt.Errorf("connectors: migrate: %w", err) } return &gormStore{db: db}, nil @@ -106,6 +119,17 @@ func (r bindingRow) binding() (Binding, error) { return b, err } } + for _, f := range []struct { + raw string + dst any + }{{r.Caps, &b.Caps}, {r.Grants, &b.Grants}, {r.Paired, &b.Paired}} { + if f.raw == "" { + continue + } + if err := json.Unmarshal([]byte(f.raw), f.dst); err != nil { + return b, err + } + } return Normalize(b), nil } @@ -120,7 +144,14 @@ func (s *gormStore) PutBinding(ctx context.Context, b Binding) error { raw, _ := json.Marshal(b.Options) options = string(raw) } - return s.db.WithContext(ctx).Save(&bindingRow{ID: b.ID, Owner: b.Owner, Kind: b.Kind, AddonID: b.AddonID, Name: b.Name, AgentID: b.AgentID, Enabled: b.Enabled, + enc := func(v any, empty bool) string { + if empty { + return "" + } + raw, _ := json.Marshal(v) + return string(raw) + } + return s.db.WithContext(ctx).Save(&bindingRow{Caps: enc(b.Caps, len(b.Caps) == 0), Grants: enc(b.Grants, len(b.Grants) == 0), Paired: enc(b.Paired, len(b.Paired) == 0), ID: b.ID, Owner: b.Owner, Kind: b.Kind, AddonID: b.AddonID, Name: b.Name, AgentID: b.AgentID, Enabled: b.Enabled, AcceptAny: b.AcceptAny, Allow: allow, NotifyChat: b.NotifyChat, Reply: b.Reply, Conversation: b.Conversation, WindowMinutes: b.WindowMinutes, FilesOff: b.FilesOff, Options: options, FileReceiveMax: b.FileReceiveMax, FileSendMax: b.FileSendMax, Token: b.Token, Created: b.Created, Updated: b.Updated}).Error @@ -168,7 +199,7 @@ func (s *gormStore) EnabledBindings(ctx context.Context) ([]Binding, error) { func (s *gormStore) DeleteBinding(ctx context.Context, id string) error { return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - for _, m := range []any{&stateRow{}, &senderRow{}, &chatRow{}} { + for _, m := range []any{&stateRow{}, &senderRow{}, &chatRow{}, &pairingRow{}} { if err := tx.Where("binding = ?", id).Delete(m).Error; err != nil { return err } @@ -274,3 +305,14 @@ func (s *gormStore) EnsureKey(ctx context.Context, generate func() ([]byte, erro } return got, nil } + +func (s *gormStore) PutPairing(ctx context.Context, binding string, p Pairing) error { + return s.db.WithContext(ctx).Save(&pairingRow{Binding: binding, Hash: p.Hash, Expires: p.Expires.UTC()}).Error +} + +func (s *gormStore) ConsumePairing(ctx context.Context, binding, hash string, now time.Time) (bool, error) { + // An expired code stays until it is replaced or the binding goes; it + // cannot match. + res := s.db.WithContext(ctx).Where("binding = ? AND hash = ? AND expires > ?", binding, hash, now.UTC()).Delete(&pairingRow{}) + return res.RowsAffected == 1, res.Error +} diff --git a/core/services/studio/connectors/json.go b/core/services/studio/connectors/json.go index 207fbdc4c3..a2c02fd768 100644 --- a/core/services/studio/connectors/json.go +++ b/core/services/studio/connectors/json.go @@ -31,10 +31,11 @@ type jsonData struct { Offsets map[string]int64 `json:"offsets"` Senders map[string][]Sender `json:"senders"` Chats map[string]string `json:"chats"` // binding + "\x00" + sender + Pairings map[string]Pairing `json:"pairings,omitempty"` } func newData() jsonData { - return jsonData{Bindings: map[string]Binding{}, Offsets: map[string]int64{}, Senders: map[string][]Sender{}, Chats: map[string]string{}} + return jsonData{Bindings: map[string]Binding{}, Offsets: map[string]int64{}, Senders: map[string][]Sender{}, Chats: map[string]string{}, Pairings: map[string]Pairing{}} } // NewJSON returns a Store. With an empty dir it keeps everything in memory. @@ -60,6 +61,9 @@ func NewJSON(dir string) (Store, error) { if s.d.Bindings == nil { s.d = newData() } + if s.d.Pairings == nil { + s.d.Pairings = map[string]Pairing{} + } return s, nil } @@ -132,6 +136,7 @@ func (s *jsonStore) DeleteBinding(_ context.Context, id string) error { delete(s.d.Bindings, id) delete(s.d.Offsets, id) delete(s.d.Senders, id) + delete(s.d.Pairings, id) for k := range s.d.Chats { if len(k) > len(id) && k[:len(id)+1] == id+"\x00" { delete(s.d.Chats, k) @@ -236,3 +241,28 @@ func (s *jsonStore) EnsureKey(_ context.Context, generate func() ([]byte, error) s.key = k return append([]byte(nil), k...), nil } + +func (s *jsonStore) PutPairing(_ context.Context, binding string, p Pairing) error { + s.mu.Lock() + defer s.mu.Unlock() + s.d.Pairings[binding] = p + return s.save() +} + +func (s *jsonStore) ConsumePairing(_ context.Context, binding, hash string, now time.Time) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + p, ok := s.d.Pairings[binding] + if !ok || p.Hash != hash { + return false, nil + } + if !now.Before(p.Expires) { + return false, nil + } + delete(s.d.Pairings, binding) + if err := s.save(); err != nil { + s.d.Pairings[binding] = p + return false, err + } + return true, nil +} diff --git a/core/services/studio/connectors/permissions_test.go b/core/services/studio/connectors/permissions_test.go new file mode 100644 index 0000000000..b9a102625d --- /dev/null +++ b/core/services/studio/connectors/permissions_test.go @@ -0,0 +1,52 @@ +package connectors_test + +import ( + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/mudler/LocalAI/core/services/studio/connectors" +) + +var _ = Describe("the capabilities of a binding", func() { + base := func() connectors.Binding { + return connectors.Binding{Name: "Bot", Kind: "chat", AgentID: "a1"} + } + It("accepts known capabilities and a grant within them", func() { + b := base() + b.Caps = []string{"memory.write", "profile.write"} + b.Grants = []connectors.RoleGrant{{Role: "admin", Caps: []string{"memory.write"}}} + Expect(connectors.Validate(b)).To(Succeed()) + }) + It("refuses an unknown or repeated capability", func() { + b := base() + b.Caps = []string{"nope"} + Expect(connectors.Validate(b)).To(MatchError(connectors.ErrInvalid)) + b.Caps = []string{"memory.write", "memory.write"} + Expect(connectors.Validate(b)).To(MatchError(connectors.ErrInvalid)) + }) + It("refuses a grant above the ceiling or of a sensitive capability", func() { + b := base() + b.Caps = []string{"memory.write", "profile.write"} + b.Grants = []connectors.RoleGrant{{Role: "admin", Caps: []string{"files.upload"}}} + Expect(connectors.Validate(b)).To(MatchError(connectors.ErrInvalid)) + b.Grants = []connectors.RoleGrant{{Role: "admin", Caps: []string{"profile.write"}}} + Expect(connectors.Validate(b)).To(MatchError(ContainSubstring("sensitive"))) + }) + It("refuses more than three paired senders", func() { + b := base() + for _, id := range []string{"1", "2", "3", "4"} { + b.Paired = append(b.Paired, connectors.PairedSender{SenderID: id}) + } + Expect(connectors.Validate(b)).To(MatchError(connectors.ErrInvalid)) + }) + It("ends a pairing after 90 days", func() { + at := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + b := base() + b.Paired = []connectors.PairedSender{{SenderID: "1", At: at}} + Expect(b.IsPaired("1", at.Add(89*24*time.Hour))).To(BeTrue()) + Expect(b.IsPaired("1", at.Add(91*24*time.Hour))).To(BeFalse()) + Expect(b.IsPaired("2", at)).To(BeFalse()) + }) +}) diff --git a/core/services/studio/connectors/types.go b/core/services/studio/connectors/types.go index 7c98993690..8a9eb7a89d 100644 --- a/core/services/studio/connectors/types.go +++ b/core/services/studio/connectors/types.go @@ -14,9 +14,12 @@ import ( "fmt" "maps" "regexp" + "slices" "strings" "time" "unicode/utf8" + + "github.com/mudler/LocalAI/pkg/addons/contract" ) // LegacyAddon is the add-on that serves the kinds core carried before @@ -68,10 +71,23 @@ const ( MaxSendersKept = 200 MaxOptions = 8 MaxOptionRunes = 32 + // MaxPaired is how many paired senders a binding holds. + MaxPaired = 3 + // MaxGrants is how many roles of a binding have a grant. + MaxGrants = contract.MaxConnectorRoles // MaxFileLimit is the largest file limit a binding can hold: 1 TiB. MaxFileLimit int64 = 1 << 40 ) +// Pairing limits. +const ( + // PairedTTL is how long a pairing lasts. After it, the sender must pair + // again. + PairedTTL = 90 * 24 * time.Hour + // PairingCodeTTL is how long a pairing code can be used. + PairingCodeTTL = 10 * time.Minute +) + // Errors. var ( ErrNotFound = errors.New("connectors: not found") @@ -119,6 +135,16 @@ type Binding struct { // limit of the kind applies. A value can only lower what the kind allows; // the host takes the smaller one. FileReceiveMax, FileSendMax int64 + // Caps is the ceiling the owner enabled: the capabilities (see + // contract.ConnectorCapabilityNames) that senders of the binding may ever + // use. Empty means none, which is today's behaviour. + Caps []string + // Grants map the roles of the kind to capabilities, each within Caps. + // The kind of the binding decides which roles exist. + Grants []RoleGrant + // Paired are the senders that the owner proved by a pairing code. Only a + // paired sender may use a sensitive capability. At most MaxPaired. + Paired []PairedSender // Token is the bot token. In a Store it is sealed bytes; the Store returns // what it was given. Token []byte @@ -126,6 +152,26 @@ type Binding struct { Updated time.Time } +// RoleGrant gives the capabilities Caps to the senders that have the role Role. +type RoleGrant struct { + Role string `json:"role"` + Caps []string `json:"caps"` +} + +// PairedSender is a sender that proved, with a code from the owner, that the +// owner controls the account. The pairing ends PairedTTL after At. +type PairedSender struct { + SenderID string `json:"sender_id"` + At time.Time `json:"at"` +} + +// Pairing is a pending pairing code of a binding. The store keeps the hash, +// never the code. +type Pairing struct { + Hash string + Expires time.Time +} + // Sender is a person who messaged a bot. type Sender struct { Binding, ID, Name string @@ -164,6 +210,15 @@ type Store interface { // EnsureKey returns the sealing key. The first call stores what generate // makes; every call after it, on any frontend, returns that key. EnsureKey(ctx context.Context, generate func() ([]byte, error)) ([]byte, error) + + // PutPairing stores the pending pairing code of a binding and replaces + // an earlier one. + PutPairing(ctx context.Context, binding string, p Pairing) error + // ConsumePairing removes the pending code of the binding and returns true + // when it has the given hash and has not expired at now. A wrong hash + // leaves the code in place. The check and the removal are one step, so a + // code works once on any frontend. + ConsumePairing(ctx context.Context, binding, hash string, now time.Time) (bool, error) } // An entry of the allowlist is a numeric ID (Telegram), an @username, or an @@ -216,6 +271,9 @@ func Validate(b Binding) error { default: return bad("the conversation mode must be %s, %s, %s or %s", ConversationSender, ConversationChat, ConversationWindow, ConversationThread) } + if err := validateCaps(b); err != nil { + return bad("%v", err) + } if len(b.Options) > MaxOptions { return bad("a binding has at most %d options", MaxOptions) } @@ -227,6 +285,44 @@ func Validate(b Binding) error { return nil } +func validateCaps(b Binding) error { + if len(b.Caps) > len(contract.ConnectorCapabilityNames()) { + return errors.New("too many capabilities") + } + for i, c := range b.Caps { + if !contract.KnownCapability(c) || slices.Contains(b.Caps[:i], c) { + return fmt.Errorf("%q is not a capability, or is repeated", c) + } + } + if len(b.Grants) > MaxGrants { + return fmt.Errorf("a binding grants at most %d roles", MaxGrants) + } + for i, g := range b.Grants { + if g.Role == "" || len(g.Role) > 64 || slices.ContainsFunc(b.Grants[:i], func(o RoleGrant) bool { return o.Role == g.Role }) { + return fmt.Errorf("a grant needs one distinct role") + } + for _, c := range g.Caps { + switch { + case !slices.Contains(b.Caps, c): + return fmt.Errorf("the role %q cannot have %q: the binding does not enable it", g.Role, c) + case contract.SensitiveCapability(c): + return fmt.Errorf("%q is sensitive: it is never granted to a role; pair the sender instead", c) + } + } + } + if len(b.Paired) > MaxPaired { + return fmt.Errorf("a binding has at most %d paired senders", MaxPaired) + } + return nil +} + +// IsPaired tells whether the sender holds a pairing that has not ended at now. +func (b Binding) IsPaired(senderID string, now time.Time) bool { + return slices.ContainsFunc(b.Paired, func(p PairedSender) bool { + return p.SenderID == senderID && now.Sub(p.At) < PairedTTL + }) +} + // ReplyMode is the reply mode of a binding, with the default filled in. func (b Binding) ReplyMode() string { if b.Reply == "" { @@ -272,5 +368,14 @@ func (b Binding) clone() Binding { b.Allow = append([]string(nil), b.Allow...) b.Token = append([]byte(nil), b.Token...) b.Options = maps.Clone(b.Options) + b.Caps = slices.Clone(b.Caps) + b.Paired = slices.Clone(b.Paired) + if b.Grants != nil { + g := make([]RoleGrant, len(b.Grants)) + for i, x := range b.Grants { + g[i] = RoleGrant{Role: x.Role, Caps: slices.Clone(x.Caps)} + } + b.Grants = g + } return b } diff --git a/core/services/studio/connectors_permissions_test.go b/core/services/studio/connectors_permissions_test.go new file mode 100644 index 0000000000..49b3c66351 --- /dev/null +++ b/core/services/studio/connectors_permissions_test.go @@ -0,0 +1,185 @@ +package studio_test + +import ( + "context" + "time" + + "github.com/mudler/LocalAI/addons/telegram/transport" + "github.com/mudler/LocalAI/core/services/studio" + "github.com/mudler/LocalAI/core/services/studio/connectors" + "github.com/mudler/LocalAI/pkg/addons/contract" + "github.com/mudler/LocalAI/tests/studiobot" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// The roles, capabilities and sender pairing of a binding. The kind in these +// specs is a plain connector kind that declares roles; nothing here depends +// on which add-on serves it. +var _ = Describe("capabilities and sender pairing of a connector", func() { + var ( + r *connRig + sh *shared + b connectors.Binding + st connectors.Store + ) + ctx := context.Background() + dm := func(sender, text string) contract.ConnectorInbound { + return contract.ConnectorInbound{BindingID: b.ID, ConnectorMessage: contract.ConnectorMessage{Chat: sender, Direct: true, SenderID: sender, SenderName: "S" + sender, Text: text}} + } + say := func(sender, text string) { + Expect(r.conns.Ingest(ctx, "telegram", dm(sender, text))).To(Succeed()) + } + lastAnswer := func() string { + a := r.bot.answers() + if len(a) == 0 { + return "" + } + return a[len(a)-1] + } + reload := func() connectors.Binding { + got, err := st.GetBinding(ctx, b.ID) + Expect(err).NotTo(HaveOccurred()) + return got + } + + BeforeEach(func() { + sh = newShared() + st = connStore() + bot := newScriptedBot() + addon := studiobot.Telegram(&transport.API{BaseURL: bot.srv.URL, Client: bot.srv.Client()}) + addon.Kind.Roles = []contract.ConnectorRole{{ID: "admin", Title: "Admin"}} + addon.Kind.Permissions = []string{contract.CapMemoryWrite, contract.CapProfileWrite} + r = newConnRigWith(sh, st, studiobot.NewTransport(addon), connSay("model answer"), nil) + r.bot = bot + b = r.bind(studio.ConnectorInput{Allow: []string{"11", "12", "13", "14"}}) + }) + + Describe("the capabilities the owner chooses", func() { + It("stores the ceiling and the grants of a role the kind declares", func() { + in := studio.ConnectorInput{Name: "Support bot", AgentID: b.AgentID, Enabled: true, Allow: b.Allow, + Caps: []string{contract.CapMemoryWrite, contract.CapProfileWrite}, + Grants: []connectors.RoleGrant{{Role: "admin", Caps: []string{contract.CapMemoryWrite}}}} + got, err := r.conns.UpdateConnector(ctx, alice, b.ID, in) + Expect(err).NotTo(HaveOccurred()) + Expect(got.Caps).To(Equal(in.Caps)) + Expect(got.Grants).To(Equal(in.Grants)) + // An update that sends none keeps them. + in.Caps, in.Grants = nil, nil + got, err = r.conns.UpdateConnector(ctx, alice, b.ID, in) + Expect(err).NotTo(HaveOccurred()) + Expect(got.Caps).To(HaveLen(2)) + // An empty list clears them. + in.Caps, in.Grants = []string{}, []connectors.RoleGrant{} + got, err = r.conns.UpdateConnector(ctx, alice, b.ID, in) + Expect(err).NotTo(HaveOccurred()) + Expect(got.Caps).To(BeEmpty()) + }) + + DescribeTable("refuses", + func(caps []string, grants []connectors.RoleGrant) { + _, err := r.conns.UpdateConnector(ctx, alice, b.ID, studio.ConnectorInput{Name: "x", AgentID: b.AgentID, Enabled: true, Allow: b.Allow, Caps: caps, Grants: grants}) + Expect(err).To(MatchError(connectors.ErrInvalid)) + }, + Entry("an unknown capability", []string{"root"}, nil), + Entry("a role the kind does not declare", []string{contract.CapMemoryWrite}, []connectors.RoleGrant{{Role: "boss", Caps: []string{contract.CapMemoryWrite}}}), + Entry("a capability the kind does not list", []string{contract.CapFilesUpload}, []connectors.RoleGrant{{Role: "admin", Caps: []string{contract.CapFilesUpload}}}), + Entry("a sensitive grant to a role", []string{contract.CapProfileWrite}, []connectors.RoleGrant{{Role: "admin", Caps: []string{contract.CapProfileWrite}}}), + Entry("a grant above the ceiling", []string{contract.CapMemoryWrite}, []connectors.RoleGrant{{Role: "admin", Caps: []string{contract.CapProfileWrite}}}), + ) + }) + + Describe("pairing", func() { + It("pairs the sender who sends the code in a direct message, and answers without a turn", func() { + pc, err := r.conns.StartPairing(ctx, alice, b.ID) + Expect(err).NotTo(HaveOccurred()) + Expect(pc.Code).To(MatchRegexp(`^LA-[A-Z0-9]{8}$`)) + Expect(pc.Expires.Sub(sh.clock.Now())).To(Equal(10 * time.Minute)) + say("11", pc.Code) + Eventually(lastAnswer, "10s").Should(HavePrefix("Paired")) + got := reload() + Expect(got.IsPaired("11", sh.clock.Now())).To(BeTrue()) + Expect(got.IsPaired("12", sh.clock.Now())).To(BeFalse()) + chats, _ := r.svc.List(ctx, alice, 20) + Expect(chats).To(BeEmpty(), "the code never starts a turn") + Expect(r.logs.String()).NotTo(ContainSubstring(pc.Code)) + }) + + It("accepts the code once only", func() { + pc, _ := r.conns.StartPairing(ctx, alice, b.ID) + say("11", pc.Code) + Eventually(lastAnswer, "10s").Should(HavePrefix("Paired")) + say("12", pc.Code) + Eventually(lastAnswer, "10s").Should(HavePrefix("That code is not valid")) + Expect(reload().IsPaired("12", sh.clock.Now())).To(BeFalse()) + }) + + It("refuses an expired code and a wrong one", func() { + pc, _ := r.conns.StartPairing(ctx, alice, b.ID) + say("11", "LA-ZZZZZZZZ") + Eventually(lastAnswer, "10s").Should(HavePrefix("That code is not valid")) + sh.clock.Advance(11 * time.Minute) + say("11", pc.Code) + Eventually(func() int { return len(r.bot.answers()) }, "10s").Should(Equal(2)) + Expect(reload().Paired).To(BeEmpty()) + }) + + It("does not pair a sender who is not on the allowlist, or in a group", func() { + pc, _ := r.conns.StartPairing(ctx, alice, b.ID) + say("99", pc.Code) + g := dm("11", pc.Code) + g.Direct, g.Mention = false, true + Expect(r.conns.Ingest(ctx, "telegram", g)).To(Succeed()) + Eventually(lastAnswer, "10s").Should(HavePrefix("Pairing works in a direct message")) + Expect(reload().Paired).To(BeEmpty()) + // The code still works in a direct message: a group attempt did not spend it. + say("11", pc.Code) + Eventually(lastAnswer, "10s").Should(HavePrefix("Paired")) + }) + + It("locks a sender out after five wrong codes", func() { + pc, _ := r.conns.StartPairing(ctx, alice, b.ID) + sh.clock.Advance(0) + for i := 0; i < 5; i++ { + say("11", "LA-AAAAAAA"+string(rune('A'+i))) + sh.clock.Advance(15 * time.Second) // stays inside the rate limit of the sender + } + n := len(r.bot.answers()) + say("11", pc.Code) + Consistently(func() int { return len(r.bot.answers()) }, "300ms").Should(Equal(n), "a locked sender gets no answer") + Expect(reload().Paired).To(BeEmpty()) + sh.clock.Advance(61 * time.Minute) + pc, _ = r.conns.StartPairing(ctx, alice, b.ID) + say("11", pc.Code) + Eventually(lastAnswer, "10s").Should(HavePrefix("Paired")) + }) + + It("holds three paired senders, and a pairing ends after 90 days", func() { + for _, id := range []string{"11", "12", "13"} { + pc, _ := r.conns.StartPairing(ctx, alice, b.ID) + say(id, pc.Code) + Eventually(func() bool { return reload().IsPaired(id, sh.clock.Now()) }, "10s").Should(BeTrue()) + } + pc, _ := r.conns.StartPairing(ctx, alice, b.ID) + say("14", pc.Code) + Eventually(lastAnswer, "10s").Should(HavePrefix("This bot already has the most")) + Expect(reload().IsPaired("14", sh.clock.Now())).To(BeFalse()) + sh.clock.Advance(91 * 24 * time.Hour) + Expect(reload().IsPaired("11", sh.clock.Now())).To(BeFalse()) + pc, _ = r.conns.StartPairing(ctx, alice, b.ID) + say("14", pc.Code) + Eventually(func() bool { return reload().IsPaired("14", sh.clock.Now()) }, "10s").Should(BeTrue()) + }) + + It("lets the owner remove a pairing, and nobody else start one", func() { + pc, _ := r.conns.StartPairing(ctx, alice, b.ID) + say("11", pc.Code) + Eventually(func() int { return len(reload().Paired) }, "10s").Should(Equal(1)) + got, err := r.conns.RemovePaired(ctx, alice, b.ID, "11") + Expect(err).NotTo(HaveOccurred()) + Expect(got.Paired).To(BeEmpty()) + _, err = r.conns.StartPairing(ctx, "bob", b.ID) + Expect(err).To(MatchError(connectors.ErrNotFound)) + }) + }) +}) diff --git a/core/services/studio/connectorsvc.go b/core/services/studio/connectorsvc.go index e90b8c6eb5..7922c2f74a 100644 --- a/core/services/studio/connectorsvc.go +++ b/core/services/studio/connectorsvc.go @@ -45,6 +45,11 @@ type ConnectorInput struct { // files the bot reads and sends. Nil: new binding none, update unchanged. // Zero clears the limit. A limit can only lower what the kind allows. FileReceiveMax, FileSendMax *int64 + // Caps are the capabilities the owner enables for the binding, and + // Grants map roles of the kind to capabilities within Caps. Nil: new + // binding none, update unchanged. An empty non-nil slice clears. + Caps []string + Grants []connectors.RoleGrant } // apply sets the fields of the input that an owner writes on a binding. @@ -66,6 +71,12 @@ func (in ConnectorInput) apply(b *connectors.Binding) { if in.FileSendMax != nil { b.FileSendMax = *in.FileSendMax } + if in.Caps != nil { + b.Caps = slices.Clone(in.Caps) + } + if in.Grants != nil { + b.Grants = slices.Clone(in.Grants) + } } // ErrTokenRefused means the platform refused the bot token of a binding. @@ -207,6 +218,35 @@ func (c *Connectors) checkOptions(ctx context.Context, b connectors.Binding) err return nil } +// checkPermissions refuses a grant to a role that the kind does not declare, +// or of a capability that the kind does not list as grantable. The generic +// rules (known names, the ceiling, no sensitive grant) are in +// connectors.Validate. A kind whose add-on does not run cannot be checked. +func (c *Connectors) checkPermissions(ctx context.Context, b connectors.Binding) error { + if len(b.Grants) == 0 { + return nil + } + kinds, err := c.o.Transport.Kinds(ctx) + if err != nil { + return nil + } + k, ok := kindFor(kinds, b) + if !ok { + return nil + } + for _, g := range b.Grants { + if _, ok := k.Role(g.Role); !ok { + return connectorBad("the role %q is not one of this kind", g.Role) + } + for _, cp := range g.Caps { + if !k.Grantable(cp) { + return connectorBad("this kind does not let %q go to a role", cp) + } + } + } + return nil +} + func connectorBad(format string, a ...any) error { return fmt.Errorf("%w: %s", connectors.ErrInvalid, fmt.Sprintf(format, a...)) } @@ -288,6 +328,9 @@ func (c *Connectors) CreateConnector(ctx context.Context, user string, in Connec if err := c.checkOptions(ctx, b); err != nil { return ConnectorInfo{}, err } + if err := c.checkPermissions(ctx, b); err != nil { + return ConnectorInfo{}, err + } if err := c.checkOwnerLimits(ctx, b, in); err != nil { return ConnectorInfo{}, err } @@ -353,6 +396,9 @@ func (c *Connectors) UpdateConnector(ctx context.Context, user, id string, in Co if err := c.checkOptions(ctx, b); err != nil { return ConnectorInfo{}, err } + if err := c.checkPermissions(ctx, b); err != nil { + return ConnectorInfo{}, err + } if err := c.checkOwnerLimits(ctx, b, in); err != nil { return ConnectorInfo{}, err } diff --git a/docs/content/features/addons.md b/docs/content/features/addons.md index 5073d7c2d7..770d4a3cf6 100644 --- a/docs/content/features/addons.md +++ b/docs/content/features/addons.md @@ -2231,6 +2231,8 @@ The manifest declares the capability. It needs no tool and no `service` flag: | `kinds[].history` | Optional, manifest `api` 2. `{"thread": true, "maxMessages": N}`: the add-on serves the op `history` for threads, and returns at most `maxMessages` (1 to 200; without it the host asks for 30). A kind without it is never asked, so it keeps the behaviour of a platform that cannot give old messages. | | `kinds[].options` | Optional, manifest `api` 2, at most 4. A choice that each binding of the kind makes: `{"id", "title", "description", "values": [{"id", "title"}], "default"}` with 2 to 8 values and a default among them. The host shows the choice on the binding page, checks the owner's value against the declaration, stores it, and sends the value in effect to the add-on in `options` of every `poll`. What the option means is the add-on's own rule. | | `kinds[].files` | Optional. The files the kind can receive and send, with a limit for each. Needs manifest `api` 2. See [File abilities and limits](#file-abilities-and-limits). | +| `kinds[].roles` | Optional, manifest `api` 2, at most 8. A role the kind can give to a sender: `{"id", "title", "description"}`, with an `id` in the same form as a kind `id`. The add-on owns the membership (for example, the admins of a workspace) and sends the role IDs of the sender in `roles` of each message, at most 8. The host drops a role the kind does not declare, and all roles for a kind that declares none. The owner of a binding maps each role to capabilities in the host. | +| `kinds[].permissions` | Optional, manifest `api` 2, at most 12. The capabilities the kind can grant to a role, from the host's closed list: `proposals.confirm`, `memory.write`, `files.upload`, `triggers.propose`, `triggers.run`, `talkback.channels` and the sensitive `talkback.new_channel`, `profile.write`, `skills.write`, `pages.write`, `dashboard.write`. An unknown name is refused. A sensitive name may be listed, but it is never granted to a role: only a sender that the owner paired can use it. | **Outbound: the connector route.** The host calls `POST /connector` on the add-on with the same bearer credential and incarnation header as every other route and a body diff --git a/docs/content/features/chats-connectors.md b/docs/content/features/chats-connectors.md index a37773584d..fccf8af088 100644 --- a/docs/content/features/chats-connectors.md +++ b/docs/content/features/chats-connectors.md @@ -448,6 +448,37 @@ this with a confirmation. A binding that already has it keeps it on later edits, and turning it off needs no confirmation. Use it for a public bot whose agent grants nothing you would mind sharing. +### Capabilities, roles and pairing + +Three things sit on top of the allowlist. They work the same for every connector +add-on. + +- **Capabilities** (`caps`). The ceiling you enable for a binding, from a closed list + (`proposals.confirm`, `memory.write`, `files.upload`, `triggers.propose`, + `triggers.run`, `talkback.channels`, and the sensitive `talkback.new_channel`, + `profile.write`, `skills.write`, `pages.write`, `dashboard.write`). A binding with + none enabled behaves as before: the allowlist is the only gate. +- **Roles and grants** (`grants`). An add-on can declare roles, such as the admins of + a workspace, and report the roles of each sender. You map a role to capabilities + with `grants: [{"role", "caps"}]`. A grant is within `caps`, within what the kind + lists as `permissions`, and never a sensitive capability. The kind list + (`GET /api/chats/connectors/kinds`) shows each kind's `roles` and `permissions`. +- **Pairing.** A sender you pair may use the sensitive capabilities. Ask for a code + with `POST /api/chats/connectors//pairing`; send it as a direct message to the + bot from an account on the allowlist. The code looks like `LA-` and eight characters, + works once and lasts ten minutes. The host checks it before any model turn, so + the code never reaches the agent or the sender log. A binding holds at most three + paired senders, and a pairing ends after 90 days. End one earlier with + `DELETE /api/chats/connectors//paired/`. A sender who sends five wrong + codes in an hour is ignored for an hour, and every attempt is logged without the + code. + +Pairing needs only a stable sender ID, so an add-on without roles supports it too. + +The host stores and checks these values and shows them to the add-on pages. The +features that a capability switches on or off (triggers from chat, talk-back, and so +on) read them as they land. + ## Notifications in Telegram Set a notification chat ID on a binding to receive your run notifications there diff --git a/pkg/addons/contract/connector.go b/pkg/addons/contract/connector.go index 5f509845c7..d559132fdd 100644 --- a/pkg/addons/contract/connector.go +++ b/pkg/addons/contract/connector.go @@ -145,6 +145,16 @@ type ConnectorKind struct { // for each direction. Without it, the host moves files inline and caps // them at MaxConnectorFileBytes. It needs manifest api MinFilesAPI. Files *ConnectorFiles `json:"files,omitempty"` + // Roles are the roles the kind can give to a sender. The add-on owns + // the membership and sends the role IDs with each message; the owner of + // a binding maps each role to capabilities. It needs manifest api + // MinPermissionsAPI. + Roles []ConnectorRole `json:"roles,omitempty"` + // Permissions are the capabilities (see ConnectorCapabilityNames) that the kind can + // grant to a role. A sensitive name may be listed to explain the pairing + // path, but it is never granted to a role. It needs manifest api + // MinPermissionsAPI. + Permissions []string `json:"permissions,omitempty"` } // ConnectorOption is one choice among a few values. @@ -292,6 +302,9 @@ func validateConnector(c *ConnectorCapability, ui *UI, api VersionRange) error { if err := k.Files.validate(api); err != nil { return err } + if err := validatePermissions(k, api); err != nil { + return err + } if k.Page != "" { ok := false if ui != nil { @@ -370,6 +383,11 @@ type ConnectorMessage struct { // is a voice message, which the host transcribes, and the transcript is // the text of the turn. Media []string `json:"media,omitempty"` + // Roles are the IDs of the roles (ConnectorKind.Roles) that the sender + // has, at most MaxMessageRoles. The host drops an ID the kind does not + // declare, and the whole field for a kind with no roles. Roles are never + // authority for a sensitive capability. + Roles []string `json:"roles,omitempty"` } // ConnectorInbound is the argument object of ConnectorInboundOp. It names the diff --git a/pkg/addons/contract/permissions.go b/pkg/addons/contract/permissions.go new file mode 100644 index 0000000000..c3a8041030 --- /dev/null +++ b/pkg/addons/contract/permissions.go @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: MIT +package contract + +import ( + "fmt" + "slices" +) + +// MinPermissionsAPI is the first manifest API version that knows the roles and +// permissions of a connector kind. +const MinPermissionsAPI uint64 = 2 + +// Limits of the permissions of a connector kind. +const ( + MaxConnectorRoles = 8 + MaxConnectorPermissions = 12 + // MaxMessageRoles is how many role IDs one ConnectorMessage carries. + MaxMessageRoles = 8 +) + +// Capability names. The host owns this closed list. The same names are used +// for what an owner enables on a binding, what an owner grants to a role of a +// kind, and what a kind declares that it can grant. +const ( + CapProposalsConfirm = "proposals.confirm" + CapMemoryWrite = "memory.write" + CapFilesUpload = "files.upload" + CapTriggersPropose = "triggers.propose" + CapTriggersRun = "triggers.run" + CapTalkbackChannels = "talkback.channels" + CapTalkbackNewChan = "talkback.new_channel" + CapProfileWrite = "profile.write" + CapSkillsWrite = "skills.write" + CapPagesWrite = "pages.write" + CapDashboardWrite = "dashboard.write" +) + +// capabilities maps each name to whether it is sensitive. A sensitive +// capability is never granted to a role: only a paired sender can use it. +var capabilities = map[string]bool{ + CapProposalsConfirm: false, + CapMemoryWrite: false, + CapFilesUpload: false, + CapTriggersPropose: false, + CapTriggersRun: false, + CapTalkbackChannels: false, + CapTalkbackNewChan: true, + CapProfileWrite: true, + CapSkillsWrite: true, + CapPagesWrite: true, + CapDashboardWrite: true, +} + +// KnownCapability reports whether name is in the closed list of capabilities. +func KnownCapability(name string) bool { _, ok := capabilities[name]; return ok } + +// SensitiveCapability reports whether name is a sensitive capability. +func SensitiveCapability(name string) bool { return capabilities[name] } + +// ConnectorCapabilityNames returns the closed list, sorted. +func ConnectorCapabilityNames() []string { + out := make([]string, 0, len(capabilities)) + for n := range capabilities { + out = append(out, n) + } + slices.Sort(out) + return out +} + +// ConnectorRole is a role that a kind can give to a sender, such as the admin +// of a workspace. The add-on decides who has which role and sends the role IDs +// with each message; the owner decides what each role may do. +type ConnectorRole struct { + // ID names the role in ConnectorMessage.Roles. It has the shape of an + // add-on namespace ID. + ID string `json:"id"` + Title string `json:"title"` + Description string `json:"description,omitempty"` +} + +// Role returns the role of the kind with the given ID. +func (k ConnectorKind) Role(id string) (ConnectorRole, bool) { + for _, r := range k.Roles { + if r.ID == id { + return r, true + } + } + return ConnectorRole{}, false +} + +// Grantable reports whether the kind lists the capability and the capability +// may go to a role: it is known and not sensitive. +func (k ConnectorKind) Grantable(capability string) bool { + return KnownCapability(capability) && !SensitiveCapability(capability) && slices.Contains(k.Permissions, capability) +} + +// FilterRoles keeps the role IDs of a message that the kind declares, without +// repeats and at most MaxMessageRoles. A kind that declares no roles keeps none. +func (k ConnectorKind) FilterRoles(ids []string) []string { + var out []string + for _, id := range ids { + if len(out) == MaxMessageRoles { + break + } + if _, ok := k.Role(id); ok && !slices.Contains(out, id) { + out = append(out, id) + } + } + return out +} + +func validatePermissions(k ConnectorKind, api VersionRange) error { + if len(k.Roles) == 0 && len(k.Permissions) == 0 { + return nil + } + if api.Max < MinPermissionsAPI { + return fmt.Errorf("connector roles and permissions need api %d", MinPermissionsAPI) + } + if len(k.Roles) > MaxConnectorRoles { + return fmt.Errorf("a connector kind has at most %d roles", MaxConnectorRoles) + } + seen := map[string]bool{} + for _, r := range k.Roles { + if !namespace.MatchString(r.ID) || seen[r.ID] || !plainText(r.Title, MaxTitleChars) || (r.Description != "" && !plainText(r.Description, MaxDescriptionChars)) { + return fmt.Errorf("invalid or repeated connector role") + } + seen[r.ID] = true + } + if len(k.Permissions) > MaxConnectorPermissions { + return fmt.Errorf("a connector kind lists at most %d permissions", MaxConnectorPermissions) + } + listed := map[string]bool{} + for _, p := range k.Permissions { + if !KnownCapability(p) { + return fmt.Errorf("unknown connector permission %q", p) + } + if listed[p] { + return fmt.Errorf("repeated connector permission %q", p) + } + listed[p] = true + } + return nil +} diff --git a/pkg/addons/contract/permissions_test.go b/pkg/addons/contract/permissions_test.go new file mode 100644 index 0000000000..17fde63de0 --- /dev/null +++ b/pkg/addons/contract/permissions_test.go @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: MIT +package contract + +import ( + "os" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("the roles and permissions of a connector kind", func() { + var m Manifest + BeforeEach(func() { + b, err := os.ReadFile("../../../tests/addons/fixtures/contracts/page.json") + Expect(err).NotTo(HaveOccurred()) + m, err = ParseManifest(b) + Expect(err).NotTo(HaveOccurred()) + m.API = VersionRange{Min: 1, Max: 2} + m.Capabilities.UI = nil + m.Capabilities.Connector = &ConnectorCapability{Kinds: []ConnectorKind{{ID: "chat", Title: "Chat", Delivery: DeliveryPull}}} + }) + host := func() HostCompatibility { + h := compatible() + h.API, h.APIMin = HostAPIVersion, HostAPIMin + h.SDK = HostSDKVersion + return h + } + kind := func() *ConnectorKind { return &m.Capabilities.Connector.Kinds[0] } + + It("accepts roles and known permissions", func() { + kind().Roles = []ConnectorRole{{ID: "admin", Title: "Admin", Description: "Workspace admin"}} + kind().Permissions = []string{CapProposalsConfirm, CapMemoryWrite, CapProfileWrite} + Expect(ValidateManifest(m, host())).To(Succeed()) + }) + + It("accepts a kind that declares neither, on api 1", func() { + m.API = VersionRange{Min: 1, Max: 1} + Expect(ValidateManifest(m, host())).To(Succeed()) + }) + + It("needs api 2", func() { + m.API = VersionRange{Min: 1, Max: 1} + kind().Permissions = []string{CapMemoryWrite} + Expect(ValidateManifest(m, host())).To(MatchError(ContainSubstring("api 2"))) + }) + + It("refuses an unknown or repeated permission", func() { + kind().Permissions = []string{"root.everything"} + Expect(ValidateManifest(m, host())).NotTo(Succeed()) + kind().Permissions = []string{CapMemoryWrite, CapMemoryWrite} + Expect(ValidateManifest(m, host())).NotTo(Succeed()) + }) + + It("refuses too many roles or permissions, a bad role ID and a repeated role", func() { + kind().Roles = nil + for i := 0; i <= MaxConnectorRoles; i++ { + kind().Roles = append(kind().Roles, ConnectorRole{ID: "r" + string(rune('a'+i)), Title: "R"}) + } + Expect(ValidateManifest(m, host())).NotTo(Succeed()) + kind().Roles = []ConnectorRole{{ID: "Bad Id", Title: "R"}} + Expect(ValidateManifest(m, host())).NotTo(Succeed()) + kind().Roles = []ConnectorRole{{ID: "a", Title: "A"}, {ID: "a", Title: "B"}} + Expect(ValidateManifest(m, host())).NotTo(Succeed()) + kind().Roles = nil + kind().Permissions = ConnectorCapabilityNames() + Expect(ValidateManifest(m, host())).To(Succeed()) // the whole list fits + kind().Permissions = append(kind().Permissions, "x.1", "x.2") + Expect(ValidateManifest(m, host())).NotTo(Succeed()) + }) + + It("never grants a sensitive capability to a role", func() { + kind().Permissions = []string{CapProfileWrite, CapMemoryWrite} + Expect(kind().Grantable(CapProfileWrite)).To(BeFalse()) + Expect(kind().Grantable(CapMemoryWrite)).To(BeTrue()) + Expect(kind().Grantable(CapFilesUpload)).To(BeFalse(), "not listed by the kind") + }) + + It("keeps only declared, distinct roles of a message", func() { + kind().Roles = []ConnectorRole{{ID: "admin", Title: "Admin"}} + Expect(kind().FilterRoles([]string{"admin", "root", "admin"})).To(Equal([]string{"admin"})) + Expect((ConnectorKind{}).FilterRoles([]string{"admin"})).To(BeEmpty()) + }) +}) diff --git a/swagger/docs.go b/swagger/docs.go index eb8b98bdf9..e5840042dc 100644 --- a/swagger/docs.go +++ b/swagger/docs.go @@ -3660,6 +3660,12 @@ const docTemplate = `{ "description": "with chat, sub-agent runs are left out and the limit counts chats", "name": "kind", "in": "query" + }, + { + "type": "string", + "description": "only chats of one origin, with sub-agent runs left out and the limit counting the chats kept: person (a person started it), automated (anything else) or one origin kind (trigger, connector, job, page). Without it, all chats", + "name": "origin", + "in": "query" } ], "responses": { @@ -3708,7 +3714,7 @@ const docTemplate = `{ } }, "409": { - "description": "The saved agent's folder is missing or not chosen (agent_folder_missing)", + "description": "The saved agent's folder is missing or not chosen (agent_folder_missing), or its complete file access exceeds the folder limit (agent_folder_limit)", "schema": { "$ref": "#/definitions/schema.ErrorResponse" } @@ -3855,7 +3861,7 @@ const docTemplate = `{ "tags": [ "chats" ], - "summary": "Read the settings of the Sandbox network (administrators).", + "summary": "Read the settings of the network of commands (administrators).", "responses": { "200": { "description": "The settings in force", @@ -3868,11 +3874,11 @@ const docTemplate = `{ }, "/api/chats/admin/egress/events": { "get": { - "description": "Every request a command made through the Sandbox proxy, newest first: who, which chat, host, port, kind (connect or http), verdict (allow, block, or done when an allowed connection ended, with its address, bytes and duration) and reason.", + "description": "Every request a command made through the proxy of the add-on, newest first: who, which chat, host, port, kind (connect or http), verdict (allow, block, or done when an allowed connection ended, with its address, bytes and duration) and reason.", "tags": [ "chats" ], - "summary": "Read the audit of the Sandbox network requests (administrators).", + "summary": "Read the audit of the network of commands requests (administrators).", "parameters": [ { "type": "integer", @@ -4690,7 +4696,7 @@ const docTemplate = `{ }, "/api/chats/agents/{id}/export": { "get": { - "description": "Returns the definition as a JSON file (version, kind localai-agent, agent). It holds no ID, owner or time. An agent with a folder is version 2 and carries folder {path, mode}, the folder's path in your files now (empty when the folder is missing or not chosen); a server that knows only version 1 refuses it. An agent without a folder is version 1. POST /api/chats/agents/import reads it. This replaces the old /api/agents/{name}/export.", + "description": "Returns the definition as a JSON file (version, kind localai-agent, agent). It holds no ID, owner or time. An agent with a folder is version 2 and carries folder {path, mode}, the folder's home-relative path now (empty when the folder is missing, not chosen, or outside your home, even if readable); importing an empty path preserves the scope as unset with a warning; a server that knows only version 1 refuses it. An agent without a folder is version 1. POST /api/chats/agents/import reads it. This replaces the old /api/agents/{name}/export.", "tags": [ "chats" ], @@ -5153,6 +5159,65 @@ const docTemplate = `{ } } }, + "/api/chats/connectors/{id}/paired/{sender}": { + "delete": { + "description": "The sender can pair again with a new code.", + "tags": [ + "chats" + ], + "summary": "End the pairing of a sender of a connector binding.", + "parameters": [ + { + "type": "string", + "description": "the binding's id", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "the sender's id", + "name": "sender", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "The binding", + "schema": { + "$ref": "#/definitions/schema.ChatsConnector" + } + } + } + } + }, + "/api/chats/connectors/{id}/pairing": { + "post": { + "description": "Pairing proves that you control a platform account. Send the code, as a direct message to the bot, from an account on the allowlist, within ten minutes. The code works once, and a new code replaces the old one. A paired sender may use the sensitive capabilities you enabled; a pairing ends after 90 days. At most three senders are paired.", + "tags": [ + "chats" + ], + "summary": "Make a pairing code for a connector binding.", + "parameters": [ + { + "type": "string", + "description": "the binding's id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "The code and when it expires", + "schema": { + "$ref": "#/definitions/schema.ChatsConnectorPairing" + } + } + } + } + }, "/api/chats/connectors/{id}/senders": { "get": { "description": "The log of people who wrote to the bot, newest first, with whether they were accepted. A message from a sender who is not accepted is ignored, never answered.", @@ -8497,11 +8562,11 @@ const docTemplate = `{ }, "/api/chats/{id}/egress": { "get": { - "description": "The preset of the chat (off, registries, all), the administrator's ceiling, the preset in force (the lower of the two), whether it applies (false when commands run on this machine, applies_reason host, or no Sandbox serves bash, none), addon (the add-on that runs the commands and draws the network controls of the chat, from its network-policy host control; absent when none does), the package registries the preset registries admits, the chat's allow and deny rules (above_limit when the ceiling no longer admits one) and the cards that asked. A sub-agent chat shows its parent's. Another user's chat is 404.", + "description": "The preset of the chat (off, registries, all), the administrator's ceiling, the preset in force (the lower of the two), whether it applies (false when commands run on this machine, applies_reason host, or no add-on serves bash, none), addon (the add-on that runs the commands and draws the network controls of the chat, from its network-policy host control; absent when none does), the package registries the preset registries admits, the chat's allow and deny rules (above_limit when the ceiling no longer admits one) and the cards that asked. A sub-agent chat shows its parent's. Another user's chat is 404.", "tags": [ "chats" ], - "summary": "Read the network of a chat's Sandbox commands.", + "summary": "Read the network of a chat's commands.", "parameters": [ { "type": "string", @@ -8531,7 +8596,7 @@ const docTemplate = `{ "tags": [ "chats" ], - "summary": "Set the network preset of a chat's Sandbox commands.", + "summary": "Set the network preset of a chat's commands.", "parameters": [ { "type": "string", @@ -8578,7 +8643,7 @@ const docTemplate = `{ "tags": [ "chats" ], - "summary": "Answer a network card of a chat's Sandbox commands.", + "summary": "Answer a network card of a chat's commands.", "parameters": [ { "type": "string", @@ -8630,7 +8695,7 @@ const docTemplate = `{ }, "/api/chats/{id}/egress/rules/{host}/{port}": { "delete": { - "description": "Removes the rule that allows or denies one host and port for the chat's Sandbox commands. A host that was denied asks again; one that was allowed follows the preset again.", + "description": "Removes the rule that allows or denies one host and port for the chat's commands. A host that was denied asks again; one that was allowed follows the preset again.", "tags": [ "chats" ], @@ -9579,7 +9644,7 @@ const docTemplate = `{ } }, "409": { - "description": "A turn is running for a command, or the saved agent's folder is missing (agent_folder_missing)", + "description": "A turn is running for a command, or the saved agent's folder is missing (agent_folder_missing), or its complete file access exceeds the folder limit (agent_folder_limit)", "schema": { "$ref": "#/definitions/schema.ErrorResponse" } @@ -14816,6 +14881,9 @@ const docTemplate = `{ "canStop": { "type": "boolean" }, + "hostOperations": { + "$ref": "#/definitions/hostops.RequestSummary" + }, "managementUrl": { "description": "ManagementURL opens the management view of the addon.", "type": "string", @@ -14885,6 +14953,14 @@ const docTemplate = `{ "type": "string", "example": "notes" }, + "network": { + "description": "Network is the network policy the add-on declares, present only while\nthe host draws its network-policy control (see contract.EntitledControls).", + "allOf": [ + { + "$ref": "#/definitions/apps.NetworkView" + } + ] + }, "shadows": { "description": "Shadows lists the built-in chat tools the add-on replaces (bash, say).", "type": "array", @@ -15015,6 +15091,32 @@ const docTemplate = `{ } } }, + "apps.HomeCommandView": { + "type": "object", + "properties": { + "alias": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "labels": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "page": { + "type": "string" + } + } + }, "apps.List": { "type": "object", "properties": { @@ -15026,6 +15128,31 @@ const docTemplate = `{ } } }, + "apps.NetworkView": { + "type": "object", + "properties": { + "kind": { + "description": "Kind is the declared kind of network policy.", + "type": "string", + "enum": [ + "egress-check" + ], + "example": "egress-check" + }, + "presets": { + "description": "Presets are the titles the add-on gives the host's network presets.", + "type": "array", + "items": { + "$ref": "#/definitions/contract.NetworkPreset" + } + }, + "title": { + "description": "Title names the network in text: the declared title, else the add-on's.", + "type": "string", + "example": "Example provider" + } + } + }, "apps.SlotView": { "type": "object", "properties": { @@ -15118,6 +15245,20 @@ const docTemplate = `{ "type": "string", "example": "/api/addons/notes/assets/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef/ui.js" }, + "homeCommands": { + "description": "HomeCommands contains only resolved navigation metadata for this app.", + "type": "array", + "items": { + "$ref": "#/definitions/apps.HomeCommandView" + } + }, + "modelActions": { + "description": "ModelActions contains only declared destinations for this installed UI.", + "type": "array", + "items": { + "$ref": "#/definitions/contract.ModelAction" + } + }, "navigation": { "type": "array", "items": { @@ -15517,6 +15658,31 @@ const docTemplate = `{ } } }, + "contract.ModelAction": { + "type": "object", + "properties": { + "capability": { + "type": "string" + }, + "page": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "contract.NetworkPreset": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, "contract.OutputDescription": { "type": "object", "properties": { @@ -15651,11 +15817,11 @@ const docTemplate = `{ "type": "object", "properties": { "name": { - "description": "Name is the skill's name and the name of its folder. A plain skill is\nnamed after the add-on: the add-on ID, or the ID and a dash and more\n(office-quickstart), so two add-ons never ship one plain name. A skill\nthat replaces a built-in one has the built-in's name (see Shadows).", + "description": "Name is the skill's name and the name of its folder. A plain skill is\nnamed after the add-on: the add-on ID, or the ID and a dash and more\n(office-quickstart), so two add-ons never ship one plain name. A skill\nfor a reserved document slot has the slot's name (see Shadows).", "type": "string" }, "shadows": { - "description": "Shadows names the built-in skill this skill replaces. It must equal Name\nand be one of ReplaceableSkills. It repeats the name on purpose: the\nreplacement is an explicit act, which the install warning and the check\nread from the manifest without opening the package. Empty: a plain\nadd-on skill.", + "description": "Shadows names the document-provider slot this skill supplies. It must equal Name\nand be one of ReplaceableSkills. It repeats the name on purpose: the\nreplacement is an explicit act, which the install warning and the check\nread from the manifest without opening the package. Empty: a plain\nadd-on skill.", "type": "string" } } @@ -16970,6 +17136,50 @@ const docTemplate = `{ } } }, + "hostops.RequestSummary": { + "type": "object", + "properties": { + "granted": { + "type": "array", + "items": { + "type": "string" + } + }, + "grantedCount": { + "type": "integer" + }, + "missingOptional": { + "type": "array", + "items": { + "type": "string" + } + }, + "missingRequired": { + "type": "array", + "items": { + "type": "string" + } + }, + "optional": { + "type": "array", + "items": { + "type": "string" + } + }, + "optionalCount": { + "type": "integer" + }, + "required": { + "type": "array", + "items": { + "type": "string" + } + }, + "requiredCount": { + "type": "integer" + } + } + }, "library.Info": { "type": "object", "properties": { @@ -18192,6 +18402,9 @@ const docTemplate = `{ "commands_reason": { "description": "CommandsReason is why commands cannot run, when Commands is unavailable.", "type": "string" + }, + "execution_status": { + "$ref": "#/definitions/schema.ChatsExecutionStatus" } } }, @@ -18289,6 +18502,13 @@ const docTemplate = `{ "type": "string" } }, + "caps": { + "description": "Caps are the capabilities you enabled for this binding: the ceiling of\nwhat any sender can use. Grants give roles of the kind some of them.", + "type": "array", + "items": { + "type": "string" + } + }, "conversation": { "description": "Conversation says which chat a message belongs to: sender (one chat\nper sender and per platform thread), chat (one chat per group, channel\nor direct chat), window (sender, and a new chat after window_minutes\nwithout a message) or thread (a new chat per message; a reply to an\nanswer goes on in its chat).", "type": "string" @@ -18299,6 +18519,27 @@ const docTemplate = `{ "enabled": { "type": "boolean" }, + "file_receive_max_bytes": { + "description": "FileReceiveMaxBytes and FileSendMaxBytes are your limits for this bot;\nzero means you set none and the limit of the kind applies.", + "type": "integer" + }, + "file_send_max_bytes": { + "type": "integer" + }, + "files": { + "description": "Files are the limits of the files the bot reads and sends, with the\nlimit that you set for this bot.", + "allOf": [ + { + "$ref": "#/definitions/schema.ChatsConnectorFiles" + } + ] + }, + "grants": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.ChatsConnectorRoleGrant" + } + }, "has_token": { "type": "boolean" }, @@ -18314,9 +18555,23 @@ const docTemplate = `{ "notify_chat": { "type": "string" }, + "options": { + "description": "Options are the choices made for the options of the kind, by option id.\nAn option that is absent has its default (see ChatsConnectorKind).", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, "owner": { "type": "string" }, + "paired": { + "description": "Paired are the senders you proved with a pairing code. A pairing ends\nat expires_at.", + "type": "array", + "items": { + "$ref": "#/definitions/schema.ChatsConnectorPaired" + } + }, "reason": { "description": "Reason says in plain words why the binding is not active.", "type": "string" @@ -18527,7 +18782,7 @@ const docTemplate = `{ "type": "string" }, "path": { - "description": "Path is the folder's path in your files now (\"/\" is all of them). It\nis absent when the folder is missing or not chosen.", + "description": "Path is home-relative (\"/\" is the whole home), or /shared/\u003cname\u003e/...\nfor a followed readable shared folder. It is absent when missing,\nnot chosen, or readable outside these public locations; a readable\nfolder without a public path still has state ok.", "type": "string" }, "state": { @@ -18686,7 +18941,7 @@ const docTemplate = `{ "type": "string" }, "folder": { - "description": "Folder chooses the one folder the agent's chats work in. A write\nwithout it keeps the folder the agent has; {\"path\": \"\"} clears it. In\nan export file it is the folder's path and mode when the file was made.", + "description": "Folder chooses the one folder the agent's chats work in. A write\nwithout it keeps the folder the agent has; {\"path\": \"\"} clears it. In\nan export file it is the home-relative path and mode when the file was\nmade. A missing, unchosen or non-home folder has an empty export path;\nimport preserves its mode as unset and warns instead of clearing scope.", "allOf": [ { "$ref": "#/definitions/schema.ChatsAgentFolderWrite" @@ -19093,6 +19348,13 @@ const docTemplate = `{ "type": "string" } }, + "caps": { + "description": "Caps are the capabilities you enabled for this binding: the ceiling of\nwhat any sender can use. Grants give roles of the kind some of them.", + "type": "array", + "items": { + "type": "string" + } + }, "conversation": { "description": "Conversation says which chat a message belongs to: sender (one chat\nper sender and per platform thread), chat (one chat per group, channel\nor direct chat), window (sender, and a new chat after window_minutes\nwithout a message) or thread (a new chat per message; a reply to an\nanswer goes on in its chat).", "type": "string" @@ -19103,6 +19365,27 @@ const docTemplate = `{ "enabled": { "type": "boolean" }, + "file_receive_max_bytes": { + "description": "FileReceiveMaxBytes and FileSendMaxBytes are your limits for this bot;\nzero means you set none and the limit of the kind applies.", + "type": "integer" + }, + "file_send_max_bytes": { + "type": "integer" + }, + "files": { + "description": "Files are the limits of the files the bot reads and sends, with the\nlimit that you set for this bot.", + "allOf": [ + { + "$ref": "#/definitions/schema.ChatsConnectorFiles" + } + ] + }, + "grants": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.ChatsConnectorRoleGrant" + } + }, "has_token": { "type": "boolean" }, @@ -19118,17 +19401,24 @@ const docTemplate = `{ "notify_chat": { "type": "string" }, - "reason": { - "description": "Reason says in plain words why the binding is not active.", - "type": "string" - }, "options": { - "description": "Options are the choices made for the options of the kind, by option id. An option that is absent has its default (see ChatsConnectorKind).", + "description": "Options are the choices made for the options of the kind, by option id.\nAn option that is absent has its default (see ChatsConnectorKind).", "type": "object", "additionalProperties": { "type": "string" } }, + "paired": { + "description": "Paired are the senders you proved with a pairing code. A pairing ends\nat expires_at.", + "type": "array", + "items": { + "$ref": "#/definitions/schema.ChatsConnectorPaired" + } + }, + "reason": { + "description": "Reason says in plain words why the binding is not active.", + "type": "string" + }, "reply_mode": { "description": "ReplyMode is what a sender sees while a turn runs: off (typing, then\nthe answer), progress (a working message that names the tools as they\nstart) or stream (a working message that shows the answer as it is\nwritten). The answer replaces the working message.", "type": "string" @@ -19142,6 +19432,56 @@ const docTemplate = `{ } } }, + "schema.ChatsConnectorFileLimit": { + "type": "object", + "properties": { + "allowed_bytes": { + "description": "AllowedBytes is the most you may set for a bot.", + "type": "integer" + }, + "declared_bytes": { + "description": "DeclaredBytes is the ability the kind declares, or zero.", + "type": "integer" + }, + "effective_bytes": { + "description": "EffectiveBytes is the largest file that moves now: the smallest of the\nhost ceiling, the declared ability, your limit and the inline cap.", + "type": "integer" + }, + "layer": { + "description": "Layer names the one that sets EffectiveBytes: owner (your limit for\nthis bot), addon (the ability the kind declares), host (the ceiling of\nthe server) or inline (the cap of the inline path).", + "type": "string" + } + } + }, + "schema.ChatsConnectorFiles": { + "type": "object", + "properties": { + "host_ceiling_bytes": { + "description": "HostCeilingBytes is the ceiling of the server for a connector file, set\nby the add-on owner; no limit above it applies.", + "type": "integer" + }, + "inline_bytes": { + "description": "InlineBytes is the cap of the inline path. Until the add-on can stream\na file, nothing larger moves, whatever the other limits are.", + "type": "integer" + }, + "receive": { + "description": "Receive is the limit of the files that people send to the bot.", + "allOf": [ + { + "$ref": "#/definitions/schema.ChatsConnectorFileLimit" + } + ] + }, + "send": { + "description": "Send is the limit of the files that the bot sends.", + "allOf": [ + { + "$ref": "#/definitions/schema.ChatsConnectorFileLimit" + } + ] + } + } + }, "schema.ChatsConnectorKind": { "type": "object", "properties": { @@ -19151,6 +19491,14 @@ const docTemplate = `{ "description": { "type": "string" }, + "files": { + "description": "Files are the limits of a binding of this kind that has no limit of\nyour own.", + "allOf": [ + { + "$ref": "#/definitions/schema.ChatsConnectorFiles" + } + ] + }, "kind": { "type": "string" }, @@ -19165,6 +19513,19 @@ const docTemplate = `{ "description": "Page is the add-on page that adds and edits bindings of this kind.", "type": "string" }, + "permissions": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.ChatsConnectorPermission" + } + }, + "roles": { + "description": "Roles are the roles the add-on can report for a sender, and\nPermissions the capabilities the kind lists. Grant them to a role with\nthe grants of a binding.", + "type": "array", + "items": { + "$ref": "#/definitions/schema.ChatsConnectorRole" + } + }, "sender_hint": { "type": "string" }, @@ -19177,6 +19538,28 @@ const docTemplate = `{ } } }, + "schema.ChatsConnectorKindList": { + "type": "object", + "properties": { + "kinds": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.ChatsConnectorKind" + } + } + } + }, + "schema.ChatsConnectorList": { + "type": "object", + "properties": { + "connectors": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.ChatsConnector" + } + } + } + }, "schema.ChatsConnectorOption": { "type": "object", "properties": { @@ -19211,25 +19594,67 @@ const docTemplate = `{ } } }, - "schema.ChatsConnectorKindList": { + "schema.ChatsConnectorPaired": { "type": "object", "properties": { - "kinds": { - "type": "array", - "items": { - "$ref": "#/definitions/schema.ChatsConnectorKind" - } + "expires_at": { + "type": "string" + }, + "paired_at": { + "type": "string" + }, + "sender_id": { + "type": "string" } } }, - "schema.ChatsConnectorList": { + "schema.ChatsConnectorPairing": { "type": "object", "properties": { - "connectors": { + "code": { + "type": "string" + }, + "expires": { + "type": "string" + } + } + }, + "schema.ChatsConnectorPermission": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "sensitive": { + "type": "boolean" + } + } + }, + "schema.ChatsConnectorRole": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, + "schema.ChatsConnectorRoleGrant": { + "type": "object", + "properties": { + "caps": { "type": "array", "items": { - "$ref": "#/definitions/schema.ChatsConnector" + "type": "string" } + }, + "role": { + "type": "string" } } }, @@ -19293,6 +19718,13 @@ const docTemplate = `{ "type": "string" } }, + "caps": { + "description": "Caps are the capabilities you enable for the binding (a closed list,\nsee the kind's permissions for the ones it can grant to a role).\nAbsent means none for a new binding and unchanged for an update; an\nempty list clears.", + "type": "array", + "items": { + "type": "string" + } + }, "confirm_accept_any": { "description": "ConfirmAcceptAny must be the exact text \"accept any message\" in the\nrequest that turns accept_any on (a create with accept_any, or an update\nthat changes it from off to on). Anyone who can message the bot then uses\nthe agent's tools as you, so the server asks you to say you mean it.", "type": "string" @@ -19304,6 +19736,20 @@ const docTemplate = `{ "enabled": { "type": "boolean" }, + "file_receive_max_bytes": { + "description": "FileReceiveMaxBytes and FileSendMaxBytes limit the files the bot reads\nand sends, in bytes. A limit can only lower the limit of the kind, which\nthe kind list shows as allowed_bytes. Zero removes your limit. Absent\nmeans none for a new binding and unchanged for an update.", + "type": "integer" + }, + "file_send_max_bytes": { + "type": "integer" + }, + "grants": { + "description": "Grants map roles of the kind to capabilities within caps. A sensitive\ncapability is never granted to a role. Absent means unchanged; an empty\nlist clears.", + "type": "array", + "items": { + "$ref": "#/definitions/schema.ChatsConnectorRoleGrant" + } + }, "kind": { "description": "Kind is the platform, such as telegram. It is used to create a binding and\nignored by an update. Empty means telegram.", "type": "string" @@ -19315,7 +19761,7 @@ const docTemplate = `{ "type": "string" }, "options": { - "description": "Options are the choices for the options of the kind, by option id. Absent means the defaults for a new binding and unchanged for an update.", + "description": "Options are the choices for the options of the kind, by option id. Absent\nmeans the defaults for a new binding and unchanged for an update.", "type": "object", "additionalProperties": { "type": "string" @@ -19643,7 +20089,7 @@ const docTemplate = `{ } }, "applies": { - "description": "Applies is false when commands run on this machine (applies_reason\nhost) or no Sandbox serves bash (none).", + "description": "Applies is true when the add-on that runs the commands reports that it\nenforces the network policy. It is false when commands run on this\nmachine (applies_reason host), or no Sandbox serves bash or the add-on\ndoes not report enforcement (none).", "type": "boolean" }, "applies_reason": { @@ -19663,6 +20109,14 @@ const docTemplate = `{ "description": "the lower of the two", "type": "string" }, + "network": { + "description": "Network names the network of addon in text and gives the preset titles\nthe add-on declares. Set with addon.", + "allOf": [ + { + "$ref": "#/definitions/schema.ChatsEgressNetwork" + } + ] + }, "preset": { "description": "off, registries, all", "type": "string" @@ -19717,6 +20171,14 @@ const docTemplate = `{ "isolation": { "type": "string" }, + "network": { + "description": "Network names the network of that add-on in text. Set with sandbox.", + "allOf": [ + { + "$ref": "#/definitions/schema.ChatsEgressNetwork" + } + ] + }, "ports": { "type": "array", "items": { @@ -19730,7 +20192,7 @@ const docTemplate = `{ } }, "sandbox": { - "description": "Sandbox is the add-on that runs commands, empty when none serves;\nIsolation is isolated, host, or empty when unknown.", + "description": "Sandbox is the add-on that runs commands, empty when none serves (the\nname of the field is kept for existing clients); Isolation is isolated,\nhost, or empty when unknown.", "type": "string" } } @@ -19842,6 +20304,41 @@ const docTemplate = `{ } } }, + "schema.ChatsEgressNetwork": { + "type": "object", + "properties": { + "presets": { + "description": "Presets are the titles the add-on gives the host's presets; a preset\nthat is not listed keeps the host's title.", + "type": "array", + "items": { + "$ref": "#/definitions/schema.ChatsEgressNetworkPreset" + } + }, + "title": { + "description": "Title names the network in text: the declared title, else the title of\nthe commands tool.", + "type": "string", + "example": "Sandbox" + } + } + }, + "schema.ChatsEgressNetworkPreset": { + "type": "object", + "properties": { + "id": { + "type": "string", + "enum": [ + "off", + "registries", + "all" + ], + "example": "off" + }, + "title": { + "type": "string", + "example": "Closed" + } + } + }, "schema.ChatsEgressPreset": { "type": "object", "properties": { @@ -19955,6 +20452,31 @@ const docTemplate = `{ } } }, + "schema.ChatsExecutionStatus": { + "type": "object", + "properties": { + "explanation": { + "type": "string" + }, + "generation": { + "type": "string" + }, + "incarnation": { + "type": "string" + }, + "isolation": { + "type": "string" + }, + "readiness": { + "type": "string" + }, + "unknown_reason": { + "description": "UnknownReason says why the status is unknown: owner_unreachable,\nnot_reported, stale, invalid_status, not_selected, no_answer,\nreported_unknown or draft (the chat does not exist yet). Absent when the status is known.", + "type": "string", + "example": "owner_unreachable" + } + } + }, "schema.ChatsExport": { "type": "object", "properties": { diff --git a/swagger/swagger.json b/swagger/swagger.json index aba9afd114..a712454ca9 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -3657,6 +3657,12 @@ "description": "with chat, sub-agent runs are left out and the limit counts chats", "name": "kind", "in": "query" + }, + { + "type": "string", + "description": "only chats of one origin, with sub-agent runs left out and the limit counting the chats kept: person (a person started it), automated (anything else) or one origin kind (trigger, connector, job, page). Without it, all chats", + "name": "origin", + "in": "query" } ], "responses": { @@ -3705,7 +3711,7 @@ } }, "409": { - "description": "The saved agent's folder is missing or not chosen (agent_folder_missing)", + "description": "The saved agent's folder is missing or not chosen (agent_folder_missing), or its complete file access exceeds the folder limit (agent_folder_limit)", "schema": { "$ref": "#/definitions/schema.ErrorResponse" } @@ -3852,7 +3858,7 @@ "tags": [ "chats" ], - "summary": "Read the settings of the Sandbox network (administrators).", + "summary": "Read the settings of the network of commands (administrators).", "responses": { "200": { "description": "The settings in force", @@ -3865,11 +3871,11 @@ }, "/api/chats/admin/egress/events": { "get": { - "description": "Every request a command made through the Sandbox proxy, newest first: who, which chat, host, port, kind (connect or http), verdict (allow, block, or done when an allowed connection ended, with its address, bytes and duration) and reason.", + "description": "Every request a command made through the proxy of the add-on, newest first: who, which chat, host, port, kind (connect or http), verdict (allow, block, or done when an allowed connection ended, with its address, bytes and duration) and reason.", "tags": [ "chats" ], - "summary": "Read the audit of the Sandbox network requests (administrators).", + "summary": "Read the audit of the network of commands requests (administrators).", "parameters": [ { "type": "integer", @@ -4687,7 +4693,7 @@ }, "/api/chats/agents/{id}/export": { "get": { - "description": "Returns the definition as a JSON file (version, kind localai-agent, agent). It holds no ID, owner or time. An agent with a folder is version 2 and carries folder {path, mode}, the folder's path in your files now (empty when the folder is missing or not chosen); a server that knows only version 1 refuses it. An agent without a folder is version 1. POST /api/chats/agents/import reads it. This replaces the old /api/agents/{name}/export.", + "description": "Returns the definition as a JSON file (version, kind localai-agent, agent). It holds no ID, owner or time. An agent with a folder is version 2 and carries folder {path, mode}, the folder's home-relative path now (empty when the folder is missing, not chosen, or outside your home, even if readable); importing an empty path preserves the scope as unset with a warning; a server that knows only version 1 refuses it. An agent without a folder is version 1. POST /api/chats/agents/import reads it. This replaces the old /api/agents/{name}/export.", "tags": [ "chats" ], @@ -5150,6 +5156,65 @@ } } }, + "/api/chats/connectors/{id}/paired/{sender}": { + "delete": { + "description": "The sender can pair again with a new code.", + "tags": [ + "chats" + ], + "summary": "End the pairing of a sender of a connector binding.", + "parameters": [ + { + "type": "string", + "description": "the binding's id", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "the sender's id", + "name": "sender", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "The binding", + "schema": { + "$ref": "#/definitions/schema.ChatsConnector" + } + } + } + } + }, + "/api/chats/connectors/{id}/pairing": { + "post": { + "description": "Pairing proves that you control a platform account. Send the code, as a direct message to the bot, from an account on the allowlist, within ten minutes. The code works once, and a new code replaces the old one. A paired sender may use the sensitive capabilities you enabled; a pairing ends after 90 days. At most three senders are paired.", + "tags": [ + "chats" + ], + "summary": "Make a pairing code for a connector binding.", + "parameters": [ + { + "type": "string", + "description": "the binding's id", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "The code and when it expires", + "schema": { + "$ref": "#/definitions/schema.ChatsConnectorPairing" + } + } + } + } + }, "/api/chats/connectors/{id}/senders": { "get": { "description": "The log of people who wrote to the bot, newest first, with whether they were accepted. A message from a sender who is not accepted is ignored, never answered.", @@ -8494,11 +8559,11 @@ }, "/api/chats/{id}/egress": { "get": { - "description": "The preset of the chat (off, registries, all), the administrator's ceiling, the preset in force (the lower of the two), whether it applies (false when commands run on this machine, applies_reason host, or no Sandbox serves bash, none), addon (the add-on that runs the commands and draws the network controls of the chat, from its network-policy host control; absent when none does), the package registries the preset registries admits, the chat's allow and deny rules (above_limit when the ceiling no longer admits one) and the cards that asked. A sub-agent chat shows its parent's. Another user's chat is 404.", + "description": "The preset of the chat (off, registries, all), the administrator's ceiling, the preset in force (the lower of the two), whether it applies (false when commands run on this machine, applies_reason host, or no add-on serves bash, none), addon (the add-on that runs the commands and draws the network controls of the chat, from its network-policy host control; absent when none does), the package registries the preset registries admits, the chat's allow and deny rules (above_limit when the ceiling no longer admits one) and the cards that asked. A sub-agent chat shows its parent's. Another user's chat is 404.", "tags": [ "chats" ], - "summary": "Read the network of a chat's Sandbox commands.", + "summary": "Read the network of a chat's commands.", "parameters": [ { "type": "string", @@ -8528,7 +8593,7 @@ "tags": [ "chats" ], - "summary": "Set the network preset of a chat's Sandbox commands.", + "summary": "Set the network preset of a chat's commands.", "parameters": [ { "type": "string", @@ -8575,7 +8640,7 @@ "tags": [ "chats" ], - "summary": "Answer a network card of a chat's Sandbox commands.", + "summary": "Answer a network card of a chat's commands.", "parameters": [ { "type": "string", @@ -8627,7 +8692,7 @@ }, "/api/chats/{id}/egress/rules/{host}/{port}": { "delete": { - "description": "Removes the rule that allows or denies one host and port for the chat's Sandbox commands. A host that was denied asks again; one that was allowed follows the preset again.", + "description": "Removes the rule that allows or denies one host and port for the chat's commands. A host that was denied asks again; one that was allowed follows the preset again.", "tags": [ "chats" ], @@ -9576,7 +9641,7 @@ } }, "409": { - "description": "A turn is running for a command, or the saved agent's folder is missing (agent_folder_missing)", + "description": "A turn is running for a command, or the saved agent's folder is missing (agent_folder_missing), or its complete file access exceeds the folder limit (agent_folder_limit)", "schema": { "$ref": "#/definitions/schema.ErrorResponse" } @@ -14813,6 +14878,9 @@ "canStop": { "type": "boolean" }, + "hostOperations": { + "$ref": "#/definitions/hostops.RequestSummary" + }, "managementUrl": { "description": "ManagementURL opens the management view of the addon.", "type": "string", @@ -14882,6 +14950,14 @@ "type": "string", "example": "notes" }, + "network": { + "description": "Network is the network policy the add-on declares, present only while\nthe host draws its network-policy control (see contract.EntitledControls).", + "allOf": [ + { + "$ref": "#/definitions/apps.NetworkView" + } + ] + }, "shadows": { "description": "Shadows lists the built-in chat tools the add-on replaces (bash, say).", "type": "array", @@ -15012,6 +15088,32 @@ } } }, + "apps.HomeCommandView": { + "type": "object", + "properties": { + "alias": { + "type": "string" + }, + "icon": { + "type": "string" + }, + "id": { + "type": "string" + }, + "label": { + "type": "string" + }, + "labels": { + "type": "object", + "additionalProperties": { + "type": "string" + } + }, + "page": { + "type": "string" + } + } + }, "apps.List": { "type": "object", "properties": { @@ -15023,6 +15125,31 @@ } } }, + "apps.NetworkView": { + "type": "object", + "properties": { + "kind": { + "description": "Kind is the declared kind of network policy.", + "type": "string", + "enum": [ + "egress-check" + ], + "example": "egress-check" + }, + "presets": { + "description": "Presets are the titles the add-on gives the host's network presets.", + "type": "array", + "items": { + "$ref": "#/definitions/contract.NetworkPreset" + } + }, + "title": { + "description": "Title names the network in text: the declared title, else the add-on's.", + "type": "string", + "example": "Example provider" + } + } + }, "apps.SlotView": { "type": "object", "properties": { @@ -15115,6 +15242,20 @@ "type": "string", "example": "/api/addons/notes/assets/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef/ui.js" }, + "homeCommands": { + "description": "HomeCommands contains only resolved navigation metadata for this app.", + "type": "array", + "items": { + "$ref": "#/definitions/apps.HomeCommandView" + } + }, + "modelActions": { + "description": "ModelActions contains only declared destinations for this installed UI.", + "type": "array", + "items": { + "$ref": "#/definitions/contract.ModelAction" + } + }, "navigation": { "type": "array", "items": { @@ -15514,6 +15655,31 @@ } } }, + "contract.ModelAction": { + "type": "object", + "properties": { + "capability": { + "type": "string" + }, + "page": { + "type": "string" + }, + "path": { + "type": "string" + } + } + }, + "contract.NetworkPreset": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, "contract.OutputDescription": { "type": "object", "properties": { @@ -15648,11 +15814,11 @@ "type": "object", "properties": { "name": { - "description": "Name is the skill's name and the name of its folder. A plain skill is\nnamed after the add-on: the add-on ID, or the ID and a dash and more\n(office-quickstart), so two add-ons never ship one plain name. A skill\nthat replaces a built-in one has the built-in's name (see Shadows).", + "description": "Name is the skill's name and the name of its folder. A plain skill is\nnamed after the add-on: the add-on ID, or the ID and a dash and more\n(office-quickstart), so two add-ons never ship one plain name. A skill\nfor a reserved document slot has the slot's name (see Shadows).", "type": "string" }, "shadows": { - "description": "Shadows names the built-in skill this skill replaces. It must equal Name\nand be one of ReplaceableSkills. It repeats the name on purpose: the\nreplacement is an explicit act, which the install warning and the check\nread from the manifest without opening the package. Empty: a plain\nadd-on skill.", + "description": "Shadows names the document-provider slot this skill supplies. It must equal Name\nand be one of ReplaceableSkills. It repeats the name on purpose: the\nreplacement is an explicit act, which the install warning and the check\nread from the manifest without opening the package. Empty: a plain\nadd-on skill.", "type": "string" } } @@ -16967,6 +17133,50 @@ } } }, + "hostops.RequestSummary": { + "type": "object", + "properties": { + "granted": { + "type": "array", + "items": { + "type": "string" + } + }, + "grantedCount": { + "type": "integer" + }, + "missingOptional": { + "type": "array", + "items": { + "type": "string" + } + }, + "missingRequired": { + "type": "array", + "items": { + "type": "string" + } + }, + "optional": { + "type": "array", + "items": { + "type": "string" + } + }, + "optionalCount": { + "type": "integer" + }, + "required": { + "type": "array", + "items": { + "type": "string" + } + }, + "requiredCount": { + "type": "integer" + } + } + }, "library.Info": { "type": "object", "properties": { @@ -18189,6 +18399,9 @@ "commands_reason": { "description": "CommandsReason is why commands cannot run, when Commands is unavailable.", "type": "string" + }, + "execution_status": { + "$ref": "#/definitions/schema.ChatsExecutionStatus" } } }, @@ -18286,6 +18499,13 @@ "type": "string" } }, + "caps": { + "description": "Caps are the capabilities you enabled for this binding: the ceiling of\nwhat any sender can use. Grants give roles of the kind some of them.", + "type": "array", + "items": { + "type": "string" + } + }, "conversation": { "description": "Conversation says which chat a message belongs to: sender (one chat\nper sender and per platform thread), chat (one chat per group, channel\nor direct chat), window (sender, and a new chat after window_minutes\nwithout a message) or thread (a new chat per message; a reply to an\nanswer goes on in its chat).", "type": "string" @@ -18296,6 +18516,27 @@ "enabled": { "type": "boolean" }, + "file_receive_max_bytes": { + "description": "FileReceiveMaxBytes and FileSendMaxBytes are your limits for this bot;\nzero means you set none and the limit of the kind applies.", + "type": "integer" + }, + "file_send_max_bytes": { + "type": "integer" + }, + "files": { + "description": "Files are the limits of the files the bot reads and sends, with the\nlimit that you set for this bot.", + "allOf": [ + { + "$ref": "#/definitions/schema.ChatsConnectorFiles" + } + ] + }, + "grants": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.ChatsConnectorRoleGrant" + } + }, "has_token": { "type": "boolean" }, @@ -18311,9 +18552,23 @@ "notify_chat": { "type": "string" }, + "options": { + "description": "Options are the choices made for the options of the kind, by option id.\nAn option that is absent has its default (see ChatsConnectorKind).", + "type": "object", + "additionalProperties": { + "type": "string" + } + }, "owner": { "type": "string" }, + "paired": { + "description": "Paired are the senders you proved with a pairing code. A pairing ends\nat expires_at.", + "type": "array", + "items": { + "$ref": "#/definitions/schema.ChatsConnectorPaired" + } + }, "reason": { "description": "Reason says in plain words why the binding is not active.", "type": "string" @@ -18524,7 +18779,7 @@ "type": "string" }, "path": { - "description": "Path is the folder's path in your files now (\"/\" is all of them). It\nis absent when the folder is missing or not chosen.", + "description": "Path is home-relative (\"/\" is the whole home), or /shared/\u003cname\u003e/...\nfor a followed readable shared folder. It is absent when missing,\nnot chosen, or readable outside these public locations; a readable\nfolder without a public path still has state ok.", "type": "string" }, "state": { @@ -18683,7 +18938,7 @@ "type": "string" }, "folder": { - "description": "Folder chooses the one folder the agent's chats work in. A write\nwithout it keeps the folder the agent has; {\"path\": \"\"} clears it. In\nan export file it is the folder's path and mode when the file was made.", + "description": "Folder chooses the one folder the agent's chats work in. A write\nwithout it keeps the folder the agent has; {\"path\": \"\"} clears it. In\nan export file it is the home-relative path and mode when the file was\nmade. A missing, unchosen or non-home folder has an empty export path;\nimport preserves its mode as unset and warns instead of clearing scope.", "allOf": [ { "$ref": "#/definitions/schema.ChatsAgentFolderWrite" @@ -19090,6 +19345,13 @@ "type": "string" } }, + "caps": { + "description": "Caps are the capabilities you enabled for this binding: the ceiling of\nwhat any sender can use. Grants give roles of the kind some of them.", + "type": "array", + "items": { + "type": "string" + } + }, "conversation": { "description": "Conversation says which chat a message belongs to: sender (one chat\nper sender and per platform thread), chat (one chat per group, channel\nor direct chat), window (sender, and a new chat after window_minutes\nwithout a message) or thread (a new chat per message; a reply to an\nanswer goes on in its chat).", "type": "string" @@ -19100,6 +19362,27 @@ "enabled": { "type": "boolean" }, + "file_receive_max_bytes": { + "description": "FileReceiveMaxBytes and FileSendMaxBytes are your limits for this bot;\nzero means you set none and the limit of the kind applies.", + "type": "integer" + }, + "file_send_max_bytes": { + "type": "integer" + }, + "files": { + "description": "Files are the limits of the files the bot reads and sends, with the\nlimit that you set for this bot.", + "allOf": [ + { + "$ref": "#/definitions/schema.ChatsConnectorFiles" + } + ] + }, + "grants": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.ChatsConnectorRoleGrant" + } + }, "has_token": { "type": "boolean" }, @@ -19115,17 +19398,24 @@ "notify_chat": { "type": "string" }, - "reason": { - "description": "Reason says in plain words why the binding is not active.", - "type": "string" - }, "options": { - "description": "Options are the choices made for the options of the kind, by option id. An option that is absent has its default (see ChatsConnectorKind).", + "description": "Options are the choices made for the options of the kind, by option id.\nAn option that is absent has its default (see ChatsConnectorKind).", "type": "object", "additionalProperties": { "type": "string" } }, + "paired": { + "description": "Paired are the senders you proved with a pairing code. A pairing ends\nat expires_at.", + "type": "array", + "items": { + "$ref": "#/definitions/schema.ChatsConnectorPaired" + } + }, + "reason": { + "description": "Reason says in plain words why the binding is not active.", + "type": "string" + }, "reply_mode": { "description": "ReplyMode is what a sender sees while a turn runs: off (typing, then\nthe answer), progress (a working message that names the tools as they\nstart) or stream (a working message that shows the answer as it is\nwritten). The answer replaces the working message.", "type": "string" @@ -19139,6 +19429,56 @@ } } }, + "schema.ChatsConnectorFileLimit": { + "type": "object", + "properties": { + "allowed_bytes": { + "description": "AllowedBytes is the most you may set for a bot.", + "type": "integer" + }, + "declared_bytes": { + "description": "DeclaredBytes is the ability the kind declares, or zero.", + "type": "integer" + }, + "effective_bytes": { + "description": "EffectiveBytes is the largest file that moves now: the smallest of the\nhost ceiling, the declared ability, your limit and the inline cap.", + "type": "integer" + }, + "layer": { + "description": "Layer names the one that sets EffectiveBytes: owner (your limit for\nthis bot), addon (the ability the kind declares), host (the ceiling of\nthe server) or inline (the cap of the inline path).", + "type": "string" + } + } + }, + "schema.ChatsConnectorFiles": { + "type": "object", + "properties": { + "host_ceiling_bytes": { + "description": "HostCeilingBytes is the ceiling of the server for a connector file, set\nby the add-on owner; no limit above it applies.", + "type": "integer" + }, + "inline_bytes": { + "description": "InlineBytes is the cap of the inline path. Until the add-on can stream\na file, nothing larger moves, whatever the other limits are.", + "type": "integer" + }, + "receive": { + "description": "Receive is the limit of the files that people send to the bot.", + "allOf": [ + { + "$ref": "#/definitions/schema.ChatsConnectorFileLimit" + } + ] + }, + "send": { + "description": "Send is the limit of the files that the bot sends.", + "allOf": [ + { + "$ref": "#/definitions/schema.ChatsConnectorFileLimit" + } + ] + } + } + }, "schema.ChatsConnectorKind": { "type": "object", "properties": { @@ -19148,6 +19488,14 @@ "description": { "type": "string" }, + "files": { + "description": "Files are the limits of a binding of this kind that has no limit of\nyour own.", + "allOf": [ + { + "$ref": "#/definitions/schema.ChatsConnectorFiles" + } + ] + }, "kind": { "type": "string" }, @@ -19162,6 +19510,19 @@ "description": "Page is the add-on page that adds and edits bindings of this kind.", "type": "string" }, + "permissions": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.ChatsConnectorPermission" + } + }, + "roles": { + "description": "Roles are the roles the add-on can report for a sender, and\nPermissions the capabilities the kind lists. Grant them to a role with\nthe grants of a binding.", + "type": "array", + "items": { + "$ref": "#/definitions/schema.ChatsConnectorRole" + } + }, "sender_hint": { "type": "string" }, @@ -19174,6 +19535,28 @@ } } }, + "schema.ChatsConnectorKindList": { + "type": "object", + "properties": { + "kinds": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.ChatsConnectorKind" + } + } + } + }, + "schema.ChatsConnectorList": { + "type": "object", + "properties": { + "connectors": { + "type": "array", + "items": { + "$ref": "#/definitions/schema.ChatsConnector" + } + } + } + }, "schema.ChatsConnectorOption": { "type": "object", "properties": { @@ -19208,25 +19591,67 @@ } } }, - "schema.ChatsConnectorKindList": { + "schema.ChatsConnectorPaired": { "type": "object", "properties": { - "kinds": { - "type": "array", - "items": { - "$ref": "#/definitions/schema.ChatsConnectorKind" - } + "expires_at": { + "type": "string" + }, + "paired_at": { + "type": "string" + }, + "sender_id": { + "type": "string" } } }, - "schema.ChatsConnectorList": { + "schema.ChatsConnectorPairing": { "type": "object", "properties": { - "connectors": { + "code": { + "type": "string" + }, + "expires": { + "type": "string" + } + } + }, + "schema.ChatsConnectorPermission": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "sensitive": { + "type": "boolean" + } + } + }, + "schema.ChatsConnectorRole": { + "type": "object", + "properties": { + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "title": { + "type": "string" + } + } + }, + "schema.ChatsConnectorRoleGrant": { + "type": "object", + "properties": { + "caps": { "type": "array", "items": { - "$ref": "#/definitions/schema.ChatsConnector" + "type": "string" } + }, + "role": { + "type": "string" } } }, @@ -19290,6 +19715,13 @@ "type": "string" } }, + "caps": { + "description": "Caps are the capabilities you enable for the binding (a closed list,\nsee the kind's permissions for the ones it can grant to a role).\nAbsent means none for a new binding and unchanged for an update; an\nempty list clears.", + "type": "array", + "items": { + "type": "string" + } + }, "confirm_accept_any": { "description": "ConfirmAcceptAny must be the exact text \"accept any message\" in the\nrequest that turns accept_any on (a create with accept_any, or an update\nthat changes it from off to on). Anyone who can message the bot then uses\nthe agent's tools as you, so the server asks you to say you mean it.", "type": "string" @@ -19301,6 +19733,20 @@ "enabled": { "type": "boolean" }, + "file_receive_max_bytes": { + "description": "FileReceiveMaxBytes and FileSendMaxBytes limit the files the bot reads\nand sends, in bytes. A limit can only lower the limit of the kind, which\nthe kind list shows as allowed_bytes. Zero removes your limit. Absent\nmeans none for a new binding and unchanged for an update.", + "type": "integer" + }, + "file_send_max_bytes": { + "type": "integer" + }, + "grants": { + "description": "Grants map roles of the kind to capabilities within caps. A sensitive\ncapability is never granted to a role. Absent means unchanged; an empty\nlist clears.", + "type": "array", + "items": { + "$ref": "#/definitions/schema.ChatsConnectorRoleGrant" + } + }, "kind": { "description": "Kind is the platform, such as telegram. It is used to create a binding and\nignored by an update. Empty means telegram.", "type": "string" @@ -19312,7 +19758,7 @@ "type": "string" }, "options": { - "description": "Options are the choices for the options of the kind, by option id. Absent means the defaults for a new binding and unchanged for an update.", + "description": "Options are the choices for the options of the kind, by option id. Absent\nmeans the defaults for a new binding and unchanged for an update.", "type": "object", "additionalProperties": { "type": "string" @@ -19640,7 +20086,7 @@ } }, "applies": { - "description": "Applies is false when commands run on this machine (applies_reason\nhost) or no Sandbox serves bash (none).", + "description": "Applies is true when the add-on that runs the commands reports that it\nenforces the network policy. It is false when commands run on this\nmachine (applies_reason host), or no Sandbox serves bash or the add-on\ndoes not report enforcement (none).", "type": "boolean" }, "applies_reason": { @@ -19660,6 +20106,14 @@ "description": "the lower of the two", "type": "string" }, + "network": { + "description": "Network names the network of addon in text and gives the preset titles\nthe add-on declares. Set with addon.", + "allOf": [ + { + "$ref": "#/definitions/schema.ChatsEgressNetwork" + } + ] + }, "preset": { "description": "off, registries, all", "type": "string" @@ -19714,6 +20168,14 @@ "isolation": { "type": "string" }, + "network": { + "description": "Network names the network of that add-on in text. Set with sandbox.", + "allOf": [ + { + "$ref": "#/definitions/schema.ChatsEgressNetwork" + } + ] + }, "ports": { "type": "array", "items": { @@ -19727,7 +20189,7 @@ } }, "sandbox": { - "description": "Sandbox is the add-on that runs commands, empty when none serves;\nIsolation is isolated, host, or empty when unknown.", + "description": "Sandbox is the add-on that runs commands, empty when none serves (the\nname of the field is kept for existing clients); Isolation is isolated,\nhost, or empty when unknown.", "type": "string" } } @@ -19839,6 +20301,41 @@ } } }, + "schema.ChatsEgressNetwork": { + "type": "object", + "properties": { + "presets": { + "description": "Presets are the titles the add-on gives the host's presets; a preset\nthat is not listed keeps the host's title.", + "type": "array", + "items": { + "$ref": "#/definitions/schema.ChatsEgressNetworkPreset" + } + }, + "title": { + "description": "Title names the network in text: the declared title, else the title of\nthe commands tool.", + "type": "string", + "example": "Sandbox" + } + } + }, + "schema.ChatsEgressNetworkPreset": { + "type": "object", + "properties": { + "id": { + "type": "string", + "enum": [ + "off", + "registries", + "all" + ], + "example": "off" + }, + "title": { + "type": "string", + "example": "Closed" + } + } + }, "schema.ChatsEgressPreset": { "type": "object", "properties": { @@ -19952,6 +20449,31 @@ } } }, + "schema.ChatsExecutionStatus": { + "type": "object", + "properties": { + "explanation": { + "type": "string" + }, + "generation": { + "type": "string" + }, + "incarnation": { + "type": "string" + }, + "isolation": { + "type": "string" + }, + "readiness": { + "type": "string" + }, + "unknown_reason": { + "description": "UnknownReason says why the status is unknown: owner_unreachable,\nnot_reported, stale, invalid_status, not_selected, no_answer,\nreported_unknown or draft (the chat does not exist yet). Absent when the status is known.", + "type": "string", + "example": "owner_unreachable" + } + } + }, "schema.ChatsExport": { "type": "object", "properties": { diff --git a/swagger/swagger.yaml b/swagger/swagger.yaml index 4018e56555..893251e8bc 100644 --- a/swagger/swagger.yaml +++ b/swagger/swagger.yaml @@ -166,6 +166,8 @@ definitions: type: boolean canStop: type: boolean + hostOperations: + $ref: '#/definitions/hostops.RequestSummary' managementUrl: description: ManagementURL opens the management view of the addon. example: /app/addons?manage=notes @@ -231,6 +233,12 @@ definitions: name: example: notes type: string + network: + allOf: + - $ref: '#/definitions/apps.NetworkView' + description: |- + Network is the network policy the add-on declares, present only while + the host draws its network-policy control (see contract.EntitledControls). shadows: description: Shadows lists the built-in chat tools the add-on replaces (bash, say). @@ -351,6 +359,23 @@ definitions: example: summarize type: string type: object + apps.HomeCommandView: + properties: + alias: + type: string + icon: + type: string + id: + type: string + label: + type: string + labels: + additionalProperties: + type: string + type: object + page: + type: string + type: object apps.List: properties: apps: @@ -358,6 +383,25 @@ definitions: $ref: '#/definitions/apps.App' type: array type: object + apps.NetworkView: + properties: + kind: + description: Kind is the declared kind of network policy. + enum: + - egress-check + example: egress-check + type: string + presets: + description: Presets are the titles the add-on gives the host's network presets. + items: + $ref: '#/definitions/contract.NetworkPreset' + type: array + title: + description: 'Title names the network in text: the declared title, else the + add-on''s.' + example: Example provider + type: string + type: object apps.SlotView: properties: component: @@ -441,6 +485,18 @@ definitions: package digest, so a new package is a new URL. example: /api/addons/notes/assets/0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef/ui.js type: string + homeCommands: + description: HomeCommands contains only resolved navigation metadata for this + app. + items: + $ref: '#/definitions/apps.HomeCommandView' + type: array + modelActions: + description: ModelActions contains only declared destinations for this installed + UI. + items: + $ref: '#/definitions/contract.ModelAction' + type: array navigation: items: type: string @@ -743,6 +799,22 @@ definitions: type: string type: array type: object + contract.ModelAction: + properties: + capability: + type: string + page: + type: string + path: + type: string + type: object + contract.NetworkPreset: + properties: + id: + type: string + title: + type: string + type: object contract.OutputDescription: properties: handle: @@ -840,11 +912,11 @@ definitions: Name is the skill's name and the name of its folder. A plain skill is named after the add-on: the add-on ID, or the ID and a dash and more (office-quickstart), so two add-ons never ship one plain name. A skill - that replaces a built-in one has the built-in's name (see Shadows). + for a reserved document slot has the slot's name (see Shadows). type: string shadows: description: |- - Shadows names the built-in skill this skill replaces. It must equal Name + Shadows names the document-provider slot this skill supplies. It must equal Name and be one of ReplaceableSkills. It repeats the name on purpose: the replacement is an explicit act, which the install warning and the check read from the manifest without opening the package. Empty: a plain @@ -1868,6 +1940,35 @@ definitions: example: 4f1c type: string type: object + hostops.RequestSummary: + properties: + granted: + items: + type: string + type: array + grantedCount: + type: integer + missingOptional: + items: + type: string + type: array + missingRequired: + items: + type: string + type: array + optional: + items: + type: string + type: array + optionalCount: + type: integer + required: + items: + type: string + type: array + requiredCount: + type: integer + type: object library.Info: properties: authReference: @@ -2753,6 +2854,8 @@ definitions: commands_reason: description: CommandsReason is why commands cannot run, when Commands is unavailable. type: string + execution_status: + $ref: '#/definitions/schema.ChatsExecutionStatus' type: object schema.ChatsAddonSwitches: properties: @@ -2818,6 +2921,13 @@ definitions: items: type: string type: array + caps: + description: |- + Caps are the capabilities you enabled for this binding: the ceiling of + what any sender can use. Grants give roles of the kind some of them. + items: + type: string + type: array conversation: description: |- Conversation says which chat a message belongs to: sender (one chat @@ -2830,6 +2940,23 @@ definitions: type: string enabled: type: boolean + file_receive_max_bytes: + description: |- + FileReceiveMaxBytes and FileSendMaxBytes are your limits for this bot; + zero means you set none and the limit of the kind applies. + type: integer + file_send_max_bytes: + type: integer + files: + allOf: + - $ref: '#/definitions/schema.ChatsConnectorFiles' + description: |- + Files are the limits of the files the bot reads and sends, with the + limit that you set for this bot. + grants: + items: + $ref: '#/definitions/schema.ChatsConnectorRoleGrant' + type: array has_token: type: boolean id: @@ -2840,8 +2967,22 @@ definitions: type: string notify_chat: type: string + options: + additionalProperties: + type: string + description: |- + Options are the choices made for the options of the kind, by option id. + An option that is absent has its default (see ChatsConnectorKind). + type: object owner: type: string + paired: + description: |- + Paired are the senders you proved with a pairing code. A pairing ends + at expires_at. + items: + $ref: '#/definitions/schema.ChatsConnectorPaired' + type: array reason: description: Reason says in plain words why the binding is not active. type: string @@ -3000,8 +3141,10 @@ definitions: type: string path: description: |- - Path is the folder's path in your files now ("/" is all of them). It - is absent when the folder is missing or not chosen. + Path is home-relative ("/" is the whole home), or /shared//... + for a followed readable shared folder. It is absent when missing, + not chosen, or readable outside these public locations; a readable + folder without a public path still has state ok. type: string state: description: |- @@ -3128,7 +3271,9 @@ definitions: description: |- Folder chooses the one folder the agent's chats work in. A write without it keeps the folder the agent has; {"path": ""} clears it. In - an export file it is the folder's path and mode when the file was made. + an export file it is the home-relative path and mode when the file was + made. A missing, unchosen or non-home folder has an empty export path; + import preserves its mode as unset and warns instead of clearing scope. instructions: type: string mcp_servers: @@ -3460,6 +3605,13 @@ definitions: items: type: string type: array + caps: + description: |- + Caps are the capabilities you enabled for this binding: the ceiling of + what any sender can use. Grants give roles of the kind some of them. + items: + type: string + type: array conversation: description: |- Conversation says which chat a message belongs to: sender (one chat @@ -3472,6 +3624,23 @@ definitions: type: string enabled: type: boolean + file_receive_max_bytes: + description: |- + FileReceiveMaxBytes and FileSendMaxBytes are your limits for this bot; + zero means you set none and the limit of the kind applies. + type: integer + file_send_max_bytes: + type: integer + files: + allOf: + - $ref: '#/definitions/schema.ChatsConnectorFiles' + description: |- + Files are the limits of the files the bot reads and sends, with the + limit that you set for this bot. + grants: + items: + $ref: '#/definitions/schema.ChatsConnectorRoleGrant' + type: array has_token: type: boolean id: @@ -3482,9 +3651,6 @@ definitions: type: string notify_chat: type: string - reason: - description: Reason says in plain words why the binding is not active. - type: string options: additionalProperties: type: string @@ -3492,6 +3658,16 @@ definitions: Options are the choices made for the options of the kind, by option id. An option that is absent has its default (see ChatsConnectorKind). type: object + paired: + description: |- + Paired are the senders you proved with a pairing code. A pairing ends + at expires_at. + items: + $ref: '#/definitions/schema.ChatsConnectorPaired' + type: array + reason: + description: Reason says in plain words why the binding is not active. + type: string reply_mode: description: |- ReplyMode is what a sender sees while a turn runs: off (typing, then @@ -3507,12 +3683,59 @@ definitions: window_minutes: type: integer type: object + schema.ChatsConnectorFileLimit: + properties: + allowed_bytes: + description: AllowedBytes is the most you may set for a bot. + type: integer + declared_bytes: + description: DeclaredBytes is the ability the kind declares, or zero. + type: integer + effective_bytes: + description: |- + EffectiveBytes is the largest file that moves now: the smallest of the + host ceiling, the declared ability, your limit and the inline cap. + type: integer + layer: + description: |- + Layer names the one that sets EffectiveBytes: owner (your limit for + this bot), addon (the ability the kind declares), host (the ceiling of + the server) or inline (the cap of the inline path). + type: string + type: object + schema.ChatsConnectorFiles: + properties: + host_ceiling_bytes: + description: |- + HostCeilingBytes is the ceiling of the server for a connector file, set + by the add-on owner; no limit above it applies. + type: integer + inline_bytes: + description: |- + InlineBytes is the cap of the inline path. Until the add-on can stream + a file, nothing larger moves, whatever the other limits are. + type: integer + receive: + allOf: + - $ref: '#/definitions/schema.ChatsConnectorFileLimit' + description: Receive is the limit of the files that people send to the bot. + send: + allOf: + - $ref: '#/definitions/schema.ChatsConnectorFileLimit' + description: Send is the limit of the files that the bot sends. + type: object schema.ChatsConnectorKind: properties: addon: type: string description: type: string + files: + allOf: + - $ref: '#/definitions/schema.ChatsConnectorFiles' + description: |- + Files are the limits of a binding of this kind that has no limit of + your own. kind: type: string options: @@ -3525,6 +3748,18 @@ definitions: description: Page is the add-on page that adds and edits bindings of this kind. type: string + permissions: + items: + $ref: '#/definitions/schema.ChatsConnectorPermission' + type: array + roles: + description: |- + Roles are the roles the add-on can report for a sender, and + Permissions the capabilities the kind lists. Grant them to a role with + the grants of a binding. + items: + $ref: '#/definitions/schema.ChatsConnectorRole' + type: array sender_hint: type: string thread_history: @@ -3536,6 +3771,20 @@ definitions: title: type: string type: object + schema.ChatsConnectorKindList: + properties: + kinds: + items: + $ref: '#/definitions/schema.ChatsConnectorKind' + type: array + type: object + schema.ChatsConnectorList: + properties: + connectors: + items: + $ref: '#/definitions/schema.ChatsConnector' + type: array + type: object schema.ChatsConnectorOption: properties: default: @@ -3558,19 +3807,46 @@ definitions: title: type: string type: object - schema.ChatsConnectorKindList: + schema.ChatsConnectorPaired: properties: - kinds: - items: - $ref: '#/definitions/schema.ChatsConnectorKind' - type: array + expires_at: + type: string + paired_at: + type: string + sender_id: + type: string type: object - schema.ChatsConnectorList: + schema.ChatsConnectorPairing: properties: - connectors: + code: + type: string + expires: + type: string + type: object + schema.ChatsConnectorPermission: + properties: + name: + type: string + sensitive: + type: boolean + type: object + schema.ChatsConnectorRole: + properties: + description: + type: string + id: + type: string + title: + type: string + type: object + schema.ChatsConnectorRoleGrant: + properties: + caps: items: - $ref: '#/definitions/schema.ChatsConnector' + type: string type: array + role: + type: string type: object schema.ChatsConnectorSender: properties: @@ -3611,6 +3887,15 @@ definitions: items: type: string type: array + caps: + description: |- + Caps are the capabilities you enable for the binding (a closed list, + see the kind's permissions for the ones it can grant to a role). + Absent means none for a new binding and unchanged for an update; an + empty list clears. + items: + type: string + type: array confirm_accept_any: description: |- ConfirmAcceptAny must be the exact text "accept any message" in the @@ -3623,6 +3908,23 @@ definitions: type: string enabled: type: boolean + file_receive_max_bytes: + description: |- + FileReceiveMaxBytes and FileSendMaxBytes limit the files the bot reads + and sends, in bytes. A limit can only lower the limit of the kind, which + the kind list shows as allowed_bytes. Zero removes your limit. Absent + means none for a new binding and unchanged for an update. + type: integer + file_send_max_bytes: + type: integer + grants: + description: |- + Grants map roles of the kind to capabilities within caps. A sensitive + capability is never granted to a role. Absent means unchanged; an empty + list clears. + items: + $ref: '#/definitions/schema.ChatsConnectorRoleGrant' + type: array kind: description: |- Kind is the platform, such as telegram. It is used to create a binding and @@ -3894,8 +4196,10 @@ definitions: type: array applies: description: |- - Applies is false when commands run on this machine (applies_reason - host) or no Sandbox serves bash (none). + Applies is true when the add-on that runs the commands reports that it + enforces the network policy. It is false when commands run on this + machine (applies_reason host), or no Sandbox serves bash or the add-on + does not report enforcement (none). type: boolean applies_reason: type: string @@ -3909,6 +4213,12 @@ definitions: effective: description: the lower of the two type: string + network: + allOf: + - $ref: '#/definitions/schema.ChatsEgressNetwork' + description: |- + Network names the network of addon in text and gives the preset titles + the add-on declares. Set with addon. preset: description: off, registries, all type: string @@ -3945,6 +4255,10 @@ definitions: type: array isolation: type: string + network: + allOf: + - $ref: '#/definitions/schema.ChatsEgressNetwork' + description: Network names the network of that add-on in text. Set with sandbox. ports: items: type: integer @@ -3955,8 +4269,9 @@ definitions: type: array sandbox: description: |- - Sandbox is the add-on that runs commands, empty when none serves; - Isolation is isolated, host, or empty when unknown. + Sandbox is the add-on that runs commands, empty when none serves (the + name of the field is kept for existing clients); Isolation is isolated, + host, or empty when unknown. type: string type: object schema.ChatsEgressCard: @@ -4032,6 +4347,35 @@ definitions: $ref: '#/definitions/schema.ChatsEgressEvent' type: array type: object + schema.ChatsEgressNetwork: + properties: + presets: + description: |- + Presets are the titles the add-on gives the host's presets; a preset + that is not listed keeps the host's title. + items: + $ref: '#/definitions/schema.ChatsEgressNetworkPreset' + type: array + title: + description: |- + Title names the network in text: the declared title, else the title of + the commands tool. + example: Sandbox + type: string + type: object + schema.ChatsEgressNetworkPreset: + properties: + id: + enum: + - "off" + - registries + - all + example: "off" + type: string + title: + example: Closed + type: string + type: object schema.ChatsEgressPreset: properties: preset: @@ -4113,6 +4457,26 @@ definitions: - $ref: '#/definitions/schema.ChatsUsage' description: 'Usage is set on usage events: what one model request used.' type: object + schema.ChatsExecutionStatus: + properties: + explanation: + type: string + generation: + type: string + incarnation: + type: string + isolation: + type: string + readiness: + type: string + unknown_reason: + description: |- + UnknownReason says why the status is unknown: owner_unreachable, + not_reported, stale, invalid_status, not_selected, no_answer, + reported_unknown or draft (the chat does not exist yet). Absent when the status is known. + example: owner_unreachable + type: string + type: object schema.ChatsExport: properties: created: @@ -12261,6 +12625,13 @@ paths: in: query name: kind type: string + - description: 'only chats of one origin, with sub-agent runs left out and the + limit counting the chats kept: person (a person started it), automated (anything + else) or one origin kind (trigger, connector, job, page). Without it, all + chats' + in: query + name: origin + type: string responses: "200": description: Conversations, including sub-agent runs unless kind=chat @@ -12295,7 +12666,8 @@ paths: schema: $ref: '#/definitions/schema.ErrorResponse' "409": - description: The saved agent's folder is missing or not chosen (agent_folder_missing) + description: The saved agent's folder is missing or not chosen (agent_folder_missing), + or its complete file access exceeds the folder limit (agent_folder_limit) schema: $ref: '#/definitions/schema.ErrorResponse' summary: Start a chat. @@ -12621,7 +12993,7 @@ paths: get: description: The preset of the chat (off, registries, all), the administrator's ceiling, the preset in force (the lower of the two), whether it applies (false - when commands run on this machine, applies_reason host, or no Sandbox serves + when commands run on this machine, applies_reason host, or no add-on serves bash, none), addon (the add-on that runs the commands and draws the network controls of the chat, from its network-policy host control; absent when none does), the package registries the preset registries admits, the chat's allow @@ -12643,7 +13015,7 @@ paths: description: No such conversation, or it is another user's schema: $ref: '#/definitions/schema.ErrorResponse' - summary: Read the network of a chat's Sandbox commands. + summary: Read the network of a chat's commands. tags: - chats put: @@ -12676,7 +13048,7 @@ paths: description: No such conversation, or it is another user's schema: $ref: '#/definitions/schema.ErrorResponse' - summary: Set the network preset of a chat's Sandbox commands. + summary: Set the network preset of a chat's commands. tags: - chats /api/chats/{id}/egress/consent: @@ -12723,14 +13095,14 @@ paths: the same answer again is 204 schema: $ref: '#/definitions/schema.ErrorResponse' - summary: Answer a network card of a chat's Sandbox commands. + summary: Answer a network card of a chat's commands. tags: - chats /api/chats/{id}/egress/rules/{host}/{port}: delete: description: Removes the rule that allows or denies one host and port for the - chat's Sandbox commands. A host that was denied asks again; one that was allowed - follows the preset again. + chat's commands. A host that was denied asks again; one that was allowed follows + the preset again. parameters: - description: conversation id in: path @@ -13433,7 +13805,8 @@ paths: $ref: '#/definitions/schema.ErrorResponse' "409": description: A turn is running for a command, or the saved agent's folder - is missing (agent_folder_missing) + is missing (agent_folder_missing), or its complete file access exceeds + the folder limit (agent_folder_limit) schema: $ref: '#/definitions/schema.ErrorResponse' summary: Send a message to a chat. @@ -13765,15 +14138,15 @@ paths: description: The settings in force schema: $ref: '#/definitions/schema.ChatsEgressAdmin' - summary: Read the settings of the Sandbox network (administrators). + summary: Read the settings of the network of commands (administrators). tags: - chats /api/chats/admin/egress/events: get: - description: 'Every request a command made through the Sandbox proxy, newest - first: who, which chat, host, port, kind (connect or http), verdict (allow, - block, or done when an allowed connection ended, with its address, bytes and - duration) and reason.' + description: 'Every request a command made through the proxy of the add-on, + newest first: who, which chat, host, port, kind (connect or http), verdict + (allow, block, or done when an allowed connection ended, with its address, + bytes and duration) and reason.' parameters: - description: How many lines (default 100, at most 500) in: query @@ -13800,7 +14173,7 @@ paths: description: The newest lines first schema: $ref: '#/definitions/schema.ChatsEgressEventList' - summary: Read the audit of the Sandbox network requests (administrators). + summary: Read the audit of the network of commands requests (administrators). tags: - chats /api/chats/admin/host-exec/events: @@ -14296,10 +14669,11 @@ paths: get: description: Returns the definition as a JSON file (version, kind localai-agent, agent). It holds no ID, owner or time. An agent with a folder is version 2 - and carries folder {path, mode}, the folder's path in your files now (empty - when the folder is missing or not chosen); a server that knows only version - 1 refuses it. An agent without a folder is version 1. POST /api/chats/agents/import - reads it. This replaces the old /api/agents/{name}/export. + and carries folder {path, mode}, the folder's home-relative path now (empty + when the folder is missing, not chosen, or outside your home, even if readable); + importing an empty path preserves the scope as unset with a warning; a server + that knows only version 1 refuses it. An agent without a folder is version + 1. POST /api/chats/agents/import reads it. This replaces the old /api/agents/{name}/export. parameters: - description: the agent's id in: path @@ -14681,6 +15055,49 @@ paths: summary: Switch a connector binding on. tags: - chats + /api/chats/connectors/{id}/paired/{sender}: + delete: + description: The sender can pair again with a new code. + parameters: + - description: the binding's id + in: path + name: id + required: true + type: string + - description: the sender's id + in: path + name: sender + required: true + type: string + responses: + "200": + description: The binding + schema: + $ref: '#/definitions/schema.ChatsConnector' + summary: End the pairing of a sender of a connector binding. + tags: + - chats + /api/chats/connectors/{id}/pairing: + post: + description: Pairing proves that you control a platform account. Send the code, + as a direct message to the bot, from an account on the allowlist, within ten + minutes. The code works once, and a new code replaces the old one. A paired + sender may use the sensitive capabilities you enabled; a pairing ends after + 90 days. At most three senders are paired. + parameters: + - description: the binding's id + in: path + name: id + required: true + type: string + responses: + "200": + description: The code and when it expires + schema: + $ref: '#/definitions/schema.ChatsConnectorPairing' + summary: Make a pairing code for a connector binding. + tags: + - chats /api/chats/connectors/{id}/senders: get: description: The log of people who wrote to the bot, newest first, with whether