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
61 changes: 59 additions & 2 deletions internal/commands/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ a subagent that replies as the agent.
Connect the agent to a profile first (basecamp auth agent connect -P <profile>),
then run setup on that profile: it records who may drive the agent, maps
which Basecamp projects it serves, and checks the connector is
ready. Show prints what setup recorded. Then run the connector on it:
ready. Pings need no setup: an operator's ping is handed off too. Show prints what setup recorded. Then run the connector on it:

basecamp connect -P <profile> [--project <id>]... [--shadow]

Expand Down Expand Up @@ -95,7 +95,7 @@ func newConnectShowCmd() *cobra.Command {
Use: "show",
Short: "Show a profile's connector setup without changing it",
Long: `Show the connect.json a profile's setup wrote: the agent, the operator and
trust mode, and each served project.
trust mode, each served project, and who is let into a Ping.

The file is read through the same checks setup and the connector apply:
it is refused, and nothing of it shown, when it is a symlink, is not a
Expand Down Expand Up @@ -208,6 +208,13 @@ func connectShowDisplay(path string, f setup.File, markdown bool) map[string]any
"agents": agents,
"projects": strconv.Itoa(len(f.Projects)) + " served",
}
for id, entry := range f.Pings {
ids := make([]string, len(entry.Participants))
for i, person := range entry.Participants {
ids[i] = strconv.FormatInt(person, 10)
}
d[fmt.Sprintf("ping_%d", id)] = strings.Join(ids, ", ") + " by mention, as participants"
}
for id, r := range f.Projects {
settings := "served"
if r.Class != "" {
Expand Down Expand Up @@ -285,6 +292,11 @@ type connectSetupFlags struct {
threadCap optionalInt
dailyCap optionalInt

// allowInPing and disallowInPing let people into one Ping and take them
// out, each <ping-id>:<person-id>.
allowInPing []string
disallowInPing []string

serve []string
classes []string
watch []string
Expand Down Expand Up @@ -359,6 +371,20 @@ does not serve is handed off, and nobody is told. --watch-completions <project-i
makes the agent hear every trusted completion in that project without being
assigned.

Pings. A Ping is in no project, so it is never served and needs no setup.
An operator (the operator, or a person passed with --allow) gives the agent
work by Ping in every trust mode: every line of theirs in a direct Ping with
the agent is a request, and in a group Ping a line that @mentions it.
--allow-in-ping <ping-id>:<person-id> lets one more person reach the agent in
that one Ping, as a participant, by @mention only, in every trust mode;
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.

No directory is associated with a project here: the session handling the
requests chooses the repo for each one.

Expand Down Expand Up @@ -396,6 +422,7 @@ Examples:
basecamp connect setup -P bot --operator-profile me --expect-identity 4242 --serve 12345
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 --agent-thread-cap 5 --agent-daily-cap 30`,
Annotations: map[string]string{AnnotationProfileMayCreate: "true"},
Args: cobra.NoArgs,
Expand All @@ -421,6 +448,8 @@ Examples:
fl.Lookup("allow-assignments-from-authorized").NoOptDefVal = "true"
fl.StringArrayVar(&f.allowAgents, "allow-agent", nil, "Agent person id allowed to wake this agent by @mentioning it, as a participant (repeatable; default: no agent)")
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.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 @@ -628,6 +657,13 @@ func runConnectSetup(cmd *cobra.Command, app *appctx.App, f *connectSetupFlags)
trust.Operator = setup.Person{ID: operatorID}
}
trustChecks := setup.VerifyTrust(ctx, people, trust, me.ID)
// A Ping is read as the agent, as admission reads it: whether the agent
// is in it, and who is with it there, is the agent's view.
var recordedPings map[int64]admission.PingTrust
if exists {
recordedPings = existing.Pings
}
trustChecks = append(trustChecks, setup.VerifyPings(ctx, reader, next.Pings, recordedPings, me.ID)...)
for _, c := range trustChecks {
if c.Status == setup.StatusFail {
return output.ErrUsageHint("Trust was refused, and nothing was written: "+c.Name+": "+c.Message, c.Hint)
Expand Down Expand Up @@ -878,6 +914,27 @@ func (f *connectSetupFlags) changes() (setup.Changes, error) {
}
}
}
for _, list := range []struct {
flag string
raw []string
out *[]setup.PingPerson
}{
{"--allow-in-ping", f.allowInPing, &ch.AllowInPing},
{"--disallow-in-ping", f.disallowInPing, &ch.DisallowInPing},
} {
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))
}
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))
}
*list.out = append(*list.out, setup.PingPerson{Ping: ping, Person: person})
}
}
if f.threadCap.set {
ch.AgentThreadCap = &f.threadCap.value
}
Expand Down
2 changes: 1 addition & 1 deletion internal/commands/connect_run.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ func addConnectRunFlags(cmd *cobra.Command, f *connectRunFlags) {
fl := cmd.Flags()
// --project shadows the global flag of the same name and keeps its type,
// so the flag reads the same everywhere; here it may be repeated.
fl.Var((*repeatedString)(&f.projects), "project", "Only hear events in this project id (repeatable; default every project the agent can see)")
fl.Var((*repeatedString)(&f.projects), "project", "Only hear events in this project id (repeatable; default every project and ping the agent can see, and a run given one hears no ping)")
fl.BoolVar(&f.shadow, "shadow", false, "Admit and log in an isolated state directory; dispatch and post nothing")
fl.Int64Var(&f.since, "since", 0, "Enter the feed just after this event id, whatever the ledger holds")
fl.BoolVar(&f.hold, "hold", false, "Set the durable hold: intake and admission run, nothing is dispatched or posted until the hold is released, and earlier records wait for review")
Expand Down
7 changes: 4 additions & 3 deletions internal/commands/connect_setup_guided.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +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",
}

