Sitelet https://github.com/basecamp/basecamp-cli/pull/827
Skip to content

Stop the connector when Basecamp no longer takes its agent's credential - #827

Open
robzolkos wants to merge 2 commits into
mainfrom
connect-e2e/credential-watch
Open

robzolkos wants to merge 2 commits into
mainfrom
connect-e2e/credential-watch

Conversation

@robzolkos

@robzolkos robzolkos commented Oct 2, 2026 •

Copy link
Copy Markdown
Collaborator

The failure

A running basecamp connect does not stop when Basecamp stops taking its agent's credential. This happens when an Agent is disconnected in Basecamp, or connected on another computer (which rotates its secret), and when a bot user's login is revoked or expires.

  • Only the event feed checks for a refused credential, and the feed sends no request while its live WebSocket stays up.
  • Admission and intake read Basecamp all the time, but to them a refused renewal or a 401 is one failed read. Admission blocks the record as read_failed and carries on.
  • The result: every mention to the agent is blocked, with no message, until the socket happens to drop. The person sees an agent that silently ignores them.

Found by the end-to-end scenarios in #826 (disconnect, then mention the agent).

The fix

A credential watch (internal/commands/connect_credential.go), wired into runConnect, sees the reads that admission and intake make:

  • A refused token renewal is already Basecamp's answer. It stops the run at once. For an Agent this is a refused mint (invalid_client, auth.ErrAgentCredentialRefused). For a bot user it is a refused refresh (invalid_grant), which now carries a new cause, auth.ErrLoginRefused.
  • A read answered 401 is only a reason to ask. Something other than Basecamp can answer 401, so the watch asks Basecamp (confirmCredentialRefused) and stops the run only when Basecamp confirms. Signals that arrive while a check is pending coalesce.
    • Agent: the same check the feed's stop uses (agentCredentialRefused).
    • Bot user: the login is refreshed first, because a 401 can be an access token that a refresh repairs. A refused refresh is the answer, and so is a 401 for the fresh token. A login stored from a bare token, with no refresh, is asked about with its own token.
    • A rate-limited (429) or failing (5xx, network) refresh answers nothing, and the run goes on.
    • A proxy that answers 401 to every request also answers the confirm's read, so the connector stops, and the message names the wrong cause. Stopping is still right.
  • The connector exits 3 (auth) in words for its kind:
    • Agent: " was disconnected in Basecamp, or connected on another computer", hint basecamp connect setup -P <profile>. Status records disconnected.
    • Bot user: " is no longer signed in: Basecamp refused its login", hint basecamp auth login -P <profile> --expect-identity <id>. Status records a new state, signed_out, and leads with the same hint.
  • The feed's own authorization failure gets the same wording per kind, because it uses the same confirm.
  • The start's own reads are not watched: a refusal there already ends the start. A bot user's start that meets a refused refresh now says the same "no longer signed in" message.
  • The basecamp-connect skill names the bot-user message and its remedy.

Tests

  • internal/commands/connect_credential_test.go:

    • a refused Agent renewal stops the watch at once, without asking Basecamp;
    • a 401 read makes the watch ask Basecamp, and it stops when Basecamp confirms;
    • a 401 that Basecamp does not confirm leaves the run going, and the next 401 asks again;
    • other failures (a renewal that failed for another reason, 403, 404, 429, 500) neither ask nor stop;
    • bot user, against a test server: a refused refresh stops the watch at once; a 401 confirmed by a 401 for a fresh token stops it; a 401 that a fresh token clears runs on; a refresh refused while confirming is the answer; a 429, 500 or 503 refresh does not stop; a login with no refresh token is asked about with its own token.
  • internal/commands/connect_run_disconnect_test.go: connectorStoppedBy wording, hint and status state for an Agent and for a bot user, for every way the refusal is met; a refused bot refresh at start; status leads with "Signed out" for a bot user.

  • internal/auth/auth_test.go: an invalid_grant refresh carries auth.ErrLoginRefused; invalid_request, 429 and 503 do not.

  • End to end, in Test how basecamp connect fails and recovers, end to end #826 (e2e/connect/failures_test.go), for an Agent:

    • TestAnAgentDisconnectedWhileRunningStopsTheConnector: disconnect, then a read gets 401. This proves the watch's 401 wiring.
    • TestAnAgentDisconnectedWhileRunningStopsTheConnectorOnReconnect: disconnect, then the feed's reconnect is refused.
    • TestAnAgentConnectedElsewhereStopsTheConnectorAtItsNextRenewal: connected on another computer; the next renewal gets invalid_client.

    Each exits 3 with the message. No end-to-end scenario covers the bot-user path yet.

