Skip to content

fix(auth): prevent stalled keyrings from hanging Linux commands - #805

Open
robzolkos wants to merge 8 commits into
mainfrom
fix-linux-keyring-timeouts
Open

robzolkos wants to merge 8 commits into
mainfrom
fix-linux-keyring-timeouts

Conversation

@robzolkos

@robzolkos robzolkos commented Sep 30, 2026 •

Copy link
Copy Markdown
Collaborator

What

Linux commands now return instead of hanging indefinitely on a stalled keyring. A failed initial probe retains the existing warned file fallback; a later keyring timeout returns an error without silently switching storage or suggesting another login.

  • Linux probe and operation limits are 10 seconds. Interactive macOS/Windows behavior is unchanged.
  • doctor and legacy migration also bound their keyring calls. Unreadable credentials get no login hint or breadcrumb; genuinely missing/broken agent credentials keep their client-credentials remedy.
  • BASECAMP_NO_KEYRING=1 is documented in root/auth/doctor help and SECURITY.md. Migration honors it without marking skipped keyring entries as migrated.

Why

A local Unix socket that stalls the real D-Bus authentication path reproduces the indefinite desktop wait. Subsequent keyring operations and legacy probes needed bounds too, otherwise fixing startup alone leaves other commands able to hang.

Dependency: review and merge basecamp/cli#79 first. This PR pins its reviewed immutable commit 86242a69567b; the Nix vendor hash was regenerated and its Docker Nix build passed. Neither PR has been merged or given auto-merge.

Testing

  • make check passes via full bin/ci on final HEAD 21451b89e88623a20aac18978773a301f16211eb.
  • Hosted checks: 35 passed, 5 conditionally skipped; none failed or pending.
  • Red regression commit 01b43a2c precedes fix 6ab6743a: desktop credential loading stays blocked after 12 seconds before the fix, and returns after approximately 10 seconds afterward. Headless and explicit-bypass controls pass.
  • Real auth status --json against a stalled socket returns after approximately 10.03 seconds with warned file fallback; explicit bypass returns in approximately 0.03 seconds.
  • Additional red commit 5f3c7a54 precedes breadcrumb fix 297efe71. Real doctor checks now distinguish an unreadable store from missing credentials and preserve the agent login remedy.
  • Final binary builds for Linux amd64/arm64, macOS amd64/arm64, and Windows amd64.

Blast radius: no API/SDK change. Fresh Fizzy full tests and command race tests pass against the shared library in a disposable workspace; its zero-value timeout behavior is unchanged. HEY owns a separate credential store. Windows command-test compilation has an unrelated failure on unchanged main too (newOperatorFixture / connectSetupPath); all shipped binary targets compile.

Review corrections: the original change missed direct legacy migration calls, doctor's follow-on recovery/skip paths, timeout remedies on an already-active file backend, and completion markers after bypassed migration. Those paths now have fixes and regression coverage. The credential-subprocess test also has a wider timing margin.

Limits: a started write/delete cannot be canceled and may complete after the timeout. The shared store refuses further operations rather than claiming a safe retry. An unusually slow desktop unlock can hit the initial 10-second bound and trigger the warned fallback. Later-operation timeouts are covered with injected-provider tests, not a complete fake Secret Service. The specific GNOME Keyring crash relationship has not been reproduced.

Related: #800

Earlier automated review and reproduction receipts

Intent

Fix #800. The red subprocess test was committed first (01b43a2), followed by the Linux fix (6ab6743); preserve that history. Linux GUI/TTY and headless credential stores use 10-second ProbeTimeout and OperationTimeout via basecamp/cli#79, pinned to immutable reviewed commit 86242a69567b. Zero/default behavior of other library consumers and interactive macOS/Windows remains unchanged. Keep warned initial-probe plaintext fallback; an established keyring operation timeout is an error with BASECAMP_NO_KEYRING=1 as the explicit plaintext remedy, not a missing login or automatic backend switch. Cover reads/writes/deletes/migration, doctor's bounded best-effort legacy lookup and credential read errors, preserving kind-aware agent remedies. Document the env override in actual root/auth/doctor help and SECURITY.md. No SDK bump or API changes. Nix hash was recomputed and a Docker Nix build passed. The full bin/ci is GREEN with repo-pinned zizmor 1.30.0 and Ruby 3.3 explicitly ahead of ~/.local/bin on PATH; use GOWORK=off to avoid the unrelated parent workspace. All five shipped binaries compile; Windows command TEST compilation fails on unchanged main too (missing newOperatorFixture/connectSetupPath), do not change unrelated connector tests. Fresh Fizzy main's full tests pass against the new shared library in a disposable go.work; HEY has its own store and no shared credstore dependency. Perform scoped review, tests, lint and docs; open an unmerged PR, include dependency ordering (shared PR79 first), blast radius, red-before-fix evidence and checks. Do NOT merge, enable auto-merge, close issue800, squash, or drop prior commits. CI watcher is explicitly skipped only because the local gh wrapper pollutes JSON; actual hosted GitHub checks will be verified separately by the outer agent, not skipped. Basecamp full local bin/ci ran green in this session.

What Changed