// connectGOOS is the platform guided setup checks before connecting. A
Expand Down Expand Up @@ -635,13 +636,13 @@ func renderGuidedSummary(w io.Writer, r *output.Renderer, agent guidedAgent, fil
// try it.
fmt.Fprintln(w, " Works in: no projects")
fmt.Fprintln(w)
fmt.Fprintf(w, "It isn't working in any projects, so it won't pick up any mentions. To add one: basecamp connect setup -P %s --serve <project-id>\n", richtext.ShellQuote(name))
fmt.Fprintf(w, "It isn't working in any projects, so only your pings reach it. To add one: basecamp connect setup -P %s --serve <project-id>\n", richtext.ShellQuote(name))
return
}
fmt.Fprintf(w, " Works in: %s\n", strings.Join(served, ", "))
if running {
fmt.Fprintln(w, " Running: yes")
fmt.Fprintf(w, "\nMention %s in one of those projects to try it.\n", agentName)
fmt.Fprintf(w, "\nMention %s in one of those projects, or ping it, to try it.\n", agentName)
} else {
// The connector only listens: the person's own Claude Code session
// does the work, through the basecamp-connect skill.
Expand All @@ -650,6 +651,6 @@ func renderGuidedSummary(w io.Writer, r *output.Renderer, agent guidedAgent, fil
fmt.Fprintln(w, "To start it, run /basecamp-connect in your Claude Code session. It starts the")
fmt.Fprintln(w, "connector and handles each request with your own setup. The connector on its own:")
fmt.Fprintln(w, " basecamp connect -P "+richtext.ShellQuote(name))
fmt.Fprintf(w, "\nOnce it's running, mention %s in one of those projects to try it.\n", agentName)
fmt.Fprintf(w, "\nOnce it's running, mention %s in one of those projects, or ping it, to try it.\n", agentName)
}
}
2 changes: 1 addition & 1 deletion internal/commands/connect_setup_guided_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ func TestGuidedConnectSetupFromNothing(t *testing.T) {
assert.Contains(t, out, "Running: no")
assert.Contains(t, out, "To start it, run /basecamp-connect in your Claude Code session.")
assert.Contains(t, out, " basecamp connect -P agent\n")
assert.Contains(t, out, "Once it's running, mention Marie Chef in one of those projects to try it.")
assert.Contains(t, out, "Once it's running, mention Marie Chef in one of those projects, or ping it, to try it.")
for _, internal := range []string{"Connected profile", "Account: 999", "auth status", "connect.json", "Identity", "Person " + strconv.FormatInt(setupOperatorPerson, 10)} {
assert.NotContains(t, out, internal)
}
Expand Down
68 changes: 67 additions & 1 deletion internal/commands/connect_setup_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -470,7 +470,7 @@ func TestConnectSetupCanWithdrawTheLastServedProject(t *testing.T) {
assert.Empty(t, f.Projects, "the withdrawal is on disk, not just reported")

// And the operator is told what they are now left with.
assert.Contains(t, out, "no work at all")
assert.Contains(t, out, "only its operators' pings reach this agent")

// A first setup still has to serve something: that is the other question.
require.NoError(t, os.Remove(connectSetupPath(t, "agent")))
Expand Down Expand Up @@ -1607,3 +1607,69 @@ func TestConnectFeedAsksForAgentsOnlyWhenOneIsAllowed(t *testing.T) {
p.Trust.AgentIDs = []int64{setupPeerAgent}
assert.Equal(t, []string{"agent", "person"}, connectActorTypes(p))
}

// --allow-in-ping lets a person into one Ping the agent is in, checked as the
// agent reads that Ping; --disallow-in-ping takes them out, and the entries
// are kept across runs. show tells a person all of it.
func TestConnectSetupLetsAPersonIntoOnePing(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": setupClientPerson, "name": "Client", "personable_type": "User", "client": true},
{"id": setupPeerAgent, "name": "Peer (agent)", "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-in-ping", fmt.Sprintf("%d:%d", ping, guest))
require.NoError(t, err)
assert.Equal(t, map[int64]admission.PingTrust{ping: {Participants: []int64{guest}}}, f.Pings)
assert.Equal(t, admission.TrustOperator, f.Trust.Mode, "trusts nobody anywhere else")
assert.Nil(t, f.Trust.AllowlistIDs)

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("%d by mention, as participants", guest))

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

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

f, err = run("--disallow-in-ping", fmt.Sprintf("%d:%d", ping, guest))
require.NoError(t, err)
assert.Nil(t, f.Pings)
}
31 changes: 25 additions & 6 deletions internal/connector/admission/doc.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,30 @@
// least one of the type's triggers, the bucket is in scope, and a trigger
// that only a served project admits is in one. Most account traffic
// ends here, for the price of its pointer.
// 2. In project trust mode a performer the policy does not name is confirmed
// 2. A chat line in a bucket connect.json does not serve is read as a Ping
// (Reads.Pings), because where the line was said decides whose word
// counts, and that comes before anyone's trust is read. A bucket that
// is no Ping goes on as an unserved project's; its answer is cached.
// 3. In project trust mode a performer the policy does not name is confirmed
// as a non-client member of the project. That is a read, cached per
// project, and it is the one trust decision the pointer alone cannot make.
// 3. The RecordingSummary read (the SDK's step-10 helper) resolves the
// 4. The RecordingSummary read (the SDK's step-10 helper) resolves the
// recording through the typed read its event type names. A boost's type
// names no recording, so a boost is read first, for the boosted
// recording's type and for the boost itself. A recording still drafted or
// since trashed is stale.
// 4. The trigger rules decide what the event is to the agent: mentioned (the
// 5. The trigger rules decide what the event is to the agent: mentioned (the
// agent's Person id is among the mention attachments — never a name),
// subscribed (a subscription read on the commented recording), assigned
// (the recording's events, at most five pages, for details.added_person_ids)
// completed (trusted completer and a stake: the served project's
// watch_completions flag, an assignment, or a subscription), or boosted
// (a boost on the recording itself, which the agent wrote, given by the
// trusted performer; a boost on one of a recording's events is not).
// 5. Whether the project is served, its class, the reply destination, the
// trusted performer; a boost on one of a recording's events is not), or,
// in a Ping, pinged (an operator's line in a direct Ping, the agent and
// its writer alone; in a group Ping, and from a participant anywhere,
// only a mention counts).
// 6. Whether the project is served, its class, the reply destination, the
// conversation key and the content snapshot come from the policy and the
// summary, never from the pointer.
//
Expand Down Expand Up @@ -66,6 +73,17 @@
// Admission only labels; what a participant may get the agent to do is the
// policy of whoever reads the request.
//
// A Ping is no project, so project trust has no members there: an operator
// gives the agent work in any Ping, in every trust mode, in words an operator
// wrote. The one participant a Ping has is a person connect.json lets into
// 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
// conversation is the Ping (PingConversationKey), answered in its one
// Campfire.
//
// # Invariants
//
// Every rule below is held by a test that fails without it.
Expand All @@ -79,7 +97,8 @@
// 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, and held to the agent caps. With no
// 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, 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
23 changes: 21 additions & 2 deletions internal/connector/admission/fakes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,16 @@ type fakeReads struct {
memberAsOf []time.Time
boosts map[int64]*basecamp.Boost // boost id → boost
boostErr error
pings map[int64]*basecamp.Ping // bucket id → the Ping it is
pingErr error

summaryCalls int
summaryRefs []basecamp.RecordingRef
subCalls []int64
assignCalls int
memberCalls int
boostCalls int
pingCalls int
}

func newFakeReads() *fakeReads {
Expand All @@ -69,11 +72,12 @@ func newFakeReads() *fakeReads {
assignments: map[int64][]int64{},
members: map[int64]map[int64]bool{},
boosts: map[int64]*basecamp.Boost{},
pings: map[int64]*basecamp.Ping{},
}
}

func (f *fakeReads) reads() Reads {
return Reads{Summaries: f, Subscriptions: f, Assignments: f, Boosts: f, Members: f}
return Reads{Summaries: f, Subscriptions: f, Assignments: f, Boosts: f, Pings: f, Members: f}
}

func (f *fakeReads) Summarize(_ context.Context, ref basecamp.RecordingRef) (*basecamp.RecordingSummary, error) {
Expand Down Expand Up @@ -143,10 +147,25 @@ func (f *fakeReads) Get(_ context.Context, boostID int64) (*basecamp.Boost, erro
return &copied, nil
}

func (f *fakeReads) Ping(_ context.Context, bucketID int64) (*basecamp.Ping, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.pingCalls++
if f.pingErr != nil {
return nil, f.pingErr
}
p, ok := f.pings[bucketID]
if !ok {
return nil, nil
}
copied := *p
return &copied, nil
}

func (f *fakeReads) totalReads() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.summaryCalls + len(f.subCalls) + f.assignCalls + f.memberCalls + f.boostCalls
return f.summaryCalls + len(f.subCalls) + f.assignCalls + f.memberCalls + f.boostCalls + f.pingCalls
}

var errTransport = errors.New("connection reset")
Expand Down
Loading
Loading