This PR is independent of the end-to-end stack. #826 carries the first commit of this PR (not the bot-user commit), which drops out on rebase once this merges.


Summary by cubic

Stops a running basecamp connect when Basecamp no longer takes its agent's credential - the agent was disconnected in Basecamp, or connected on another computer, which rotates its secret. Previously only the event feed checked for this, but it asks for nothing while its live WebSocket stays up, so the connector ran on blocking every mention silently.

A credential watch now sees the reads admission and intake make:

  • A refused token renewal (invalid_client) is already Basecamp's answer and stops the run immediately.
  • A read answered 401 asks Basecamp to confirm first, and stops the run only on confirmation; bot users aren't affected since the confirm answers false for them.
  • Either way the connector exits 3 with the existing disconnect message and records the status as disconnected.

The start's own reads aren't watched; a refusal there already ends the start.

Written for commit bfd1e36. Summary will update on new commits.

Review in cubic

A running connector learned that its agent was disconnected in Basecamp,
or connected on another computer, only from the event feed. The feed
asks Basecamp for something only when it connects, and while its live
connection stays up it asks for nothing. Admission and intake ask all
the time, but to them a refusal was one failed read among others:
admission blocked the mention it was deciding as read_failed, intake
kept the projects it last read, and the connector ran on. Every mention
addressed to the agent was blocked, and nothing said why, for as long as
the socket held.

Reproduced end to end against the fake Basecamp: disconnect the agent,
or connect it on another computer and let its token come due, while the
connector streams; the operator's next mention is blocked read_failed
and the connector does not exit.

A credential watch now sees the reads admission and intake make. A
token renewal the token endpoint refuses is Basecamp's answer already,
and stops the run at once. A read answered 401 is only a reason to ask,
since something other than Basecamp can answer 401: the watch asks
Basecamp itself, as the feed's own stop does, and stops the run only
when it confirms. Either way the connector exits 3 with the words the
feed's stop uses, and status records it as disconnected.
Copilot AI balanced review requested due to automatic review settings October 2, 2026 18:39
@github-actions github-actions Bot added commands CLI command implementations tests Tests (unit and e2e) labels Oct 2, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

A synchronous confirmation can delay processing a definitive credential refusal.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)
What changed in this PR

Adds credential monitoring so a running connector stops when Basecamp rejects its agent credentials.

Changes:

  • Watches API 401 responses and refused token renewals.
  • Confirms ambiguous 401 responses before stopping.
  • Adds focused credential-watch tests.

[!TIP]
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

