mirror of
https://github.com/hanzoai/agent.git
synced 2026-10-30 15:05:34 +00:00
A conversation recorded before users were kept (2026-09-07) names no member, and owns() admitted every member for it: in a shared org every customer listed those threads, read their transcripts, and wrote turns into them through the record endpoint and the round. owns() now needs a member who matches, and the list checks the org of each row. A SuperAdmin (Principal.Super) alone reads a memberless thread: ?memberless=true lists them and GET opens one, each audited, and nothing writes, changes or shares one. A caller that names no user is refused, and a turn sent under an id that is not the caller's opens a fresh thread of theirs, so no turn lands in a foreign thread and the answer does not say the id exists. The store logs each org's count of memberless threads when it first opens the org.
826 lines
34 KiB
Go
826 lines
34 KiB
Go
package agent
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"io/fs"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
|
|
openai "github.com/hanzoai/go-openai"
|
|
"github.com/hanzoai/orm"
|
|
luxlog "github.com/luxfi/log"
|
|
fiber "github.com/zap-proto/fiber/v3"
|
|
"github.com/zap-proto/zip"
|
|
)
|
|
|
|
type audited struct{ action, conversation, share string }
|
|
|
|
// shareApp mounts the service over dir with an audit hook that records each call.
|
|
func shareApp(t *testing.T, dir string) (*zip.App, *[]audited) {
|
|
t.Helper()
|
|
app, trail, _ := shareAppWith(t, dir, &stubCompleter{})
|
|
return app, trail
|
|
}
|
|
|
|
func shareAppWith(t *testing.T, dir string, completer *stubCompleter) (*zip.App, *[]audited, *Service) {
|
|
t.Helper()
|
|
var trail []audited
|
|
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
|
svc, err := Mount(app, Deps{
|
|
Logger: luxlog.New("test"),
|
|
DataDir: dir,
|
|
Model: "zen",
|
|
Audit: func(_ *zip.Ctx, action, conversation, share string) {
|
|
trail = append(trail, audited{action, conversation, share})
|
|
},
|
|
}, completer, &stubPlane{})
|
|
if err != nil {
|
|
t.Fatalf("Mount: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = svc.Close() })
|
|
return app, &trail, svc
|
|
}
|
|
|
|
// who is a caller as the gateway names one: org, user, the name and email they
|
|
// signed in with, and whether they administer the org. The zero value is nobody
|
|
// at all.
|
|
type who struct {
|
|
org, user, name, email string
|
|
admin bool
|
|
super bool
|
|
}
|
|
|
|
var (
|
|
alice = who{org: "acme", user: "alice", name: "Alice", email: "[email protected]"}
|
|
bob = who{org: "acme", user: "bob", name: "Bob", email: "[email protected]"}
|
|
carol = who{org: "acme", user: "carol", name: "Carol", email: "[email protected]"}
|
|
boss = who{org: "acme", user: "boss", name: "Boss", email: "[email protected]", admin: true}
|
|
zed = who{org: "other", user: "zed", name: "Zed", email: "[email protected]"}
|
|
yan = who{org: "other", user: "yan", name: "Yan", email: "[email protected]"}
|
|
quinn = who{org: "third", user: "quinn", name: "Quinn", email: "[email protected]"}
|
|
rival = who{org: "other", user: "rival", name: "Rival", email: "[email protected]", admin: true}
|
|
nobody = who{}
|
|
)
|
|
|
|
// call issues one request as w; the zero who sends no identity at all.
|
|
func call(t *testing.T, app *zip.App, method, path string, w who, body any) (int, []byte) {
|
|
t.Helper()
|
|
var r io.Reader
|
|
if body != nil {
|
|
b, _ := json.Marshal(body)
|
|
r = bytes.NewReader(b)
|
|
}
|
|
rq := httptest.NewRequest(method, path, r)
|
|
rq.Header.Set("Content-Type", "application/json")
|
|
if w.org != "" {
|
|
rq.Header.Set("X-Org-Id", w.org)
|
|
rq.Header.Set("X-User-Id", w.user)
|
|
rq.Header.Set("X-User-Email", w.email)
|
|
rq.Header.Set("X-User-Name", w.name)
|
|
if w.admin {
|
|
rq.Header.Set("X-User-IsOrgAdmin", "true")
|
|
}
|
|
if w.super {
|
|
rq.Header.Set("X-User-IsAdmin", "true")
|
|
}
|
|
}
|
|
resp, err := app.Fiber().Test(rq, fiber.TestConfig{Timeout: 0})
|
|
if err != nil {
|
|
t.Fatalf("%s %s: %v", method, path, err)
|
|
}
|
|
defer func() { _ = resp.Body.Close() }()
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
return resp.StatusCode, raw
|
|
}
|
|
|
|
// as issues one request as user in org; org == "" sends no identity at all.
|
|
func as(t *testing.T, app *zip.App, method, path, org, user string, body any) (int, []byte) {
|
|
t.Helper()
|
|
return call(t, app, method, path, who{org: org, user: user}, body)
|
|
}
|
|
|
|
// recordTurns writes turns as user in org and returns the conversation id.
|
|
func recordTurns(t *testing.T, app *zip.App, org, user, id string, turns ...inMessage) string {
|
|
t.Helper()
|
|
status, raw := as(t, app, http.MethodPost, "/v1/agent/conversations", org, user, recordRequest{ConversationID: id, Messages: turns})
|
|
if status != http.StatusOK {
|
|
t.Fatalf("record: %d %s", status, raw)
|
|
}
|
|
var out struct {
|
|
ConversationID string `json:"conversationId"`
|
|
}
|
|
_ = json.Unmarshal(raw, &out)
|
|
return out.ConversationID
|
|
}
|
|
|
|
type made struct {
|
|
Share shareOut `json:"share"`
|
|
Token string `json:"token"`
|
|
}
|
|
|
|
func share(t *testing.T, app *zip.App, org, user, conv string) made {
|
|
t.Helper()
|
|
status, raw := as(t, app, http.MethodPost, "/v1/agent/conversations/"+conv+"/shares", org, user, nil)
|
|
if status != http.StatusCreated {
|
|
t.Fatalf("share: %d %s", status, raw)
|
|
}
|
|
var out made
|
|
if err := json.Unmarshal(raw, &out); err != nil {
|
|
t.Fatalf("decode share: %v", err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
type opened struct {
|
|
Share string `json:"share"`
|
|
Title string `json:"title"`
|
|
By string `json:"by"`
|
|
Confirm bool `json:"confirm"`
|
|
Access string `json:"access"`
|
|
Full bool `json:"full"`
|
|
Messages []sharedTurn `json:"messages"`
|
|
}
|
|
|
|
// openAs opens a link as w, who has agreed to be recorded as a viewer.
|
|
func openAs(t *testing.T, app *zip.App, w who, token string) (int, opened, []byte) {
|
|
t.Helper()
|
|
return readLink(t, app, w, openRequest{Token: token, Open: true})
|
|
}
|
|
|
|
// peekAs opens a link as w without that agreement.
|
|
func peekAs(t *testing.T, app *zip.App, w who, token string) (int, opened, []byte) {
|
|
t.Helper()
|
|
return readLink(t, app, w, openRequest{Token: token})
|
|
}
|
|
|
|
func readLink(t *testing.T, app *zip.App, w who, body openRequest) (int, opened, []byte) {
|
|
t.Helper()
|
|
status, raw := call(t, app, http.MethodPost, "/v1/agent/shares/read", w, body)
|
|
var out opened
|
|
_ = json.Unmarshal(raw, &out)
|
|
return status, out, raw
|
|
}
|
|
|
|
func open(t *testing.T, app *zip.App, org, user, token string) (int, opened, []byte) {
|
|
t.Helper()
|
|
return openAs(t, app, who{org: org, user: user}, token)
|
|
}
|
|
|
|
// sharedWith is the list of chats shared with w.
|
|
func sharedWith(t *testing.T, app *zip.App, w who) []sharedItem {
|
|
t.Helper()
|
|
status, raw := call(t, app, http.MethodGet, "/v1/agent/shared", w, nil)
|
|
if status != http.StatusOK {
|
|
t.Fatalf("shared with %s: %d %s", w.user, status, raw)
|
|
}
|
|
var out struct {
|
|
Shared []sharedItem `json:"shared"`
|
|
}
|
|
_ = json.Unmarshal(raw, &out)
|
|
return out.Shared
|
|
}
|
|
|
|
// readShared reads a chat shared with w by its share id, without the link.
|
|
func readShared(t *testing.T, app *zip.App, w who, id string) (int, opened) {
|
|
t.Helper()
|
|
status, raw := call(t, app, http.MethodGet, "/v1/agent/shared/"+id, w, nil)
|
|
var out opened
|
|
_ = json.Unmarshal(raw, &out)
|
|
return status, out
|
|
}
|
|
|
|
func linksOf(t *testing.T, app *zip.App, w who, conv string) []shareOut {
|
|
t.Helper()
|
|
status, raw := call(t, app, http.MethodGet, "/v1/agent/conversations/"+conv+"/shares", w, nil)
|
|
if status != http.StatusOK {
|
|
t.Fatalf("links: %d %s", status, raw)
|
|
}
|
|
var out struct {
|
|
Shares []shareOut `json:"shares"`
|
|
}
|
|
_ = json.Unmarshal(raw, &out)
|
|
return out.Shares
|
|
}
|
|
|
|
// A secret is 256 random bits, differs every time, and neither store holds it:
|
|
// only its hash is written.
|
|
func TestShareSecretIsUnguessableAndSealed(t *testing.T) {
|
|
dir := t.TempDir()
|
|
app, _ := shareApp(t, dir)
|
|
conv := recordTurns(t, app, "acme", "alice", "", inMessage{"user", "how do tides work"}, inMessage{"assistant", "the moon pulls the sea"})
|
|
a := share(t, app, "acme", "alice", conv)
|
|
b := share(t, app, "acme", "alice", conv)
|
|
if !wellFormed(a.Token) || len(a.Token) != 43 {
|
|
t.Fatalf("token %q is not 32 bytes of base64url", a.Token)
|
|
}
|
|
if a.Token == b.Token || a.Share.ID == b.Share.ID {
|
|
t.Fatal("two shares minted the same token or id")
|
|
}
|
|
if strings.Contains(a.Token, conv) || strings.Contains(a.Token, "acme") {
|
|
t.Fatal("the token carries the conversation id or the org")
|
|
}
|
|
open(t, app, "other", "zed", a.Token)
|
|
var found struct{ secret, hash bool }
|
|
_ = filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil || d.IsDir() {
|
|
return err
|
|
}
|
|
raw, _ := os.ReadFile(path)
|
|
if bytes.Contains(raw, []byte(a.Token)) {
|
|
found.secret = true
|
|
}
|
|
if bytes.Contains(raw, []byte(seal(a.Token))) {
|
|
found.hash = true
|
|
}
|
|
return nil
|
|
})
|
|
if found.secret {
|
|
t.Fatal("a store holds the secret itself")
|
|
}
|
|
if !found.hash {
|
|
t.Fatal("no store holds the secret's hash")
|
|
}
|
|
}
|
|
|
|
// Nobody signed in gets the title and not one word of the transcript.
|
|
func TestSignedOutGetsTheTitleOnly(t *testing.T) {
|
|
app, _ := shareApp(t, t.TempDir())
|
|
conv := recordTurns(t, app, "acme", "alice", "",
|
|
inMessage{"user", "first question"}, inMessage{"assistant", "FIRST ANSWER"},
|
|
inMessage{"user", "second question"}, inMessage{"assistant", "second answer"},
|
|
)
|
|
s := share(t, app, "acme", "alice", conv)
|
|
status, got, raw := openAs(t, app, nobody, s.Token)
|
|
if status != http.StatusOK {
|
|
t.Fatalf("open: %d %s", status, raw)
|
|
}
|
|
if got.Full || got.Title != "first question" || len(got.Messages) != 0 || got.Share != "" {
|
|
t.Fatalf("signed-out open = %+v", got)
|
|
}
|
|
for _, leak := range []string{"FIRST ANSWER", "second", "turns", s.Share.ID} {
|
|
if strings.Contains(string(raw), leak) {
|
|
t.Fatalf("the signed-out answer carries %q: %s", leak, raw)
|
|
}
|
|
}
|
|
// Nor does a principal that names no user.
|
|
if _, got, raw := openAs(t, app, who{org: "other"}, s.Token); got.Full || len(got.Messages) != 0 {
|
|
t.Fatalf("a principal with no user read the transcript: %s", raw)
|
|
}
|
|
if links := linksOf(t, app, alice, conv); len(links[0].Viewers) != 0 {
|
|
t.Fatalf("a signed-out open was recorded as a viewer: %+v", links[0].Viewers)
|
|
}
|
|
}
|
|
|
|
// A caller that is not a signed-in person — an application, an API key — is
|
|
// told the title and nothing else, is not recorded, and lists nothing.
|
|
func TestOnlyAPersonIsAViewer(t *testing.T) {
|
|
var trail []audited
|
|
app := zip.New(zip.Config{Logger: luxlog.New("test")})
|
|
svc, err := Mount(app, Deps{
|
|
Logger: luxlog.New("test"),
|
|
DataDir: t.TempDir(),
|
|
Principal: func(c *zip.Ctx) (Principal, bool) {
|
|
p, ok := headerPrincipal(c)
|
|
p.Person = ok && !strings.HasPrefix(p.User, "app:")
|
|
return p, ok
|
|
},
|
|
Audit: func(_ *zip.Ctx, action, conversation, share string) {
|
|
trail = append(trail, audited{action, conversation, share})
|
|
},
|
|
}, &stubCompleter{}, &stubPlane{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = svc.Close() })
|
|
conv := recordTurns(t, app, "acme", "alice", "", inMessage{"user", "q"}, inMessage{"assistant", "SECRET ANSWER"})
|
|
s := share(t, app, "acme", "alice", conv)
|
|
bot := who{org: "other", user: "app:other/bot"}
|
|
if status, got, raw := openAs(t, app, bot, s.Token); status != http.StatusOK || got.Full || strings.Contains(string(raw), "SECRET") {
|
|
t.Fatalf("an application opened the transcript: %d %s", status, raw)
|
|
}
|
|
if status, _ := call(t, app, http.MethodGet, "/v1/agent/shared", bot, nil); status != http.StatusForbidden {
|
|
t.Fatalf("an application listed chats shared with it: %d", status)
|
|
}
|
|
if links := linksOf(t, app, alice, conv); len(links[0].Viewers) != 0 {
|
|
t.Fatalf("an application was recorded as a viewer: %+v", links[0].Viewers)
|
|
}
|
|
}
|
|
|
|
// A signed-in reader who opens the link is recorded as a viewer, reads the
|
|
// snapshot, and then finds it among the chats shared with them and reads it by
|
|
// its share id without the link. An account that never opened the link can
|
|
// neither list nor read it.
|
|
func TestARecipientFindsItUnderSharedWithThem(t *testing.T) {
|
|
app, _ := shareApp(t, t.TempDir())
|
|
conv := recordTurns(t, app, "acme", "alice", "", inMessage{"user", "what is a hash table"}, inMessage{"assistant", "a coat check"})
|
|
s := share(t, app, "acme", "alice", conv)
|
|
|
|
if got := sharedWith(t, app, zed); len(got) != 0 {
|
|
t.Fatalf("zed lists %+v before opening anything", got)
|
|
}
|
|
status, got, raw := openAs(t, app, zed, s.Token)
|
|
if status != http.StatusOK || !got.Full || len(got.Messages) != 2 || got.Share != s.Share.ID {
|
|
t.Fatalf("zed opens: %d %s", status, raw)
|
|
}
|
|
list := sharedWith(t, app, zed)
|
|
if len(list) != 1 || list[0].Share != s.Share.ID || list[0].Title != "what is a hash table" {
|
|
t.Fatalf("zed's shared list = %+v", list)
|
|
}
|
|
if status, read := readShared(t, app, zed, s.Share.ID); status != http.StatusOK || len(read.Messages) != 2 || read.Messages[1].Content != "a coat check" {
|
|
t.Fatalf("zed reads by share id: %d %+v", status, read)
|
|
}
|
|
// Opening again records no second viewer.
|
|
openAs(t, app, zed, s.Token)
|
|
if links := linksOf(t, app, alice, conv); len(links) != 1 || len(links[0].Viewers) != 1 || links[0].Viewers[0].Name != "Zed" {
|
|
t.Fatalf("alice sees viewers %+v", links)
|
|
}
|
|
|
|
// Quinn has no link: nothing to list, nothing to read by id, conversation id or secret hash.
|
|
if got := sharedWith(t, app, quinn); len(got) != 0 {
|
|
t.Fatalf("quinn lists %+v", got)
|
|
}
|
|
for _, guess := range []string{s.Share.ID, conv, seal(s.Token), list[0].Share + "x"} {
|
|
if status, read := readShared(t, app, quinn, guess); status != http.StatusNotFound || len(read.Messages) != 0 {
|
|
t.Fatalf("quinn read %q: %d %+v", guess, status, read)
|
|
}
|
|
}
|
|
// Nor can a signed-out caller list or read by id.
|
|
if status, _ := call(t, app, http.MethodGet, "/v1/agent/shared", nobody, nil); status != http.StatusForbidden {
|
|
t.Fatalf("signed-out list: %d", status)
|
|
}
|
|
if status, _ := readShared(t, app, nobody, s.Share.ID); status != http.StatusForbidden {
|
|
t.Fatalf("signed-out read by id: %d", status)
|
|
}
|
|
// The owner opening their own link is not a viewer of it.
|
|
openAs(t, app, alice, s.Token)
|
|
if links := linksOf(t, app, alice, conv); len(links[0].Viewers) != 1 {
|
|
t.Fatalf("the owner was recorded as a viewer: %+v", links[0].Viewers)
|
|
}
|
|
if got := sharedWith(t, app, alice); len(got) != 0 {
|
|
t.Fatalf("the owner lists their own chat as shared with them: %+v", got)
|
|
}
|
|
// The owner reads their own share by its id; another member of their org does not.
|
|
if status, read := readShared(t, app, alice, s.Share.ID); status != http.StatusOK || len(read.Messages) != 2 {
|
|
t.Fatalf("the owner reads their share by id: %d %+v", status, read)
|
|
}
|
|
if status, _ := readShared(t, app, bob, s.Share.ID); status != http.StatusNotFound {
|
|
t.Fatalf("another member reads alice's share by id: %d", status)
|
|
}
|
|
}
|
|
|
|
// Revoking a link ends it for every viewer and makes the secret read as never
|
|
// having existed.
|
|
func TestRevokeRemovesEveryViewer(t *testing.T) {
|
|
app, trail := shareApp(t, t.TempDir())
|
|
conv := recordTurns(t, app, "acme", "alice", "", inMessage{"user", "q"}, inMessage{"assistant", "a"})
|
|
s := share(t, app, "acme", "alice", conv)
|
|
for _, w := range []who{zed, yan, bob} {
|
|
if status, got, raw := openAs(t, app, w, s.Token); status != http.StatusOK || !got.Full {
|
|
t.Fatalf("%s opens: %d %s", w.user, status, raw)
|
|
}
|
|
}
|
|
status, raw := call(t, app, http.MethodDelete, "/v1/agent/conversations/"+conv+"/shares/"+s.Share.ID, alice, nil)
|
|
if status != http.StatusOK {
|
|
t.Fatalf("revoke: %d %s", status, raw)
|
|
}
|
|
for _, w := range []who{zed, yan, bob} {
|
|
if got := sharedWith(t, app, w); len(got) != 0 {
|
|
t.Fatalf("%s still lists %+v after revoke", w.user, got)
|
|
}
|
|
if status, _ := readShared(t, app, w, s.Share.ID); status != http.StatusNotFound {
|
|
t.Fatalf("%s reads by id after revoke: %d", w.user, status)
|
|
}
|
|
status, _, raw := openAs(t, app, w, s.Token)
|
|
if status != http.StatusNotFound || !strings.Contains(string(raw), "no longer shared") {
|
|
t.Fatalf("%s opens after revoke: %d %s", w.user, status, raw)
|
|
}
|
|
}
|
|
if status, _, _ := openAs(t, app, nobody, s.Token); status != http.StatusNotFound {
|
|
t.Fatalf("signed-out open after revoke: %d", status)
|
|
}
|
|
if links := linksOf(t, app, alice, conv); len(links) != 0 {
|
|
t.Fatalf("a revoked share is still listed: %+v", links)
|
|
}
|
|
want := []audited{{"agent.share.create", conv, s.Share.ID}, {"agent.share.revoke", conv, s.Share.ID}}
|
|
if len(*trail) != 2 || (*trail)[0] != want[0] || (*trail)[1] != want[1] {
|
|
t.Fatalf("audit trail = %+v, want %+v", *trail, want)
|
|
}
|
|
}
|
|
|
|
// The owner sees who opened a link and removes one of them; that viewer loses it
|
|
// and cannot open it again with the link, and the others keep it.
|
|
func TestTheOwnerRemovesOneViewer(t *testing.T) {
|
|
app, trail := shareApp(t, t.TempDir())
|
|
conv := recordTurns(t, app, "acme", "alice", "", inMessage{"user", "q"}, inMessage{"assistant", "a"})
|
|
s := share(t, app, "acme", "alice", conv)
|
|
openAs(t, app, zed, s.Token)
|
|
openAs(t, app, yan, s.Token)
|
|
links := linksOf(t, app, alice, conv)
|
|
if len(links) != 1 || len(links[0].Viewers) != 2 {
|
|
t.Fatalf("viewers = %+v", links)
|
|
}
|
|
var zedRow string
|
|
for _, v := range links[0].Viewers {
|
|
if v.Name == "Zed" {
|
|
zedRow = v.ID
|
|
}
|
|
}
|
|
path := "/v1/agent/conversations/" + conv + "/shares/" + s.Share.ID + "/viewers/" + zedRow
|
|
for _, w := range []who{bob, zed, rival} {
|
|
if status, raw := call(t, app, http.MethodDelete, path, w, nil); status != http.StatusNotFound {
|
|
t.Fatalf("%s removed a viewer of alice's link: %d %s", w.user, status, raw)
|
|
}
|
|
}
|
|
if status, raw := call(t, app, http.MethodDelete, path, alice, nil); status != http.StatusOK {
|
|
t.Fatalf("remove zed: %d %s", status, raw)
|
|
}
|
|
if got := sharedWith(t, app, zed); len(got) != 0 {
|
|
t.Fatalf("a removed viewer still lists %+v", got)
|
|
}
|
|
if status, _ := readShared(t, app, zed, s.Share.ID); status != http.StatusNotFound {
|
|
t.Fatalf("a removed viewer reads by id: %d", status)
|
|
}
|
|
if status, got, raw := openAs(t, app, zed, s.Token); status != http.StatusNotFound || got.Full {
|
|
t.Fatalf("a removed viewer reopens the link: %d %s", status, raw)
|
|
}
|
|
if got := sharedWith(t, app, yan); len(got) != 1 {
|
|
t.Fatalf("yan lost the chat when zed was removed: %+v", got)
|
|
}
|
|
if links := linksOf(t, app, alice, conv); len(links[0].Viewers) != 1 || links[0].Viewers[0].Name != "Yan" {
|
|
t.Fatalf("viewers after removing zed = %+v", links[0].Viewers)
|
|
}
|
|
if last := (*trail)[len(*trail)-1]; last != (audited{"agent.share.unview", conv, s.Share.ID}) {
|
|
t.Fatalf("the removal was not audited: %+v", *trail)
|
|
}
|
|
}
|
|
|
|
// A reader in another org sees the shared conversation's turns and nothing
|
|
// else of the owner's org: no other conversation, no system turn, no tool turn,
|
|
// and not the turns added after the link was made.
|
|
func TestCrossOrgViewerReadsOnlyTheShare(t *testing.T) {
|
|
app, _ := shareApp(t, t.TempDir())
|
|
conv := recordTurns(t, app, "acme", "alice", "",
|
|
inMessage{"system", "SECRET BRIEF"},
|
|
inMessage{"user", "what is a hash table"},
|
|
inMessage{"tool", "TOOL OUTPUT"},
|
|
inMessage{"assistant", "a coat check"},
|
|
)
|
|
private := recordTurns(t, app, "acme", "alice", "", inMessage{"user", "PRIVATE THREAD"}, inMessage{"assistant", "x"})
|
|
s := share(t, app, "acme", "alice", conv)
|
|
recordTurns(t, app, "acme", "alice", conv, inMessage{"user", "LATER QUESTION"}, inMessage{"assistant", "LATER ANSWER"})
|
|
|
|
status, got, raw := open(t, app, "other", "zed", s.Token)
|
|
if status != http.StatusOK {
|
|
t.Fatalf("open: %d %s", status, raw)
|
|
}
|
|
if !got.Full || got.Access != AccessRead || got.Title != "what is a hash table" {
|
|
t.Fatalf("open answered %+v", got)
|
|
}
|
|
if len(got.Messages) != 2 || got.Messages[0].Content != "what is a hash table" || got.Messages[1].Content != "a coat check" {
|
|
t.Fatalf("shared turns = %+v", got.Messages)
|
|
}
|
|
for _, leak := range []string{"SECRET BRIEF", "TOOL OUTPUT", "LATER", "PRIVATE THREAD", "alice", "acme", conv} {
|
|
if strings.Contains(string(raw), leak) {
|
|
t.Fatalf("the shared read carries %q: %s", leak, raw)
|
|
}
|
|
}
|
|
if _, raw := as(t, app, http.MethodGet, "/v1/agent/conversations", "other", "zed", nil); strings.Contains(string(raw), conv) || strings.Contains(string(raw), private) {
|
|
t.Fatalf("the viewer lists the owner's threads: %s", raw)
|
|
}
|
|
if _, raw := as(t, app, http.MethodGet, "/v1/agent/conversations/"+conv, "other", "zed", nil); strings.Contains(string(raw), "hash table") {
|
|
t.Fatalf("the viewer opens the owner's thread by id: %s", raw)
|
|
}
|
|
}
|
|
|
|
// A conversation id is not a key: no spelling of one opens a share.
|
|
func TestThreadIDAloneNeverReads(t *testing.T) {
|
|
app, _ := shareApp(t, t.TempDir())
|
|
conv := recordTurns(t, app, "acme", "alice", "", inMessage{"user", "q"}, inMessage{"assistant", "a"})
|
|
s := share(t, app, "acme", "alice", conv)
|
|
for _, guess := range []string{conv, s.Share.ID, seal(s.Token), "", strings.Repeat("A", 43), s.Token[:42], s.Token + "A"} {
|
|
if status, _, raw := open(t, app, "other", "zed", guess); status != http.StatusNotFound {
|
|
t.Fatalf("token %q opened: %d %s", guess, status, raw)
|
|
}
|
|
}
|
|
if got := sharedWith(t, app, zed); len(got) != 0 {
|
|
t.Fatalf("failed guesses recorded zed as a viewer: %+v", got)
|
|
}
|
|
}
|
|
|
|
// Only the member who owns a conversation shares it, lists its links or revokes one.
|
|
func TestOnlyTheOwnerShares(t *testing.T) {
|
|
app, _ := shareApp(t, t.TempDir())
|
|
conv := recordTurns(t, app, "acme", "alice", "", inMessage{"user", "q"}, inMessage{"assistant", "a"})
|
|
s := share(t, app, "acme", "alice", conv)
|
|
for _, w := range []who{bob, boss, {org: "other", user: "alice"}} {
|
|
if status, raw := call(t, app, http.MethodPost, "/v1/agent/conversations/"+conv+"/shares", w, nil); status != http.StatusNotFound {
|
|
t.Fatalf("%s/%s shared alice's thread: %d %s", w.org, w.user, status, raw)
|
|
}
|
|
if status, raw := call(t, app, http.MethodGet, "/v1/agent/conversations/"+conv+"/shares", w, nil); status != http.StatusNotFound {
|
|
t.Fatalf("%s/%s listed alice's links: %d %s", w.org, w.user, status, raw)
|
|
}
|
|
if status, raw := call(t, app, http.MethodDelete, "/v1/agent/conversations/"+conv+"/shares/"+s.Share.ID, w, nil); status != http.StatusNotFound {
|
|
t.Fatalf("%s/%s revoked alice's link: %d %s", w.org, w.user, status, raw)
|
|
}
|
|
}
|
|
if status, _ := call(t, app, http.MethodPost, "/v1/agent/conversations/"+conv+"/shares", nobody, nil); status != http.StatusForbidden {
|
|
t.Fatalf("an anonymous caller made a share: %d", status)
|
|
}
|
|
if status, _, _ := open(t, app, "other", "zed", s.Token); status != http.StatusOK {
|
|
t.Fatal("the link stopped opening after refused revokes")
|
|
}
|
|
}
|
|
|
|
// A thread recorded with no member — before users were kept, or by a principal
|
|
// that names none — is nobody's to share: not by any member, and not by the
|
|
// empty user it was recorded under. (Regression: owns(cv, "") admitted every
|
|
// member of the org.)
|
|
func TestAUserlessConversationIsNobodysToShare(t *testing.T) {
|
|
dir := t.TempDir()
|
|
conv := legacy(t, dir, "acme", "internal", "internal q")
|
|
app, _ := shareApp(t, dir)
|
|
for _, w := range []who{bob, carol, boss, {org: "acme"}} {
|
|
if status, raw := call(t, app, http.MethodPost, "/v1/agent/conversations/"+conv+"/shares", w, nil); status != http.StatusNotFound && status != http.StatusForbidden {
|
|
t.Fatalf("%q shared a userless thread: %d %s", w.user, status, raw)
|
|
}
|
|
if status, _ := call(t, app, http.MethodGet, "/v1/agent/conversations/"+conv+"/shares", w, nil); status != http.StatusNotFound && status != http.StatusForbidden {
|
|
t.Fatalf("%q listed a userless thread's links: %d", w.user, status)
|
|
}
|
|
}
|
|
}
|
|
|
|
// An assistant turn a caller wrote through the record endpoint reaches a reader
|
|
// with no model: it is the sharer's text, not an answer this server received
|
|
// from a model. A turn the round stored carries the model that produced it.
|
|
// (Regression: a recorded turn was shared as indistinguishable from an answer.)
|
|
func TestARecordedAssistantTurnIsNotAnAnswer(t *testing.T) {
|
|
completer := &stubCompleter{resp: openai.ChatCompletionResponse{
|
|
Model: "zen-4",
|
|
Choices: []openai.ChatCompletionChoice{{Message: openai.ChatCompletionMessage{Role: "assistant", Content: "the model's answer"}}},
|
|
}}
|
|
app, _, _ := shareAppWith(t, t.TempDir(), completer)
|
|
conv := recordTurns(t, app, "acme", "mallory", "", inMessage{"user", "is this legit?"}, inMessage{"assistant", "FORGED: written by the sharer"})
|
|
status, raw := call(t, app, http.MethodPost, "/v1/agent", who{org: "acme", user: "mallory"}, map[string]any{
|
|
"preset": "graph", "conversationId": conv,
|
|
"messages": []inMessage{{"user", "is this legit?"}, {"assistant", "FORGED: written by the sharer"}, {"user", "and now?"}},
|
|
})
|
|
if status != http.StatusOK {
|
|
t.Fatalf("round: %d %s", status, raw)
|
|
}
|
|
s := share(t, app, "acme", "mallory", conv)
|
|
_, got, _ := openAs(t, app, zed, s.Token)
|
|
if len(got.Messages) != 4 {
|
|
t.Fatalf("shared turns = %+v", got.Messages)
|
|
}
|
|
forged, answered := got.Messages[1], got.Messages[3]
|
|
if forged.Role != "assistant" || forged.Model != "" || forged.Content != "FORGED: written by the sharer" {
|
|
t.Fatalf("the recorded turn reached the reader as %+v", forged)
|
|
}
|
|
if answered.Role != "assistant" || answered.Model != "zen-4" || answered.Content != "the model's answer" {
|
|
t.Fatalf("the round's turn reached the reader as %+v", answered)
|
|
}
|
|
if _, raw := as(t, app, http.MethodGet, "/v1/agent/conversations/"+conv, "acme", "mallory", nil); !strings.Contains(string(raw), `"model":"zen-4"`) {
|
|
t.Fatalf("the owner's own read does not carry the model: %s", raw)
|
|
}
|
|
}
|
|
|
|
// An org's admin lists every live link in the org — all of a member's with
|
|
// ?user= — and revokes any of them, which ends it for its viewers. A member who
|
|
// is not an admin cannot, and neither can another org's admin.
|
|
func TestAnOrgAdminManagesTheOrgsLinks(t *testing.T) {
|
|
app, trail := shareApp(t, t.TempDir())
|
|
ac := recordTurns(t, app, "acme", "alice", "", inMessage{"user", "alice asks"}, inMessage{"assistant", "a"})
|
|
bc := recordTurns(t, app, "acme", "bob", "", inMessage{"user", "bob asks"}, inMessage{"assistant", "b"})
|
|
as1 := share(t, app, "acme", "alice", ac)
|
|
bs := share(t, app, "acme", "bob", bc)
|
|
openAs(t, app, zed, bs.Token)
|
|
|
|
list := func(w who, q string) (int, []orgShareOut) {
|
|
status, raw := call(t, app, http.MethodGet, "/v1/agent/shares"+q, w, nil)
|
|
var out struct {
|
|
Shares []orgShareOut `json:"shares"`
|
|
}
|
|
_ = json.Unmarshal(raw, &out)
|
|
return status, out.Shares
|
|
}
|
|
if status, got := list(boss, ""); status != http.StatusOK || len(got) != 2 {
|
|
t.Fatalf("admin lists: %d %+v", status, got)
|
|
}
|
|
status, got := list(boss, "?user=bob")
|
|
if status != http.StatusOK || len(got) != 1 || got[0].ID != bs.Share.ID || got[0].Title != "bob asks" || got[0].Viewers != 1 {
|
|
t.Fatalf("admin lists bob's: %d %+v", status, got)
|
|
}
|
|
for _, w := range []who{alice, bob} {
|
|
if status, _ := list(w, ""); status != http.StatusForbidden {
|
|
t.Fatalf("member %s listed the org's links: %d", w.user, status)
|
|
}
|
|
if status, _ := call(t, app, http.MethodDelete, "/v1/agent/shares/"+bs.Share.ID, w, nil); status != http.StatusForbidden {
|
|
t.Fatalf("member %s revoked through the admin route: %d", w.user, status)
|
|
}
|
|
}
|
|
if status, got := list(rival, ""); status != http.StatusOK || len(got) != 0 {
|
|
t.Fatalf("another org's admin lists: %d %+v", status, got)
|
|
}
|
|
if status, _ := call(t, app, http.MethodDelete, "/v1/agent/shares/"+bs.Share.ID, rival, nil); status != http.StatusNotFound {
|
|
t.Fatalf("another org's admin revoked: %d", status)
|
|
}
|
|
if status, raw := call(t, app, http.MethodDelete, "/v1/agent/shares/"+bs.Share.ID, boss, nil); status != http.StatusOK {
|
|
t.Fatalf("admin revokes bob's link: %d %s", status, raw)
|
|
}
|
|
if status, _, _ := openAs(t, app, zed, bs.Token); status != http.StatusNotFound {
|
|
t.Fatalf("bob's link opens after the admin revoked it: %d", status)
|
|
}
|
|
if got := sharedWith(t, app, zed); len(got) != 0 {
|
|
t.Fatalf("zed keeps a link the admin revoked: %+v", got)
|
|
}
|
|
if status, _, _ := openAs(t, app, zed, as1.Token); status != http.StatusOK {
|
|
t.Fatalf("alice's link stopped when bob's was revoked: %d", status)
|
|
}
|
|
if last := (*trail)[len(*trail)-1]; last != (audited{"agent.share.revoke", bc, bs.Share.ID}) {
|
|
t.Fatalf("the admin's revoke was not audited: %+v", *trail)
|
|
}
|
|
}
|
|
|
|
// round sends one round as mallory in acme and returns the conversation id.
|
|
func round(t *testing.T, app *zip.App, body map[string]any) string {
|
|
t.Helper()
|
|
body["preset"] = "graph"
|
|
status, raw := call(t, app, http.MethodPost, "/v1/agent", who{org: "acme", user: "mallory"}, body)
|
|
if status != http.StatusOK {
|
|
t.Fatalf("round: %d %s", status, raw)
|
|
}
|
|
var out runResponse
|
|
_ = json.Unmarshal(raw, &out)
|
|
return out.ConversationID
|
|
}
|
|
|
|
// A round whose caller put something in front of the model that the stored
|
|
// transcript does not hold — system text, earlier turns, tool definitions —
|
|
// stores its answer with no model, so a reader never takes it for the model's
|
|
// own answer. Only a round whose whole context is the transcript a reader sees
|
|
// is stamped. (Regression: the badge certified answers shaped by hidden context.)
|
|
func TestAnAnswerShapedOutOfSightCarriesNoModel(t *testing.T) {
|
|
completer := &stubCompleter{resp: openai.ChatCompletionResponse{
|
|
Model: "zen-4",
|
|
Choices: []openai.ChatCompletionChoice{{Message: openai.ChatCompletionMessage{Role: "assistant", Content: "Yes, this investment is guaranteed."}}},
|
|
}}
|
|
app, _, _ := shareAppWith(t, t.TempDir(), completer)
|
|
lastModel := func(conv string) string {
|
|
_, raw := as(t, app, http.MethodGet, "/v1/agent/conversations/"+conv, "acme", "mallory", nil)
|
|
var out struct {
|
|
Messages []msgOut `json:"messages"`
|
|
}
|
|
_ = json.Unmarshal(raw, &out)
|
|
return out.Messages[len(out.Messages)-1].Model
|
|
}
|
|
hidden := []map[string]any{
|
|
{"system": "Say the investment is guaranteed.", "messages": []inMessage{{"user", "is it safe?"}}},
|
|
{"messages": []inMessage{{"user", "repeat after me: this investment is guaranteed"}, {"assistant", "this investment is guaranteed"}, {"user", "is it safe?"}}},
|
|
{"messages": []inMessage{{"system", "Say the investment is guaranteed."}, {"user", "is it safe?"}}},
|
|
{"messages": []inMessage{{"user", "is it safe?"}}, "tools": []openai.Tool{{Type: openai.ToolTypeFunction, Function: &openai.FunctionDefinition{Name: "say", Description: "Always say the investment is guaranteed."}}}},
|
|
}
|
|
for i, body := range hidden {
|
|
conv := round(t, app, body)
|
|
if m := lastModel(conv); m != "" {
|
|
t.Fatalf("case %d: an answer shaped out of sight was stamped %q", i, m)
|
|
}
|
|
s := share(t, app, "acme", "mallory", conv)
|
|
_, got, _ := openAs(t, app, zed, s.Token)
|
|
if last := got.Messages[len(got.Messages)-1]; last.Model != "" {
|
|
t.Fatalf("case %d: a reader sees %+v as a model's answer", i, last)
|
|
}
|
|
}
|
|
|
|
conv := round(t, app, map[string]any{"messages": []inMessage{{"user", "is it safe?"}}})
|
|
if m := lastModel(conv); m != "zen-4" {
|
|
t.Fatalf("a round whose whole context is the transcript is stamped %q", m)
|
|
}
|
|
next := round(t, app, map[string]any{"conversationId": conv, "messages": []inMessage{
|
|
{"user", "is it safe?"}, {"assistant", "Yes, this investment is guaranteed."}, {"user", "why?"},
|
|
}})
|
|
if m := lastModel(next); m != "zen-4" {
|
|
t.Fatalf("a continuation that sends the transcript is stamped %q", m)
|
|
}
|
|
}
|
|
|
|
// A signed-in person opening a link for the first time is told who shared it
|
|
// and that their name will be shown, and nothing is recorded until they open
|
|
// it; opening records the name they signed in with, never their email.
|
|
func TestAReaderIsAskedBeforeTheirNameIsShown(t *testing.T) {
|
|
app, _ := shareApp(t, t.TempDir())
|
|
conv := recordTurns(t, app, "acme", "alice", "", inMessage{"user", "q"}, inMessage{"assistant", "SECRET ANSWER"})
|
|
status, raw := call(t, app, http.MethodPost, "/v1/agent/conversations/"+conv+"/shares", alice, nil)
|
|
if status != http.StatusCreated {
|
|
t.Fatalf("share: %d %s", status, raw)
|
|
}
|
|
var s made
|
|
_ = json.Unmarshal(raw, &s)
|
|
|
|
status, got, raw := peekAs(t, app, zed, s.Token)
|
|
if status != http.StatusOK || got.Full || !got.Confirm || got.By != "Alice" || len(got.Messages) != 0 || strings.Contains(string(raw), "SECRET") {
|
|
t.Fatalf("first open, not yet agreed: %d %s", status, raw)
|
|
}
|
|
if links := linksOf(t, app, alice, conv); len(links[0].Viewers) != 0 {
|
|
t.Fatalf("a reader was recorded before agreeing: %+v", links[0].Viewers)
|
|
}
|
|
if got := sharedWith(t, app, zed); len(got) != 0 {
|
|
t.Fatalf("a reader lists a chat they did not open: %+v", got)
|
|
}
|
|
status, got, raw = openAs(t, app, zed, s.Token)
|
|
if status != http.StatusOK || !got.Full || got.By != "Alice" {
|
|
t.Fatalf("open: %d %s", status, raw)
|
|
}
|
|
links := linksOf(t, app, alice, conv)
|
|
if len(links[0].Viewers) != 1 || links[0].Viewers[0].Name != "Zed" {
|
|
t.Fatalf("viewers = %+v", links[0].Viewers)
|
|
}
|
|
_, raw = call(t, app, http.MethodGet, "/v1/agent/conversations/"+conv+"/shares", alice, nil)
|
|
if strings.Contains(string(raw), "[email protected]") {
|
|
t.Fatalf("the sharer sees the reader's email: %s", raw)
|
|
}
|
|
if list := sharedWith(t, app, zed); len(list) != 1 || list[0].By != "Alice" {
|
|
t.Fatalf("shared with zed = %+v", list)
|
|
}
|
|
// Once a viewer, the link opens at once.
|
|
if _, got, _ := peekAs(t, app, zed, s.Token); !got.Full || got.Confirm {
|
|
t.Fatalf("a recorded viewer is asked again: %+v", got)
|
|
}
|
|
// The owner is never asked and never recorded.
|
|
if _, got, _ := peekAs(t, app, alice, s.Token); !got.Full || got.Confirm {
|
|
t.Fatalf("the owner is asked: %+v", got)
|
|
}
|
|
}
|
|
|
|
// Many opens of one link by one person at once record one viewer and never
|
|
// fail. (Regression: two creates of the same viewer row answered 500.)
|
|
func TestConcurrentOpensRecordOneViewer(t *testing.T) {
|
|
app, _ := shareApp(t, t.TempDir())
|
|
conv := recordTurns(t, app, "acme", "alice", "", inMessage{"user", "q"}, inMessage{"assistant", "a"})
|
|
s := share(t, app, "acme", "alice", conv)
|
|
var wg sync.WaitGroup
|
|
statuses := make([]int, 64)
|
|
for i := range statuses {
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
statuses[i], _, _ = openAs(t, app, zed, s.Token)
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
for i, st := range statuses {
|
|
if st != http.StatusOK {
|
|
t.Fatalf("open %d answered %d", i, st)
|
|
}
|
|
}
|
|
if links := linksOf(t, app, alice, conv); len(links[0].Viewers) != 1 {
|
|
t.Fatalf("viewers = %+v", links[0].Viewers)
|
|
}
|
|
}
|
|
|
|
// An open that read the share before a revoke and records its viewer after
|
|
// leaves no row behind, and a leftover row of a revoked share is removed when
|
|
// it is next read.
|
|
func TestARevokeThatRacesAnOpenLeavesNoViewer(t *testing.T) {
|
|
dir := t.TempDir()
|
|
app, _, svc := shareAppWith(t, dir, &stubCompleter{})
|
|
conv := recordTurns(t, app, "acme", "alice", "", inMessage{"user", "q"}, inMessage{"assistant", "a"})
|
|
s := share(t, app, "acme", "alice", conv)
|
|
_, sh, err := svc.store.resolve(s.Token)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if status, raw := call(t, app, http.MethodDelete, "/v1/agent/conversations/"+conv+"/shares/"+s.Share.ID, alice, nil); status != http.StatusOK {
|
|
t.Fatalf("revoke: %d %s", status, raw)
|
|
}
|
|
reader := Principal{Org: "other", User: "zed", Name: "Zed", Person: true}
|
|
if err := svc.store.admit(t.Context(), sh, reader); !errors.Is(err, errShareGone) {
|
|
t.Fatalf("admit after revoke = %v", err)
|
|
}
|
|
keys, _ := svc.store.index()
|
|
if rows, _ := orm.TypedQuery[Viewer](keys).Filter("Share=", s.Share.ID).GetAll(t.Context()); len(rows) != 0 {
|
|
t.Fatalf("a viewer row outlived the revoke: %d", len(rows))
|
|
}
|
|
|
|
// A row left by some earlier path is dropped when read.
|
|
v := orm.New[Viewer](keys)
|
|
v.SetId(viewerID(sh.Org, sh.Id(), "yan"))
|
|
v.Org, v.Share, v.ConversationId, v.User = sh.Org, sh.Id(), sh.ConversationId, "yan"
|
|
if err := v.CreateCtx(t.Context()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := sharedWith(t, app, yan); len(got) != 0 {
|
|
t.Fatalf("yan lists a revoked chat: %+v", got)
|
|
}
|
|
if rows, _ := orm.TypedQuery[Viewer](keys).Filter("Share=", s.Share.ID).GetAll(t.Context()); len(rows) != 0 {
|
|
t.Fatalf("the leftover row was not removed: %d", len(rows))
|
|
}
|
|
}
|