Refs #800. Depends on basecamp/cli#79. This branch pins github.com/basecamp/cli to the reviewed commit 86242a69567b, so #79 needs to land first. The Nix vendor hash is updated to match.

  • Credential store (internal/auth): On Linux, the keyring availability probe and every later read, write, delete and migrate now have a 10-second limit, including GUI and TTY sessions. On macOS and Windows, only headless sessions bound the probe, as before. If the first probe times out, the CLI still falls back to plaintext file storage with a warning. If an operation times out after the keyring is already in use, the CLI returns an error instead of reporting a missing login or switching backends. That error suggests BASECAMP_NO_KEYRING=1 as the explicit plaintext option, and only while the keyring is the active store. A subprocess test reproduces the stalled D-Bus handshake. It was committed ahead of the fix so it fails first.
  • doctor and migrate (internal/commands):
    • When the credential store can't be read, doctor now reports "Could not read stored credentials" and skips Authentication with "credentials could not be read", not "no credentials" plus a login hint. Agent credentials that are stored but unusable keep their kind-specific remedy.
    • doctor's legacy bcq::* keyring lookup is best-effort and shares one 10-second budget on Linux.
    • basecamp migrate skips keyring migration when BASECAMP_NO_KEYRING is set. On Linux, each legacy keyring call gives up after 10 seconds, and once one call stalls, the rest fail immediately.
  • Docs and help: Root help has a new CREDENTIAL STORAGE section. auth --help, doctor --help and SECURITY.md now document BASECAMP_NO_KEYRING, the Linux timeouts, and the fact that a timed-out write may still complete.

Risk Assessment

✅ Low: The follow-up commit fixes all four round-1 findings that called for a change, and the no-op tradeoff note needs none. Doctor now reports an unreadable store as a read error, not a missing login. basecamp migrate now honours BASECAMP_NO_KEYRING and bounds its Linux keyring calls. The plaintext-storage remedy is added only while the keyring is in use, and the subprocess test has a wider timing margin. No new defects or intent conflicts turned up.

Testing

Red before fix, green after. The committed subprocess test TestIssue800DesktopKeyringHandshakeMustBeBounded fails on 01b43a2's code: the desktop read is still blocked after 12s. At HEAD it passes, with the desktop read bounded at about 10s. Focused unit tests all pass. They cover: - the Linux probe and operation timeouts, with the non-Linux interactive default unchanged; - a keyring operation timeout returned as an error that names BASECAMP_NO_KEYRING=1, without that remedy when the store has already fallen back to the file; - doctor treating an unreadable store differently from a missing login, and its bounded legacy-install lookup; - the agent-specific login remedy; - bounded migrate and migrate skipping the keyring when bypassed; - the help text. The pinned library's credstore tests pass, and the binary cross-compiles for every platform checked. End to end with the real binaries against a fake desktop D-Bus whose Secret Service handshake never finishes: - Before the fix, auth status, doctor and migrate all hang until killed at 40s. - After the fix, auth status returns in 10s with the plaintext-fallback warning and reads the stored token. doctor finishes in 20s: 10s for the credential probe and 10s for the separate legacy-install lookup. migrate returns in 10s with an error that names BASECAMP_NO_KEYRING=1. - With BASECAMP_NO_KEYRING=1, all three return immediately and print no warning. Help and docs. Root, auth and doctor help render the documented override, and SECURITY.md documents it. Not covered end to end. A timeout after a successful probe would need a working fake Secret Service, so only the unit tests cover it. Scope and cleanup. I did not run the full suite or linters, as this phase requires. Temporary binaries and the extracted base tree were removed; the worktree is clean.

Evidence: Red before fix: subprocess test fails on 01b43a2
=== RUN   TestIssue800DesktopKeyringHandshakeMustBeBounded
=== RUN   TestIssue800DesktopKeyringHandshakeMustBeBounded/desktop
    issue800_keyring_linux_test.go:27: 
        	Error Trace:	/home/rzolkos/.no-mistakes/worktrees/3cdd98e5cfef/01M3S9XAG0RR2CGETZDM0WM72T/internal/auth/issue800_keyring_linux_test.go:105
        	            				/home/rzolkos/.no-mistakes/worktrees/3cdd98e5cfef/01M3S9XAG0RR2CGETZDM0WM72T/internal/auth/issue800_keyring_linux_test.go:27
        	Error:      	Received unexpected error:
        	            	context deadline exceeded
        	Test:       	TestIssue800DesktopKeyringHandshakeMustBeBounded/desktop
        	Messages:   	credential read remained blocked in D-Bus authentication after 12s; child output: === RUN   TestIssue800CredentialReadHelper
=== RUN   TestIssue800DesktopKeyringHandshakeMustBeBounded/headless_control
=== RUN   TestIssue800DesktopKeyringHandshakeMustBeBounded/no_keyring_control
--- FAIL: TestIssue800DesktopKeyringHandshakeMustBeBounded (22.06s)
    --- FAIL: TestIssue800DesktopKeyringHandshakeMustBeBounded/desktop (12.01s)
    --- PASS: TestIssue800DesktopKeyringHandshakeMustBeBounded/headless_control (10.03s)
    --- PASS: TestIssue800DesktopKeyringHandshakeMustBeBounded/no_keyring_control (0.03s)
=== RUN   TestIssue800CredentialReadHelper
    issue800_keyring_linux_test.go:118: subprocess helper
--- SKIP: TestIssue800CredentialReadHelper (0.00s)
FAIL
FAIL	github.com/basecamp/basecamp-cli/internal/auth	22.098s
FAIL
Evidence: Green after fix: same subprocess test passes at HEAD
=== RUN   TestIssue800DesktopKeyringHandshakeMustBeBounded
=== RUN   TestIssue800DesktopKeyringHandshakeMustBeBounded/desktop
=== RUN   TestIssue800DesktopKeyringHandshakeMustBeBounded/headless_control
=== RUN   TestIssue800DesktopKeyringHandshakeMustBeBounded/no_keyring_control
--- PASS: TestIssue800DesktopKeyringHandshakeMustBeBounded (20.10s)
    --- PASS: TestIssue800DesktopKeyringHandshakeMustBeBounded/desktop (10.03s)
    --- PASS: TestIssue800DesktopKeyringHandshakeMustBeBounded/headless_control (10.04s)
    --- PASS: TestIssue800DesktopKeyringHandshakeMustBeBounded/no_keyring_control (0.03s)