File Description
internal/​commands/​connect_run.go Wires credential monitoring into connector reads and shutdown handling.
internal/​commands/​connect_credential.go Implements token and transport credential monitoring.
internal/​commands/​connect_credential_test.go Tests refusal, confirmation, and ignored-failure behavior.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread internal/commands/connect_credential.go Outdated
Comment on lines +69 to +70
if confirm(ctx) {
return errAgentCredentialNotTaken

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 3 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="internal/commands/connect_credential_test.go">

<violation number="1" location="internal/commands/connect_credential_test.go:73">
P2: This only tests retries after a confirmation finishes, not coalescing while one is pending. Add a case that blocks `confirm`, sends multiple 401s, and asserts only one confirmation starts.</violation>
</file>

<file name="internal/commands/connect_credential.go">

<violation number="1" location="internal/commands/connect_credential.go:68">
P3: Running `confirm(ctx)` inline in the select blocks the loop for up to the confirm's 15s timeout, so a `ctx.Done()` or a freshly refused renewal arriving during the confirmation is not observed until it returns. The impact is bounded (a shutdown signal or a refuse arriving mid-confirm waits behind the network call), but this part's goroutine is what the connector's `wg.Wait()` and the second-signal "stop now" path wait on in connect_run.go. Consider restarting the select immediately after `confirm` returns, or making cancellation answerable while a confirmation is pending, so a refusal is not left buffered longer than necessary.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

for range 2 {
roundTrip(t, transport)
select {
case <-asks:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This only tests retries after a confirmation finishes, not coalescing while one is pending. Add a case that blocks confirm, sends multiple 401s, and asserts only one confirmation starts.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At internal/commands/connect_credential_test.go, line 73:

<comment>This only tests retries after a confirmation finishes, not coalescing while one is pending. Add a case that blocks `confirm`, sends multiple 401s, and asserts only one confirmation starts.</comment>

<file context>
@@ -0,0 +1,134 @@
+	for range 2 {
+		roundTrip(t, transport)
+		select {
+		case <-asks:
+		case <-time.After(10 * time.Second):
+			t.Fatal("the watch did not ask about a 401")
</file context>

return nil
case err := <-w.refused:
return err
case <-w.suspect:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Running confirm(ctx) inline in the select blocks the loop for up to the confirm's 15s timeout, so a ctx.Done() or a freshly refused renewal arriving during the confirmation is not observed until it returns. The impact is bounded (a shutdown signal or a refuse arriving mid-confirm waits behind the network call), but this part's goroutine is what the connector's wg.Wait() and the second-signal "stop now" path wait on in connect_run.go. Consider restarting the select immediately after confirm returns, or making cancellation answerable while a confirmation is pending, so a refusal is not left buffered longer than necessary.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At internal/commands/connect_credential.go, line 68:

<comment>Running `confirm(ctx)` inline in the select blocks the loop for up to the confirm's 15s timeout, so a `ctx.Done()` or a freshly refused renewal arriving during the confirmation is not observed until it returns. The impact is bounded (a shutdown signal or a refuse arriving mid-confirm waits behind the network call), but this part's goroutine is what the connector's `wg.Wait()` and the second-signal "stop now" path wait on in connect_run.go. Consider restarting the select immediately after `confirm` returns, or making cancellation answerable while a confirmation is pending, so a refusal is not left buffered longer than necessary.</comment>

<file context>
@@ -0,0 +1,114 @@
+			return nil
+		case err := <-w.refused:
+			return err
+		case <-w.suspect:
+			if confirm(ctx) {
+				return errAgentCredentialNotTaken
</file context>

The credential watch only ever stopped an Agent: its confirm answered no for
a bot user, so a bot whose login was revoked kept running and blocked every
mention as read_failed, the failure the watch exists to end.

A bot user's refresh the token endpoint refuses (invalid_grant) now carries
auth.ErrLoginRefused, and the watch treats it as Basecamp's answer, as it
does an Agent's refused mint. A 401 for a bot user is confirmed with a fresh
token: the login is refreshed, a refused refresh is the answer, and so is a
401 for the fresh token. A rate-limited or failing refresh answers nothing.

The exit says "<bot> is no longer signed in: Basecamp refused its login",
exit 3, with the login that signs it in again under its pinned identity.
Status records it as signed_out and leads with the same remedy. A start that
meets a refused refresh says the same.
Copilot AI balanced review requested due to automatic review settings October 2, 2026 19:20
@github-actions github-actions Bot added skills Agent skills auth OAuth authentication labels Oct 2, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Launchpad client mismatches are incorrectly classified as definitively expired logins, producing misleading shutdown guidance.

Review effort: Balanced
Findings: 2 Medium severity · 1 Low severity

Open (3)

Comment thread internal/auth/auth.go
Comment on lines +689 to +690
refused := m.errAuth(msg)
refused.Cause = ErrLoginRefused
return state, e.Message, e
}

// errCredentialRefused says Basecamp refused who's credential in the words

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 existing issue remains and 3 new issues found across 10 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="internal/commands/connect_operator.go">

<violation number="1" location="internal/commands/connect_operator.go:252">
P2: `connect status --json` reports only `signed out`; the pinned identity and `botSignInHint` are available only to the styled renderer. Include the sign-in command in JSON-accessible output so non-TTY operators can recover the bot login.</violation>
</file>

<file name="internal/commands/connect_run.go">

<violation number="1" location="internal/commands/connect_run.go:530">
P2: `RefreshRefusal` can fail locally even when a refresh token exists, but this path then treats the old access token's 401 as proof the bot login was revoked. Return false for preflight failures when a refresh token exists; probe the current token only when no refresh token is stored.</violation>
</file>

<file name="internal/auth/auth.go">

<violation number="1" location="internal/auth/auth.go:690">
P2: Do not mark every Launchpad `invalid_grant` as a definitive login refusal; a client-ID/secret mismatch is recoverable and must not stop the connector with a misleading signed-out remedy.</violation>
</file>

Requires human review: Auto-approval blocked because this review re-detected 1 unresolved issue already reported by Cubic.

Re-trigger cubic

case connector.ConnectionDisconnected:
parts = append(parts, "disconnected")
case connector.ConnectionSignedOut:
parts = append(parts, "signed out")

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: connect status --json reports only signed out; the pinned identity and botSignInHint are available only to the styled renderer. Include the sign-in command in JSON-accessible output so non-TTY operators can recover the bot login.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At internal/commands/connect_operator.go, line 252:

<comment>`connect status --json` reports only `signed out`; the pinned identity and `botSignInHint` are available only to the styled renderer. Include the sign-in command in JSON-accessible output so non-TTY operators can recover the bot login.</comment>

<file context>
@@ -241,8 +244,13 @@ func runConnectStatus(cmd *cobra.Command, shadow bool) error {
+		case connector.ConnectionDisconnected:
+			parts = append(parts, "disconnected")
+		case connector.ConnectionSignedOut:
+			parts = append(parts, "signed out")
+		}
 	}
</file context>
Suggested change
parts = append(parts, "signed out")
parts = append(parts, "signed out; "+botSignInHint(r.name(), r.identity))

Comment on lines +530 to +534
if app.Auth.RefreshRefusal(creds) == nil {
if err := app.Auth.Refresh(ctx); err != nil {
return errors.Is(err, auth.ErrLoginRefused)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: RefreshRefusal can fail locally even when a refresh token exists, but this path then treats the old access token's 401 as proof the bot login was revoked. Return false for preflight failures when a refresh token exists; probe the current token only when no refresh token is stored.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At internal/commands/connect_run.go, line 530:

<comment>`RefreshRefusal` can fail locally even when a refresh token exists, but this path then treats the old access token's 401 as proof the bot login was revoked. Return false for preflight failures when a refresh token exists; probe the current token only when no refresh token is stored.</comment>

<file context>
@@ -468,19 +492,60 @@ func agentDisconnectedAtStart(kind string, err error) bool {
+	if err != nil {
+		return false
+	}
+	if app.Auth.RefreshRefusal(creds) == nil {
+		if err := app.Auth.Refresh(ctx); err != nil {
+			return errors.Is(err, auth.ErrLoginRefused)
</file context>
Suggested change
if app.Auth.RefreshRefusal(creds) == nil {
if err := app.Auth.Refresh(ctx); err != nil {
return errors.Is(err, auth.ErrLoginRefused)
}
}
if creds.RefreshToken != "" {
if app.Auth.RefreshRefusal(creds) != nil {
return false
}
if err := app.Auth.Refresh(ctx); err != nil {
return errors.Is(err, auth.ErrLoginRefused)
}
}

Comment thread internal/auth/auth.go
}
return m.errAuth(msg)
refused := m.errAuth(msg)
refused.Cause = ErrLoginRefused

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Do not mark every Launchpad invalid_grant as a definitive login refusal; a client-ID/secret mismatch is recoverable and must not stop the connector with a misleading signed-out remedy.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At internal/auth/auth.go, line 690:

<comment>Do not mark every Launchpad `invalid_grant` as a definitive login refusal; a client-ID/secret mismatch is recoverable and must not stop the connector with a misleading signed-out remedy.</comment>

<file context>
@@ -679,7 +686,9 @@ func (m *Manager) refreshCredential(ctx context.Context, origin string, creds *C
 		}
-		return m.errAuth(msg)
+		refused := m.errAuth(msg)
+		refused.Cause = ErrLoginRefused
+		return refused
 	}
</file context>
Suggested change
refused.Cause = ErrLoginRefused
if creds.OAuthType != "launchpad" {
refused.Cause = ErrLoginRefused
}

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

auth OAuth authentication commands CLI command implementations skills Agent skills tests Tests (unit and e2e)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants