mirror of
https://github.com/mudler/LocalAI.git
synced 2026-11-06 04:23:15 -05:00
A connection now has a write mode, chosen on the add-on page: off (the default), propose (each change waits for the person to confirm) or auto (a chat of the person makes it at once). The host enforces it for every write tool of an add-on that holds a credential, before the add-on is called, and holds the call to the repository list of the connection. Host: - Binding.WriteMode, stored in both stores and in the connector API (write_mode). Absent means off, so existing bindings stay read only. - A credentialed add-on may declare write tools. They name the resource with the credential slot argument, never resourceId. A tool can set approval "always", a ceiling that keeps a proposal even in auto. - The proposal broker fetches the credential for the preview and again for the apply, so it is never stored. A mode lowered between the proposal and the confirmation refuses the apply. A proposal that awaits a person cannot be confirmed by a chat turn. - New capability name tools.write for the roles a connector kind declares. Add-on, tools (kind write): comment-issue, comment-pr, create-issue, edit-issue (title, close or reopen), add-labels, review-comment-pr. They preview plain facts, refuse a stale target, cap and screen the text for secrets, and keep a receipt journal plus a hidden marker in the text, so a change whose answer was lost is not sent twice. Assisted-by: Claude:claude-sonnet-5-5 go gofmt ginkgo node
308 lines
14 KiB
Go
308 lines
14 KiB
Go
// SPDX-License-Identifier: MIT
|
|
package tools_test
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/mudler/LocalAI/addons/github/githubtest"
|
|
"github.com/mudler/LocalAI/addons/github/internal/ghapi"
|
|
"github.com/mudler/LocalAI/addons/github/internal/tools"
|
|
"github.com/mudler/LocalAI/addons/internal/protocol"
|
|
"github.com/mudler/LocalAI/pkg/addons/contract"
|
|
. "github.com/onsi/ginkgo/v2"
|
|
. "github.com/onsi/gomega"
|
|
)
|
|
|
|
var _ = Describe("the write tools", func() {
|
|
var (
|
|
srv *githubtest.Server
|
|
repo *githubtest.Repo
|
|
rt *tools.Runtime
|
|
ctx = context.Background()
|
|
seq int
|
|
)
|
|
newRuntime := func(dir string) *tools.Runtime {
|
|
api := &ghapi.Client{Base: srv.URL, HTTP: srv.Client(), Now: time.Now}
|
|
r := tools.New(api)
|
|
if dir != "" {
|
|
r.WithDataDir(dir)
|
|
}
|
|
return r
|
|
}
|
|
BeforeEach(func() {
|
|
seq = 0
|
|
srv = githubtest.New()
|
|
DeferCleanup(srv.Close)
|
|
srv.AddToken(token, "octo")
|
|
repo = srv.AddRepo("acme/widgets")
|
|
repo.Issues = []*githubtest.Issue{
|
|
{Number: 1, Title: "Bug", Body: "it breaks", State: "open", User: "ann", Labels: []string{"bug"}},
|
|
{Number: 2, Title: "Fix", State: "open", User: "bob", IsPR: true},
|
|
{Number: 3, Title: "Locked", State: "open", User: "ann", Locked: true},
|
|
}
|
|
repo.PRs = []*githubtest.PR{{Number: 2, Title: "Fix", State: "open", User: "bob", Base: "main", Head: "fix", HeadSHA: "deadbeef"}}
|
|
rt = newRuntime("")
|
|
})
|
|
|
|
envelope := func(tool, key, op string, args map[string]any) contract.Envelope {
|
|
args["tool"] = tool
|
|
raw, _ := json.Marshal(args)
|
|
return contract.Envelope{Request: contract.Request{AddonID: "github", OperationID: op, Arguments: raw, Credential: token, ResourceKey: key}}
|
|
}
|
|
opID := func() string { seq++; return "op-" + string(rune('a'+seq)) }
|
|
posts := func() int {
|
|
n := 0
|
|
for _, c := range srv.Calls() {
|
|
if c.Method != "" && c.Method != "GET" {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
preview := func(tool string, args map[string]any) (contract.Preview, error) {
|
|
return rt.Preview(ctx, envelope(tool, "acme/widgets", opID(), args))
|
|
}
|
|
// do previews then applies, as the host does, and returns the receipt.
|
|
do := func(r *tools.Runtime, tool string, args map[string]any) contract.Receipt {
|
|
env := envelope(tool, "acme/widgets", opID(), args)
|
|
p, err := r.Preview(ctx, env)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
rc, err := r.Apply(ctx, env, p.Revision)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
return rc
|
|
}
|
|
|
|
It("previews a comment with plain facts and changes nothing", func() {
|
|
p, err := preview(tools.ToolCommentIssue, map[string]any{"repo": "acme/widgets", "number": 1, "body": strings.Repeat("hello ", 400)})
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(posts()).To(BeZero())
|
|
var s struct {
|
|
Count int `json:"count"`
|
|
Sample map[string]any `json:"sample"`
|
|
}
|
|
Expect(json.Unmarshal(p.Summary, &s)).To(Succeed())
|
|
Expect(s.Count).To(Equal(1))
|
|
Expect(s.Sample).To(HaveKeyWithValue("targetTitle", "Bug"))
|
|
Expect(s.Sample).To(HaveKeyWithValue("bodyCut", true))
|
|
Expect(s.Sample).To(HaveKeyWithValue("public", true))
|
|
Expect(len(p.Summary)).To(BeNumerically("<", 2200), "short enough for a chat")
|
|
})
|
|
|
|
It("posts a comment once, with the token in the header only, and answers with a link", func() {
|
|
rc := do(rt, tools.ToolCommentIssue, map[string]any{"repo": "acme/widgets", "number": 1, "body": "Thanks, looking."})
|
|
Expect(rc.State).To(Equal(contract.ReceiptCommitted))
|
|
Expect(repo.Issues[0].Comments).To(HaveLen(1))
|
|
Expect(repo.Issues[0].Comments[0]).To(HavePrefix("Thanks, looking."))
|
|
Expect(repo.Issues[0].Comments[0]).To(ContainSubstring("localai-op:"))
|
|
var r reply
|
|
Expect(json.Unmarshal(rc.Result.Payload, &r)).To(Succeed())
|
|
Expect(r.Untrusted).To(BeTrue())
|
|
Expect(r.Data["url"]).To(ContainSubstring("https://github.com/acme/widgets/issues/1#issuecomment-"))
|
|
Expect(len(rc.Result.Payload)).To(BeNumerically("<=", 512), "small enough for the chat to copy")
|
|
for _, c := range srv.Calls() {
|
|
Expect(c.Token).To(Equal(token))
|
|
Expect(c.Path + c.Query + c.Body).NotTo(ContainSubstring("SECRET"))
|
|
}
|
|
Expect(string(rc.Result.Payload)).NotTo(ContainSubstring(token))
|
|
})
|
|
|
|
It("answers a repeat of the same operation from its receipt and posts nothing more", func() {
|
|
env := envelope(tools.ToolCommentIssue, "acme/widgets", "op-same", map[string]any{"repo": "acme/widgets", "number": 1, "body": "once"})
|
|
p, err := rt.Preview(ctx, env)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
first, err := rt.Apply(ctx, env, p.Revision)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
again, err := rt.Apply(ctx, env, p.Revision)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(again).To(Equal(first))
|
|
Expect(repo.Issues[0].Comments).To(HaveLen(1))
|
|
got, err := rt.Receipt(ctx, "op-same")
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(got.State).To(Equal(contract.ReceiptCommitted))
|
|
})
|
|
|
|
It("keeps its receipts across a restart", func() {
|
|
dir := GinkgoT().TempDir()
|
|
r1 := newRuntime(dir)
|
|
env := envelope(tools.ToolCommentIssue, "acme/widgets", "op-keep", map[string]any{"repo": "acme/widgets", "number": 1, "body": "once"})
|
|
p, err := r1.Preview(ctx, env)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
_, err = r1.Apply(ctx, env, p.Revision)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
r2 := newRuntime(dir)
|
|
_, err = r2.Apply(ctx, env, p.Revision)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(repo.Issues[0].Comments).To(HaveLen(1))
|
|
})
|
|
|
|
It("does not repeat a comment whose answer was lost: it finds its marker, and says unknown meanwhile", func() {
|
|
env := envelope(tools.ToolCommentIssue, "acme/widgets", "op-lost", map[string]any{"repo": "acme/widgets", "number": 1, "body": "maybe twice"})
|
|
p, err := rt.Preview(ctx, env)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
srv.DropWrites(true)
|
|
_, err = rt.Apply(ctx, env, p.Revision)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(repo.Issues[0].Comments).To(HaveLen(1), "GitHub did get it")
|
|
got, _ := rt.Receipt(ctx, "op-lost")
|
|
Expect(got.State).To(Equal(contract.ReceiptUnknown))
|
|
srv.DropWrites(false)
|
|
rc, err := rt.Apply(ctx, env, p.Revision)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(rc.State).To(Equal(contract.ReceiptCommitted))
|
|
Expect(repo.Issues[0].Comments).To(HaveLen(1), "found by its marker, not sent again")
|
|
})
|
|
|
|
It("refuses a repository other than the allowed one before any call", func() {
|
|
_, err := rt.Preview(ctx, envelope(tools.ToolCommentIssue, "acme/other", "op-x", map[string]any{"repo": "acme/widgets", "number": 1, "body": "x"}))
|
|
Expect(err).To(MatchError(protocol.ErrBadRequest))
|
|
_, err = rt.Preview(ctx, envelope(tools.ToolCommentIssue, "", "op-x", map[string]any{"repo": "acme/widgets", "number": 1, "body": "x"}))
|
|
Expect(err).To(MatchError(protocol.ErrBadRequest))
|
|
Expect(srv.Calls()).To(BeEmpty())
|
|
})
|
|
|
|
It("refuses text with a secret, an oversize text and an empty one, before any call", func() {
|
|
for _, body := range []string{
|
|
"my token is " + token,
|
|
"use ghp_abcdefghijklmnopqrstuvwxyz0123456789 here",
|
|
"github_pat_11ABCDEFG0123456789_abcdefghijklmnopqrstuvwxyz",
|
|
"-----BEGIN RSA PRIVATE KEY-----\nabc",
|
|
"Authorization: Bearer abcdef",
|
|
strings.Repeat("a", tools.MaxWriteBody+1),
|
|
" ",
|
|
"bell\x07",
|
|
} {
|
|
_, err := preview(tools.ToolCommentIssue, map[string]any{"repo": "acme/widgets", "number": 1, "body": body})
|
|
Expect(err).To(MatchError(protocol.ErrBadRequest), body[:min(len(body), 20)])
|
|
Expect(err.Error()).NotTo(ContainSubstring(token))
|
|
}
|
|
_, err := preview(tools.ToolCreateIssue, map[string]any{"repo": "acme/widgets", "title": "has " + token})
|
|
Expect(err).To(MatchError(protocol.ErrBadRequest))
|
|
_, err = preview(tools.ToolAddLabels, map[string]any{"repo": "acme/widgets", "number": 1, "labels": []string{"a,b"}})
|
|
Expect(err).To(MatchError(protocol.ErrBadRequest))
|
|
Expect(srv.Calls()).To(BeEmpty())
|
|
})
|
|
|
|
It("keeps each comment tool on its own kind of target and refuses a locked conversation", func() {
|
|
_, err := preview(tools.ToolCommentIssue, map[string]any{"repo": "acme/widgets", "number": 2, "body": "x"})
|
|
Expect(err).To(MatchError(protocol.ErrBadRequest))
|
|
_, err = preview(tools.ToolCommentPR, map[string]any{"repo": "acme/widgets", "number": 1, "body": "x"})
|
|
Expect(err).To(MatchError(protocol.ErrBadRequest))
|
|
_, err = preview(tools.ToolCommentIssue, map[string]any{"repo": "acme/widgets", "number": 3, "body": "x"})
|
|
Expect(err).To(MatchError(protocol.ErrBadRequest))
|
|
_, err = preview(tools.ToolCommentPR, map[string]any{"repo": "acme/widgets", "number": 2, "body": "x"})
|
|
Expect(err).NotTo(HaveOccurred())
|
|
})
|
|
|
|
It("opens an issue with labels and a marker", func() {
|
|
rc := do(rt, tools.ToolCreateIssue, map[string]any{"repo": "acme/widgets", "title": "New problem", "body": "details", "labels": []string{"bug", "help wanted"}})
|
|
Expect(rc.State).To(Equal(contract.ReceiptCommitted))
|
|
Expect(repo.Issues).To(HaveLen(4))
|
|
i := repo.Issues[3]
|
|
Expect(i.Title).To(Equal("New problem"))
|
|
Expect(i.Body).To(ContainSubstring("localai-op:"))
|
|
Expect(i.Labels).To(ConsistOf("bug", "help wanted"))
|
|
var r reply
|
|
Expect(json.Unmarshal(rc.Result.Payload, &r)).To(Succeed())
|
|
Expect(r.Data["number"]).To(BeEquivalentTo(4))
|
|
})
|
|
|
|
It("does not open a second issue when the first answer was lost", func() {
|
|
env := envelope(tools.ToolCreateIssue, "acme/widgets", "op-issue", map[string]any{"repo": "acme/widgets", "title": "Once", "body": "b"})
|
|
p, err := rt.Preview(ctx, env)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
srv.DropWrites(true)
|
|
_, err = rt.Apply(ctx, env, p.Revision)
|
|
Expect(err).To(HaveOccurred())
|
|
srv.DropWrites(false)
|
|
rc, err := rt.Apply(ctx, env, p.Revision)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(rc.State).To(Equal(contract.ReceiptCommitted))
|
|
Expect(repo.Issues).To(HaveLen(4))
|
|
})
|
|
|
|
It("closes an issue with a reason, and refuses to close what is closed", func() {
|
|
rc := do(rt, tools.ToolEditIssue, map[string]any{"repo": "acme/widgets", "number": 1, "state": "closed", "reason": "not_planned"})
|
|
Expect(rc.State).To(Equal(contract.ReceiptCommitted))
|
|
Expect(repo.Issues[0].State).To(Equal("closed"))
|
|
Expect(repo.Issues[0].Reason).To(Equal("not_planned"))
|
|
_, err := preview(tools.ToolEditIssue, map[string]any{"repo": "acme/widgets", "number": 1, "state": "closed"})
|
|
Expect(err).To(MatchError(protocol.ErrBadRequest))
|
|
_, err = preview(tools.ToolEditIssue, map[string]any{"repo": "acme/widgets", "number": 1})
|
|
Expect(err).To(MatchError(protocol.ErrBadRequest))
|
|
_, err = preview(tools.ToolEditIssue, map[string]any{"repo": "acme/widgets", "number": 1, "state": "open", "reason": "completed"})
|
|
Expect(err).To(MatchError(protocol.ErrBadRequest))
|
|
})
|
|
|
|
It("rejects an approval whose target changed after the preview, and sends nothing", func() {
|
|
env := envelope(tools.ToolEditIssue, "acme/widgets", "op-stale", map[string]any{"repo": "acme/widgets", "number": 1, "title": "Better title"})
|
|
p, err := rt.Preview(ctx, env)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
repo.Issues[0].Title = "Someone renamed it"
|
|
before := posts()
|
|
rc, err := rt.Apply(ctx, env, p.Revision)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(rc.State).To(Equal(contract.ReceiptRejected))
|
|
Expect(posts()).To(Equal(before))
|
|
Expect(repo.Issues[0].Title).To(Equal("Someone renamed it"))
|
|
})
|
|
|
|
It("adds labels without removing the others", func() {
|
|
rc := do(rt, tools.ToolAddLabels, map[string]any{"repo": "acme/widgets", "number": 1, "labels": []string{"bug", "urgent"}})
|
|
Expect(rc.State).To(Equal(contract.ReceiptCommitted))
|
|
Expect(repo.Issues[0].Labels).To(ConsistOf("bug", "urgent"))
|
|
})
|
|
|
|
It("comments on a line of a pull request at the head commit, and refuses an issue number", func() {
|
|
rc := do(rt, tools.ToolReviewCommentPR, map[string]any{"repo": "acme/widgets", "number": 2, "path": "src/a.go", "line": 7, "body": "nit"})
|
|
Expect(rc.State).To(Equal(contract.ReceiptCommitted))
|
|
Expect(repo.PRs[0].Inline).To(HaveLen(1))
|
|
c := repo.PRs[0].Inline[0]
|
|
Expect([]any{c.Path, c.Line, c.Side, c.CommitID}).To(Equal([]any{"src/a.go", 7, "RIGHT", "deadbeef"}))
|
|
_, err := preview(tools.ToolReviewCommentPR, map[string]any{"repo": "acme/widgets", "number": 1, "path": "a.go", "line": 1, "body": "x"})
|
|
Expect(err).To(MatchError(protocol.ErrBadRequest))
|
|
_, err = preview(tools.ToolReviewCommentPR, map[string]any{"repo": "acme/widgets", "number": 2, "path": "../a.go", "line": 1, "body": "x"})
|
|
Expect(err).To(MatchError(protocol.ErrBadRequest))
|
|
})
|
|
|
|
It("turns a rate limit on apply into a refusal that says when to retry, and can try again after", func() {
|
|
env := envelope(tools.ToolCommentIssue, "acme/widgets", "op-limit", map[string]any{"repo": "acme/widgets", "number": 1, "body": "later"})
|
|
p, err := rt.Preview(ctx, env)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
srv.LimitSecondary(30)
|
|
_, err = rt.Apply(ctx, env, p.Revision)
|
|
Expect(err).To(MatchError(protocol.ErrBadRequest))
|
|
Expect(err.Error()).To(ContainSubstring("30 seconds"))
|
|
srv.Unlimit()
|
|
got, _ := rt.Receipt(ctx, "op-limit")
|
|
Expect(got.State).To(Equal(contract.ReceiptUnknown))
|
|
rc, err := rt.Apply(ctx, env, p.Revision)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
Expect(rc.State).To(Equal(contract.ReceiptCommitted))
|
|
Expect(repo.Issues[0].Comments).To(HaveLen(1))
|
|
})
|
|
|
|
It("never puts the token in an error", func() {
|
|
srv.Fail("/repos/acme/widgets/issues/1/comments", 403)
|
|
env := envelope(tools.ToolCommentIssue, "acme/widgets", "op-403", map[string]any{"repo": "acme/widgets", "number": 1, "body": "x"})
|
|
p, err := rt.Preview(ctx, env)
|
|
Expect(err).NotTo(HaveOccurred())
|
|
_, err = rt.Apply(ctx, env, p.Revision)
|
|
Expect(err).To(MatchError(protocol.ErrBadRequest))
|
|
Expect(err.Error()).NotTo(ContainSubstring(token))
|
|
env.Request.Credential = "github_pat_bad"
|
|
_, err = rt.Preview(ctx, env)
|
|
Expect(err).To(HaveOccurred())
|
|
Expect(err.Error()).NotTo(ContainSubstring("github_pat_bad"))
|
|
})
|
|
|
|
It("is not reachable through the read route", func() {
|
|
raw, _ := json.Marshal(map[string]any{"tool": tools.ToolCommentIssue, "repo": "acme/widgets", "number": 1, "body": "x"})
|
|
_, err := rt.Read(ctx, contract.Envelope{Request: contract.Request{Arguments: raw, Credential: token, ResourceKey: "acme/widgets"}})
|
|
Expect(err).To(MatchError(protocol.ErrBadRequest))
|
|
Expect(posts()).To(BeZero())
|
|
})
|
|
})
|