=== RUN   TestIssue800CredentialReadHelper
    issue800_keyring_linux_test.go:119: subprocess helper
--- SKIP: TestIssue800CredentialReadHelper (0.00s)
PASS
ok  	github.com/basecamp/basecamp-cli/internal/auth	20.122s
Evidence: CLI transcript: auth status and doctor against a stalled D-Bus, before and after the fix and with BASECAMP_NO_KEYRING=1
================================================================
### BEFORE fix (c0b5896): auth status, desktop session, stalled keyring
$ basecamp-base auth status
  -> exit=124 (KILLED by 40s timeout: hung) elapsed=40.0s
================================================================
### AFTER fix (HEAD): auth status, desktop session, stalled keyring
$ basecamp-head auth status
  warning: system keyring unavailable (keyring probe timed out after 10s: context deadline exceeded), credentials stored in plaintext at /tmp/i800home.D2Ce/cfg/basecamp/credentials.json
  {
    "ok": true,
    "data": {
      "authenticated": true,
      "base_url": "http://127.0.0.1:9",
      "expired": false,
      "expires_at": "2100-01-01T00:00:00Z",
      "expires_in": "642129h49m15s",
      "oauth_type": "bc5",
      "refreshable": false,
      "source": "oauth",
      "storage": "file"
    },
    "summary": "Logged in to http://127.0.0.1:9",
    "meta": {
      "stats": {
        "cache_hits": 0,
        "cache_rate": 0,
        "duration_ms": 10000,
        "failed": 0,
        "latency_ms": 0,
        "operations": 0,
        "requests": 0,
        "retries": 0
      }
    }
  }
  -> exit=0 elapsed=10.0s
================================================================
### AFTER fix (HEAD): auth status with explicit BASECAMP_NO_KEYRING=1
$ BASECAMP_NO_KEYRING=1 basecamp-head auth status
  {
    "ok": true,
    "data": {
      "authenticated": true,
      "base_url": "http://127.0.0.1:9",
      "expired": false,
      "expires_at": "2100-01-01T00:00:00Z",
      "expires_in": "642129h49m15s",
      "oauth_type": "bc5",
      "refreshable": false,
      "source": "oauth",
      "storage": "file"
    },
    "summary": "Logged in to http://127.0.0.1:9",
    "meta": {
      "stats": {
        "cache_hits": 0,
        "cache_rate": 0,
        "duration_ms": 0,
        "failed": 0,
        "latency_ms": 0,
        "operations": 0,
        "requests": 0,
        "retries": 0
      }
    }
  }
  -> exit=0 elapsed=0.0s
================================================================
### BEFORE fix (c0b5896): doctor, desktop session, stalled keyring
$ basecamp-base doctor
  -> exit=124 (KILLED by 40s timeout: hung) elapsed=40.0s
================================================================
### AFTER fix (HEAD): doctor, desktop session, stalled keyring
$ basecamp-head doctor
  warning: system keyring unavailable (keyring probe timed out after 10s: context deadline exceeded), credentials stored in plaintext at /tmp/i800home.D2Ce/cfg/basecamp/credentials.json
  {
    "ok": true,
    "data": {
      "checks": [
        {
          "message": "dev (built from source)",
          "name": "CLI Version",
          "status": "pass"
        },
        {
          "message": "v0.19.0 (6abe227204a4)",
          "name": "SDK",
          "status": "pass"
        },
        {
          "hint": "Create /tmp/i800home.D2Ce/cfg/basecamp/config.json to persist settings",
          "message": "Not found (using defaults)",
          "name": "Global Config",
          "status": "warn"
        },
        {
          "message": "/tmp/i800home.D2Ce/cfg/basecamp/credentials.json",
          "name": "Credentials",
          "status": "pass"
        },
        {
          "message": "Valid",
          "name": "Authentication",
          "status": "pass"
        },
        {
          "hint": "Error: Network error: Get \"http://127.0.0.1:9/authorization.json\": dial tcp 127.0.0.1:9: connect: connection refused",
          "message": "Cannot connect to Basecamp API",
          "name": "API Connectivity",
          "status": "fail"
        },
        {
          "hint": "Set account_id in config or use --account flag",
          "message": "Skipped (no account configured)",
          "name": "Account Access",
          "status": "skip"
        },
        {
          "message": "/tmp/i800home.D2Ce/cache/basecamp (will be created on first use)",
          "name": "Cache",
          "status": "pass"
        },
        {
          "message": "Could not detect shell",
          "name": "Shell Completion",
          "status": "skip"
        }
      ],
      "failed": 1,
      "passed": 5,
      "skipped": 2,
      "warned": 1
    },
    "summary": "5 passed, 1 failed, 1 warning, 2 skipped",
    "breadcrumbs": [
      {
        "action": "status",
        "cmd": "basecamp auth status",
        "description": "Check authentication status"
      }
    ],
    "meta": {
      "stats": {
        "cache_hits": 0,
        "cache_rate": 0,
        "duration_ms": 20009,
        "failed": 1,
        "latency_ms": 0,
        "operations": 1,
        "requests": 1,
        "retries": 0
      }
    }
  }
  -> exit=0 elapsed=20.0s
