diff --git a/README.md b/README.md index 02a39f6ca..f0f60e77c 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/internal/auth/agent_connect.go b/internal/auth/agent_connect.go index fafca6a99..b575be75e 100644 --- a/internal/auth/agent_connect.go +++ b/internal/auth/agent_connect.go @@ -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 diff --git a/internal/auth/agent_connect_test.go b/internal/auth/agent_connect_test.go index e4da47f27..8dc9e76bf 100644 --- a/internal/auth/agent_connect_test.go +++ b/internal/auth/agent_connect_test.go @@ -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") } @@ -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") } @@ -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 @@ -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 @@ -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") } @@ -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") } diff --git a/internal/commands/auth_agent.go b/internal/commands/auth_agent.go index a70239f35..07a3a6976 100644 --- a/internal/commands/auth_agent.go +++ b/internal/commands/auth_agent.go @@ -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. @@ -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 `" + `.`, +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 `" + `.`, Annotations: map[string]string{AnnotationProfileMayCreate: "true"}, Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { @@ -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") diff --git a/internal/commands/connect.go b/internal/commands/connect.go index c59318449..d62595b30 100644 --- a/internal/commands/connect.go +++ b/internal/commands/connect.go @@ -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 close a record without running it @@ -358,6 +363,10 @@ does not serve is handed off, and nobody is told. --watch-completions '` (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. @@ -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 '' --json`, which checks the file is safe @@ -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 ''` (it reconnects with one approval), then start it again. - **Already running**: another connector for this agent holds its lock, on