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
12 changes: 9 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -267,9 +267,15 @@ basecamp connect setup -P my-agent --serve 12345 # who may give it work,
basecamp connect -P my-agent # the watcher
```

Connect each agent once. Every connection rotates the agent's secret, so a
second one, from this CLI or from the HTTP handshake on Basecamp's instructions
page, disconnects the first and asks you to approve again.
Each approval adds a connection with its own secret, named after the computer
(rename it on My > Agent). A personal agent can have several, on one computer
or several, and connecting another, from this CLI or from the HTTP handshake on
Basecamp's instructions page, leaves the others working. Disconnecting one in
Basecamp stops only that one. Each connection serves the projects its own setup
names; when two serve the same project, a request both admit can be answered
twice, so serve each project from one. A shared agent keeps a single
connection: connecting it again disconnects the first and asks you to approve
again.

In Claude Code, the `/basecamp-connect` skill runs the watcher and hands each
request to a subagent. It ships in this CLI's plugin from 0.12.0 on, and an
Expand Down
4 changes: 2 additions & 2 deletions internal/auth/agent_connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -344,14 +344,14 @@ func reconnectRemedy(err error) error {
return err
}
hinted := *e
hinted.Hint = "Disconnect the agent in Basecamp and run the connect command again; the secret this connection was handed is gone."
hinted.Hint = "Disconnect the new connection in Basecamp and run the connect command again; the secret it was handed is gone."
return &hinted
}

// warnHandoverLost says what a failure after the handover costs, in the
// one line an operator has to act on.
func warnHandoverLost(log func(string)) {
log("warning: the connection was approved and the agent's client secret was handed over once, but keeping it failed. Disconnect the agent in Basecamp and connect again.")
log("warning: the connection was approved and its client secret was handed over once, but keeping it failed. Disconnect the new connection in Basecamp and connect again.")
}

// spentHandoverError marks an error raised once the poll had already been
Expand Down
12 changes: 6 additions & 6 deletions internal/auth/agent_connect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,7 @@ func TestConnectAgentRefusesATokenWiderThanTheApproval(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "wider than the scope")
assertNoAgentCredential(t, m)
assert.Contains(t, cl.joined(), "Disconnect the agent in Basecamp and connect again")
assert.Contains(t, cl.joined(), "Disconnect the new connection in Basecamp and connect again")
assert.NotContains(t, cl.joined(), "minted", "no token from a refused mint is kept")
}

Expand Down Expand Up @@ -535,7 +535,7 @@ func TestConnectAgentRefusesAScopeItCannotStore(t *testing.T) {
assertNoAgentCredential(t, m)
// The poll was answered, so the code is burned and the secret minted
// whatever this process made of the answer.
assert.Contains(t, cl.joined(), "Disconnect the agent in Basecamp and connect again")
assert.Contains(t, cl.joined(), "Disconnect the new connection in Basecamp and connect again")
var e *output.Error
assert.ErrorAs(t, err, &e, "the refusal keeps its own class through the spent-handover marker")
}
Expand Down Expand Up @@ -701,7 +701,7 @@ func TestConnectAgentStoresNothingWhenTheProfileCannotBeCommitted(t *testing.T)
// failure leaves the agent connected to nothing. Saying "nothing was
// stored" alone would send the operator looking for a credential that
// no longer exists anywhere.
assert.Contains(t, cl.joined(), "Disconnect the agent in Basecamp and connect again")
assert.Contains(t, cl.joined(), "Disconnect the new connection in Basecamp and connect again")
}

// TestConnectAgentRefusesTheLaunchpadFallback: Launchpad has no agent
Expand Down Expand Up @@ -842,7 +842,7 @@ func TestConnectAgentRefusesAHandoverWiderThanTheRequest(t *testing.T) {
assert.Contains(t, err.Error(), "read-only")
assert.Empty(t, as.calls(&as.tokenForms), "a handover wider than the request is never minted with")
assertNoAgentCredential(t, m)
assert.Contains(t, cl.joined(), "Disconnect the agent in Basecamp and connect again")
assert.Contains(t, cl.joined(), "Disconnect the new connection in Basecamp and connect again")
}

// TestConnectAgentHonorsASlowRetryAfter: a Retry-After longer than a
Expand Down Expand Up @@ -880,7 +880,7 @@ func TestConnectAgentHandsBackARemedyTheOperatorCanFollow(t *testing.T) {
require.Error(t, err)
var e *output.Error
require.ErrorAs(t, err, &e)
assert.Contains(t, e.Hint, "Disconnect the agent in Basecamp")
assert.Contains(t, e.Hint, "Disconnect the new connection in Basecamp")
assert.NotContains(t, e.Hint, "--with-client-credentials")
}

Expand Down Expand Up @@ -939,7 +939,7 @@ func TestConnectAgentTreatsAnInterruptedHandoverAsSpent(t *testing.T) {
cl := &collectLogger{}
_, err := m.ConnectAgent(context.Background(), connectOptions(cl, newTestClock()))
require.Error(t, err)
assert.Contains(t, cl.joined(), "Disconnect the agent in Basecamp and connect again")
assert.Contains(t, cl.joined(), "Disconnect the new connection in Basecamp and connect again")
assertNoAgentCredential(t, m)
assert.Empty(t, as.calls(&as.tokenForms), "nothing was minted with a credential that never arrived")
}
15 changes: 12 additions & 3 deletions internal/commands/auth_agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ the agent's own OAuth client: this CLI mints an access token from it
whenever it needs one, so there is no token to paste and none to renew by
hand.

Each approval adds a connection with a secret of its own, named after this
computer unless --device-name names it. A personal agent's other
connections, on this computer or another, keep working. A shared agent
keeps a single connection: connecting it replaces the one it had, wherever
that was. Connecting a profile again stores the new connection in place of
the one it held; for a personal agent, the old one stays in Basecamp until
you disconnect it there.

The account comes from the agent you approve — pass --account only to
insist on one, and the connection is refused if the agent belongs to
another.
Expand All @@ -65,8 +73,9 @@ Over SSH, in CI, or on a host with no display the browser is skipped and
the link is yours to open on any device; --local forces a launch anyway,
--no-browser skips it. Ctrl-C cancels the wait and stores nothing.

Disconnecting is done in Basecamp: disconnecting the agent there kills the
client secret this holds. Drop the local copy with ` + "`basecamp auth logout -P <profile>`" + `.`,
Disconnecting is done in Basecamp: disconnecting this connection there kills
the secret this holds, and the agent's other connections keep working. Drop
the local copy with ` + "`basecamp auth logout -P <profile>`" + `.`,
Annotations: map[string]string{AnnotationProfileMayCreate: "true"},
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
Expand Down Expand Up @@ -94,7 +103,7 @@ client secret this holds. Drop the local copy with ` + "`basecamp auth logout -P
},
}

