From 4229ed3fc89be44020134aed7cd53a577a9db317 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 6 Oct 2026 10:56:16 -0700 Subject: [PATCH 1/4] Tell an agent's owner to disconnect it, not rotate its secret auth logout and auth revoke on an agent profile said to rotate the client secret in Basecamp to end the agent's access. My > Agent has no rotate control (reconnecting is the rotation), and a rotation leaves the agent's tokens alive for their hour anyway. Disconnect is what ends its access, so that is what both now say. auth agent's help also stops calling every agent one an administrator created: a personal agent is created by its owner when they connect it. --- internal/auth/agent_test.go | 2 +- internal/auth/revoke.go | 6 ++++-- internal/commands/auth.go | 2 +- internal/commands/auth_agent.go | 4 ++-- internal/commands/auth_logout_test.go | 23 +++++++++++++++++++++++ 5 files changed, 31 insertions(+), 6 deletions(-) diff --git a/internal/auth/agent_test.go b/internal/auth/agent_test.go index f07691881..e5fea4eb1 100644 --- a/internal/auth/agent_test.go +++ b/internal/auth/agent_test.go @@ -421,7 +421,7 @@ func TestAgentRevokeIsRefusedAndKeepsTheCredential(t *testing.T) { err := m.RevokeStored(context.Background()) require.Error(t, err) assert.Equal(t, output.CodeUsage, output.AsError(err).Code) - assert.Contains(t, err.Error(), "rotate the client secret") + assert.Contains(t, err.Error(), "disconnect the agent in Basecamp") assert.Empty(t, as.revokeCalls(), "a revocation was sent for an agent self-token") creds, loadErr := m.store.Load(key) diff --git a/internal/auth/revoke.go b/internal/auth/revoke.go index 2c114cf6c..ee5b73497 100644 --- a/internal/auth/revoke.go +++ b/internal/auth/revoke.go @@ -45,7 +45,9 @@ const ( // RevokeSkippedAgent: an agent self-token is minted on demand from a // client id and secret, so revoking one accomplishes nothing — the // same client mints another on the next command. Ending an agent's - // access means rotating the client secret in Basecamp. + // access means disconnecting it in Basecamp, which kills the client + // secret and every token minted from it. (Basecamp has no separate + // "rotate secret": reconnecting is the rotation.) RevokeSkippedAgent = "agent" ) @@ -194,7 +196,7 @@ func (m *Manager) RevokeStored(ctx context.Context) error { "Forget the credential locally instead: basecamp auth logout") case RevokeSkippedAgent: return output.ErrUsageHint("An agent self-token is not worth revoking: the client that minted it can mint another", - "Forget the credential locally instead (basecamp auth logout), and rotate the client secret in Basecamp to end the agent's access") + "Forget the credential locally instead (basecamp auth logout), and disconnect the agent in Basecamp to end its access") } if err := m.Revoke(ctx, creds); err != nil { // Keep the failure's taxonomy — a transport failure or a 5xx diff --git a/internal/commands/auth.go b/internal/commands/auth.go index 9f3bcf4d4..28d7339bd 100644 --- a/internal/commands/auth.go +++ b/internal/commands/auth.go @@ -1607,7 +1607,7 @@ func describeLogout(done string, result *auth.LogoutResult) (summary string, fie summary = done + " (forgot the imported token; it stays valid until revoked in Basecamp)" case result.Skipped == auth.RevokeSkippedAgent: fields["reason"] = result.Skipped - summary = done + " (forgot the agent credential; rotate the client secret in Basecamp to end its access)" + summary = done + " (forgot the agent credential; it stays connected until you disconnect the agent in Basecamp)" case result.Err != nil: fields["reason"] = result.Err.Error() fields["remaining"] = result.Remaining diff --git a/internal/commands/auth_agent.go b/internal/commands/auth_agent.go index a70239f35..a66eeac4b 100644 --- a/internal/commands/auth_agent.go +++ b/internal/commands/auth_agent.go @@ -19,8 +19,8 @@ func newAuthAgentCmd() *cobra.Command { cmd := &cobra.Command{ Use: "agent", Short: "Connect this computer to a Basecamp agent", - Long: `Connect this computer to a Basecamp agent — an identity your Basecamp -administrator created — so commands run as that agent. + Long: `Connect this computer to a Basecamp agent — your own personal agent, or +one your Basecamp administrator created — so commands run as that agent. The connection hands this CLI the agent's own OAuth client, which mints the access tokens it spends. Nobody pastes a credential: you approve the diff --git a/internal/commands/auth_logout_test.go b/internal/commands/auth_logout_test.go index 07e1d8ec2..41c359669 100644 --- a/internal/commands/auth_logout_test.go +++ b/internal/commands/auth_logout_test.go @@ -121,6 +121,19 @@ func bc5LogoutCredentials(s *revocationServer) *auth.Credentials { } } +// agentLogoutCredentials is a connected agent's stored credential: its own +// client id and secret, and a self-token minted from them. +func agentLogoutCredentials(s *revocationServer) *auth.Credentials { + return &auth.Credentials{ + AccessToken: "agent-at", + OAuthType: "agent", + ClientID: "bc-agent-1", + ClientSecret: "agent-secret", + TokenEndpoint: s.srv.URL + "/oauth/tokens", + Scope: "full", + } +} + func decodeLogoutJSON(t *testing.T, buf *bytes.Buffer) map[string]any { t.Helper() var envelope struct { @@ -181,6 +194,16 @@ func TestAuthLogoutHumanCopy(t *testing.T) { assert.False(t, app.Auth.IsAuthenticated()) }) + t.Run("agent", func(t *testing.T) { + s := startRevocationServer(t) + app, buf := newLogoutTestApp(t, s, output.FormatStyled, agentLogoutCredentials(s)) + require.NoError(t, runLogout(t, app)) + assert.Contains(t, buf.String(), "Logged out (forgot the agent credential; it stays connected until you disconnect the agent in Basecamp)") + assert.NotContains(t, buf.String(), "rotate", "Basecamp has no rotate-secret control to send anyone to") + assert.Empty(t, s.revoked(), "an agent self-token is minted on demand; revoking one ends nothing") + assert.False(t, app.Auth.IsAuthenticated()) + }) + t.Run("not logged in", func(t *testing.T) { s := startRevocationServer(t) app, buf := newLogoutTestApp(t, s, output.FormatStyled, nil) From babca54e676530d1c70cf9ed6a7d2a5c36a2a5d5 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Tue, 6 Oct 2026 21:05:59 -0700 Subject: [PATCH 2/4] Say disconnect in the README too, where it still said rotate The Agents section still told the owner to rotate the client secret to end an agent's access, the advice auth logout and auth revoke just stopped giving. Disconnecting is what ends it. --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 02a39f6ca..803b64d39 100644 --- a/README.md +++ b/README.md @@ -239,8 +239,9 @@ op read "op://Vault/Item/credential" | basecamp auth login --with-client-credent The login mints once, which is what proves the client id and secret: a refused mint stores nothing. `--account` is required when the profile does not exist yet. `basecamp auth logout` forgets the credential; there is no useful -revocation, since the same client would mint another — rotate the client -secret in Basecamp to end an agent's access. +revocation, since the same client would mint another — disconnect the agent in +Basecamp to end its access, which kills its client secret and every token +minted from it. ### Connecting an agent to this computer From 1a7e93a1c01832de8564f60e36d4a7f381c83dfb Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 14 Oct 2026 00:24:52 -0700 Subject: [PATCH 3/4] Point logout and revoke at this connection, not the whole agent Basecamp is about to let a personal agent hold several connections, one per computer or profile, each with its own secret. Then "disconnect the agent" is My > Agent's Disconnect all, which ends every computer, and rotating the client secret rewrites only the first connection's. auth logout and auth revoke now say to disconnect this connection in Basecamp. That is right today as well: an agent has one connection, and disconnecting it is what My > Agent's Disconnect does. The README says where to find it for a personal agent and a shared one, and that Disconnect all, once My > Agent lists several, ends every one. --- README.md | 8 +++++--- internal/auth/agent_test.go | 6 +++--- internal/auth/revoke.go | 13 ++++++++----- internal/commands/auth.go | 2 +- internal/commands/auth_logout_test.go | 2 +- 5 files changed, 18 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 803b64d39..3ecf2f7be 100644 --- a/README.md +++ b/README.md @@ -239,9 +239,11 @@ op read "op://Vault/Item/credential" | basecamp auth login --with-client-credent The login mints once, which is what proves the client id and secret: a refused mint stores nothing. `--account` is required when the profile does not exist yet. `basecamp auth logout` forgets the credential; there is no useful -revocation, since the same client would mint another — disconnect the agent in -Basecamp to end its access, which kills its client secret and every token -minted from it. +revocation, since the same client would mint another. To end its access, +disconnect its connection in Basecamp (on My > Agent for your own agent, in +Adminland for a shared one), which kills that connection's secret and every +token minted from it. Where My > Agent lists several connections, disconnect +the one this profile used: Disconnect all ends every one of them. ### Connecting an agent to this computer diff --git a/internal/auth/agent_test.go b/internal/auth/agent_test.go index e5fea4eb1..d5bc410b1 100644 --- a/internal/auth/agent_test.go +++ b/internal/auth/agent_test.go @@ -392,9 +392,9 @@ func TestAgentMintRefusesARedirect(t *testing.T) { assert.Zero(t, *sinkCalls, "the client secret followed a redirect") } -// TestAgentLogoutSaysRotatingTheSecretIsTheRemedy: revoking one self-token +// TestAgentLogoutForgetsWithoutRevoking: revoking one self-token // accomplishes nothing while the client that minted it can mint another. -func TestAgentLogoutSaysRotatingTheSecretIsTheRemedy(t *testing.T) { +func TestAgentLogoutForgetsWithoutRevoking(t *testing.T) { as := startDeviceAS(t) m := newDeviceTestManager(t, as.srv.URL) key := storeAgent(t, m, agentCredential(as.srv.URL+"/oauth/token", time.Now().Add(time.Hour))) @@ -421,7 +421,7 @@ func TestAgentRevokeIsRefusedAndKeepsTheCredential(t *testing.T) { err := m.RevokeStored(context.Background()) require.Error(t, err) assert.Equal(t, output.CodeUsage, output.AsError(err).Code) - assert.Contains(t, err.Error(), "disconnect the agent in Basecamp") + assert.Contains(t, output.AsError(err).Hint, "disconnect this connection in Basecamp") assert.Empty(t, as.revokeCalls(), "a revocation was sent for an agent self-token") creds, loadErr := m.store.Load(key) diff --git a/internal/auth/revoke.go b/internal/auth/revoke.go index ee5b73497..d7d462eca 100644 --- a/internal/auth/revoke.go +++ b/internal/auth/revoke.go @@ -44,10 +44,13 @@ const ( RevokeSkippedImported = "imported_token" // RevokeSkippedAgent: an agent self-token is minted on demand from a // client id and secret, so revoking one accomplishes nothing — the - // same client mints another on the next command. Ending an agent's - // access means disconnecting it in Basecamp, which kills the client - // secret and every token minted from it. (Basecamp has no separate - // "rotate secret": reconnecting is the rotation.) + // same client mints another on the next command. What ends this + // credential's access is disconnecting its connection in Basecamp, + // which kills that connection's secret and every token minted from it. + // Once an agent can hold several connections, neither older remedy is + // right: rotating the client secret (Basecamp has no control for it) + // rewrites only the first connection's, and disconnecting the whole + // agent ends every other computer's connection too. RevokeSkippedAgent = "agent" ) @@ -196,7 +199,7 @@ func (m *Manager) RevokeStored(ctx context.Context) error { "Forget the credential locally instead: basecamp auth logout") case RevokeSkippedAgent: return output.ErrUsageHint("An agent self-token is not worth revoking: the client that minted it can mint another", - "Forget the credential locally instead (basecamp auth logout), and disconnect the agent in Basecamp to end its access") + "Forget the credential locally instead (basecamp auth logout), and disconnect this connection in Basecamp to end its access") } if err := m.Revoke(ctx, creds); err != nil { // Keep the failure's taxonomy — a transport failure or a 5xx diff --git a/internal/commands/auth.go b/internal/commands/auth.go index 28d7339bd..af938461f 100644 --- a/internal/commands/auth.go +++ b/internal/commands/auth.go @@ -1607,7 +1607,7 @@ func describeLogout(done string, result *auth.LogoutResult) (summary string, fie summary = done + " (forgot the imported token; it stays valid until revoked in Basecamp)" case result.Skipped == auth.RevokeSkippedAgent: fields["reason"] = result.Skipped - summary = done + " (forgot the agent credential; it stays connected until you disconnect the agent in Basecamp)" + summary = done + " (forgot the agent credential; this connection stays live until you disconnect it in Basecamp)" case result.Err != nil: fields["reason"] = result.Err.Error() fields["remaining"] = result.Remaining diff --git a/internal/commands/auth_logout_test.go b/internal/commands/auth_logout_test.go index 41c359669..08968d6b0 100644 --- a/internal/commands/auth_logout_test.go +++ b/internal/commands/auth_logout_test.go @@ -198,7 +198,7 @@ func TestAuthLogoutHumanCopy(t *testing.T) { s := startRevocationServer(t) app, buf := newLogoutTestApp(t, s, output.FormatStyled, agentLogoutCredentials(s)) require.NoError(t, runLogout(t, app)) - assert.Contains(t, buf.String(), "Logged out (forgot the agent credential; it stays connected until you disconnect the agent in Basecamp)") + assert.Contains(t, buf.String(), "Logged out (forgot the agent credential; this connection stays live until you disconnect it in Basecamp)") assert.NotContains(t, buf.String(), "rotate", "Basecamp has no rotate-secret control to send anyone to") assert.Empty(t, s.revoked(), "an agent self-token is minted on demand; revoking one ends nothing") assert.False(t, app.Auth.IsAuthenticated()) From 0904fa53ab00c04ed56859822e3d8ee48fe83f91 Mon Sep 17 00:00:00 2001 From: Jeremy Daer Date: Wed, 14 Oct 2026 00:39:57 -0700 Subject: [PATCH 4/4] Say what ends its access in logout, not that the connection is still live "This connection stays live until you disconnect it" is false when it was already disconnected, or replaced from another computer, which is often why someone is logging out. Saying what to do, as auth revoke's hint does, is true either way. --- internal/commands/auth.go | 2 +- internal/commands/auth_logout_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/commands/auth.go b/internal/commands/auth.go index af938461f..ecab70202 100644 --- a/internal/commands/auth.go +++ b/internal/commands/auth.go @@ -1607,7 +1607,7 @@ func describeLogout(done string, result *auth.LogoutResult) (summary string, fie summary = done + " (forgot the imported token; it stays valid until revoked in Basecamp)" case result.Skipped == auth.RevokeSkippedAgent: fields["reason"] = result.Skipped - summary = done + " (forgot the agent credential; this connection stays live until you disconnect it in Basecamp)" + summary = done + " (forgot the agent credential; disconnect this connection in Basecamp to end its access)" case result.Err != nil: fields["reason"] = result.Err.Error() fields["remaining"] = result.Remaining diff --git a/internal/commands/auth_logout_test.go b/internal/commands/auth_logout_test.go index 08968d6b0..d4f93deaf 100644 --- a/internal/commands/auth_logout_test.go +++ b/internal/commands/auth_logout_test.go @@ -198,7 +198,7 @@ func TestAuthLogoutHumanCopy(t *testing.T) { s := startRevocationServer(t) app, buf := newLogoutTestApp(t, s, output.FormatStyled, agentLogoutCredentials(s)) require.NoError(t, runLogout(t, app)) - assert.Contains(t, buf.String(), "Logged out (forgot the agent credential; this connection stays live until you disconnect it in Basecamp)") + assert.Contains(t, buf.String(), "Logged out (forgot the agent credential; disconnect this connection in Basecamp to end its access)") assert.NotContains(t, buf.String(), "rotate", "Basecamp has no rotate-secret control to send anyone to") assert.Empty(t, s.revoked(), "an agent self-token is minted on demand; revoking one ends nothing") assert.False(t, app.Auth.IsAuthenticated())