================================================================
### AFTER fix (HEAD): doctor with explicit BASECAMP_NO_KEYRING=1
$ BASECAMP_NO_KEYRING=1 basecamp-head doctor
  {
    "ok": true,
    "data": {
      "checks": [
        {
          "message": "dev (built from source)",
          "name": "CLI Version",
          "status": "pass"
        },
        {
          "message": "v0.19.0 (6abe227204a4)",
          "name": "SDK",
          "status": "pass"
        },
        {
          "hint": "Create /tmp/i800home.D2Ce/cfg/basecamp/config.json to persist settings",
          "message": "Not found (using defaults)",
          "name": "Global Config",
          "status": "warn"
        },
        {
          "message": "/tmp/i800home.D2Ce/cfg/basecamp/credentials.json",
          "name": "Credentials",
          "status": "pass"
        },
        {
          "message": "Valid",
          "name": "Authentication",
          "status": "pass"
        },
        {
          "hint": "Error: Network error: Get \"http://127.0.0.1:9/authorization.json\": dial tcp 127.0.0.1:9: connect: connection refused",
          "message": "Cannot connect to Basecamp API",
          "name": "API Connectivity",
          "status": "fail"
        },
        {
          "hint": "Set account_id in config or use --account flag",
          "message": "Skipped (no account configured)",
          "name": "Account Access",
          "status": "skip"
        },
        {
          "message": "/tmp/i800home.D2Ce/cache/basecamp (will be created on first use)",
          "name": "Cache",
          "status": "pass"
        },
        {
          "message": "Could not detect shell",
          "name": "Shell Completion",
          "status": "skip"
        }
      ],
      "failed": 1,
      "passed": 5,
      "skipped": 2,
      "warned": 1
    },
    "summary": "5 passed, 1 failed, 1 warning, 2 skipped",
    "breadcrumbs": [
      {
        "action": "status",
        "cmd": "basecamp auth status",
        "description": "Check authentication status"
      }
    ],
    "meta": {
      "stats": {
        "cache_hits": 0,
        "cache_rate": 0,
        "duration_ms": 1,
        "failed": 1,
        "latency_ms": 0,
        "operations": 1,
        "requests": 1,
        "retries": 0
      }
    }
  }
  -> exit=0 elapsed=0.0s
================================================================
fake bus log:
      4   handshake stalled after AUTH EXTERNAL
Evidence: CLI transcript: migrate against a stalled D-Bus, before and after the fix and with BASECAMP_NO_KEYRING=1
================================================================
### BEFORE fix (c0b5896): migrate, desktop session, stalled keyring
$ basecamp-base migrate
  -> exit=124 (KILLED by 40s timeout: hung) elapsed=40.0s
================================================================
### AFTER fix (HEAD): migrate, desktop session, stalled keyring
$ basecamp-head migrate
  {
    "ok": true,
    "data": {
      "cache_message": "no legacy cache directory found",
      "cache_moved": false,
      "keyring_errors": [
        "failed to read http://127.0.0.1:9: keyring operation timed out: context deadline exceeded; set BASECAMP_NO_KEYRING=1 to skip the keyring"
      ],
      "keyring_migrated": 0,
      "theme_message": "no legacy theme directory found",
      "theme_moved": false
    },
    "summary": "1 keyring errors",
    "breadcrumbs": [
      {
        "action": "doctor",
        "cmd": "basecamp doctor",
        "description": "Verify installation health"
      }
    ],
    "meta": {
      "stats": {
        "cache_hits": 0,
        "cache_rate": 0,
        "duration_ms": 10000,
        "failed": 0,
        "latency_ms": 0,
        "operations": 0,
        "requests": 0,
        "retries": 0
      }
    }
  }
  -> exit=0 elapsed=10.0s
================================================================
### AFTER fix (HEAD): migrate with explicit BASECAMP_NO_KEYRING=1
$ BASECAMP_NO_KEYRING=1 basecamp-head migrate
  {
    "ok": true,
    "data": {
      "cache_message": "no legacy cache directory found",
      "cache_moved": false,
      "keyring_migrated": 0,
      "theme_message": "no legacy theme directory found",
      "theme_moved": false
    },
    "summary": "nothing to migrate",
    "breadcrumbs": [
      {
        "action": "doctor",
        "cmd": "basecamp doctor",
        "description": "Verify installation health"
      }
    ],
    "meta": {
      "stats": {
        "cache_hits": 0,
        "cache_rate": 0,
        "duration_ms": 0,
        "failed": 0,
        "latency_ms": 0,
        "operations": 0,
        "requests": 0,
        "retries": 0
      }
    }
  }
  -> exit=0 elapsed=0.0s
================================================================
fake bus log:
      2   handshake stalled after AUTH EXTERNAL
Evidence: Rendered root, auth and doctor help showing BASECAMP_NO_KEYRING
$ basecamp --help
41-CREDENTIAL STORAGE
42:  Set BASECAMP_NO_KEYRING=1 to bypass the system keyring.
43-  Uses plaintext credentials.json in the config directory (mode 0600).
44-
45-EXAMPLES
46-  $ basecamp projects
47-  $ basecamp todos
48-  $ basecamp todos create "Write the proposal"
49-  $ basecamp search "quarterly review"
50-

$ basecamp auth --help
2-
3:Credentials use the system keyring when available. Set BASECAMP_NO_KEYRING=1
4-before running a command to bypass the keyring and use plaintext credentials
5-in the config directory (credentials.json, mode 0600).
6-
7-On Linux, keyring probes and operations time out after 10 seconds. An initial
8-probe failure uses the warned file fallback. A later operation timeout returns
9-an error without switching storage; a timed-out write may still complete.
10-
11-USAGE