cmd.Flags().StringVar(&deviceName, "device-name", "", "Name this computer on the approval page (default: this host's name)")
cmd.Flags().StringVar(&deviceName, "device-name", "", "Name this connection on the approval page and in Basecamp (default: this host's name)")
cmd.Flags().StringVar(&softwareName, "software-name", "", "Name the software this CLI runs inside, shown as \"Connected to\"")
cmd.Flags().StringVar(&scope, "scope", "", "OAuth scope to ask for: 'read' or 'full' (default full; the approval page can downgrade it)")
cmd.Flags().BoolVar(&noBrowser, "no-browser", false, "Print the link instead of opening a browser")
Expand Down
9 changes: 9 additions & 0 deletions internal/commands/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,11 @@ off nothing. --hold sets a durable hold: intake and admission run, nothing is
handed off, and earlier records wait for review, until basecamp connect
release. Linux and macOS only.

Each basecamp auth agent connect adds a connection with its own secret, so a
personal agent can run a connector on several computers at once. Each one
serves the projects its own setup names; when two serve the same project, a
request both admit can be answered twice.

basecamp connect status what it heard and holds
basecamp connect doctor what it needs to run
basecamp connect discard <id> close a record without running it
Expand Down Expand Up @@ -358,6 +363,10 @@ 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.

