Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 17 additions & 11 deletions go/internal/comms/agent_caller.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"fmt"

"connectrpc.com/connect"
"google.golang.org/protobuf/proto"

compassv1 "github.com/RigelBuild/compass/go/gen/compass/v1"
"github.com/RigelBuild/compass/go/internal/store"
Expand Down Expand Up @@ -274,27 +275,32 @@ func (c *Comms) CreateChannelAsAccount(
return resp.Msg, nil
}

// UpdateChannelMembersAsAccount executes one agent-initiated UpdateChannelMembers
// as account, mirroring UpdatePinnedBoardAsAccount: WithActor + the shared
// UpdateChannelMembers handler path, so the membership authz, the store ops, and
// the ChannelChanged fan-out are identical to a human caller's. A non-member or
// invisible channel collapses to the same code a human gets. The request always
// names its channel explicitly (channel_id), so there is no home-channel
// defaulting here.
func (c *Comms) UpdateChannelMembersAsAccount(
// UpdateChannelMembersAsAccountByName executes one agent-initiated UpdateChannelMembers
// as account. Agent tools address channels by NAME, so channel_id is resolved
// within account's visible set first (unknown or invisible → CodeNotFound,
// ambiguous → CodeInvalidArgument), with no home default. The resolved request
// then runs the shared handler under WithActor, so authz and fan-out match a
// human caller's.
func (c *Comms) UpdateChannelMembersAsAccountByName(
ctx context.Context,
account store.AccountID,
req *compassv1.UpdateChannelMembersRequest,
) (*compassv1.UpdateChannelMembersResponse, error) {
if account == "" {
return nil, errNoActor
}
resp, err := c.UpdateChannelMembers(WithActor(ctx, account), connect.NewRequest(req))
ch, err := c.store.ChannelByNameForViewer(ctx, account, req.GetChannelId())
if err != nil {
return nil, edgeError(err)
}
resolved := proto.CloneOf(req)
resolved.ChannelId = string(ch.ID)
resp, err := c.UpdateChannelMembers(WithActor(ctx, account), connect.NewRequest(resolved))
if err != nil {
return nil, err
}
if resp == nil {
return nil, connect.NewError(connect.CodeInternal, errors.New("comms UpdateChannelMembersAsAccount: UpdateChannelMembers returned nil response"))
return nil, connect.NewError(connect.CodeInternal, errors.New("comms UpdateChannelMembersAsAccountByName: UpdateChannelMembers returned nil response"))
}
return resp.Msg, nil
}
Expand Down Expand Up @@ -325,7 +331,7 @@ func (c *Comms) CreateChannelGroupAsAccount(
}

// OpenDMAsAccount executes one agent-initiated OpenDM as account, mirroring
// UpdateChannelMembersAsAccount: WithActor + the shared OpenDM handler path, so
// UpdatePinnedBoardAsAccount: WithActor + the shared OpenDM handler path, so
// the peer resolve, the same-owner authz, the reserved-DM-group upsert, and the
// post-commit ChannelChanged fan-out are identical to a human caller's. An
// unknown, cross-owner, or self peer collapses to the same code a human gets. The
Expand Down
103 changes: 79 additions & 24 deletions go/internal/comms/org_mgmt_pgtest_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,10 +99,10 @@ func TestCreateChannelAsAccountUnknownMemberHandleIsNotFound(t *testing.T) {
connectNotFoundFor(t, err, "ghost", "CreateChannelAsAccount with an unresolvable member handle")
}

// TestUpdateChannelMembersAsAccountAddsMember: an agent adds a member to a channel
// TestUpdateChannelMembersAsAccountByNameAddsMember: an agent adds a member to a channel
// it authored (and so can mutate) → the updated Channel carries the new member,
// and a ChannelChanged is fanned out (parity with the human caller's path).
func TestUpdateChannelMembersAsAccountAddsMember(t *testing.T) {
func TestUpdateChannelMembersAsAccountByNameAddsMember(t *testing.T) {
h := newStreamHarness(t)
ctx := context.Background()
owner := mustUser(t, h.store, "owner")
Expand All @@ -116,12 +116,12 @@ func TestUpdateChannelMembersAsAccountAddsMember(t *testing.T) {

events := firstEventAfterBoundary(t, h, owner.ID, &compassv1.SubscribeCommsRequest{SinceSeq: 0})

resp, err := h.svc.UpdateChannelMembersAsAccount(ctx, agent.ID, &compassv1.UpdateChannelMembersRequest{
ChannelId: string(ch.ID),
resp, err := h.svc.UpdateChannelMembersAsAccountByName(ctx, agent.ID, &compassv1.UpdateChannelMembersRequest{
ChannelId: ch.Name,
AddMemberHandles: []string{newcomer.Handle},
})
if err != nil {
t.Fatalf("UpdateChannelMembersAsAccount: %v", err)
t.Fatalf("UpdateChannelMembersAsAccountByName: %v", err)
}
if !slices.Contains(resp.GetChannel().GetMemberAccountIds(), string(newcomer.ID)) {
t.Fatalf("member set = %v, want it to contain the added %q", resp.GetChannel().GetMemberAccountIds(), newcomer.ID)
Expand All @@ -137,13 +137,13 @@ func TestUpdateChannelMembersAsAccountAddsMember(t *testing.T) {
}
}

// TestUpdateChannelMembersAsAccountUnknownMemberHandleIsNotFound: an agent adds a
// TestUpdateChannelMembersAsAccountByNameUnknownMemberHandleIsNotFound: an agent adds a
// member naming a handle that resolves to no account → the T3 batch resolver
// (AccountsByHandles, OQ-2) fails the whole call with the oracle-safe CodeNotFound
// naming the submitted handle, identical to the code a human caller gets. The
// org-management adapter inherits that resolution; it never partially applies a
// member set with an unresolved handle in it.
func TestUpdateChannelMembersAsAccountUnknownMemberHandleIsNotFound(t *testing.T) {
func TestUpdateChannelMembersAsAccountByNameUnknownMemberHandleIsNotFound(t *testing.T) {
svc, st := newHandler(t)
ctx := context.Background()
owner := mustUser(t, st, "owner")
Expand All @@ -154,22 +154,22 @@ func TestUpdateChannelMembersAsAccountUnknownMemberHandleIsNotFound(t *testing.T
t.Fatalf("CreateChannel: %v", err)
}

_, err = svc.UpdateChannelMembersAsAccount(ctx, agent.ID, &compassv1.UpdateChannelMembersRequest{
ChannelId: string(ch.ID),
_, err = svc.UpdateChannelMembersAsAccountByName(ctx, agent.ID, &compassv1.UpdateChannelMembersRequest{
ChannelId: ch.Name,
AddMemberHandles: []string{"ghost"},
})
connectNotFoundFor(t, err, "ghost", "UpdateChannelMembersAsAccount with an unresolvable member handle")
connectNotFoundFor(t, err, "ghost", "UpdateChannelMembersAsAccountByName with an unresolvable member handle")
}

// TestUpdateChannelMembersAsAccountInvisibleMemberHandleIsNotFound: an agent adds
// TestUpdateChannelMembersAsAccountByNameInvisibleMemberHandleIsNotFound: an agent adds
// a member naming a handle that IS a real account but one the caller cannot see —
// an agent living only under a DIFFERENT owner's per-owner namespace (DL-271). The
// bare handle misses the global user index and misses the caller-owner agent index,
// so it resolves to nothing and collapses to the SAME oracle-safe CodeNotFound an
// entirely-unknown handle gets: the caller cannot distinguish "no such handle" from
// "a handle I'm not allowed to see", so it cannot probe another owner's roster by
// naming its agents as members.
func TestUpdateChannelMembersAsAccountInvisibleMemberHandleIsNotFound(t *testing.T) {
func TestUpdateChannelMembersAsAccountByNameInvisibleMemberHandleIsNotFound(t *testing.T) {
svc, st := newHandler(t)
ctx := context.Background()
owner := mustUser(t, st, "owner")
Expand All @@ -184,24 +184,24 @@ func TestUpdateChannelMembersAsAccountInvisibleMemberHandleIsNotFound(t *testing
t.Fatalf("CreateChannel: %v", err)
}

_, err = svc.UpdateChannelMembersAsAccount(ctx, agent.ID, &compassv1.UpdateChannelMembersRequest{
ChannelId: string(ch.ID),
_, err = svc.UpdateChannelMembersAsAccountByName(ctx, agent.ID, &compassv1.UpdateChannelMembersRequest{
ChannelId: ch.Name,
AddMemberHandles: []string{otherAgent.Handle},
})
connectNotFoundFor(t, err, otherAgent.Handle, "UpdateChannelMembersAsAccount with a foreign-owner (invisible) member handle")
connectNotFoundFor(t, err, otherAgent.Handle, "UpdateChannelMembersAsAccountByName with a foreign-owner (invisible) member handle")
}

// TestUpdateChannelMembersAsAccountEmptyAccountIsNoActor: an empty account →
// TestUpdateChannelMembersAsAccountByNameEmptyAccountIsNoActor: an empty account →
// errNoActor (CodeInvalidArgument).
func TestUpdateChannelMembersAsAccountEmptyAccountIsNoActor(t *testing.T) {
func TestUpdateChannelMembersAsAccountByNameEmptyAccountIsNoActor(t *testing.T) {
svc, _ := newHandler(t)
_, err := svc.UpdateChannelMembersAsAccount(context.Background(), "", &compassv1.UpdateChannelMembersRequest{ChannelId: "ch-1"})
connectCodeIs(t, err, connect.CodeInvalidArgument, "UpdateChannelMembersAsAccount empty account")
_, err := svc.UpdateChannelMembersAsAccountByName(context.Background(), "", &compassv1.UpdateChannelMembersRequest{ChannelId: "ch-1"})
connectCodeIs(t, err, connect.CodeInvalidArgument, "UpdateChannelMembersAsAccountByName empty account")
}

// TestUpdateChannelMembersAsAccountNonMemberIsNotFound: an agent mutating a
// TestUpdateChannelMembersAsAccountByNameNonMemberIsNotFound: an agent mutating a
// channel it cannot see collapses to the SAME CodeNotFound a human non-member gets.
func TestUpdateChannelMembersAsAccountNonMemberIsNotFound(t *testing.T) {
func TestUpdateChannelMembersAsAccountByNameNonMemberIsNotFound(t *testing.T) {
svc, st := newHandler(t)
ctx := context.Background()
owner := mustUser(t, st, "owner")
Expand All @@ -213,11 +213,66 @@ func TestUpdateChannelMembersAsAccountNonMemberIsNotFound(t *testing.T) {
t.Fatalf("CreateChannel: %v", err)
}

_, err = svc.UpdateChannelMembersAsAccount(ctx, strangerAgent.ID, &compassv1.UpdateChannelMembersRequest{
ChannelId: string(ch.ID),
_, err = svc.UpdateChannelMembersAsAccountByName(ctx, strangerAgent.ID, &compassv1.UpdateChannelMembersRequest{
ChannelId: ch.Name,
AddMemberHandles: []string{stranger.Handle},
})
connectCodeIs(t, err, connect.CodeNotFound, "UpdateChannelMembersAsAccount on invisible channel")
connectCodeIs(t, err, connect.CodeNotFound, "UpdateChannelMembersAsAccountByName on invisible channel")
}

// Agent tools address channels by name only; a bare id must miss, not silently
// work.
func TestUpdateChannelMembersAsAccountByNameChannelIDIsNotFound(t *testing.T) {
svc, st := newHandler(t)
ctx := context.Background()
owner := mustUser(t, st, "owner")
agent := mustAgent(t, st, owner.ID, "manager")
newcomer := mustUser(t, st, "newcomer")

ch, err := st.CreateChannel(ctx, agent.ID, store.NewChannel{Name: "room", Kind: store.ChannelKindChannel})
if err != nil {
t.Fatalf("CreateChannel: %v", err)
}

_, err = svc.UpdateChannelMembersAsAccountByName(ctx, agent.ID, &compassv1.UpdateChannelMembersRequest{
ChannelId: string(ch.ID),
AddMemberHandles: []string{newcomer.Handle},
})
connectCodeIs(t, err, connect.CodeNotFound, "UpdateChannelMembersAsAccountByName by channel id")
}

// Two visible channels sharing a name must be refused, never silently picked.
func TestUpdateChannelMembersAsAccountByNameAmbiguousChannelIsInvalidArgument(t *testing.T) {
svc, st := newHandler(t)
ctx := context.Background()
owner := mustUser(t, st, "owner")
agent := mustAgent(t, st, owner.ID, "manager")
newcomer := mustUser(t, st, "newcomer")
for range 2 {
if _, err := st.CreateChannel(ctx, agent.ID, store.NewChannel{Name: "dupe", Kind: store.ChannelKindChannel}); err != nil {
t.Fatalf("CreateChannel(dupe): %v", err)
}
}

_, err := svc.UpdateChannelMembersAsAccountByName(ctx, agent.ID, &compassv1.UpdateChannelMembersRequest{
ChannelId: "dupe",
AddMemberHandles: []string{newcomer.Handle},
})
connectCodeIs(t, err, connect.CodeInvalidArgument, "UpdateChannelMembersAsAccountByName on ambiguous channel name")
}

// An empty channel name has no home default: it misses like any unknown name.
func TestUpdateChannelMembersAsAccountByNameEmptyChannelHasNoHomeDefault(t *testing.T) {
svc, st := newHandler(t)
ctx := context.Background()
owner := mustUser(t, st, "owner")
agent := mustAgent(t, st, owner.ID, "manager")
newcomer := mustUser(t, st, "newcomer")

_, err := svc.UpdateChannelMembersAsAccountByName(ctx, agent.ID, &compassv1.UpdateChannelMembersRequest{
AddMemberHandles: []string{newcomer.Handle},
})
connectCodeIs(t, err, connect.CodeNotFound, "UpdateChannelMembersAsAccountByName with empty channel")
}

// TestCreateChannelGroupAsAccountReturnsGroup: an agent creates a top-level group
Expand Down
3 changes: 3 additions & 0 deletions go/internal/gen/compass/v1/agent_gateway.pb.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion go/internal/runnerhub/helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -320,7 +320,7 @@ func (f *fakeCommsCaller) CreateChannelAsAccount(_ context.Context, account stor
return f.createChannelResp, nil
}

func (f *fakeCommsCaller) UpdateChannelMembersAsAccount(_ context.Context, account store.AccountID, req *compassv1.UpdateChannelMembersRequest) (*compassv1.UpdateChannelMembersResponse, error) {
func (f *fakeCommsCaller) UpdateChannelMembersAsAccountByName(_ context.Context, account store.AccountID, req *compassv1.UpdateChannelMembersRequest) (*compassv1.UpdateChannelMembersResponse, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, commsCall{account: account, updateMembers: req})
Expand Down
4 changes: 3 additions & 1 deletion go/internal/runnerhub/hub.go
Original file line number Diff line number Diff line change
Expand Up @@ -332,7 +332,9 @@ type CommsCaller interface { //nolint:interfacebloat // one method per agent-com
SetStatusAsAccount(ctx context.Context, account store.AccountID, activity string) (string, error)
UpdatePinnedBoardAsAccount(ctx context.Context, account store.AccountID, req *compassv1.UpdatePinnedBoardRequest) (*compassv1.UpdatePinnedBoardResponse, error)
CreateChannelAsAccount(ctx context.Context, account store.AccountID, req *compassv1.CreateChannelRequest) (*compassv1.CreateChannelResponse, error)
UpdateChannelMembersAsAccount(ctx context.Context, account store.AccountID, req *compassv1.UpdateChannelMembersRequest) (*compassv1.UpdateChannelMembersResponse, error)
// UpdateChannelMembersAsAccountByName is the agent-tool path: channel_id is a
// channel NAME, resolved within account's visible set, with no home default.
UpdateChannelMembersAsAccountByName(ctx context.Context, account store.AccountID, req *compassv1.UpdateChannelMembersRequest) (*compassv1.UpdateChannelMembersResponse, error)
CreateChannelGroupAsAccount(ctx context.Context, account store.AccountID, req *compassv1.CreateChannelGroupRequest) (*compassv1.CreateChannelGroupResponse, error)
// OpenDMAsAccount resolves-or-creates the two-party peer DM between account
// and the request's peer handle (RIG-2962 T3), same-owner authz enforced
Expand Down
2 changes: 1 addition & 1 deletion go/internal/runnerhub/relay_comms.go
Original file line number Diff line number Diff line change
Expand Up @@ -824,7 +824,7 @@ func (h *Hub) executeCall(
Result: &compassv1internal.CommsCallResult_CreateChannel{CreateChannel: resp},
}, nil
case *compassv1internal.CommsCallRequest_UpdateMembers:
resp, err := h.comms.UpdateChannelMembersAsAccount(ctx, account, c.UpdateMembers)
resp, err := h.comms.UpdateChannelMembersAsAccountByName(ctx, account, c.UpdateMembers)
if err != nil {
return nil, err
}
Expand Down
2 changes: 1 addition & 1 deletion go/internal/runnerhub/relay_org_mgmt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ package runnerhub

// The org-management relay arms (RIG-2673 T3): RelayCommsCall dispatches a
// create_channel call to CreateChannelAsAccount, an update_members call to
// UpdateChannelMembersAsAccount, and a create_channel_group call to
// UpdateChannelMembersAsAccountByName, and a create_channel_group call to
// CreateChannelGroupAsAccount — each under the bound account, wrapping the
// matching result oneof, with call_id round-tripped. A tool error on an arm is
// rendered in-band as a CommsCallError, never a transport teardown. Driven
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ export type CommsCallRequest = Message<"compass.v1.CommsCallRequest"> & {
*/
call: {
/**
* post, list and update_members carry a channel NAME in channel_id, resolved
* within the caller's visible set: unknown or invisible is NOT_FOUND,
* ambiguous is INVALID_ARGUMENT. Only list defaults an empty name to home.
*
* @generated from field: compass.v1.PostMessageRequest post = 2;
*/
value: PostMessageRequest;
Expand Down
3 changes: 3 additions & 0 deletions proto/compass/v1/agent_gateway.proto
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,9 @@ service AgentGateway {
message CommsCallRequest {
string call_id = 1;
oneof call {
// post, list and update_members carry a channel NAME in channel_id, resolved
// within the caller's visible set: unknown or invisible is NOT_FOUND,
// ambiguous is INVALID_ARGUMENT. Only list defaults an empty name to home.
PostMessageRequest post = 2;
ListMessagesRequest list = 3;
GetRosterRequest roster = 4;
Expand Down
Loading