Repository navigation
Conversation
Login: - `webex login --device` uses the Webex OAuth device grant and prints "Open <url> and enter <code>" (plus a QR code on a terminal); with --output json the code and the result are emitted as JSON on stdout. - Plain `webex login` picks device login over SSH, in CI, on Linux without a display, or when the browser flow cannot start. --browser and WEBEX_LOGIN_MODE override the choice. - Tokens fall back to a 0600 credentials file under ~/.config/webex-cli when no OS keyring is available; WEBEX_TOKEN_STORE forces a store and `auth status` reports it. Refreshes are serialized with a file lock. - WEBEX_REFRESH_TOKEN (with optional WEBEX_CLIENT_ID/SECRET) mints access tokens for unattended use, and `webex auth export` prints a refresh token to seed it. Install and update: - install.sh, install.ps1 and `webex update` find the latest version from the release checksums.txt, then the Go module proxy, then the GitHub API, and accept a WEBEX_CLI_VERSION pin. - Archives are verified against checksums.txt before install. - post-install runs without prompts when there is no terminal and never fails the installer; skill files are embedded in the binary. - Add `webex --version` and honor INSTALL_DIR. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EdRf9uewJ53wofcX185zGC
`@cloverhound/webex-cli` is a small Node shim whose optionalDependencies are one `@cloverhound/webex-cli-<os>-<cpu>` package per release binary, so npm installs only the matching binary with no install script and no GitHub download. That reaches sandboxes that allow package registries but block other hosts, and lets agent MCP configs use `npx -y @cloverhound/webex-cli mcp serve`. The shim forwards signals so MCP hosts don't orphan the binary. The release workflow stages the packages from goreleaser's dist/ and publishes them with provenance once an NPM_TOKEN secret exists. `webex update` points npm installs back at npm. The binary also embeds Go's fallback root certificates, which are used only when the OS has no CA bundle, as in slim container images like node:slim. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EdRf9uewJ53wofcX185zGC
`webex mcp serve` no longer resolves a token at startup. Each tool call runs the CLI in a child process that resolves its own login, so a host that starts the server in a fresh sandbox gets working tools once someone logs in, instead of a server that exited at launch. The release workflow now always runs the npm step with npm 11, which authenticates through trusted publishing when the packages are configured for it and falls back to the NPM_TOKEN secret for the first publish. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EdRf9uewJ53wofcX185zGC
Treat a token missing from both stores as absent, and report a keyring read failure instead of "not found" when the file has no copy, so the purge still fails safe on a locked keychain. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EdRf9uewJ53wofcX185zGC
7e17596 to
bdd941c
Compare
- Reject write-capable logins in `auth export` under read-only mode, and pick its user the same way other commands do (--user, $WEBEX_USER, folder default). - Purge cached $WEBEX_REFRESH_TOKEN tokens on read-only login and `logout --all`. - Share one prompt check and confirm form, drawn on stderr, across login, export, read-only exit, and post-install; $WEBEX_CLI_NONINTERACTIVE now covers them all. - Export ErrTokenNotFound, return the store from SaveTokenWithStore, drop a stale keyring copy when saving falls back to the file, and rename SourceKeyring to SourceStored and keyring.go to token.go. - Report read-only purge results in login JSON, and explain env tokens and store errors accurately in `auth status`. - Use go.mod's Go version in CI and release workflows, run gofmt, and update docs that still described the keyring as the only store. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EdRf9uewJ53wofcX185zGC
edumansky
left a comment
There was a problem hiding this comment.
Codex review (gpt-6-astra, reasoning effort: high)
Requested focus: correctness, plus awkward duplication and poor factoring.
Overall,I would address the token-store cleanup and recovery issues before merging. The principal integration problem is that main’s single-store read-only assumptions remain underneath the new dual-store behavior; auth-session ownership also needs tightening. Diff whitespace checks and shell/JavaScript syntax checks passed. This was a static review of 0c202e2; no files were modified and runtime tests were not run.
| if errors.Is(err, ErrTokenNotFound) || (err == nil && tok.ReadOnly) { | ||
| continue | ||
| } | ||
| if err := DeleteToken(email); err != nil { |
There was a problem hiding this comment.
[P1] Purge both token stores independently
PurgeWriteTokens inspects only the token selected by LoadToken. For example, an existing write-capable keyring login survives WEBEX_TOKEN_STORE=file webex login --read-only for the same email: the new file token is read-only, so cleanup skips that user and reports success. This breaks the read-only guarantee inherited from main. Add a store-level purge operation that inspects both backends regardless of selection, removes every write-capable copy, and reports incomplete cleanup.
There was a problem hiding this comment.
Valid; fixed in b3be81f. PurgeWriteTokens now reads both the keyring and the credentials file whatever $WEBEX_TOKEN_STORE selects (readCopies). It deletes each copy that is not read-only from the store holding it, and removes a user from config only when no read-only copy is left. Regression test: TestPurgeWriteTokensChecksEveryStore.
| // The keyring is read first, so an older entry left there would shadow this | ||
| // token once the keyring works again. | ||
| _ = keyring.Delete(serviceName, key) | ||
| return StoreFile, nil |
There was a problem hiding this comment.
[P2] Preserve fallback-token precedence when the keyring recovers
When a temporarily unavailable keyring rejects both Set and Delete, saving the replacement token to the file still reports success. Once the keyring recovers, storeGet unconditionally returns its older token, hiding the newer file token. After refresh-token rotation, subsequent refreshes can therefore fail despite valid credentials remaining on disk. Persist which backend holds the authoritative token, or reconcile token versions before choosing one; best-effort deletion cannot establish precedence.
There was a problem hiding this comment.
Valid; fixed in b3be81f. LoadTokenWithStore now reads every selected store and returns the copy with the newest IssuedAt, so an older keyring entry left behind during a keyring outage can no longer hide the newer file token. The best-effort keyring delete stays, for cleanup only. Regression test: TestLoadTokenPrefersNewestCopy.
| } | ||
| if time.Now().After(deadline) { | ||
| fmt.Fprintf(os.Stderr, "Warning: timed out waiting for %s; continuing without it\n", f.Name()) | ||
| return fn() |
There was a problem hiding this comment.
[P2] Do not execute protected operations after lock timeout
After waiting 30 seconds, a second process executes the callback while the first still holds the lock. A slow refresh request can therefore trigger concurrent use of the same refresh token, defeating the new serialization mechanism. The same helper protects credentials-file read/modify/write operations, where unlocked execution can lose updates. Return a timeout error without running the callback, and distinguish any explicitly supported no-persistence fallback from operations that require exclusive access.
There was a problem hiding this comment.
Valid; fixed in b3be81f. When another process holds the lock past the timeout, withFileLock now returns an error and does not run the callback. It still runs the callback when locking itself is impossible (lock file cannot be created, flock unsupported), because then no other process can hold the lock either. Regression test: TestWithFileLockTimesOutWithoutRunning.
| return nil, "", fmt.Errorf("device token request: %w", err) | ||
| } | ||
| defer resp.Body.Close() | ||
| body, _ := io.ReadAll(resp.Body) |
There was a problem hiding this comment.
[P2] Apply the device-login deadline to HTTP requests
The polling context and expiry are checked only between requests. requestDeviceToken creates a request without that context and uses http.DefaultClient, which has no overall timeout. If the endpoint accepts a connection but stalls, login can hang indefinitely beyond code expiry, and cancelling PollDeviceToken cannot interrupt it. Derive a context bounded by code expiry, pass it into the request using NewRequestWithContext, and bound the initial authorization request too.
There was a problem hiding this comment.
Valid; fixed in b3be81f. PollDeviceToken now derives a context that ends when the code expires and passes it to requestDeviceToken (via NewRequestWithContext). A stalled request therefore ends with the "code expired" error, and cancelling the caller's context still returns that cancellation. RequestDeviceCode is now bounded by a 30s timeout. Regression test: TestPollDeviceTokenStopsStalledRequestAtExpiry.
| if lockErr != nil { | ||
| return "", "", "", lockErr | ||
| } | ||
| return entry.Token.AccessToken, entry.Email, entry.OrgID, nil |
There was a problem hiding this comment.
[P2] Retain rotated refresh tokens when disk caching fails
If caching fails, the function returns only the access token and identity; the rotated refresh token is immediately discarded, rather than retained until process exit as the warning suggests. A subsequent 401 invokes MakeEnvRefresher, which reloads the unavailable cache and retries the original environment token. If rotation invalidated that token, recovery fails within the same process. Keep the complete token in an auth-session object captured by the refresher, with disk persistence as an optional backing store. Returning that refresher from resolution would also remove the command layer’s source-specific reconstruction.
There was a problem hiding this comment.
Valid; fixed in b3be81f. Each refreshed entry is now also kept in memory for the life of the process (envRefreshMemo), and loadEnvRefreshEntry returns whichever is newer, that or the file entry. A refresh after a 401 then uses the rotated refresh token even when the cache file cannot be written. DeleteEnvRefreshCache clears the memory copy too. I did not take the larger refactor of returning a refresher from ResolveToken; it changes the command wiring beyond this fix. Regression test: TestEnvRefreshKeepsRotatedTokenWithoutCache.
| return nil | ||
| } | ||
|
|
||
| for _, env := range []string{"WEBEX_TOKEN", auth.RefreshTokenEnv} { |
There was a problem hiding this comment.
[P3] Report environment authentication before checking the default user
The new environment-status reporting is unreachable when cfg.DefaultUser is empty. That is the normal fresh-container case for WEBEX_REFRESH_TOKEN, so auth status reports “No authenticated user” even though API commands authenticate successfully. Inspect environment sources before the stored-user early return, and distinguish an available environment credential from a verified token without unnecessarily refreshing it.
There was a problem hiding this comment.
Valid; fixed in b3be81f. auth status now reports $WEBEX_TOKEN/$WEBEX_REFRESH_TOKEN before checking for a stored default user. With no stored user it says commands authenticate with the env value, and it no longer prints "No authenticated user" in that case. It does not refresh the token just to show status. Regression test: TestAuthStatusReportsEnvTokenWithoutStoredLogin.
| TokenType: tr.TokenType, | ||
| IssuedAt: now, | ||
| Scopes: scopes, | ||
| }, "", nil |
There was a problem hiding this comment.
[P3] Share token-response construction between login flows
The device flow duplicates exchangeCode’s granted-scope fallback, timestamp calculation, and StoredToken construction. Both represent the same successful initial grant, so future validation or metadata changes must now be maintained in parallel. Extract a shared token-response conversion helper while keeping the different HTTP exchanges and device polling separate.
There was a problem hiding this comment.
Valid; fixed in b3be81f. The authorization-code, device, and refresh grants now share tokenResponse.storedToken (building StoredToken and its timestamps) and tokenResponse.grantedScopes (the granted-scope fallback). Refresh still keeps the original grant's scopes, client credentials, and read-only flag. This is a pure refactor, so it has no new test; the existing device, login, and refresh tests cover it.
- Purge write-capable tokens from both stores whatever $WEBEX_TOKEN_STORE selects, keeping users who still have a read-only copy. - Load the newest copy when the keyring and the credentials file both hold one, so a stale keyring entry cannot shadow a token saved during a keyring outage. - Fail instead of running unlocked when another process holds a token lock past the timeout. - Bound device-login HTTP requests by the code's expiry, and the authorization request by a fixed timeout. - Keep the rotated $WEBEX_REFRESH_TOKEN entry in memory when the cache file cannot be written. - Report env credentials in `auth status` when there is no stored user. - Share token-response conversion between the code, device, and refresh grants. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EdRf9uewJ53wofcX185zGC
Codex review follow-upCodex (
Not taken: Codex also suggested returning a refresher from Checks: each new test failed before the fix and passes after it. |
Summary
Lets the CLI install and log in where there is no browser, no keyring, or no access to
api.github.com: AI agent sandboxes, CI, SSH sessions and containers.Login
webex login --deviceuses the Webex OAuth device grant and printsOpen <url> and enter <code>, plus a QR code on a terminal. With--output json, the code and the result go to stdout as JSON for agents. It never opens a browser.webex loginuses device login over SSH, in CI, on Linux without a display, or when the browser flow can't start.--browserandWEBEX_LOGIN_MODEoverride this.0600file at~/.config/webex-cli/credentials.json.WEBEX_TOKEN_STORE=keyring|fileforces a store, andwebex auth statusshows which one is in use. A file lock stops parallel commands from refreshing the token twice.WEBEX_REFRESH_TOKEN(with optionalWEBEX_CLIENT_IDandWEBEX_CLIENT_SECRET) supports unattended use.webex auth exportprints a refresh token to seed it.webex mcp servestarts without a login. Each tool call checks the login itself.Install and update
install.sh,install.ps1andwebex updatefind the latest version from the releasechecksums.txt, then the Go module proxy, then the GitHub API.WEBEX_CLI_VERSIONpins a version.checksums.txt, and nothing is installed on a mismatch.webex --versionand support forINSTALL_DIR.npm
@cloverhound/webex-cliplus one package per platform. npm installs only the matching binary, with no install script.release.ymlpublishes them through npm trusted publishing with provenance.webex updatetells npm users to update with npm.Test plan
go build,go vetandgo testpass, and the Windows and Linux cross-builds compile.v0.17.0-rc.2andv0.17.0-rc.3published as pre-releases. rc.3 published all 7 npm packages through trusted publishing, with no token in the repo.npx -y @cloverhound/webex-cli@0.17.0-rc.3runs innode:22-slim.install.shinstalls with a verified checksum in Ubuntu with no TTY.install.ps1on Windows. Not run.🤖 Generated with Claude Code
https://claude.ai/code/session_01EdRf9uewJ53wofcX185zGC
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.