$ basecamp doctor --help
12-
13:If the system keyring stalls, set BASECAMP_NO_KEYRING=1 before running doctor.
14-This explicitly selects plaintext credential storage (credentials.json, mode
15-0600); it does not copy credentials out of the keyring. Linux keyring checks
16-have a 10-second deadline, including the best-effort legacy-install lookup.
17-
18-Examples:
19-  basecamp doctor              # Run all diagnostic checks
20-  basecamp doctor --json       # Output results as JSON
21-  basecamp doctor --verbose    # Show additional debug information
Evidence: SECURITY.md documentation diff
diff --git a/SECURITY.md b/SECURITY.md
index 64d11a9..33b76ff 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -26,7 +26,23 @@ If system keyring is unavailable (headless servers, containers), set:
 export BASECAMP_NO_KEYRING=1
 `` `
 
-Credentials will be stored in `~/.config/basecamp/credentials.json` with `0600` permissions.
+Credentials will be stored in `~/.config/basecamp/credentials.json` with `0600` permissions
+(or in the configured XDG config directory). This is plaintext storage, not encryption.
+Any non-empty `BASECAMP_NO_KEYRING` value bypasses the keyring before it is probed.
+
+On Linux, the availability probe and each later keyring operation are bounded
+by 10 seconds, including desktop sessions. An initial probe timeout uses the
+file fallback and prints a warning on the first credential read or write.
+Existing keyring credentials are not copied to the file: a fallback file may
+be absent or stale.
+
+After a successful probe, a later timeout returns an error; it never silently
+switches to plaintext or serves stale file credentials. The keyring library
+cannot cancel a started operation, so a timed-out write or delete may still
+complete. That store refuses further keyring operations for the rest of the
+process. Do not automatically retry a write whose outcome is unknown.
+
+Interactive macOS and Windows keyring behavior is unchanged.
 
 ## Supported Versions
 
Evidence: Focused unit test results
--- PASS: TestEnsureBoundsLinuxKeyringAndHeadlessProbes (0.00s)
--- PASS: TestKeyringTimeoutIsAnErrorWithAnExplicitFileStorageRemedy (0.00s)
--- PASS: TestFileStoreErrorsAfterAProbeTimeoutOmitTheFileStorageRemedy (0.00s)
PASS
ok  	github.com/basecamp/basecamp-cli/internal/auth	0.030s
--- PASS: TestAuthStatusCheckAcceptsAValidAgentToken (0.00s)
--- PASS: TestAuthStatusCheckOnAnAgentWithoutAnAccountAsksForOne (0.00s)
--- PASS: TestAuthStatusOnABrokenAgentOffersTheAgentLogin (0.00s)
--- PASS: TestDoctorDoesNotCallAnUnreadableStoreAMissingLogin (0.00s)
--- PASS: TestDoctorLegacyKeyringProbeIsBounded (0.04s)
--- PASS: TestDoctorLegacyKeyringProbeFindsHealthyEntries (0.00s)
--- PASS: TestMigrateKeyring_StalledKeyringIsBounded (0.04s)
--- PASS: TestMigrateSkipsKeyringWhenNoKeyring (0.00s)
PASS
ok  	github.com/basecamp/basecamp-cli/internal/commands	0.144s
--- PASS: TestKeyringBypassIsDiscoverableInHelp (0.00s)
    --- PASS: TestKeyringBypassIsDiscoverableInHelp/--help (0.00s)
    --- PASS: TestKeyringBypassIsDiscoverableInHelp/auth_--help (0.00s)
    --- PASS: TestKeyringBypassIsDiscoverableInHelp/doctor_--help (0.00s)
PASS
ok  	github.com/basecamp/basecamp-cli/internal/cli	0.032s
ok  	github.com/basecamp/cli/credstore	0.206s
--- PASS: TestDoctorOffersTheAgentLoginForABrokenAgent (0.00s)
ok  	github.com/basecamp/basecamp-cli/internal/commands	0.036s
Evidence: Fake stalled Secret Service D-Bus server
# Fake D-Bus session bus that stalls the SASL EXTERNAL handshake (issue #800).
import socket, sys, os, threading
path = sys.argv[1]
if os.path.exists(path): os.unlink(path)
s = socket.socket(socket.AF_UNIX); s.bind(path); s.listen(16)
held = []
def serve(c):
    f = c.makefile('rb')
    f.readline()                       # "\0AUTH\r\n"
    c.sendall(b"REJECTED EXTERNAL\r\n")
    f.readline()                       # "AUTH EXTERNAL ..."
    print("handshake stalled after AUTH EXTERNAL", flush=True)
    held.append(c)                     # never answer OK
while True:
    c, _ = s.accept()
    threading.Thread(target=serve, args=(c,), daemon=True).start()
Evidence: Script for the auth status / doctor end-to-end check
#!/usr/bin/env bash
# Drive the real basecamp binary against a fake Secret Service bus whose
# SASL handshake never completes (basecamp/basecamp-cli#800), in an isolated
# HOME/XDG_CONFIG_HOME seeded with a plaintext credentials.json.
set -u
E=/tmp/no-mistakes-evidence/01M3S9XAG0RR2CGETZDM0WM72T
SOCKDIR=$(mktemp -d /tmp/i800.XXXX); SANDBOX=$(mktemp -d /tmp/i800home.XXXX)
python3 $E/stalled_dbus.py $SOCKDIR/bus > $SOCKDIR/bus.log 2>&1 & BUS=$!
trap 'kill $BUS 2>/dev/null; rm -rf $SOCKDIR $SANDBOX' EXIT
sleep 0.5
mkdir -p $SANDBOX/cfg/basecamp
printf '{\n  "http://127.0.0.1:9": {"access_token":"fallback-token","oauth_type":"bc5","expires_at":4102444800}\n}\n' > $SANDBOX/cfg/basecamp/credentials.json
chmod 600 $SANDBOX/cfg/basecamp/credentials.json
run() { # label, binary, extra-env, args...
  local label=$1 bin=$2 extra=$3; shift 3
  echo "================================================================"
  echo "### $label"
  echo "\$ ${extra:+$extra }$(basename $bin) $*"
  local start=$(date +%s.%N)
  env -i PATH=/usr/bin:/bin HOME=$SANDBOX XDG_CONFIG_HOME=$SANDBOX/cfg XDG_CACHE_HOME=$SANDBOX/cache \
      XDG_RUNTIME_DIR=$SOCKDIR WAYLAND_DISPLAY=wayland-issue800 TERM=dumb NO_COLOR=1 \
      DBUS_SESSION_BUS_ADDRESS=unix:path=$SOCKDIR/bus BASECAMP_BASE_URL=http://127.0.0.1:9 \
      BASECAMP_NO_UPDATE_CHECK=1 $extra \
      timeout 40 $bin "$@" 2>&1 | sed 's/^/  /'
  local rc=${PIPESTATUS[0]}
  printf '  -> exit=%s%s elapsed=%.1fs\n' $rc "$([ $rc = 124 ] && echo ' (KILLED by 40s timeout: hung)')" "$(echo "$(date +%s.%N) - $start" | bc)"
}
run "BEFORE fix (c0b5896): auth status, desktop session, stalled keyring" /tmp/issue800-bin/basecamp-base "" auth status
run "AFTER fix (HEAD): auth status, desktop session, stalled keyring"  /tmp/issue800-bin/basecamp-head "" auth status
run "AFTER fix (HEAD): auth status with explicit BASECAMP_NO_KEYRING=1" /tmp/issue800-bin/basecamp-head "BASECAMP_NO_KEYRING=1" auth status
run "BEFORE fix (c0b5896): doctor, desktop session, stalled keyring"   /tmp/issue800-bin/basecamp-base "" doctor
run "AFTER fix (HEAD): doctor, desktop session, stalled keyring"      /tmp/issue800-bin/basecamp-head "" doctor
run "AFTER fix (HEAD): doctor with explicit BASECAMP_NO_KEYRING=1"     /tmp/issue800-bin/basecamp-head "BASECAMP_NO_KEYRING=1" doctor
echo "================================================================"
echo "fake bus log:"; sed 's/^/  /' $SOCKDIR/bus.log | sort | uniq -c
Evidence: Script for the migrate end-to-end check
#!/usr/bin/env bash
# Drive the real basecamp binary against a fake Secret Service bus whose
# SASL handshake never completes (basecamp/basecamp-cli#800), in an isolated
# HOME/XDG_CONFIG_HOME seeded with a plaintext credentials.json.
set -u
E=/tmp/no-mistakes-evidence/01M3S9XAG0RR2CGETZDM0WM72T
SOCKDIR=$(mktemp -d /tmp/i800.XXXX); SANDBOX=$(mktemp -d /tmp/i800home.XXXX)
python3 $E/stalled_dbus.py $SOCKDIR/bus > $SOCKDIR/bus.log 2>&1 & BUS=$!
trap 'kill $BUS 2>/dev/null; rm -rf $SOCKDIR $SANDBOX' EXIT
sleep 0.5
mkdir -p $SANDBOX/cfg/basecamp
printf '{\n  "http://127.0.0.1:9": {"access_token":"fallback-token","oauth_type":"bc5","expires_at":4102444800}\n}\n' > $SANDBOX/cfg/basecamp/credentials.json
chmod 600 $SANDBOX/cfg/basecamp/credentials.json
run() { # label, binary, extra-env, args...
  local label=$1 bin=$2 extra=$3; shift 3
  echo "================================================================"
  echo "### $label"
  echo "\$ ${extra:+$extra }$(basename $bin) $*"
  local start=$(date +%s.%N)
  env -i PATH=/usr/bin:/bin HOME=$SANDBOX XDG_CONFIG_HOME=$SANDBOX/cfg XDG_CACHE_HOME=$SANDBOX/cache \
      XDG_RUNTIME_DIR=$SOCKDIR WAYLAND_DISPLAY=wayland-issue800 TERM=dumb NO_COLOR=1 \
      DBUS_SESSION_BUS_ADDRESS=unix:path=$SOCKDIR/bus BASECAMP_BASE_URL=http://127.0.0.1:9 \
      BASECAMP_NO_UPDATE_CHECK=1 $extra \
      timeout 40 $bin "$@" 2>&1 | sed 's/^/  /'
  local rc=${PIPESTATUS[0]}
  printf '  -> exit=%s%s elapsed=%.1fs\n' $rc "$([ $rc = 124 ] && echo ' (KILLED by 40s timeout: hung)')" "$(echo "$(date +%s.%N) - $start" | bc)"
}






mkdir -p $SANDBOX/.config/bcq; echo '{}' > $SANDBOX/.config/bcq/config.json
run "BEFORE fix (c0b5896): migrate, desktop session, stalled keyring" /tmp/issue800-bin/basecamp-base "" migrate
run "AFTER fix (HEAD): migrate, desktop session, stalled keyring"  /tmp/issue800-bin/basecamp-head "" migrate
run "AFTER fix (HEAD): migrate with explicit BASECAMP_NO_KEYRING=1" /tmp/issue800-bin/basecamp-head "BASECAMP_NO_KEYRING=1" migrate
echo "================================================================"
echo "fake bus log:"; sed 's/^/  /' $SOCKDIR/bus.log | sort | uniq -c
Evidence: Before vs after summary
BEFORE (c0b5896) basecamp auth status -> exit=124 (killed by 40s timeout: hung)
AFTER (HEAD) basecamp auth status -> warning: system keyring unavailable (keyring probe timed out after 10s ...), storage=file, authenticated=true, elapsed=10.0s
AFTER BASECAMP_NO_KEYRING=1 auth status -> no warning, elapsed=0.0s
BEFORE doctor -> hung (killed at 40s); AFTER doctor -> completes in 20.0s; with BASECAMP_NO_KEYRING=1 -> 0.0s
BEFORE migrate -> hung (killed at 40s); AFTER migrate -> 10.0s, keyring_errors: "keyring operation timed out ...; set BASECAMP_NO_KEYRING=1 to skip the keyring"

Pipeline

Updates from git push no-mistakes

✅ **intent** - passed

✅ No issues found.

✅ **Rebase** - passed

✅ No issues found.

🔧 **Review** - 5 issues found → auto-fixed ✅
  • ⚠️ internal/commands/doctor.go:178 - When checkCredentials fails because the keyring read timed out (or the store is otherwise unreadable), runDoctorChecks still adds an Authentication entry reading "Skipped (no credentials)" with Hint app.Auth.LoginHint(). So after a Linux keyring timeout, doctor tells the user they have no credentials and should run an OAuth login. The intent says a timeout is "an error with BASECAMP_NO_KEYRING=1 as the explicit plaintext remedy, not a missing login", and the new checkCredentials branch deliberately leaves Hint empty for this case, but the next check puts the login remedy back. Fix: keep the login-hinted skip only for a real missing or unusable credential (the !authenticated or CodeAuth cases). For a read error, skip with a message such as "Skipped (credentials could not be read)" and no login hint.
  • ⚠️ internal/commands/migrate.go:160 - Sibling path left unbounded. basecamp migrate calls go-keyring directly through keyringOps.get/set/delete, with no Linux timeout, and it runs even when BASECAMP_NO_KEYRING is set. On a desktop Linux session with the auth login/status/doctor hang forever (no timeout) when keyring's D-Bus handshake stalls; BASECAMP_NO_KEYRING=1 works but is undocumented #800 D-Bus stall it still hangs forever. That contradicts the new root help ("Set BASECAMP_NO_KEYRING=1 to bypass the system keyring") and SECURITY.md ("Any non-empty BASECAMP_NO_KEYRING value bypasses the keyring"). Doctor's legacy check can also recommend basecamp migrate after its own bounded lookup gives up, if bcq cache or theme directories exist. Suggested fix: skip migrateKeyring when BASECAMP_NO_KEYRING is set, and on Linux run it under the same budget as legacyKeyringEntryExists. This goes somewhat beyond the stated scope, so confirm with the author.
  • ℹ️ internal/auth/keyring.go:282 - keyringOperationError decides by errors.Is(DeadlineExceeded) plus a "keyring" substring. After an initial probe timeout, credstore's fallback file Load wraps probeErr ("keyring probe timed out…: context deadline exceeded") into every file error. A non-missing file failure, such as a corrupt credentials.json or permission denied, therefore gets "to use plaintext credential storage explicitly, set BASECAMP_NO_KEYRING=1" appended, even though the store is already on plaintext and the env var would not fix the problem. Consider adding the remedy only when s.ensure().UsingKeyring() is true.
  • ℹ️ internal/auth/keyring.go:141 - Tradeoff note. Linux Secret Service backends (gnome-keyring, KWallet) also show unlock prompts for locked collections. Under the 10s probe and operation bounds, a user who takes longer than 10s to unlock will get either the warned plaintext fallback (probe) or an error that blocks keyring use for the rest of the process (operation). The intent explicitly authorizes 10s bounds for Linux GUI/TTY sessions, so no change is needed, but SECURITY.md and the code comment could mention that a slow unlock prompt on Linux is also cut off.
  • ℹ️ internal/auth/issue800_keyring_linux_test.go:93 - The subprocess test gives only a 2s margin over the 10s probe for process startup, the test-framework init and the file load. It is also sequential and adds about 20s of wall time to go test ./internal/auth on Linux. release.yml runs the whole suite with -race across parallel packages, where a loaded runner could go over the 12s budget and fail the test spuriously. A slightly larger margin, such as +5s, would still catch the unbounded hang and lower that risk.

🔧 Fix: Bound migrate keyring calls, fix doctor read-error skip and remedy
✅ Re-checked - no issues remain.

✅ **Test** - passed

✅ No issues found.

  • Red before fix: temporarily restored 01b43a2's internal/auth/, go.mod and go.sum in the worktree and ran GOWORK=off go test ./internal/auth/ -run TestIssue800 -count=1 -v. The desktop subtest FAILED: the read was still blocked in D-Bus authentication after 12s. The headless and no-keyring controls passed. Files were then restored from HEAD.
  • Green after fix: GOWORK=off go test ./internal/auth/ -run TestIssue800 -count=1 -v at HEAD. The desktop subtest passed in 10.03s, and so did the headless and no-keyring controls.
  • GOWORK=off go test ./internal/auth/ -run 'TestEnsureBoundsLinuxKeyringAndHeadlessProbes|TestKeyringTimeoutIsAnErrorWithAnExplicitFileStorageRemedy|TestFileStoreErrorsAfterAProbeTimeoutOmitTheFileStorageRemedy' -v
  • GOWORK=off go test ./internal/commands/ -run 'TestDoctorDoesNotCallAnUnreadableStoreAMissingLogin|TestDoctorLegacyKeyringProbe|TestMigrateKeyring_StalledKeyringIsBounded|TestMigrateSkipsKeyringWhenNoKeyring|TestAuthStatus.*Agent|TestDoctorOffersTheAgentLoginForABrokenAgent' -v
  • GOWORK=off go test ./internal/cli/ -run TestKeyringBypassIsDiscoverableInHelp -v, which covers root, auth and doctor help
  • Broader targeted packages: go test ./internal/auth/ -run 'Ensure|Keyring|FileStore|Store', go test ./internal/cli/ -run 'Help', go test ./internal/commands/ -run 'Doctor|Migrate|Credential|AuthStatus|LegacyInstall'
  • GOWORK=off go test github.com/basecamp/cli/credstore against the pinned 86242a69567b library
  • Manual CLI check: built the base (c0b5896) and HEAD binaries and ran auth status and doctor in an isolated HOME/XDG setup with a seeded credentials.json, WAYLAND_DISPLAY set, and DBUS_SESSION_BUS_ADDRESS pointed at stalled_dbus.py. Each was run with and without BASECAMP_NO_KEYRING=1 (script: drive_stalled_bus.sh).
  • Manual CLI check: migrate with a legacy bcq config, before and after the fix, and with BASECAMP_NO_KEYRING=1 (script: drive_migrate.sh)
  • Rendered basecamp --help, basecamp auth --help and basecamp doctor --help from the HEAD binary and captured the BASECAMP_NO_KEYRING sections. Captured the SECURITY.md diff.
  • Cross-compiled ./cmd/basecamp with CGO_ENABLED=0 for darwin, linux and windows on amd64 and arm64, plus freebsd/amd64 and openbsd/arm64. All built.
✅ **Document** - passed

✅ No issues found.

✅ **Lint** - passed

✅ No issues found.

✅ **Push** - passed

✅ No issues found.


Summary by cubic

Fixes the CLI hanging indefinitely when the Linux keyring's Secret Service D-Bus handshake stalls (#800) by bounding keyring probes and operations with a 10-second deadline.

Behavior

  • Keyring probe and read/write/delete/migrate calls now time out after 10 seconds on Linux, including GUI and TTY sessions; macOS and Windows interactive behavior is unchanged.
  • A timed-out initial probe still falls back to plaintext file storage with a warning, while a timeout after the keyring is in use returns an error naming BASECAMP_NO_KEYRING=1, never a missing-login message or automatic backend switch.
  • doctor reports an unreadable store as a read error and skips authentication and login breadcrumbs instead of prompting for a login; its legacy bcq::* lookup is best-effort and shares the 10-second budget.
  • basecamp migrate bounds its Linux keyring calls, skips the keyring when BASECAMP_NO_KEYRING is set, and skips the completion marker in that case so leftover bcq entries are still found on a later run.
  • Documents BASECAMP_NO_KEYRING, the Linux timeouts, and the timed-out-write caveat in root/auth/doctor help and SECURITY.md.

Adoption

Written for commit 21451b8. Summary will update on new commits.

Review in cubic

Copilot AI balanced review requested due to automatic review settings September 30, 2026 14:15
@github-actions github-actions Bot added commands CLI command implementations tests Tests (unit and e2e) auth OAuth authentication docs deps labels Sep 30, 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

🔵 Needs a closer look

Authentication storage behavior depends on the still-unmerged basecamp/cli#79 concurrency and timeout implementation.

Review effort: Balanced
Findings: None

What changed in this PR

Bounds Linux keyring operations to prevent stalled D-Bus handshakes from hanging the CLI, with improved diagnostics, migration handling, documentation, and regression coverage.

Changes:

  • Applies 10-second Linux keyring probe/operation limits through basecamp/cli#79.
  • Preserves explicit fallback/error semantics across authentication, doctor, and migration.
  • Documents and tests BASECAMP_NO_KEYRING.

[!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
SECURITY.md Documents keyring deadlines and plaintext fallback risks.
nix/​package.nix Updates the dependency vendor hash.
go.mod Pins the reviewed credential-store implementation.
go.sum Updates dependency checksums.
internal/​auth/​keyring.go Configures Linux timeouts and improves timeout remedies.
internal/​auth/​keyring_test.go Tests timeout configuration and errors.
internal/​auth/​issue800_keyring_linux_test.go Reproduces the stalled D-Bus handshake.
internal/​commands/​auth.go Adds credential-storage help.
internal/​commands/​auth_status_agent_test.go Adapts agent diagnostic coverage.
internal/​commands/​doctor.go Distinguishes unreadable credentials and bounds legacy lookup.
internal/​commands/​doctor_test.go Covers doctor timeout and diagnostic behavior.
internal/​commands/​migrate.go Bounds legacy keyring migration and honors bypass.
internal/​commands/​migrate_test.go Tests migration timeout and bypass behavior.
internal/​cli/​help.go Adds root credential-storage guidance.
internal/​cli/​help_test.go Verifies override discoverability.

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

Copilot AI balanced review requested due to automatic review settings September 30, 2026 14:41

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

Doctor can still convert a timeout during its later authentication read into an incorrect login recommendation.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)

Comment on lines +174 to +179
} else if unreadable {
checks = append(checks, Check{
Name: "Authentication",
Status: "skip",
Message: "Skipped (credentials could not be read)",
})
Copilot AI balanced review requested due to automatic review settings September 30, 2026 14:52

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

🔵 Needs a closer look

A timeout during doctor’s later authentication read still incorrectly recommends logging in.

Review effort: Balanced
Findings: 1 Medium severity

Open (1)

@robzolkos robzolkos changed the title fix(auth): bound Linux keyring calls so a stalled D-Bus handshake cannot hang the CLI fix(auth): prevent stalled keyrings from hanging Linux commands Sep 30, 2026

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 deps docs tests Tests (unit and e2e)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

auth login/status/doctor hang forever (no timeout) when keyring's D-Bus handshake stalls; BASECAMP_NO_KEYRING=1 works but is undocumented

2 participants