From 270a3a12eaeb0f5faebfb845eec3942b8bc688b7 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 3 Nov 2026 21:55:36 +0000 Subject: [PATCH] feat(addons): match the host access routes to the React client The confirm header names what the administrator acted on: addonId@digest for an install, the add-on ID for an edit or a revoke, "blanket" and "deny" for the two switches. The operation ID is optional on every write but the install; the receiving host makes one when the caller sent none. Preview and page statuses are allowed, not_allowed, blocked or unsupported in both. A preview names what an upgrade adds, using the identity of the installed package. The page carries the library, the new optional access and the required operations that keep an add-on from running. "keep" relabels named blanket grants as the administrator's own. The blanket switch ignores scope options when it is off. Assisted-by: Claude:claude-sonnet-5-5 go gofmt ginkgo swag --- core/http/endpoints/addons/hostgrants.go | 110 +++++++++++++----- .../endpoints/addons/hostgrants_auth_test.go | 4 +- core/http/endpoints/addons/hostgrants_test.go | 59 +++++++--- .../endpoints/localai/api_instructions.go | 2 +- core/services/addons/gateway/hostgrants.go | 68 ++++++----- .../addons/gateway/hostgrants_owner.go | 64 +++++++--- .../addons/gateway/hostgrants_test.go | 70 +++++++++-- swagger/docs.go | 51 +++++--- swagger/swagger.json | 51 +++++--- swagger/swagger.yaml | 98 +++++++++------- 10 files changed, 406 insertions(+), 171 deletions(-) diff --git a/core/http/endpoints/addons/hostgrants.go b/core/http/endpoints/addons/hostgrants.go index cb21d17411..a3a6a27fb0 100644 --- a/core/http/endpoints/addons/hostgrants.go +++ b/core/http/endpoints/addons/hostgrants.go @@ -2,6 +2,8 @@ package addons import ( + "crypto/rand" + "encoding/hex" "encoding/json" "errors" "strconv" @@ -52,12 +54,22 @@ func hostGrantsFailure(c echo.Context, e error) error { return resultError(c, e) } -// hostGrantsDo checks the human surface of a write, runs the call and writes -// the data of the reply. -func hostGrantsDo(c echo.Context, g *gateway.Gateway, s settings, a contract.Actor, call gateway.HostGrantsCall) error { +// The value of the confirm header on each write. It names what the +// administrator acted on, so a form post or a link, which cannot set a custom +// header, cannot make the call: addonId@digest for an install, the add-on ID +// for an edit or a revoke, "blanket" and "deny" for the two switches. +const ( + hostGrantsConfirmBlanket = "blanket" + hostGrantsConfirmDeny = "deny" +) + +// hostGrantsDo checks the human surface of a write (confirm is the expected +// header value; empty for a read), runs the call and writes the data of the +// reply. +func hostGrantsDo(c echo.Context, g *gateway.Gateway, s settings, a contract.Actor, call gateway.HostGrantsCall, confirm string) error { ctx := c.Request().Context() - if call.OperationID != "" { - if !humanSurface(c, s, a, call.OperationID) { + if confirm != "" || call.OperationID != "" { + if !humanSurface(c, s, a, confirm) { return c.JSON(403, AddonErrorResponse{Error: AddonError{Type: gateway.HostGrantsNotHumanSurface, Code: 403, Message: "This action requires your own browser session"}}) } ctx = gateway.WithHumanSurface(ctx) @@ -74,9 +86,22 @@ func hostGrantsDo(c echo.Context, g *gateway.Gateway, s settings, a contract.Act func validOperationID(id string) bool { return id != "" && len(id) <= 128 } -// hostGrantsBody is the shared front of a route with a JSON body: administrator -// check, strict decode, operation ID, call. -func hostGrantsBody[T any](g *gateway.Gateway, db *gorm.DB, opts []Option, op string, opOf func(*T) string, addon func(echo.Context) string, prepare func(*T) string) echo.HandlerFunc { +// newHostGrantsOperationID makes the operation ID of a write whose caller sent +// none. A caller that wants a retry to be the same change sends its own. +func newHostGrantsOperationID() (string, error) { + var raw [16]byte + if _, err := rand.Read(raw[:]); err != nil { + return "", err + } + return "hg-" + hex.EncodeToString(raw[:]), nil +} + +// hostGrantsBody is the shared front of a route with a JSON body: +// administrator check, strict decode, operation ID, call. confirm gives the +// expected confirm header of a write ("" for a read); op may be changed by +// prepare (the deny dry run is a read). +func hostGrantsBody[T any](g *gateway.Gateway, db *gorm.DB, opts []Option, op string, + opOf func(*T) *string, addon func(echo.Context) string, confirm func(echo.Context, *T) string, prepare func(*T) string) echo.HandlerFunc { s := resolve(opts) return func(c echo.Context) error { a, err, ok := hostGrantsAdmin(c, db, s) @@ -91,14 +116,22 @@ func hostGrantsBody[T any](g *gateway.Gateway, db *gorm.DB, opts []Option, op st if prepare != nil { callOp = prepare(&req) } - id := opOf(&req) write := callOp != gateway.HostGrantsPreview && callOp != gateway.HostGrantsDenyPreview - if write && !validOperationID(id) { - return failure(c, 400, "An operationId is required") - } - if !write && id != "" { - // A read has no operation of its own; the field is not accepted. - return failure(c, 400, "Invalid host access request") + id := opOf(&req) + want := "" + if write { + if *id == "" { + var e error + if *id, e = newHostGrantsOperationID(); e != nil { + return failure(c, 503, "Addons unavailable") + } + } + if !validOperationID(*id) { + return failure(c, 400, "Invalid operationId") + } + want = confirm(c, &req) + } else { + *id = "" } body, e := json.Marshal(req) if e != nil { @@ -106,16 +139,18 @@ func hostGrantsBody[T any](g *gateway.Gateway, db *gorm.DB, opts []Option, op st } call := gateway.HostGrantsCall{Op: callOp, Body: body} if write { - call.OperationID = id + call.OperationID = *id } if addon != nil { call.AddonID = addon(c) } noteRequest(c, contract.Request{Action: "hostgrants-" + callOp, AddonID: call.AddonID}) - return hostGrantsDo(c, g, s, a, call) + return hostGrantsDo(c, g, s, a, call, want) } } +func addonParam(c echo.Context) string { return c.Param("addonId") } + // GetHostGrants reads the host access state. // @Summary Read the host access policy and what each add-on holds (administrators) // @Description Administrators only (an administrator's session or API key). Returns the revision of the grants document, whether the deployment locks the UI store, the blanket approval switch (mode off, official or any; setBy ui or deployment), the deployment floor (the policy file and flags, read-only), the deny rules by source (file and flags, which cannot be edited here, and ui), and for each installed add-on the operations its verified manifest requests (required and optional) with a plain title, text and risk, whether each is allowed, not_allowed, blocked or unsupported, where the grant comes from (deployment, install, upgrade, edit or blanket), when and by whom, the operations granted that the manifest no longer requests, an identity mismatch flag and a pending upgrade that waits for approval. Read-only. Nothing in the answer is a credential. No MCP tool maps to this route. @@ -133,7 +168,7 @@ func GetHostGrants(g *gateway.Gateway, db *gorm.DB, opts ...Option) echo.Handler if !ok { return err } - return hostGrantsDo(c, g, s, a, gateway.HostGrantsCall{Op: gateway.HostGrantsRead}) + return hostGrantsDo(c, g, s, a, gateway.HostGrantsCall{Op: gateway.HostGrantsRead}, "") } } @@ -151,12 +186,12 @@ func GetHostGrants(g *gateway.Gateway, db *gorm.DB, opts ...Option) echo.Handler // @Failure 503 {object} AddonErrorResponse "Addons or the owner are unavailable" // @Router /api/addons/host-grants/preview [post] func PreviewHostGrants(g *gateway.Gateway, db *gorm.DB, opts ...Option) echo.HandlerFunc { - return hostGrantsBody(g, db, opts, gateway.HostGrantsPreview, func(*gateway.HostGrantsPreviewRequest) string { return "" }, nil, nil) + return hostGrantsBody(g, db, opts, gateway.HostGrantsPreview, func(*gateway.HostGrantsPreviewRequest) *string { var none string; return &none }, nil, nil, nil) } // InstallWithHostGrants installs or upgrades with an approval. // @Summary Install or upgrade an add-on with the host access the administrator approved (browser session) -// @Description Administrators only, from their own browser session (same-origin fetch, X-LocalAI-Confirm equal to the operationId). API keys, bearer tokens, MCP sessions and tool calls are refused with 403 not_human_surface. Carries the approval the dialog collected: the digest of the previewed package, the operations ticked, the revision of the grants document and the preview nonce. The owner stages the package again and grants only the operations that are approved AND declared by that verified manifest AND registered by the host AND not denied; a different package is 409 approval_stale, a changed document 409 revision_conflict, an expired or reused nonce 409 approval_stale. A required operation that is neither held nor approved is refused (approval_required) and one that a deny rule blocks is 403 blocked_by_policy; nothing is written then. For action upgrade, expectedDigest and candidateDigest pin the upgrade as in the plain upgrade. The plain dispatch install carries no approval. Repeating the operationId with the same request returns the recorded operation. After outcome_unknown keep the operationId and poll the operation status. +// @Description Administrators only, from their own browser session (same-origin fetch, X-LocalAI-Confirm equal to addonId@digest, the add-on and the digest of the approved package). API keys, bearer tokens, MCP sessions and tool calls are refused with 403 not_human_surface. Carries the approval the dialog collected: the digest of the previewed package, the operations ticked, the revision of the grants document and the preview nonce. The owner stages the package again and grants only the operations that are approved AND declared by that verified manifest AND registered by the host AND not denied; a different package is 409 approval_stale, a changed document 409 revision_conflict, an expired or reused nonce 409 approval_stale. A required operation that is neither held nor approved is refused (approval_required) and one that a deny rule blocks is 403 blocked_by_policy; nothing is written then. For action upgrade, expectedDigest and candidateDigest pin the upgrade as in the plain upgrade. The plain dispatch install carries no approval. Repeating the operationId with the same request returns the recorded operation. After outcome_unknown keep the operationId and poll the operation status. // @Tags Addons // @Accept json // @Produce json @@ -169,12 +204,15 @@ func PreviewHostGrants(g *gateway.Gateway, db *gorm.DB, opts ...Option) echo.Han // @Failure 503 {object} AddonErrorResponse "Unavailable, or outcome_unknown (with operationId)" // @Router /api/addons/host-grants/install [post] func InstallWithHostGrants(g *gateway.Gateway, db *gorm.DB, opts ...Option) echo.HandlerFunc { - return hostGrantsBody(g, db, opts, gateway.HostGrantsInstall, func(r *gateway.HostGrantsInstallRequest) string { return r.OperationID }, nil, nil) + return hostGrantsBody(g, db, opts, gateway.HostGrantsInstall, func(r *gateway.HostGrantsInstallRequest) *string { return &r.OperationID }, nil, + func(_ echo.Context, r *gateway.HostGrantsInstallRequest) string { + return r.AddonID + "@" + r.Approve.Digest + }, nil) } // EditHostGrants replaces the UI grants of an add-on. // @Summary Set the host operations an add-on keeps through the UI (browser session) -// @Description Administrators only, from their own browser session (X-LocalAI-Confirm equal to the operationId). ops is the full set the add-on keeps: operations left out are revoked, new ones must be declared by the installed manifest, registered by the host and not denied (403 blocked_by_policy). Grants made by the policy file or flags are not editable here. keepBlanketAsOwn relabels the blanket grants as the administrator's own. revision is the revision the caller read; a stale one is 409 revision_conflict. Takes effect for the next call of the add-on with no restart. +// @Description Administrators only, from their own browser session (X-LocalAI-Confirm equal to the add-on ID). operationId is optional (the host makes one); send your own to make a retry the same change. ops is the full set the add-on keeps: operations left out are revoked, new ones must be declared by the installed manifest, registered by the host and not denied (403 blocked_by_policy). Grants made by the policy file or flags are not editable here. keep names held blanket grants to relabel as the administrator's own. revision is the revision the caller read; a stale one is 409 revision_conflict. Takes effect for the next call of the add-on with no restart. // @Tags Addons // @Accept json // @Produce json @@ -189,17 +227,18 @@ func InstallWithHostGrants(g *gateway.Gateway, db *gorm.DB, opts ...Option) echo // @Failure 503 {object} AddonErrorResponse "Unavailable, or outcome_unknown (with operationId)" // @Router /api/addons/host-grants/{addonId} [put] func EditHostGrants(g *gateway.Gateway, db *gorm.DB, opts ...Option) echo.HandlerFunc { - return hostGrantsBody(g, db, opts, gateway.HostGrantsEdit, func(r *gateway.HostGrantsEditRequest) string { return r.OperationID }, func(c echo.Context) string { return c.Param("addonId") }, nil) + return hostGrantsBody(g, db, opts, gateway.HostGrantsEdit, func(r *gateway.HostGrantsEditRequest) *string { return &r.OperationID }, addonParam, + func(c echo.Context, _ *gateway.HostGrantsEditRequest) string { return c.Param("addonId") }, nil) } // RevokeHostGrants removes the UI grants of an add-on. // @Summary Revoke every host operation granted to an add-on through the UI (browser session) -// @Description Administrators only, from their own browser session (X-LocalAI-Confirm equal to the operationId). Removes the UI and blanket grants of the add-on; grants of the policy file stay. Query: revision (the revision the caller read) and operationId. Takes effect for the next call with no restart; a running call finishes. An add-on whose required operation is revoked becomes unavailable. +// @Description Administrators only, from their own browser session (X-LocalAI-Confirm equal to the add-on ID). Removes the UI and blanket grants of the add-on; grants of the policy file stay. Query: revision (the revision the caller read) and, optionally, operationId. Takes effect for the next call with no restart; a running call finishes. An add-on whose required operation is revoked becomes unavailable. // @Tags Addons // @Produce json // @Param addonId path string true "Add-on ID" // @Param revision query integer true "Revision of the grants document the caller read" -// @Param operationId query string true "Operation ID" +// @Param operationId query string false "Operation ID (optional)" // @Success 200 {object} gateway.HostGrantsRevision "The new revision" // @Failure 400 {object} AddonErrorResponse "Malformed request" // @Failure 401 {object} AddonErrorResponse "Named authentication required" @@ -217,19 +256,25 @@ func RevokeHostGrants(g *gateway.Gateway, db *gorm.DB, opts ...Option) echo.Hand } rev, perr := strconv.ParseUint(c.QueryParam("revision"), 10, 64) id := c.QueryParam("operationId") + if id == "" { + var e error + if id, e = newHostGrantsOperationID(); e != nil { + return failure(c, 503, "Addons unavailable") + } + } if perr != nil || !validOperationID(id) { - return failure(c, 400, "revision and operationId are required") + return failure(c, 400, "revision is required") } body, _ := json.Marshal(gateway.HostGrantsRevokeRequest{OperationID: id, Revision: rev}) call := gateway.HostGrantsCall{Op: gateway.HostGrantsRevoke, OperationID: id, AddonID: c.Param("addonId"), Body: body} noteRequest(c, contract.Request{Action: "hostgrants-revoke", AddonID: call.AddonID}) - return hostGrantsDo(c, g, s, a, call) + return hostGrantsDo(c, g, s, a, call, c.Param("addonId")) } } // SetHostGrantsBlanket changes the blanket approval. // @Summary Turn the blanket approval on, off or wider (browser session, typed confirmation) -// @Description Administrators only, from their own browser session (X-LocalAI-Confirm equal to the operationId). Mode off, official or any. With it on, an install or upgrade grants the add-on the operations its verified manifest declares as required (also the optional ones with includeOptional) without the dialog step; it never grants an undeclared or unregistered operation and never overrides a deny rule. Mode official covers only the official library with its official signer. Mode any covers every library the administrator configured: ANY LIBRARY ENTRY THEN RECEIVES EXACTLY THE OPERATIONS ITS MANIFEST DECLARES, with no review, so a malicious or compromised entry gets what it asks for. Turning it on, or widening it, needs confirm equal to "grant official add-ons what they declare" (official) or "grant any library add-on what it declares" (any), else 400 confirmation_mismatch. applyToInstalled also grants the installed add-ons in the same revision. Turning it off keeps the grants it made, marked blanket; revokeOnOff revokes them all. When the deployment fixes the mode the answer is 403 locked_by_deployment. +// @Description Administrators only, from their own browser session (X-LocalAI-Confirm equal to "blanket"). Mode off, official or any. With it on, an install or upgrade grants the add-on the operations its verified manifest declares as required (also the optional ones with includeOptional) without the dialog step; it never grants an undeclared or unregistered operation and never overrides a deny rule. Mode official covers only the official library with its official signer. Mode any covers every library the administrator configured: ANY LIBRARY ENTRY THEN RECEIVES EXACTLY THE OPERATIONS ITS MANIFEST DECLARES, with no review, so a malicious or compromised entry gets what it asks for. Turning it on, or widening it, needs confirm equal to "grant official add-ons what they declare" (official) or "grant any library add-on what it declares" (any), else 400 confirmation_mismatch. applyToInstalled also grants the installed add-ons in the same revision. Turning it off keeps the grants it made, marked blanket; revokeOnOff revokes them all. When the deployment fixes the mode the answer is 403 locked_by_deployment. // @Tags Addons // @Accept json // @Produce json @@ -242,12 +287,13 @@ func RevokeHostGrants(g *gateway.Gateway, db *gorm.DB, opts ...Option) echo.Hand // @Failure 503 {object} AddonErrorResponse "Unavailable, or outcome_unknown (with operationId)" // @Router /api/addons/host-grants/blanket [put] func SetHostGrantsBlanket(g *gateway.Gateway, db *gorm.DB, opts ...Option) echo.HandlerFunc { - return hostGrantsBody(g, db, opts, gateway.HostGrantsBlanket, func(r *gateway.HostGrantsBlanketRequest) string { return r.OperationID }, nil, nil) + return hostGrantsBody(g, db, opts, gateway.HostGrantsBlanket, func(r *gateway.HostGrantsBlanketRequest) *string { return &r.OperationID }, nil, + func(echo.Context, *gateway.HostGrantsBlanketRequest) string { return hostGrantsConfirmBlanket }, nil) } // SetHostGrantsDeny replaces the UI deny rules. // @Summary Replace the deny rules set in the UI, or preview their effect (browser session) -// @Description Administrators only. A deny rule beats every grant. deny maps an add-on ID, or "*" for all, to prefixes ("egress."), exact operation names or the single token "*". The rules of the policy file and flags are not editable here and are unioned with these. With dryRun true the answer is the affected add-ons only (affects: the operations each would lose and whether it will stop because a required one is lost) and nothing changes; a dry run needs the administrator but not the browser session. Without dryRun the change needs the administrator's own browser session (X-LocalAI-Confirm equal to the operationId), the revision the caller read (409 revision_conflict otherwise), and applies to the next call with no restart. +// @Description Administrators only. A deny rule beats every grant. deny maps an add-on ID, or "*" for all, to prefixes ("egress."), exact operation names or the single token "*". The rules of the policy file and flags are not editable here and are unioned with these. With dryRun true the answer is the affected add-ons only (affects: the operations each would lose and whether it will stop because a required one is lost) and nothing changes; a dry run needs the administrator but not the browser session. Without dryRun the change needs the administrator's own browser session (X-LocalAI-Confirm equal to "deny"), the revision the caller read (409 revision_conflict otherwise), and applies to the next call with no restart. // @Tags Addons // @Accept json // @Produce json @@ -260,11 +306,11 @@ func SetHostGrantsBlanket(g *gateway.Gateway, db *gorm.DB, opts ...Option) echo. // @Failure 503 {object} AddonErrorResponse "Unavailable, or outcome_unknown (with operationId)" // @Router /api/addons/host-grants/deny [put] func SetHostGrantsDeny(g *gateway.Gateway, db *gorm.DB, opts ...Option) echo.HandlerFunc { - return hostGrantsBody(g, db, opts, gateway.HostGrantsDeny, func(r *gateway.HostGrantsDenyRequest) string { return r.OperationID }, nil, + return hostGrantsBody(g, db, opts, gateway.HostGrantsDeny, func(r *gateway.HostGrantsDenyRequest) *string { return &r.OperationID }, nil, + func(echo.Context, *gateway.HostGrantsDenyRequest) string { return hostGrantsConfirmDeny }, func(r *gateway.HostGrantsDenyRequest) string { if r.DryRun { // A dry run is a read and has no operation of its own. - r.OperationID = "" return gateway.HostGrantsDenyPreview } return gateway.HostGrantsDeny diff --git a/core/http/endpoints/addons/hostgrants_auth_test.go b/core/http/endpoints/addons/hostgrants_auth_test.go index ef6c064774..66fcd71289 100644 --- a/core/http/endpoints/addons/hostgrants_auth_test.go +++ b/core/http/endpoints/addons/hostgrants_auth_test.go @@ -67,7 +67,7 @@ var _ = Describe("host access routes with real sessions and API keys", func() { r.Header.Set("Sec-Fetch-Site", "same-origin") r.Header.Set("Sec-Fetch-Dest", "empty") r.Header.Set("Sec-Fetch-Mode", "cors") - r.Header.Set(ConfirmHeader, "op1") + r.Header.Set(ConfirmHeader, "sandbox") } } // The session of an administrator passes every gate. @@ -75,7 +75,7 @@ var _ = Describe("host access routes with real sessions and API keys", func() { Expect(put(func(r *http.Request) { r.Header.Set("Authorization", "Bearer "+adminKey) r.Header.Set("Sec-Fetch-Site", "same-origin") - r.Header.Set(ConfirmHeader, "op1") + r.Header.Set(ConfirmHeader, "sandbox") })).To(Equal(403), "an administrator API key") Expect(put(func(r *http.Request) { tab(adminSession)(r) diff --git a/core/http/endpoints/addons/hostgrants_test.go b/core/http/endpoints/addons/hostgrants_test.go index 6c9b5a02ef..0249726be5 100644 --- a/core/http/endpoints/addons/hostgrants_test.go +++ b/core/http/endpoints/addons/hostgrants_test.go @@ -71,6 +71,7 @@ var _ = Describe("host access routes", func() { registerHostGrants(e, &gateway.Gateway{HostGrantsOwner: svc, Revalidate: func(context.Context, contract.Actor) error { return nil }}) }) + // confirm is the value the confirm header must carry. type req struct{ method, path, body, op string } // browser is the administrator's own tab: same-origin fetch, the confirm header. browser := func(r *http.Request, op string) { @@ -104,16 +105,16 @@ var _ = Describe("host access routes", func() { asHuman := func(q req) (int, map[string]any) { return do(q, func(r *http.Request) { browser(r, q.op) }) } edit := func(op string, rev int, ops string) req { - return req{"PUT", "/api/addons/host-grants/sandbox", `{"operationId":"` + op + `","revision":` + itoa(rev) + `,"ops":` + ops + `}`, op} + return req{"PUT", "/api/addons/host-grants/sandbox", `{"operationId":"` + op + `","revision":` + itoa(rev) + `,"ops":` + ops + `}`, "sandbox"} } // The write routes, each with a body that decodes, for the guard table. writes := []req{ edit("w1", 0, `["egress.check"]`), - {"DELETE", "/api/addons/host-grants/sandbox?revision=0&operationId=w2", "", "w2"}, - {"PUT", "/api/addons/host-grants/blanket", `{"operationId":"w3","revision":0,"mode":"off"}`, "w3"}, - {"PUT", "/api/addons/host-grants/deny", `{"operationId":"w4","revision":0,"deny":{}}`, "w4"}, - {"POST", "/api/addons/host-grants/install", `{"operationId":"w5","action":"install","sourceId":"official","addonId":"newone","version":"1.0.0","approve":{"digest":"sha256:aa","ops":[],"revision":0,"nonce":"n"}}`, "w5"}, + {"DELETE", "/api/addons/host-grants/sandbox?revision=0&operationId=w2", "", "sandbox"}, + {"PUT", "/api/addons/host-grants/blanket", `{"operationId":"w3","revision":0,"mode":"off"}`, "blanket"}, + {"PUT", "/api/addons/host-grants/deny", `{"operationId":"w4","revision":0,"deny":{}}`, "deny"}, + {"POST", "/api/addons/host-grants/install", `{"operationId":"w5","action":"install","sourceId":"official","addonId":"newone","version":"1.0.0","approve":{"digest":"sha256:aa","ops":[],"revision":0,"nonce":"n"}}`, "newone@sha256:aa"}, } It("serves the reads to an administrator by API key as well", func() { @@ -128,6 +129,28 @@ var _ = Describe("host access routes", func() { } }) + It("answers the shapes the React client reads", func() { + _, ov := do(req{"GET", "/api/addons/host-grants", "", ""}, func(r *http.Request) { browser(r, "") }) + for _, k := range []string{"revision", "locked", "blanket", "floor", "deny", "addons"} { + Expect(ov).To(HaveKey(k)) + } + Expect(ov["blanket"]).To(HaveKeyWithValue("setBy", "ui")) + sbx := ov["addons"].([]any)[0].(map[string]any) + for _, k := range []string{"id", "title", "version", "sourceId", "state", "requested", "ops", "noLongerRequested", "newOptional", "identityMismatch", "stopped"} { + if k == "sourceId" { + continue // empty until the manager reports the library + } + Expect(sbx).To(HaveKey(k), k) + } + for _, o := range sbx["ops"].([]any) { + Expect(o.(map[string]any)["status"]).To(BeElementOf("allowed", "not_allowed", "blocked", "unsupported")) + } + _, pv := do(req{"POST", "/api/addons/host-grants/preview", `{"sourceId":"official","addonId":"newone","version":"1.0.0"}`, ""}, func(r *http.Request) { browser(r, "") }) + for _, k := range []string{"digest", "version", "revision", "nonce", "official", "added", "requested", "ops", "blockedRequired", "canInstall"} { + Expect(pv).To(HaveKey(k), k) + } + }) + DescribeTable("refuses every write that is not the administrator's own tab, with 403 not_human_surface", func(name string, mutate func(*http.Request, string)) { for _, q := range writes { @@ -177,23 +200,23 @@ var _ = Describe("host access routes", func() { code, out := asHuman(edit("e1", 0, `["connector.inbound"]`)) Expect(code).To(Equal(400), "not declared by the manifest") Expect(errType(out)).To(Equal("invalid_request")) - code, out = asHuman(req{"PUT", "/api/addons/host-grants/ghost", `{"operationId":"e2","revision":0,"ops":["egress.check"]}`, "e2"}) + code, out = asHuman(req{"PUT", "/api/addons/host-grants/ghost", `{"operationId":"e2","revision":0,"ops":["egress.check"]}`, "ghost"}) Expect(code).To(Equal(404)) Expect(errType(out)).To(Equal("not_found")) - code, _ = asHuman(req{"PUT", "/api/addons/host-grants/sandbox", `{"operationId":"e3","revision":0,"ops":[],"surprise":1}`, "e3"}) + code, _ = asHuman(req{"PUT", "/api/addons/host-grants/sandbox", `{"operationId":"e3","revision":0,"ops":[],"surprise":1}`, "sandbox"}) Expect(code).To(Equal(400), "an unknown field") - code, _ = asHuman(req{"PUT", "/api/addons/host-grants/sandbox", `{"revision":0,"ops":[]}`, ""}) - Expect(code).To(Equal(400), "an operation ID is required") - code, _ = asHuman(req{"DELETE", "/api/addons/host-grants/sandbox?operationId=r1", "", "r1"}) + code, _ = asHuman(req{"PUT", "/api/addons/host-grants/sandbox", `{"revision":0,"ops":[]}`, "sandbox"}) + Expect(code).To(Equal(200), "the host makes an operation ID when the caller sent none") + code, _ = asHuman(req{"DELETE", "/api/addons/host-grants/sandbox?operationId=r1", "", "sandbox"}) Expect(code).To(Equal(400), "the revision is required") }) It("revokes with the revision in the query", func() { asHuman(edit("e1", 0, `["egress.check"]`)) - code, out := asHuman(req{"DELETE", "/api/addons/host-grants/sandbox?revision=0&operationId=r1", "", "r1"}) + code, out := asHuman(req{"DELETE", "/api/addons/host-grants/sandbox?revision=0&operationId=r1", "", "sandbox"}) Expect(code).To(Equal(409), "stale") Expect(errType(out)).To(Equal("revision_conflict")) - code, out = asHuman(req{"DELETE", "/api/addons/host-grants/sandbox?revision=1&operationId=r2", "", "r2"}) + code, out = asHuman(req{"DELETE", "/api/addons/host-grants/sandbox?revision=1&operationId=r2", "", "sandbox"}) Expect(code).To(Equal(200)) Expect(out["revision"]).To(BeEquivalentTo(2)) }) @@ -203,29 +226,29 @@ var _ = Describe("host access routes", func() { Expect(code).To(Equal(200)) nonce := prev["nonce"].(string) body := `{"operationId":"i1","action":"install","sourceId":"official","addonId":"newone","version":"1.0.0","approve":{"digest":"sha256:aa","ops":["workspace.materialize"],"revision":0,"nonce":"` + nonce + `"}}` - code, out := do(req{"POST", "/api/addons/host-grants/install", body, "i1"}, func(r *http.Request) { browser(r, "i1"); r.Header.Set("Authorization", "Bearer k") }) + code, out := do(req{"POST", "/api/addons/host-grants/install", body, "i1"}, func(r *http.Request) { browser(r, "newone@sha256:aa"); r.Header.Set("Authorization", "Bearer k") }) Expect(code).To(Equal(403)) Expect(errType(out)).To(Equal("not_human_surface")) Expect(mgr.submitted).To(BeEmpty()) - code, out = asHuman(req{"POST", "/api/addons/host-grants/install", body, "i1"}) + code, out = asHuman(req{"POST", "/api/addons/host-grants/install", body, "newone@sha256:aa"}) Expect(code).To(Equal(200)) Expect(out["Phase"]).To(Equal("completed")) Expect(mgr.submitted).To(HaveLen(1)) - code, out = asHuman(req{"POST", "/api/addons/host-grants/install", strings.Replace(body, `"i1"`, `"i2"`, 1), "i2"}) + code, out = asHuman(req{"POST", "/api/addons/host-grants/install", strings.Replace(body, `"i1"`, `"i2"`, 1), "newone@sha256:aa"}) Expect(code).To(Equal(409), "the nonce is used up") Expect(errType(out)).To(Equal("approval_stale")) }) It("needs the typed text for the blanket, and a human surface for a deny, but not for its dry run", func() { - q := req{"PUT", "/api/addons/host-grants/blanket", `{"operationId":"b1","revision":0,"mode":"any","confirm":"yes"}`, "b1"} + q := req{"PUT", "/api/addons/host-grants/blanket", `{"operationId":"b1","revision":0,"mode":"any","confirm":"yes"}`, "blanket"} code, out := asHuman(q) Expect(code).To(Equal(400)) Expect(errType(out)).To(Equal("confirmation_mismatch")) q.body = `{"operationId":"b2","revision":0,"mode":"any","confirm":"` + gateway.BlanketConfirmAny + `"}` - q.op = "b2" + q.op = "blanket" code, _ = asHuman(q) Expect(code).To(Equal(200)) - code, out = asHuman(req{"PUT", "/api/addons/host-grants/deny", `{"operationId":"d1","revision":1,"deny":{"sandbox":["workspace."]}}`, "d1"}) + code, out = asHuman(req{"PUT", "/api/addons/host-grants/deny", `{"operationId":"d1","revision":1,"deny":{"sandbox":["workspace."]}}`, "deny"}) Expect(code).To(Equal(200)) Expect(out["revision"]).To(BeEquivalentTo(2)) }) diff --git a/core/http/endpoints/localai/api_instructions.go b/core/http/endpoints/localai/api_instructions.go index 262554b11f..970bd6ada6 100644 --- a/core/http/endpoints/localai/api_instructions.go +++ b/core/http/endpoints/localai/api_instructions.go @@ -30,7 +30,7 @@ var instructionDefs = []instructionDef{ {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; acts (act.files, act.addons, act.addons:, act.send, act.spend: what a run may do with nobody present, within its agent's grants; a run without any only reads) is set in the body of a create or replace (left out keeps it, [] clears it) and read back with acts_held, read_only and read_only_why; 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: the agent's builtins list holds memory.write, profile.write, skills.write, pages.write or dashboard.write (GET /api/chats/agents/builtin-grants lists every grant with title, description and sensitive; none is on by default). propose_change also takes kind skill (a SKILL.md; a skill with locked: true in its frontmatter is refused), page (target is the page name, files is {path: text} with an index.html) and dashboard (the full home.json, checked against its schema). 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 connectors (a connector is listed as dropped; add the bot again in the Connectors page); 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. Host access (administrators only, never tools; no MCP tool maps to any of it): GET /api/addons/host-grants reads the policy and what each add-on holds (administrator session or API key): the deployment floor (policy file and flags, read-only), the deny rules by source, the blanket approval switch, and per add-on the host operations its verified manifest requests (required and optional) with a plain title, text and risk and whether each is allowed, not_allowed, blocked or unsupported, its source (deployment, install, upgrade, edit or blanket), the grants the manifest no longer requests and an upgrade that waits for approval. POST /api/addons/host-grants/preview {sourceId, addonId, version} stages the release, reads the verified manifest and answers what an install would ask for (held, grantable, blocked by a deny rule, unsupported), the package digest, canInstall, the grants revision and a single-use nonce (five minutes, bound to the credential); it changes no state. Every write accepts only the administrator's own browser session (same-origin fetch, X-LocalAI-Confirm equal to the operationId); an API key, bearer token, MCP session or tool call is refused with 403 not_human_surface, and in a split deployment the receiving frontend forwards that decision to the owner as a signed field and only the owner enforces and stores. POST /api/addons/host-grants/install {operationId, action install|upgrade, sourceId, addonId, version, expectedDigest and candidateDigest for an upgrade, approve {digest, ops, revision, nonce}} installs with the approval: the owner stages the package again and grants only the operations that are approved, declared by that verified manifest, registered by the host and not denied (409 approval_stale for another package or a used or expired nonce, 409 revision_conflict for a changed document, 403 blocked_by_policy when a required operation is denied, 403 approval_required when one is neither held nor approved). The plain dispatch install carries no approval, so an agent that installs cannot grant. PUT /api/addons/host-grants/{addonId} {operationId, revision, ops, keepBlanketAsOwn} sets the operations the add-on keeps through the UI; DELETE /api/addons/host-grants/{addonId}?revision=&operationId= revokes them all; PUT /api/addons/host-grants/deny {operationId, revision, deny} replaces the UI deny rules (a deny beats every grant; the rules of the file and flags are not editable here), and with dryRun true it answers which add-ons would lose which operations and which would stop, from an administrator without the browser session; PUT /api/addons/host-grants/blanket {operationId, revision, mode off|official|any, includeOptional, applyToInstalled, revokeOnOff, confirm} turns the blanket approval on or off: with it on, an install or upgrade grants the operations its verified manifest declares as required (and optional ones with includeOptional) without the dialog, never an undeclared one and never past a deny rule; mode any covers every library the administrator configured, so ANY LIBRARY ENTRY RECEIVES THE OPERATIONS ITS MANIFEST DECLARES with no review; turning it on or wider needs confirm equal to \"grant official add-ons what they declare\" or \"grant any library add-on what it declares\"; turning it off keeps the grants it made unless revokeOnOff is true. A write takes effect for the next host call with no restart. A stale revision is 409 revision_conflict; repeating an operationId with the same request returns the same result and with another request is 409 operation_conflict. A deployment can lock the store (403 locked_by_deployment) or fix the blanket mode."}, + {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. Host access (administrators only, never tools; no MCP tool maps to any of it): GET /api/addons/host-grants reads the policy and what each add-on holds (administrator session or API key): the deployment floor (policy file and flags, read-only), the deny rules by source, the blanket approval switch, and per add-on the host operations its verified manifest requests (required and optional) with a plain title, text and risk and whether each is allowed, not_allowed, blocked or unsupported, its source (deployment, install, upgrade, edit or blanket), the grants the manifest no longer requests and an upgrade that waits for approval. POST /api/addons/host-grants/preview {sourceId, addonId, version} stages the release, reads the verified manifest and answers what an install would ask for (held, grantable, blocked by a deny rule, unsupported), the package digest, canInstall, the grants revision and a single-use nonce (five minutes, bound to the credential); it changes no state. Every write accepts only the administrator's own browser session (same-origin fetch; X-LocalAI-Confirm equal to addonId@digest for the install, the add-on ID for an edit or a revoke, blanket for the blanket switch and deny for the deny rules; operationId is optional on all but the install, and the host makes one); an API key, bearer token, MCP session or tool call is refused with 403 not_human_surface, and in a split deployment the receiving frontend forwards that decision to the owner as a signed field and only the owner enforces and stores. POST /api/addons/host-grants/install {operationId, action install|upgrade, sourceId, addonId, version, expectedDigest and candidateDigest for an upgrade, approve {digest, ops, revision, nonce}} installs with the approval: the owner stages the package again and grants only the operations that are approved, declared by that verified manifest, registered by the host and not denied (409 approval_stale for another package or a used or expired nonce, 409 revision_conflict for a changed document, 403 blocked_by_policy when a required operation is denied, 403 approval_required when one is neither held nor approved). The plain dispatch install carries no approval, so an agent that installs cannot grant. PUT /api/addons/host-grants/{addonId} {revision, ops, keep} sets the operations the add-on keeps through the UI (keep names held blanket grants to relabel as the administrator's own); DELETE /api/addons/host-grants/{addonId}?revision= revokes them all; PUT /api/addons/host-grants/deny {revision, deny, dryRun} replaces the UI deny rules (a deny beats every grant; the rules of the file and flags are not editable here), and with dryRun true it answers which add-ons would lose which operations and which would stop, from an administrator without the browser session; PUT /api/addons/host-grants/blanket {revision, mode off|official|any, includeOptional, applyToInstalled, revokeOnOff, confirm} turns the blanket approval on or off: with it on, an install or upgrade grants the operations its verified manifest declares as required (and optional ones with includeOptional) without the dialog, never an undeclared one and never past a deny rule; mode any covers every library the administrator configured, so ANY LIBRARY ENTRY RECEIVES THE OPERATIONS ITS MANIFEST DECLARES with no review; turning it on or wider needs confirm equal to \"grant official add-ons what they declare\" or \"grant any library add-on what it declares\"; turning it off keeps the grants it made unless revokeOnOff is true. A write takes effect for the next host call with no restart. A stale revision is 409 revision_conflict; repeating an operationId with the same request returns the same result and with another request is 409 operation_conflict. A deployment can lock the store (403 locked_by_deployment) or fix the blanket mode."}, {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."}, {Name: "addon-navigation", Description: "The built-in tiles and the per-user pins of the Add-ons drawer", Tags: []string{"AddonNavigation"}, Intro: "GET /api/addons/builtin lists the page add-ons the caller may open. GET and PUT /api/addons/pins read and replace the caller's ordered pins. All of them need an authenticated caller; none needs the addons feature or the administrator role."}, {Name: "fine-tuning-quantization", Description: "Export status of the fine-tuning and quantization add-ons", Tags: []string{"fine-tuning", "quantization"}, Intro: "GET /api/fine-tuning/status and GET /api/quantization/status return {\"model_exports\": bool}. It is false only when this instance does not see the models path of the add-on owner, so exporting or importing a model here would write where the owner cannot read; it is the same value as the model_exports key of GET /api/features. The routes need an authenticated caller with the fine-tuning or quantization permission and answer 503 when add-ons are off. No MCP tool maps to them: the value is a read-only switch for the add-on UIs."}, diff --git a/core/services/addons/gateway/hostgrants.go b/core/services/addons/gateway/hostgrants.go index 5ac18ad9e6..926b0ab204 100644 --- a/core/services/addons/gateway/hostgrants.go +++ b/core/services/addons/gateway/hostgrants.go @@ -228,6 +228,11 @@ type InstalledAccess struct { Official bool Required, Optional []string PendingUpgrade *PendingUpgrade + // SourceID is the library the add-on came from. + SourceID string + // NewOptional are optional operations the installed version asks for + // that an upgrade added and that nothing grants yet. + NewOptional []string } // HostAccessManager is what the owner service needs from the add-on manager. @@ -279,13 +284,12 @@ type HostGrantsInstallRequest struct { // HostGrantsEditRequest is the body of PUT /api/addons/host-grants/{addonId}. type HostGrantsEditRequest struct { - OperationID string `json:"operationId"` - Revision uint64 `json:"revision"` - // Ops is the full set of operations the add-on keeps through the UI. - Ops []string `json:"ops"` - // KeepBlanketAsOwn relabels the blanket grants of the add-on as the - // administrator's own, so revoking the blanket grants leaves them. - KeepBlanketAsOwn bool `json:"keepBlanketAsOwn,omitempty"` + OperationID string `json:"operationId"` + Revision uint64 `json:"revision"` + Ops []string `json:"ops"` + // Keep names held blanket grants to relabel as the administrator's own, + // so revoking the blanket grants leaves them. + Keep []string `json:"keep,omitempty"` } // HostGrantsRevokeRequest is the body the route builds from the query of @@ -331,17 +335,17 @@ type HostOpView struct { Title string `json:"title,omitempty"` Text string `json:"text,omitempty"` Risk string `json:"risk,omitempty"` - // Status is allowed, not_allowed, blocked or unsupported. In a preview it - // is held, grantable, blocked or unsupported. + // Status is allowed, not_allowed, blocked or unsupported, in the page and + // in a preview alike. In a preview not_allowed means the dialog may grant it. Status string `json:"status"` Source string `json:"source,omitempty"` Reason string `json:"reason,omitempty"` At string `json:"at,omitempty"` By string `json:"by,omitempty"` Required bool `json:"required,omitempty"` - // Hinted is true in a preview when the operation was granted before an - // earlier uninstall, so the dialog may tick it. - Hinted bool `json:"hinted,omitempty"` + // Hint is true in a preview when the operation was granted before an + // earlier uninstall, so the dialog may tick it. It grants nothing. + Hint bool `json:"hint,omitempty"` } // HostRequested lists what a manifest asks for. @@ -352,16 +356,21 @@ type HostRequested struct { // HostGrantsAddon is the entry of one installed add-on. type HostGrantsAddon struct { - ID string `json:"id"` - Title string `json:"title"` - Version string `json:"version"` - State string `json:"state"` - Requested HostRequested `json:"requested"` - Ops []HostOpView `json:"ops"` - NoLongerRequested []HostOpView `json:"noLongerRequested"` - IdentityMismatch bool `json:"identityMismatch"` - PendingUpgrade *PendingUpgrade `json:"pendingUpgrade,omitempty"` - BlanketGrantsCount int `json:"blanketGrants"` + ID string `json:"id"` + Title string `json:"title"` + Version string `json:"version"` + State string `json:"state"` + Requested HostRequested `json:"requested"` + Ops []HostOpView `json:"ops"` + NoLongerRequested []HostOpView `json:"noLongerRequested"` + IdentityMismatch bool `json:"identityMismatch"` + PendingUpgrade *PendingUpgrade `json:"pendingUpgrade,omitempty"` + SourceID string `json:"sourceId,omitempty"` + NewOptional []string `json:"newOptional"` + // Stopped lists the required operations the add-on lacks, which keep it + // from running ("stopped: needs X"). + Stopped string `json:"stopped,omitempty"` + BlanketGrantsCount int `json:"blanketGrants"` } // HostBlanketView is the blanket switch as the page shows it. @@ -409,12 +418,15 @@ type HostBlanketPreview struct { // HostGrantsPreviewView is the response of the preview. type HostGrantsPreviewView struct { - AddonID string `json:"addonId"` - Title string `json:"title"` - Digest string `json:"digest"` - Version string `json:"version"` - Installed bool `json:"installed"` - Official bool `json:"official"` + AddonID string `json:"addonId"` + Title string `json:"title"` + Digest string `json:"digest"` + Version string `json:"version"` + Installed bool `json:"installed"` + Official bool `json:"official"` + // Added are the operations this version asks for that the installed + // version did not. Empty on an install. + Added []string `json:"added"` Requested HostRequested `json:"requested"` Ops []HostOpView `json:"ops"` CanInstall bool `json:"canInstall"` diff --git a/core/services/addons/gateway/hostgrants_owner.go b/core/services/addons/gateway/hostgrants_owner.go index 0bddd7f974..cb8ff2ac1d 100644 --- a/core/services/addons/gateway/hostgrants_owner.go +++ b/core/services/addons/gateway/hostgrants_owner.go @@ -334,7 +334,7 @@ func (o *HostGrantsOwner) addonView(doc hostgrants.Snapshot, in InstalledAccess) for _, g := range entry.Ops { grant[g.Name] = g } - v := HostGrantsAddon{ID: in.AddonID, Title: in.Title, Version: in.Version, State: in.State, PendingUpgrade: in.PendingUpgrade, + v := HostGrantsAddon{ID: in.AddonID, Title: in.Title, Version: in.Version, State: in.State, PendingUpgrade: in.PendingUpgrade, SourceID: in.SourceID, NewOptional: nonNil(sorted(in.NewOptional)), Requested: HostRequested{Required: nonNil(sorted(in.Required)), Optional: nonNil(sorted(in.Optional))}, Ops: []HostOpView{}, NoLongerRequested: []HostOpView{}} view := func(op string) HostOpView { @@ -352,9 +352,15 @@ func (o *HostGrantsOwner) addonView(doc hostgrants.Snapshot, in InstalledAccess) o.describe(&ov) return ov } + var lacking []string for _, op := range declared { - v.Ops = append(v.Ops, view(op)) + ov := view(op) + v.Ops = append(v.Ops, ov) + if ov.Required && ov.Status != "allowed" { + lacking = append(lacking, op) + } } + v.Stopped = strings.Join(lacking, ", ") for _, g := range entry.Ops { if g.Source == hostgrants.SourceBlanket { v.BlanketGrantsCount++ @@ -432,10 +438,23 @@ func (o *HostGrantsOwner) preview(ctx context.Context, a contract.Actor, r HostG if err != nil { return HostGrantsPreviewView{}, err } - // The verified manifest of the staged package is the only truth. The - // identity of a not yet installed package is unknown, so a UI entry of an - // older install of the same ID never counts as held here. - c := o.context("") + // The verified manifest of the staged package is the only truth. For an + // upgrade the identity of the installed package says what is held already; + // a package that is not installed yet holds nothing from the UI. + var installed InstalledAccess + identity := "" + if rel.Installed { + list, lerr := o.cfg.Manager.HostAccess(ctx) + if lerr != nil { + return HostGrantsPreviewView{}, lerr + } + for _, in := range list { + if in.AddonID == rel.AddonID { + installed, identity = in, in.Identity + } + } + } + c := o.context(identity) declared := unique(rel.Required, rel.Optional) plan := o.cfg.Floor.Plan(doc, c, hostgrants.PlanInput{AddonID: rel.AddonID, Required: rel.Required, Optional: rel.Optional, Wanted: declared}) req := map[string]bool{} @@ -444,10 +463,11 @@ func (o *HostGrantsOwner) preview(ctx context.Context, a contract.Actor, r HostG } hint := doc.Hints[rel.AddonID] v := HostGrantsPreviewView{AddonID: rel.AddonID, Title: rel.Title, Digest: rel.Digest, Version: rel.Version, Installed: rel.Installed, - Official: rel.Official, Revision: doc.Revision, Ops: []HostOpView{}, BlockedReq: nonNil(plan.BlockedRequired), + Official: rel.Official, Revision: doc.Revision, Ops: []HostOpView{}, BlockedReq: nonNil(plan.BlockedRequired), Added: []string{}, Requested: HostRequested{Required: nonNil(sorted(rel.Required)), Optional: nonNil(sorted(rel.Optional))}} + before := unique(installed.Required, installed.Optional) for _, op := range declared { - ov := HostOpView{Name: op, Required: req[op], Hinted: slices.Contains(hint, op)} + ov := HostOpView{Name: op, Required: req[op], Hint: slices.Contains(hint, op)} switch { case slices.Contains(plan.Denied, op): _, why := o.cfg.Floor.Denied(doc, rel.AddonID, op) @@ -455,13 +475,16 @@ func (o *HostGrantsOwner) preview(ctx context.Context, a contract.Actor, r HostG case slices.Contains(plan.Unsupported, op): ov.Status = "unsupported" case slices.Contains(plan.Held, op): - ov.Status = "held" - ov.Source = string(hostgrants.SourceDeployment) + ov.Status = "allowed" + ov.Source = string(o.cfg.Floor.Decide(doc, c, rel.AddonID, op).Source) default: - ov.Status = "grantable" + ov.Status = "not_allowed" } o.describe(&ov) v.Ops = append(v.Ops, ov) + if rel.Installed && !slices.Contains(before, op) { + v.Added = append(v.Added, op) + } } v.CanInstall = onlyGrantableMissing(plan, rel.Required) b := o.cfg.Floor.EvaluateBlanket(doc, c, o.cfg.BlanketOverride, hostgrants.BlanketInput{AddonID: rel.AddonID, Official: rel.Official, Required: rel.Required, Optional: rel.Optional}) @@ -602,8 +625,17 @@ func (o *HostGrantsOwner) edit(ctx context.Context, a contract.Actor, id string, removed = append(removed, g.Name) } } - if r.KeepBlanketAsOwn { - next = hostgrants.KeepAsOwn(next, o.now(), a.UserID) + if len(r.Keep) != 0 { + // Relabel only the blanket grants the caller named and still keeps. + keep := map[string]bool{} + for _, k := range r.Keep { + keep[k] = true + } + for i, g := range next.Ops { + if keep[g.Name] && g.Source == hostgrants.SourceBlanket { + next.Ops[i] = hostgrants.OpGrant{Name: g.Name, Source: hostgrants.SourceEdit, At: o.now(), By: a.UserID, Version: g.Version} + } + } } var patch hostgrants.Patch if len(next.Ops) == 0 { @@ -682,7 +714,11 @@ func (o *HostGrantsOwner) blanket(ctx context.Context, a contract.Actor, r HostG default: return nil, o.invalid() } - if (r.Mode == hostgrants.BlanketOff && (r.IncludeOptional || r.ApplyToInstalled)) || (r.Mode != hostgrants.BlanketOff && r.RevokeOnOff) { + if r.Mode == hostgrants.BlanketOff { + // Off has no scope to widen: the options are ignored, not refused. + r.IncludeOptional, r.ApplyToInstalled = false, false + } + if r.Mode != hostgrants.BlanketOff && r.RevokeOnOff { return nil, o.invalid() } doc, err := o.cfg.Store.Snapshot(ctx) diff --git a/core/services/addons/gateway/hostgrants_test.go b/core/services/addons/gateway/hostgrants_test.go index feea03cd96..b2b48a29e3 100644 --- a/core/services/addons/gateway/hostgrants_test.go +++ b/core/services/addons/gateway/hostgrants_test.go @@ -174,8 +174,8 @@ var _ = Describe("The host access owner service", func() { Expect(v.Ops).To(HaveLen(2)) Expect(v.Ops[1].Name).To(Equal("workspace.materialize")) Expect(v.Ops[1].Required).To(BeTrue()) - Expect(v.Ops[1].Status).To(Equal("grantable")) - Expect(v.Ops[0].Status).To(Equal("grantable")) + Expect(v.Ops[1].Status).To(Equal("not_allowed")) + Expect(v.Ops[0].Status).To(Equal("not_allowed")) Expect(v.Blanket.Applies).To(BeFalse()) }) It("shows a deny winning over everything, and cannot install a blocked required operation", func() { @@ -206,10 +206,10 @@ var _ = Describe("The host access owner service", func() { for _, o := range v.Ops { switch o.Name { case "egress.check": - Expect(o.Status).To(Equal("held")) + Expect(o.Status).To(Equal("allowed")) case "workspace.materialize": - Expect(o.Status).To(Equal("grantable")) - Expect(o.Hinted).To(BeTrue()) + Expect(o.Status).To(Equal("not_allowed")) + Expect(o.Hint).To(BeTrue()) } } }) @@ -449,9 +449,11 @@ var _ = Describe("The host access owner service", func() { }) It("rejects options that do not fit the mode", func() { f := newHGFixture() - _, err := b(f, "b1", 0, HostGrantsBlanketRequest{Mode: "off", IncludeOptional: true}) + _, err := b(f, "b1", 0, HostGrantsBlanketRequest{Mode: "off", IncludeOptional: true, ApplyToInstalled: true}) + Expect(err).NotTo(HaveOccurred(), "off ignores the scope options") + _, err = b(f, "b4", 1, HostGrantsBlanketRequest{Mode: "official", RevokeOnOff: true, Confirm: BlanketConfirmOfficial}) Expect(typeOf(err)).To(Equal("invalid_request")) - _, err = b(f, "b2", 0, HostGrantsBlanketRequest{Mode: "sometimes"}) + _, err = b(f, "b2", 1, HostGrantsBlanketRequest{Mode: "sometimes"}) Expect(typeOf(err)).To(Equal("invalid_request")) }) }) @@ -531,3 +533,57 @@ var _ = Describe("host access actions on the typed dispatch", func() { } }) }) + +var _ = Describe("host access as the pages read it", func() { + It("names what an upgrade adds, using the installed package to tell what is held", func() { + f := newHGFixture() + _, err := f.svc.Serve(human, admin, call(HostGrantsEdit, "e1", "sandbox", HostGrantsEditRequest{OperationID: "e1", Ops: []string{"workspace.materialize"}})) + Expect(err).NotTo(HaveOccurred()) + f.mgr.release = ReleaseAccess{AddonID: "sandbox", Title: "Sandbox", Version: "1.1.0", Digest: "sha256:bb", Installed: true, + Required: []string{"workspace.materialize", "workspace.export"}, Optional: []string{"egress.check"}} + v := data[HostGrantsPreviewView](f.svc.Serve(context.Background(), admin, call(HostGrantsPreview, "", "", HostGrantsPreviewRequest{SourceID: "official", AddonID: "sandbox", Version: "1.1.0"}))) + Expect(v.Added).To(Equal([]string{"workspace.export"})) + status := map[string]string{} + for _, o := range v.Ops { + status[o.Name] = o.Status + } + Expect(status).To(Equal(map[string]string{"workspace.materialize": "allowed", "workspace.export": "not_allowed", "egress.check": "not_allowed"})) + }) + + It("says why an add-on is stopped, and carries its library and new optional access", func() { + f := newHGFixture() + f.mgr.installed[0].SourceID = "official" + f.mgr.installed[0].NewOptional = []string{"egress.check"} + v := data[HostGrantsView](f.svc.Serve(context.Background(), admin, call(HostGrantsRead, "", "", nil))) + var sbx HostGrantsAddon + for _, a := range v.Addons { + if a.ID == "sandbox" { + sbx = a + } + } + Expect(sbx.Stopped).To(Equal("workspace.materialize")) + Expect(sbx.SourceID).To(Equal("official")) + Expect(sbx.NewOptional).To(Equal([]string{"egress.check"})) + }) + + It("keeps a blanket grant as the administrator's own, so revoking the blanket grants leaves it", func() { + f := newHGFixture() + _, err := f.svc.Serve(human, admin, call(HostGrantsBlanket, "b1", "", HostGrantsBlanketRequest{OperationID: "b1", Mode: "any", ApplyToInstalled: true, Confirm: BlanketConfirmAny})) + Expect(err).NotTo(HaveOccurred()) + r := data[HostGrantsAddonResult](f.svc.Serve(human, admin, call(HostGrantsEdit, "e1", "sandbox", HostGrantsEditRequest{OperationID: "e1", Revision: 1, Ops: []string{"workspace.materialize"}, Keep: []string{"workspace.materialize"}}))) + Expect(r.Addon.Ops[2].Name).To(Equal("workspace.materialize")) + Expect(r.Addon.Ops[2].Source).To(Equal("edit")) + _, err = f.svc.Serve(human, admin, call(HostGrantsBlanket, "b2", "", HostGrantsBlanketRequest{OperationID: "b2", Revision: 2, Mode: "off", RevokeOnOff: true})) + Expect(err).NotTo(HaveOccurred()) + v := data[HostGrantsView](f.svc.Serve(context.Background(), admin, call(HostGrantsRead, "", "", nil))) + for _, a := range v.Addons { + if a.ID == "sandbox" { + for _, o := range a.Ops { + if o.Name == "workspace.materialize" { + Expect(o.Status).To(Equal("allowed")) + } + } + } + } + }) +}) diff --git a/swagger/docs.go b/swagger/docs.go index da3aeb84f9..f79310062f 100644 --- a/swagger/docs.go +++ b/swagger/docs.go @@ -792,7 +792,7 @@ const docTemplate = `{ }, "/api/addons/host-grants/blanket": { "put": { - "description": "Administrators only, from their own browser session (X-LocalAI-Confirm equal to the operationId). Mode off, official or any. With it on, an install or upgrade grants the add-on the operations its verified manifest declares as required (also the optional ones with includeOptional) without the dialog step; it never grants an undeclared or unregistered operation and never overrides a deny rule. Mode official covers only the official library with its official signer. Mode any covers every library the administrator configured: ANY LIBRARY ENTRY THEN RECEIVES EXACTLY THE OPERATIONS ITS MANIFEST DECLARES, with no review, so a malicious or compromised entry gets what it asks for. Turning it on, or widening it, needs confirm equal to \"grant official add-ons what they declare\" (official) or \"grant any library add-on what it declares\" (any), else 400 confirmation_mismatch. applyToInstalled also grants the installed add-ons in the same revision. Turning it off keeps the grants it made, marked blanket; revokeOnOff revokes them all. When the deployment fixes the mode the answer is 403 locked_by_deployment.", + "description": "Administrators only, from their own browser session (X-LocalAI-Confirm equal to \"blanket\"). Mode off, official or any. With it on, an install or upgrade grants the add-on the operations its verified manifest declares as required (also the optional ones with includeOptional) without the dialog step; it never grants an undeclared or unregistered operation and never overrides a deny rule. Mode official covers only the official library with its official signer. Mode any covers every library the administrator configured: ANY LIBRARY ENTRY THEN RECEIVES EXACTLY THE OPERATIONS ITS MANIFEST DECLARES, with no review, so a malicious or compromised entry gets what it asks for. Turning it on, or widening it, needs confirm equal to \"grant official add-ons what they declare\" (official) or \"grant any library add-on what it declares\" (any), else 400 confirmation_mismatch. applyToInstalled also grants the installed add-ons in the same revision. Turning it off keeps the grants it made, marked blanket; revokeOnOff revokes them all. When the deployment fixes the mode the answer is 403 locked_by_deployment.", "consumes": [ "application/json" ], @@ -856,7 +856,7 @@ const docTemplate = `{ }, "/api/addons/host-grants/deny": { "put": { - "description": "Administrators only. A deny rule beats every grant. deny maps an add-on ID, or \"*\" for all, to prefixes (\"egress.\"), exact operation names or the single token \"*\". The rules of the policy file and flags are not editable here and are unioned with these. With dryRun true the answer is the affected add-ons only (affects: the operations each would lose and whether it will stop because a required one is lost) and nothing changes; a dry run needs the administrator but not the browser session. Without dryRun the change needs the administrator's own browser session (X-LocalAI-Confirm equal to the operationId), the revision the caller read (409 revision_conflict otherwise), and applies to the next call with no restart.", + "description": "Administrators only. A deny rule beats every grant. deny maps an add-on ID, or \"*\" for all, to prefixes (\"egress.\"), exact operation names or the single token \"*\". The rules of the policy file and flags are not editable here and are unioned with these. With dryRun true the answer is the affected add-ons only (affects: the operations each would lose and whether it will stop because a required one is lost) and nothing changes; a dry run needs the administrator but not the browser session. Without dryRun the change needs the administrator's own browser session (X-LocalAI-Confirm equal to \"deny\"), the revision the caller read (409 revision_conflict otherwise), and applies to the next call with no restart.", "consumes": [ "application/json" ], @@ -920,7 +920,7 @@ const docTemplate = `{ }, "/api/addons/host-grants/install": { "post": { - "description": "Administrators only, from their own browser session (same-origin fetch, X-LocalAI-Confirm equal to the operationId). API keys, bearer tokens, MCP sessions and tool calls are refused with 403 not_human_surface. Carries the approval the dialog collected: the digest of the previewed package, the operations ticked, the revision of the grants document and the preview nonce. The owner stages the package again and grants only the operations that are approved AND declared by that verified manifest AND registered by the host AND not denied; a different package is 409 approval_stale, a changed document 409 revision_conflict, an expired or reused nonce 409 approval_stale. A required operation that is neither held nor approved is refused (approval_required) and one that a deny rule blocks is 403 blocked_by_policy; nothing is written then. For action upgrade, expectedDigest and candidateDigest pin the upgrade as in the plain upgrade. The plain dispatch install carries no approval. Repeating the operationId with the same request returns the recorded operation. After outcome_unknown keep the operationId and poll the operation status.", + "description": "Administrators only, from their own browser session (same-origin fetch, X-LocalAI-Confirm equal to addonId@digest, the add-on and the digest of the approved package). API keys, bearer tokens, MCP sessions and tool calls are refused with 403 not_human_surface. Carries the approval the dialog collected: the digest of the previewed package, the operations ticked, the revision of the grants document and the preview nonce. The owner stages the package again and grants only the operations that are approved AND declared by that verified manifest AND registered by the host AND not denied; a different package is 409 approval_stale, a changed document 409 revision_conflict, an expired or reused nonce 409 approval_stale. A required operation that is neither held nor approved is refused (approval_required) and one that a deny rule blocks is 403 blocked_by_policy; nothing is written then. For action upgrade, expectedDigest and candidateDigest pin the upgrade as in the plain upgrade. The plain dispatch install carries no approval. Repeating the operationId with the same request returns the recorded operation. After outcome_unknown keep the operationId and poll the operation status.", "consumes": [ "application/json" ], @@ -1042,7 +1042,7 @@ const docTemplate = `{ }, "/api/addons/host-grants/{addonId}": { "put": { - "description": "Administrators only, from their own browser session (X-LocalAI-Confirm equal to the operationId). ops is the full set the add-on keeps: operations left out are revoked, new ones must be declared by the installed manifest, registered by the host and not denied (403 blocked_by_policy). Grants made by the policy file or flags are not editable here. keepBlanketAsOwn relabels the blanket grants as the administrator's own. revision is the revision the caller read; a stale one is 409 revision_conflict. Takes effect for the next call of the add-on with no restart.", + "description": "Administrators only, from their own browser session (X-LocalAI-Confirm equal to the add-on ID). operationId is optional (the host makes one); send your own to make a retry the same change. ops is the full set the add-on keeps: operations left out are revoked, new ones must be declared by the installed manifest, registered by the host and not denied (403 blocked_by_policy). Grants made by the policy file or flags are not editable here. keep names held blanket grants to relabel as the administrator's own. revision is the revision the caller read; a stale one is 409 revision_conflict. Takes effect for the next call of the add-on with no restart.", "consumes": [ "application/json" ], @@ -1117,7 +1117,7 @@ const docTemplate = `{ } }, "delete": { - "description": "Administrators only, from their own browser session (X-LocalAI-Confirm equal to the operationId). Removes the UI and blanket grants of the add-on; grants of the policy file stay. Query: revision (the revision the caller read) and operationId. Takes effect for the next call with no restart; a running call finishes. An add-on whose required operation is revoked becomes unavailable.", + "description": "Administrators only, from their own browser session (X-LocalAI-Confirm equal to the add-on ID). Removes the UI and blanket grants of the add-on; grants of the policy file stay. Query: revision (the revision the caller read) and, optionally, operationId. Takes effect for the next call with no restart; a running call finishes. An add-on whose required operation is revoked becomes unavailable.", "produces": [ "application/json" ], @@ -1142,10 +1142,9 @@ const docTemplate = `{ }, { "type": "string", - "description": "Operation ID", + "description": "Operation ID (optional)", "name": "operationId", - "in": "query", - "required": true + "in": "query" } ], "responses": { @@ -17414,6 +17413,12 @@ const docTemplate = `{ "identityMismatch": { "type": "boolean" }, + "newOptional": { + "type": "array", + "items": { + "type": "string" + } + }, "noLongerRequested": { "type": "array", "items": { @@ -17432,9 +17437,16 @@ const docTemplate = `{ "requested": { "$ref": "#/definitions/gateway.HostRequested" }, + "sourceId": { + "type": "string" + }, "state": { "type": "string" }, + "stopped": { + "description": "Stopped lists the required operations the add-on lacks, which keep it\nfrom running (\"stopped: needs X\").", + "type": "string" + }, "title": { "type": "string" }, @@ -17607,15 +17619,17 @@ const docTemplate = `{ "gateway.HostGrantsEditRequest": { "type": "object", "properties": { - "keepBlanketAsOwn": { - "description": "KeepBlanketAsOwn relabels the blanket grants of the add-on as the\nadministrator's own, so revoking the blanket grants leaves them.", - "type": "boolean" + "keep": { + "description": "Keep names held blanket grants to relabel as the administrator's own,\nso revoking the blanket grants leaves them.", + "type": "array", + "items": { + "type": "string" + } }, "operationId": { "type": "string" }, "ops": { - "description": "Ops is the full set of operations the add-on keeps through the UI.", "type": "array", "items": { "type": "string" @@ -17688,6 +17702,13 @@ const docTemplate = `{ "gateway.HostGrantsPreviewView": { "type": "object", "properties": { + "added": { + "description": "Added are the operations this version asks for that the installed\nversion did not. Empty on an install.", + "type": "array", + "items": { + "type": "string" + } + }, "addonId": { "type": "string" }, @@ -17784,8 +17805,8 @@ const docTemplate = `{ "by": { "type": "string" }, - "hinted": { - "description": "Hinted is true in a preview when the operation was granted before an\nearlier uninstall, so the dialog may tick it.", + "hint": { + "description": "Hint is true in a preview when the operation was granted before an\nearlier uninstall, so the dialog may tick it. It grants nothing.", "type": "boolean" }, "name": { @@ -17804,7 +17825,7 @@ const docTemplate = `{ "type": "string" }, "status": { - "description": "Status is allowed, not_allowed, blocked or unsupported. In a preview it\nis held, grantable, blocked or unsupported.", + "description": "Status is allowed, not_allowed, blocked or unsupported, in the page and\nin a preview alike. In a preview not_allowed means the dialog may grant it.", "type": "string" }, "text": { diff --git a/swagger/swagger.json b/swagger/swagger.json index 0faa004e3f..e2c037ddc2 100644 --- a/swagger/swagger.json +++ b/swagger/swagger.json @@ -789,7 +789,7 @@ }, "/api/addons/host-grants/blanket": { "put": { - "description": "Administrators only, from their own browser session (X-LocalAI-Confirm equal to the operationId). Mode off, official or any. With it on, an install or upgrade grants the add-on the operations its verified manifest declares as required (also the optional ones with includeOptional) without the dialog step; it never grants an undeclared or unregistered operation and never overrides a deny rule. Mode official covers only the official library with its official signer. Mode any covers every library the administrator configured: ANY LIBRARY ENTRY THEN RECEIVES EXACTLY THE OPERATIONS ITS MANIFEST DECLARES, with no review, so a malicious or compromised entry gets what it asks for. Turning it on, or widening it, needs confirm equal to \"grant official add-ons what they declare\" (official) or \"grant any library add-on what it declares\" (any), else 400 confirmation_mismatch. applyToInstalled also grants the installed add-ons in the same revision. Turning it off keeps the grants it made, marked blanket; revokeOnOff revokes them all. When the deployment fixes the mode the answer is 403 locked_by_deployment.", + "description": "Administrators only, from their own browser session (X-LocalAI-Confirm equal to \"blanket\"). Mode off, official or any. With it on, an install or upgrade grants the add-on the operations its verified manifest declares as required (also the optional ones with includeOptional) without the dialog step; it never grants an undeclared or unregistered operation and never overrides a deny rule. Mode official covers only the official library with its official signer. Mode any covers every library the administrator configured: ANY LIBRARY ENTRY THEN RECEIVES EXACTLY THE OPERATIONS ITS MANIFEST DECLARES, with no review, so a malicious or compromised entry gets what it asks for. Turning it on, or widening it, needs confirm equal to \"grant official add-ons what they declare\" (official) or \"grant any library add-on what it declares\" (any), else 400 confirmation_mismatch. applyToInstalled also grants the installed add-ons in the same revision. Turning it off keeps the grants it made, marked blanket; revokeOnOff revokes them all. When the deployment fixes the mode the answer is 403 locked_by_deployment.", "consumes": [ "application/json" ], @@ -853,7 +853,7 @@ }, "/api/addons/host-grants/deny": { "put": { - "description": "Administrators only. A deny rule beats every grant. deny maps an add-on ID, or \"*\" for all, to prefixes (\"egress.\"), exact operation names or the single token \"*\". The rules of the policy file and flags are not editable here and are unioned with these. With dryRun true the answer is the affected add-ons only (affects: the operations each would lose and whether it will stop because a required one is lost) and nothing changes; a dry run needs the administrator but not the browser session. Without dryRun the change needs the administrator's own browser session (X-LocalAI-Confirm equal to the operationId), the revision the caller read (409 revision_conflict otherwise), and applies to the next call with no restart.", + "description": "Administrators only. A deny rule beats every grant. deny maps an add-on ID, or \"*\" for all, to prefixes (\"egress.\"), exact operation names or the single token \"*\". The rules of the policy file and flags are not editable here and are unioned with these. With dryRun true the answer is the affected add-ons only (affects: the operations each would lose and whether it will stop because a required one is lost) and nothing changes; a dry run needs the administrator but not the browser session. Without dryRun the change needs the administrator's own browser session (X-LocalAI-Confirm equal to \"deny\"), the revision the caller read (409 revision_conflict otherwise), and applies to the next call with no restart.", "consumes": [ "application/json" ], @@ -917,7 +917,7 @@ }, "/api/addons/host-grants/install": { "post": { - "description": "Administrators only, from their own browser session (same-origin fetch, X-LocalAI-Confirm equal to the operationId). API keys, bearer tokens, MCP sessions and tool calls are refused with 403 not_human_surface. Carries the approval the dialog collected: the digest of the previewed package, the operations ticked, the revision of the grants document and the preview nonce. The owner stages the package again and grants only the operations that are approved AND declared by that verified manifest AND registered by the host AND not denied; a different package is 409 approval_stale, a changed document 409 revision_conflict, an expired or reused nonce 409 approval_stale. A required operation that is neither held nor approved is refused (approval_required) and one that a deny rule blocks is 403 blocked_by_policy; nothing is written then. For action upgrade, expectedDigest and candidateDigest pin the upgrade as in the plain upgrade. The plain dispatch install carries no approval. Repeating the operationId with the same request returns the recorded operation. After outcome_unknown keep the operationId and poll the operation status.", + "description": "Administrators only, from their own browser session (same-origin fetch, X-LocalAI-Confirm equal to addonId@digest, the add-on and the digest of the approved package). API keys, bearer tokens, MCP sessions and tool calls are refused with 403 not_human_surface. Carries the approval the dialog collected: the digest of the previewed package, the operations ticked, the revision of the grants document and the preview nonce. The owner stages the package again and grants only the operations that are approved AND declared by that verified manifest AND registered by the host AND not denied; a different package is 409 approval_stale, a changed document 409 revision_conflict, an expired or reused nonce 409 approval_stale. A required operation that is neither held nor approved is refused (approval_required) and one that a deny rule blocks is 403 blocked_by_policy; nothing is written then. For action upgrade, expectedDigest and candidateDigest pin the upgrade as in the plain upgrade. The plain dispatch install carries no approval. Repeating the operationId with the same request returns the recorded operation. After outcome_unknown keep the operationId and poll the operation status.", "consumes": [ "application/json" ], @@ -1039,7 +1039,7 @@ }, "/api/addons/host-grants/{addonId}": { "put": { - "description": "Administrators only, from their own browser session (X-LocalAI-Confirm equal to the operationId). ops is the full set the add-on keeps: operations left out are revoked, new ones must be declared by the installed manifest, registered by the host and not denied (403 blocked_by_policy). Grants made by the policy file or flags are not editable here. keepBlanketAsOwn relabels the blanket grants as the administrator's own. revision is the revision the caller read; a stale one is 409 revision_conflict. Takes effect for the next call of the add-on with no restart.", + "description": "Administrators only, from their own browser session (X-LocalAI-Confirm equal to the add-on ID). operationId is optional (the host makes one); send your own to make a retry the same change. ops is the full set the add-on keeps: operations left out are revoked, new ones must be declared by the installed manifest, registered by the host and not denied (403 blocked_by_policy). Grants made by the policy file or flags are not editable here. keep names held blanket grants to relabel as the administrator's own. revision is the revision the caller read; a stale one is 409 revision_conflict. Takes effect for the next call of the add-on with no restart.", "consumes": [ "application/json" ], @@ -1114,7 +1114,7 @@ } }, "delete": { - "description": "Administrators only, from their own browser session (X-LocalAI-Confirm equal to the operationId). Removes the UI and blanket grants of the add-on; grants of the policy file stay. Query: revision (the revision the caller read) and operationId. Takes effect for the next call with no restart; a running call finishes. An add-on whose required operation is revoked becomes unavailable.", + "description": "Administrators only, from their own browser session (X-LocalAI-Confirm equal to the add-on ID). Removes the UI and blanket grants of the add-on; grants of the policy file stay. Query: revision (the revision the caller read) and, optionally, operationId. Takes effect for the next call with no restart; a running call finishes. An add-on whose required operation is revoked becomes unavailable.", "produces": [ "application/json" ], @@ -1139,10 +1139,9 @@ }, { "type": "string", - "description": "Operation ID", + "description": "Operation ID (optional)", "name": "operationId", - "in": "query", - "required": true + "in": "query" } ], "responses": { @@ -17411,6 +17410,12 @@ "identityMismatch": { "type": "boolean" }, + "newOptional": { + "type": "array", + "items": { + "type": "string" + } + }, "noLongerRequested": { "type": "array", "items": { @@ -17429,9 +17434,16 @@ "requested": { "$ref": "#/definitions/gateway.HostRequested" }, + "sourceId": { + "type": "string" + }, "state": { "type": "string" }, + "stopped": { + "description": "Stopped lists the required operations the add-on lacks, which keep it\nfrom running (\"stopped: needs X\").", + "type": "string" + }, "title": { "type": "string" }, @@ -17604,15 +17616,17 @@ "gateway.HostGrantsEditRequest": { "type": "object", "properties": { - "keepBlanketAsOwn": { - "description": "KeepBlanketAsOwn relabels the blanket grants of the add-on as the\nadministrator's own, so revoking the blanket grants leaves them.", - "type": "boolean" + "keep": { + "description": "Keep names held blanket grants to relabel as the administrator's own,\nso revoking the blanket grants leaves them.", + "type": "array", + "items": { + "type": "string" + } }, "operationId": { "type": "string" }, "ops": { - "description": "Ops is the full set of operations the add-on keeps through the UI.", "type": "array", "items": { "type": "string" @@ -17685,6 +17699,13 @@ "gateway.HostGrantsPreviewView": { "type": "object", "properties": { + "added": { + "description": "Added are the operations this version asks for that the installed\nversion did not. Empty on an install.", + "type": "array", + "items": { + "type": "string" + } + }, "addonId": { "type": "string" }, @@ -17781,8 +17802,8 @@ "by": { "type": "string" }, - "hinted": { - "description": "Hinted is true in a preview when the operation was granted before an\nearlier uninstall, so the dialog may tick it.", + "hint": { + "description": "Hint is true in a preview when the operation was granted before an\nearlier uninstall, so the dialog may tick it. It grants nothing.", "type": "boolean" }, "name": { @@ -17801,7 +17822,7 @@ "type": "string" }, "status": { - "description": "Status is allowed, not_allowed, blocked or unsupported. In a preview it\nis held, grantable, blocked or unsupported.", + "description": "Status is allowed, not_allowed, blocked or unsupported, in the page and\nin a preview alike. In a preview not_allowed means the dialog may grant it.", "type": "string" }, "text": { diff --git a/swagger/swagger.yaml b/swagger/swagger.yaml index 764cba41c6..8c9648a19c 100644 --- a/swagger/swagger.yaml +++ b/swagger/swagger.yaml @@ -1749,6 +1749,10 @@ definitions: type: string identityMismatch: type: boolean + newOptional: + items: + type: string + type: array noLongerRequested: items: $ref: '#/definitions/gateway.HostOpView' @@ -1761,8 +1765,15 @@ definitions: $ref: '#/definitions/gateway.PendingUpgrade' requested: $ref: '#/definitions/gateway.HostRequested' + sourceId: + type: string state: type: string + stopped: + description: |- + Stopped lists the required operations the add-on lacks, which keep it + from running ("stopped: needs X"). + type: string title: type: string version: @@ -1879,16 +1890,16 @@ definitions: type: object gateway.HostGrantsEditRequest: properties: - keepBlanketAsOwn: + keep: description: |- - KeepBlanketAsOwn relabels the blanket grants of the add-on as the - administrator's own, so revoking the blanket grants leaves them. - type: boolean + Keep names held blanket grants to relabel as the administrator's own, + so revoking the blanket grants leaves them. + items: + type: string + type: array operationId: type: string ops: - description: Ops is the full set of operations the add-on keeps through the - UI. items: type: string type: array @@ -1936,6 +1947,13 @@ definitions: type: object gateway.HostGrantsPreviewView: properties: + added: + description: |- + Added are the operations this version asks for that the installed + version did not. Empty on an install. + items: + type: string + type: array addonId: type: string blanket: @@ -1999,10 +2017,10 @@ definitions: type: string by: type: string - hinted: + hint: description: |- - Hinted is true in a preview when the operation was granted before an - earlier uninstall, so the dialog may tick it. + Hint is true in a preview when the operation was granted before an + earlier uninstall, so the dialog may tick it. It grants nothing. type: boolean name: type: string @@ -2016,8 +2034,8 @@ definitions: type: string status: description: |- - Status is allowed, not_allowed, blocked or unsupported. In a preview it - is held, grantable, blocked or unsupported. + Status is allowed, not_allowed, blocked or unsupported, in the page and + in a preview alike. In a preview not_allowed means the dialog may grant it. type: string text: type: string @@ -11841,10 +11859,11 @@ paths: /api/addons/host-grants/{addonId}: delete: description: 'Administrators only, from their own browser session (X-LocalAI-Confirm - equal to the operationId). Removes the UI and blanket grants of the add-on; + equal to the add-on ID). Removes the UI and blanket grants of the add-on; grants of the policy file stay. Query: revision (the revision the caller read) - and operationId. Takes effect for the next call with no restart; a running - call finishes. An add-on whose required operation is revoked becomes unavailable.' + and, optionally, operationId. Takes effect for the next call with no restart; + a running call finishes. An add-on whose required operation is revoked becomes + unavailable.' parameters: - description: Add-on ID in: path @@ -11856,10 +11875,9 @@ paths: name: revision required: true type: integer - - description: Operation ID + - description: Operation ID (optional) in: query name: operationId - required: true type: string produces: - application/json @@ -11900,11 +11918,12 @@ paths: consumes: - application/json description: 'Administrators only, from their own browser session (X-LocalAI-Confirm - equal to the operationId). ops is the full set the add-on keeps: operations - left out are revoked, new ones must be declared by the installed manifest, - registered by the host and not denied (403 blocked_by_policy). Grants made - by the policy file or flags are not editable here. keepBlanketAsOwn relabels - the blanket grants as the administrator''s own. revision is the revision the + equal to the add-on ID). operationId is optional (the host makes one); send + your own to make a retry the same change. ops is the full set the add-on keeps: + operations left out are revoked, new ones must be declared by the installed + manifest, registered by the host and not denied (403 blocked_by_policy). Grants + made by the policy file or flags are not editable here. keep names held blanket + grants to relabel as the administrator''s own. revision is the revision the caller read; a stale one is 409 revision_conflict. Takes effect for the next call of the add-on with no restart.' parameters: @@ -11959,9 +11978,9 @@ paths: consumes: - application/json description: 'Administrators only, from their own browser session (X-LocalAI-Confirm - equal to the operationId). Mode off, official or any. With it on, an install - or upgrade grants the add-on the operations its verified manifest declares - as required (also the optional ones with includeOptional) without the dialog + equal to "blanket"). Mode off, official or any. With it on, an install or + upgrade grants the add-on the operations its verified manifest declares as + required (also the optional ones with includeOptional) without the dialog step; it never grants an undeclared or unregistered operation and never overrides a deny rule. Mode official covers only the official library with its official signer. Mode any covers every library the administrator configured: ANY LIBRARY @@ -12023,7 +12042,7 @@ paths: add-ons only (affects: the operations each would lose and whether it will stop because a required one is lost) and nothing changes; a dry run needs the administrator but not the browser session. Without dryRun the change needs - the administrator''s own browser session (X-LocalAI-Confirm equal to the operationId), + the administrator''s own browser session (X-LocalAI-Confirm equal to "deny"), the revision the caller read (409 revision_conflict otherwise), and applies to the next call with no restart.' parameters: @@ -12069,20 +12088,21 @@ paths: consumes: - application/json description: 'Administrators only, from their own browser session (same-origin - fetch, X-LocalAI-Confirm equal to the operationId). API keys, bearer tokens, - MCP sessions and tool calls are refused with 403 not_human_surface. Carries - the approval the dialog collected: the digest of the previewed package, the - operations ticked, the revision of the grants document and the preview nonce. - The owner stages the package again and grants only the operations that are - approved AND declared by that verified manifest AND registered by the host - AND not denied; a different package is 409 approval_stale, a changed document - 409 revision_conflict, an expired or reused nonce 409 approval_stale. A required - operation that is neither held nor approved is refused (approval_required) - and one that a deny rule blocks is 403 blocked_by_policy; nothing is written - then. For action upgrade, expectedDigest and candidateDigest pin the upgrade - as in the plain upgrade. The plain dispatch install carries no approval. Repeating - the operationId with the same request returns the recorded operation. After - outcome_unknown keep the operationId and poll the operation status.' + fetch, X-LocalAI-Confirm equal to addonId@digest, the add-on and the digest + of the approved package). API keys, bearer tokens, MCP sessions and tool calls + are refused with 403 not_human_surface. Carries the approval the dialog collected: + the digest of the previewed package, the operations ticked, the revision of + the grants document and the preview nonce. The owner stages the package again + and grants only the operations that are approved AND declared by that verified + manifest AND registered by the host AND not denied; a different package is + 409 approval_stale, a changed document 409 revision_conflict, an expired or + reused nonce 409 approval_stale. A required operation that is neither held + nor approved is refused (approval_required) and one that a deny rule blocks + is 403 blocked_by_policy; nothing is written then. For action upgrade, expectedDigest + and candidateDigest pin the upgrade as in the plain upgrade. The plain dispatch + install carries no approval. Repeating the operationId with the same request + returns the recorded operation. After outcome_unknown keep the operationId + and poll the operation status.' parameters: - description: Install with approval in: body