The list is this profile's own. When two connections of one agent serve the
same project, a request both admit there can be answered twice, so serve
each project from one of them.

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

Expand Down
4 changes: 2 additions & 2 deletions internal/commands/connect_run.go
Original file line number Diff line number Diff line change
Expand Up @@ -478,9 +478,9 @@ func feedAuthorizationFailed(err error) bool {
func errAgentDisconnected(agent, profile string) *output.Error {
who := richtext.SanitizeSingleLine(agent)
if who == "" {
who = "Your agent"
who = "your agent"
}
e := output.ErrAuth(who + " was disconnected in Basecamp, or connected on another computer")
e := output.ErrAuth("This computer's connection to " + who + " was disconnected in Basecamp")
e.Hint = "Reconnect it: basecamp connect setup -P " + richtext.ShellQuote(profile)
return e
}
Expand Down
8 changes: 4 additions & 4 deletions internal/commands/connect_run_disconnect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ func TestTheConnectorSaysPlainlyWhenItsAgentWasDisconnected(t *testing.T) {
var e *output.Error
require.ErrorAs(t, err, &e)
assert.Equal(t, output.CodeAuth, e.Code)
assert.Equal(t, "Ryan Singer (agent) was disconnected in Basecamp, or connected on another computer", e.Message)
assert.Equal(t, "This computer's connection to Ryan Singer (agent) was disconnected in Basecamp", e.Message)
assert.Equal(t, "Reconnect it: basecamp connect setup -P agent", e.Hint)
assert.NotContains(t, e.Message+e.Hint, "authorization_failed")
assert.Equal(t, connector.ConnectionDisconnected, state)
Expand All @@ -55,7 +55,7 @@ func TestARefusedTokenRenewalIsTheSameDisconnect(t *testing.T) {
state, _, got := connectorStoppedBy(err, "Ryan Singer (agent)", "agent", mustNotAsk(t))
var e *output.Error
require.ErrorAs(t, got, &e, reason)
assert.Equal(t, "Ryan Singer (agent) was disconnected in Basecamp, or connected on another computer", e.Message, reason)
assert.Equal(t, "This computer's connection to Ryan Singer (agent) was disconnected in Basecamp", e.Message, reason)
assert.Equal(t, connector.ConnectionDisconnected, state, reason)
}
}
Expand Down Expand Up @@ -97,7 +97,7 @@ func TestABotUserIsNeverAskedAboutADisconnect(t *testing.T) {
// With no name to give, the message still reads as a sentence.
func TestADisconnectWithoutTheAgentsNameSaysYourAgent(t *testing.T) {
e := errAgentDisconnected("", "agent")
assert.Equal(t, "Your agent was disconnected in Basecamp, or connected on another computer", e.Message)
assert.Equal(t, "This computer's connection to your agent was disconnected in Basecamp", e.Message)
assert.Equal(t, "Reconnect it: basecamp connect setup -P agent", e.Hint)
}

Expand All @@ -117,7 +117,7 @@ func TestTheConnectorKeepsOtherStopsAsTheyAre(t *testing.T) {
// Status says the agent was disconnected first, with the command that
// reconnects it.
func TestConnectStatusLeadsWithDisconnected(t *testing.T) {
detail := "Ryan Singer (agent) was disconnected in Basecamp, or connected on another computer"
detail := "This computer's connection to Ryan Singer (agent) was disconnected in Basecamp"
report := connectStatusReport{Profile: "agent", Status: connector.Status{Connection: &connector.ConnectionStatus{
State: connector.ConnectionDisconnected, PID: 42, ChangedAt: time.Now(), Detail: detail,
}}}
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 @@ -261,7 +261,7 @@ func ensureGuidedAgentConnection(cmd *cobra.Command, app *appctx.App, name strin
if err != nil || !refused {
return false, err
}
fmt.Fprintln(w, r.Warning.Render("This computer's connection to your agent was disconnected in Basecamp, or it was connected on another computer."))
fmt.Fprintln(w, r.Warning.Render("This computer's connection to your agent was disconnected in Basecamp."))
reconnect, err := connectSetupAsk.Confirm("Connect this computer to it again?", true)
if err != nil {
return false, err
Expand Down
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 @@ -314,7 +314,7 @@ func TestGuidedConnectSetupOffersToReconnectARefusedCredential(t *testing.T) {
out, err := runConnectSetupCmd(t, app)
require.NoError(t, err, out)

assert.Contains(t, out, "was disconnected in Basecamp, or it was connected on another computer")
assert.Contains(t, out, "This computer's connection to your agent was disconnected in Basecamp.\n")
assert.Equal(t, "Connect this computer to it again?", p.asked[0])
assert.Equal(t, 2, s.intakeCount(), "a second connection ran")
_, err = setup.Load(connectSetupPath(t, "agent"))
Expand Down
23 changes: 14 additions & 9 deletions skills/basecamp-connect/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ Claude Code only. The connector runs on Linux and macOS.

### 1. Start it, and watch its output

Only one connector per agent can run at a time. If it's already running
elsewhere, it refuses to start and says so: tell the person, and don't start a
One connector per agent can run on this computer. If one is already
running, it refuses to start and says so: tell the person, and don't start a
second.

Start it in the background with the Bash tool (`run_in_background: true`), and
Expand Down Expand Up @@ -429,14 +429,17 @@ Ask only what you can't find out.
computer with none yet): it doesn't exist. Never run `basecamp profile
create` for an agent: that signs in a person. If Basecamp's instructions
page connected this agent over HTTP before (a
`~/.basecamp-agent/credentials.json`; never read it), say first that
connecting replaces that connection, so they approve once more and the
file stops working. Then connect the agent with
`~/.basecamp-agent/credentials.json`; never read it), connecting adds a
connection beside that one, and both keep working; for a shared agent,
say first that connecting replaces that connection, so the file stops
working. Then connect the agent with
`basecamp auth agent connect -P '<profile>'` (add `--no-browser` if the person
is on another device). Show the link and code. They open the link, check the
code, choose the agent (or create one) and approve.
- `oauth_type` `agent`: already connected. Don't connect again: that rotates
the agent's secret and disconnects any other computer using it.
- `oauth_type` `agent`: already connected. Don't connect again: for a
personal agent that adds another connection and leaves the one this
profile held unused in Basecamp; for a shared agent it disconnects any
other computer using it.
- Anything else: a person's login is stored. Ask; never connect an agent over
it.

Expand Down Expand Up @@ -527,7 +530,9 @@ Failed checks worth knowing:
- **Stream ticket refused.** The account event feed isn't enabled for this
account or agent. That's a Basecamp setting the person has to ask for.
- **Scope: not full access.** The agent couldn't reply. Connect again with full
access, with the person's consent, since it rotates the secret.
access, with the person's consent: for a personal agent that adds a
connection, and the read-only one stays in Basecamp until they disconnect it
there; for a shared agent it replaces the connection.

**Never edit connect.json by hand**, and never read it directly: use
`basecamp connect show -P '<profile>' --json`, which checks the file is safe
Expand All @@ -538,7 +543,7 @@ first. Every change goes through setup, which keeps whatever you don't pass.
The background task notifies you when the connector exits. Read the end of its
output and tell the person what it means:

- **"was disconnected in Basecamp, or connected on another computer"**: run
- **"connection to … was disconnected in Basecamp"**: run
`basecamp connect setup -P '<profile>'` (it reconnects with one approval),
then start it again.
- **Already running**: another connector for this agent holds its lock, on
Expand Down
Loading