Skip to content
Open
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
47 changes: 36 additions & 11 deletions internal/commands/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,11 +213,14 @@ func connectShowDisplay(path string, f setup.File, markdown bool) map[string]any
"ack": connectAckDisplay(f.Ack()),
}
for id, entry := range f.Pings {
ids := make([]string, len(entry.Participants))
for i, person := range entry.Participants {
ids[i] = strconv.FormatInt(person, 10)
var who []string
if len(entry.Participants) > 0 {
who = append(who, joinIDs(entry.Participants)+" by mention, as participants")
}
d[fmt.Sprintf("ping_%d", id)] = strings.Join(ids, ", ") + " by mention, as participants"
if len(entry.Agents) > 0 {
who = append(who, "agents "+joinIDs(entry.Agents)+" by mention")
}
d[fmt.Sprintf("ping_%d", id)] = strings.Join(who, "; ")
}
for id, r := range f.Projects {
settings := "served"
Expand All @@ -232,6 +235,14 @@ func connectShowDisplay(path string, f setup.File, markdown bool) map[string]any
return d
}

func joinIDs(ids []int64) string {
out := make([]string, len(ids))
for i, id := range ids {
out[i] = strconv.FormatInt(id, 10)
}
return strings.Join(out, ", ")
}

// connectAckDisplay says how the connector acknowledges a request.
func connectAckDisplay(ack setup.AckBoost) string {
if ack.Off {
Expand Down Expand Up @@ -312,6 +323,9 @@ type connectSetupFlags struct {
allowInPing []string
disallowInPing []string

allowAgentInPing []string
disallowAgentInPing []string

// ackBoost is --ack-boost, the boost the connector acknowledges a
// request with; noAckBoost turns it off.
ackBoost optionalString
Expand Down Expand Up @@ -401,9 +415,14 @@ anywhere else they are who they were. Setup reads the Ping as the agent: the
agent and the person must both be in it, and the person can be neither an
agent nor a client. --disallow-in-ping takes them out. Find the Ping's id
with basecamp pings list, or in its URL (.../circles/<ping-id>), and the
person's in basecamp pings show <ping-id> --json. Anyone else's line in a
Ping is discarded, as is any agent's. Restart the connector after changing
who is let into a Ping.
person's in basecamp pings show <ping-id> --json.
--allow-agent-in-ping <ping-id>:<agent-id> lets another agent wake this one in
that one Ping, by @mention only, as a participant's request, held to the same
agent caps as --allow-agent; --disallow-agent-in-ping takes it out. Setup
reads the Ping as the agent: both agents must be in it, and the one let in
must be an Agent. --allow-agent alone reaches no Ping. Anyone else's line in
a Ping is discarded, as is any other agent's. Restart the connector after
changing who is let into a Ping.

Acknowledgement. When someone asks the agent for something (a mention, an
assignment, a ping), the connector boosts that recording as the agent before
Expand Down Expand Up @@ -451,6 +470,7 @@ Examples:
basecamp connect setup -P agent --class 12345=internal
basecamp connect setup -P agent --allow-agent 333
basecamp connect setup -P agent --allow-in-ping 2085958513:111
basecamp connect setup -P agent --allow-agent-in-ping 2085958513:333
basecamp connect setup -P agent --agent-thread-cap 5 --agent-daily-cap 30
basecamp connect setup -P agent --ack-boost 'On it'
basecamp connect setup -P agent --no-ack-boost`,
Expand Down Expand Up @@ -480,6 +500,8 @@ Examples:
fl.StringArrayVar(&f.disallowAgents, "disallow-agent", nil, "Stop allowing an agent to wake this one by mention (repeatable)")
fl.StringArrayVar(&f.allowInPing, "allow-in-ping", nil, "Let a person reach the agent in one Ping alone, by mention, as a participant: <ping-id>:<person-id> (repeatable)")
fl.StringArrayVar(&f.disallowInPing, "disallow-in-ping", nil, "Take a person let into a Ping out of it: <ping-id>:<person-id> (repeatable)")
fl.StringArrayVar(&f.allowAgentInPing, "allow-agent-in-ping", nil, "Let another agent wake this one in one Ping alone, by mention, as a participant, within the agent caps: <ping-id>:<agent-id> (repeatable)")
fl.StringArrayVar(&f.disallowAgentInPing, "disallow-agent-in-ping", nil, "Take an agent let into a Ping out of it: <ping-id>:<agent-id> (repeatable)")
fl.Var(&f.threadCap, "agent-thread-cap", fmt.Sprintf("Allowed agents' mentions handed off per thread or Campfire in 24 hours (default %d; 0 restores it; at most %d)", admission.DefaultAgentThreadCap, admission.MaxAgentCap))
fl.Var(&f.dailyCap, "agent-daily-cap", fmt.Sprintf("One allowed agent's mentions handed off across all threads in 24 hours (default %d; 0 restores it; at most %d)", admission.DefaultAgentDailyCap, admission.MaxAgentCap))
fl.StringArrayVar(&f.serve, "serve", nil, "Serve a Basecamp project: <project-id> (repeatable)")
Expand Down Expand Up @@ -949,21 +971,24 @@ func (f *connectSetupFlags) changes() (setup.Changes, error) {
}
for _, list := range []struct {
flag string
want string
raw []string
out *[]setup.PingPerson
}{
{"--allow-in-ping", f.allowInPing, &ch.AllowInPing},
{"--disallow-in-ping", f.disallowInPing, &ch.DisallowInPing},
{"--allow-in-ping", "<ping-id>:<person-id>", f.allowInPing, &ch.AllowInPing},
{"--disallow-in-ping", "<ping-id>:<person-id>", f.disallowInPing, &ch.DisallowInPing},
{"--allow-agent-in-ping", "<ping-id>:<agent-id>", f.allowAgentInPing, &ch.AllowAgentInPing},
{"--disallow-agent-in-ping", "<ping-id>:<agent-id>", f.disallowAgentInPing, &ch.DisallowAgentInPing},
} {
for _, raw := range list.raw {
pingText, personText, ok := strings.Cut(raw, ":")
ping, err := parsePositiveID(list.flag, strings.TrimSpace(pingText))
if !ok || err != nil || ping == 0 {
return ch, output.ErrUsage(fmt.Sprintf("Invalid %s %q: expected <ping-id>:<person-id>", list.flag, raw))
return ch, output.ErrUsage(fmt.Sprintf("Invalid %s %q: expected %s", list.flag, raw, list.want))
}
person, err := parsePositiveID(list.flag, strings.TrimSpace(personText))
if err != nil || person == 0 {
return ch, output.ErrUsage(fmt.Sprintf("Invalid %s %q: expected <ping-id>:<person-id>", list.flag, raw))
return ch, output.ErrUsage(fmt.Sprintf("Invalid %s %q: expected %s", list.flag, raw, list.want))
}
*list.out = append(*list.out, setup.PingPerson{Ping: ping, Person: person})
}
Expand Down
8 changes: 4 additions & 4 deletions internal/commands/connect_run.go
Original file line number Diff line number Diff line change
Expand Up @@ -673,12 +673,12 @@ func connectRunningAttrs(profile, account string, agentID int64, shadow bool, se
}

// connectActorTypes is the feed's actor filter. People only, as always,
// unless connect.json allows other agents to mention this one: then agents'
// events are asked for too, and admission's gate discards every one but an
// allowed agent's mention. A change of filter re-enters the feed under a new
// unless connect.json allows other agents to mention this one, anywhere or in
// one Ping: then agents' events are asked for too, and admission's gate
// discards every one but an allowed agent's mention. A change of filter re-enters the feed under a new
// lineage, resuming after the last event served, so nothing is replayed.
func connectActorTypes(policy admission.Policy) []string {
if len(policy.Trust.AgentIDs) > 0 {
if len(policy.AllowedAgents()) > 0 {
return []string{"agent", "person"}
}
return []string{"person"}
Expand Down
2 changes: 1 addition & 1 deletion internal/commands/connect_setup_guided.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ var connectSetupPolicyFlags = []string{
"serve", "unserve", "class", "watch-completions", "no-watch-completions",
"allow-assignments-from-authorized",
"allow-agent", "disallow-agent", "agent-thread-cap", "agent-daily-cap",
"allow-in-ping", "disallow-in-ping", "ack-boost", "no-ack-boost",
"allow-in-ping", "disallow-in-ping", "allow-agent-in-ping", "disallow-agent-in-ping", "ack-boost", "no-ack-boost",
}

// connectGOOS is the platform guided setup checks before connecting. A
Expand Down
70 changes: 70 additions & 0 deletions internal/commands/connect_setup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1606,6 +1606,76 @@ func TestConnectFeedAsksForAgentsOnlyWhenOneIsAllowed(t *testing.T) {
assert.Equal(t, []string{"person"}, connectActorTypes(p), "the default, exactly as before")
p.Trust.AgentIDs = []int64{setupPeerAgent}
assert.Equal(t, []string{"agent", "person"}, connectActorTypes(p))

p.Trust.AgentIDs = nil
p.Pings = map[int64]admission.PingTrust{2085958513: {Participants: []int64{1007}}}
assert.Equal(t, []string{"person"}, connectActorTypes(p), "a person let into a Ping is a person")
p.Pings = map[int64]admission.PingTrust{2085958513: {Agents: []int64{setupPeerAgent}}}
assert.Equal(t, []string{"agent", "person"}, connectActorTypes(p), "an agent let into one Ping")
}

func TestConnectSetupLetsAnAgentIntoOnePing(t *testing.T) {
const ping int64 = 2085958513
const guest int64 = 1007
s := startConnectSetupServer(t)
s.bodies = map[string]any{fmt.Sprintf("/999/circles/%d", ping): map[string]any{
"id": ping, "title": "Launch",
"people": []map[string]any{
{"id": setupOperatorPerson, "name": "Operator", "personable_type": "User"},
{"id": guest, "name": "Jorge", "personable_type": "User"},
{"id": setupPeerAgent, "name": "Goose", "personable_type": "Agent"},
},
}}
firstSetup(t, s)
run := func(args ...string) (setup.File, error) {
t.Helper()
out, err := runConnectSetupCmd(t, newConnectSetupApp(t, s, "agent"), args...)
if err != nil {
return setup.File{}, fmt.Errorf("%w: %s", err, out)
}
f, err := setup.Load(connectSetupPath(t, "agent"))
require.NoError(t, err)
return f, nil
}

f, err := run("--allow-agent-in-ping", fmt.Sprintf("%d:%d", ping, setupPeerAgent))
require.NoError(t, err)
assert.Equal(t, map[int64]admission.PingTrust{ping: {Agents: []int64{setupPeerAgent}}}, f.Pings)
assert.Nil(t, f.Trust.AgentIDs, "allowed in that Ping and nowhere else")

app := newConnectSetupApp(t, s, "agent")
var buf bytes.Buffer
app.Output = output.New(output.Options{Format: output.FormatStyled, Writer: &buf})
out, err := runConnectShowCmd(t, app)
require.NoError(t, err, out)
assert.Contains(t, buf.String(), fmt.Sprintf("agents %d by mention", setupPeerAgent))

f, err = run("--serve", fmt.Sprint(setupProject2))
require.NoError(t, err)
assert.Equal(t, []int64{setupPeerAgent}, f.Pings[ping].Agents, "kept when not passed")

for name, arg := range map[string]string{
"a person": fmt.Sprintf("%d:%d", ping, guest),
"the agent itself": fmt.Sprintf("%d:%d", ping, setupAgentPerson),
"an agent not in it": fmt.Sprintf("%d:%d", ping, setupPeerAgent+100),
"a Ping it is not in": fmt.Sprintf("%d:%d", ping+1, setupPeerAgent),
"the operator": fmt.Sprintf("%d:%d", ping, setupOperatorPerson),
"no agent": fmt.Sprint(ping),
"not ids": "launch:goose",
"an agent and a guest": fmt.Sprintf("%d:%d,%d", ping, setupPeerAgent, guest),
} {
_, err := run("--allow-agent-in-ping", arg)
require.Error(t, err, name)
}
_, err = run("--allow-in-ping", fmt.Sprintf("%d:%d", ping, setupPeerAgent))
require.Error(t, err, "an agent is never a participant, even one let into the Ping")
f, err = setup.Load(connectSetupPath(t, "agent"))
require.NoError(t, err)
assert.Equal(t, map[int64]admission.PingTrust{ping: {Agents: []int64{setupPeerAgent}}}, f.Pings, "a refused run wrote nothing")

f, err = run("--disallow-agent-in-ping", fmt.Sprintf("%d:%d", ping, setupPeerAgent))
require.NoError(t, err)
assert.Nil(t, f.Pings)
}

// --allow-in-ping lets a person into one Ping the agent is in, checked as the
Expand Down
13 changes: 8 additions & 5 deletions internal/connector/admission/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,10 @@
// that Ping alone (Policy.Pings, --allow-in-ping): their mention there is a
// participant's request, in every trust mode, and anywhere else they are
// whoever the rest of the policy makes them. The entry counts only once the
// Ping read says its bucket is a Ping, never in a served project, and never
// for an agent, which Validate refuses. A Ping needs no serving; its
// Ping read says its bucket is a Ping, and never in a served project. An
// agent is never a participant, which Validate refuses; the entry's agents
// (--allow-agent-in-ping) are heard there by mention, in words each wrote
// itself, as a participant's request held to the agent caps. A Ping needs no serving; its
// conversation is the Ping (PingConversationKey), answered in its one
// Campfire.
//
Expand All @@ -96,9 +98,10 @@
// put in the allowlist, performing an event whose content it did not
// write (a completion). Allowlisting an agent is an operator error that
// Validate cannot detect, and setup refuses. The one exception is an
// agent named in agent_ids: its own mention, admitted as a participant's
// request and never an operator's, held to the agent caps, and never in
// a Ping, where no agent may be a participant. With no
// agent named in agent_ids, or in one Ping's agents: its own mention,
// admitted as a participant's request and never an operator's, held to
// the agent caps. agent_ids reaches no Ping; a Ping's agents reach that
// Ping alone. With no
// agent named, the feed's actor_types=person filter excludes agents at
// source, as before.
// 2. An assignment is admitted only when the operator performed it (or a
Expand Down
22 changes: 15 additions & 7 deletions internal/connector/admission/gate.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ type GateResult struct {
// participant when only membership can trust them.
Role Role
// Agent says the performer is an agent the policy allows to mention this
// one (Trust.AgentIDs). Only the mentioned rule is open to it, its role
// is participant, and the committer holds it to the agent caps.
// one (Trust.AgentIDs, or Policy.Pings in its Ping). Only the mentioned
// rule is open to it, its role is participant, and the committer holds it
// to the agent caps.
Agent bool
// InPing says the performer is trusted only as a participant of the Ping
// the event's bucket is keyed as (Policy.Pings): only once the Ping read
Expand Down Expand Up @@ -48,13 +49,14 @@ func Gate(ev Event, p Policy, m Matrix) GateResult {
switch {
case ev.CreatorID == p.AgentID, ev.Performer() == p.AgentID:
return GateResult{Reason: ReasonAgentAuthored}
case ev.PerformedByID == nil && p.Trust.allowsAgent(ev.CreatorID):
// An agent the operator named with --allow-agent, acting as itself.
case ev.PerformedByID == nil && (p.Trust.allowsAgent(ev.CreatorID) || p.pingAgent(ev.BucketID, ev.CreatorID)):
// An agent the operator named with --allow-agent or
// --allow-agent-in-ping, acting as itself.
// Checked by id rather than by actor type, which only the push lane
// carries: setup verified the id reads back as an Agent person.
return gateAgent(ev, p, rules)
case ev.ActorType == ActorTypeAgent:
if len(p.Trust.AgentIDs) > 0 {
if len(p.AllowedAgents()) > 0 {
// Agents are allowed, and this one was not named.
return GateResult{Reason: ReasonAgentNotAllowed}
}
Expand Down Expand Up @@ -130,13 +132,19 @@ func gateAgent(ev Event, p Policy, rules []Rule) GateResult {
if !p.inScope(ev.BucketID) {
return GateResult{Reason: ReasonOutOfScope}
}
if _, served := p.served(ev.BucketID); !served {
_, served := p.served(ev.BucketID)
inPing := !served && p.pingAgent(ev.BucketID, ev.CreatorID)
switch {
case inPing:
case !served:
// Discarded here, before any read, rather than blocked no_route as a
// person's mention is: nothing waits on another agent's mention in a
// project this one does not serve, and a blocked record would be
// retried and spend reads for it. A gate discard carries no trigger,
// so it is never counted against the agent caps.
return GateResult{Reason: ReasonNoRoute}
case !p.Trust.allowsAgent(ev.CreatorID):
return GateResult{Reason: ReasonAgentNotAllowed}
}
var open []Rule
reason := ReasonNotAddressed
Expand All @@ -151,5 +159,5 @@ func gateAgent(ev Event, p Policy, rules []Rule) GateResult {
if len(open) == 0 {
return GateResult{Reason: reason}
}
return GateResult{Rules: open, Role: RoleParticipant, Agent: true}
return GateResult{Rules: open, Role: RoleParticipant, Agent: true, InPing: inPing}
}
Loading
Loading