Skip to content

Headless device login, hardened install/update, and npm packages - #12

Open
edumansky wants to merge 6 commits into
mainfrom
headless-install
Open

edumansky wants to merge 6 commits into
mainfrom
headless-install

Conversation

@edumansky

@edumansky edumansky commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

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 --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 go to stdout as JSON for agents. It never opens a browser.
  • Plain webex login uses device login over SSH, in CI, on Linux without a display, or when the browser flow can't start. --browser and WEBEX_LOGIN_MODE override this.
  • Without an OS keyring, tokens go to a 0600 file at ~/.config/webex-cli/credentials.json. WEBEX_TOKEN_STORE=keyring|file forces a store, and webex auth status shows which one is in use. A file lock stops parallel commands from refreshing the token twice.
  • WEBEX_REFRESH_TOKEN (with optional WEBEX_CLIENT_ID and WEBEX_CLIENT_SECRET) supports unattended use. webex auth export prints a refresh token to seed it.
  • webex mcp serve starts without a login. Each tool call checks the login itself.

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. WEBEX_CLI_VERSION pins a version.
  • Every download is checked against checksums.txt, and nothing is installed on a mismatch.
  • Without a terminal, post-install doesn't prompt and never fails the installer. Skill files are now built into the binary.
  • Adds webex --version and support for INSTALL_DIR.
  • The binary embeds Go's fallback root certificates for container images with no CA bundle.

npm

  • @cloverhound/webex-cli plus one package per platform. npm installs only the matching binary, with no install script.
  • release.yml publishes them through npm trusted publishing with provenance.
  • webex update tells npm users to update with npm.

Test plan

  • go build, go vet and go test pass, and the Windows and Linux cross-builds compile.
  • Device login against real Webex: code approved, token saved to the file store, and read calls covering several admin scopes succeeded.
  • Release candidates v0.17.0-rc.2 and v0.17.0-rc.3 published 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.3 runs in node:22-slim.
  • The tagged install.sh installs with a verified checksum in Ubuntu with no TTY.
  • install.ps1 on Windows. Not run.
  • Windows and x64 Linux runs of the npm package.

🤖 Generated with Claude Code

https://claude.ai/code/session_01EdRf9uewJ53wofcX185zGC


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

edumansky and others added 4 commits October 9, 2026 22:39
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
- 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 edumansky left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment thread internal/auth/readonly.go Outdated
if errors.Is(err, ErrTokenNotFound) || (err == nil && tok.ReadOnly) {
continue
}
if err := DeleteToken(email); err != nil {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment thread internal/auth/store.go
// 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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment thread internal/auth/lock.go Outdated
}
if time.Now().After(deadline) {
fmt.Fprintf(os.Stderr, "Warning: timed out waiting for %s; continuing without it\n", f.Name())
return fn()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment thread internal/auth/device.go
return nil, "", fmt.Errorf("device token request: %w", err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment thread cmd/auth.go
return nil
}

for _, env := range []string{"WEBEX_TOKEN", auth.RefreshTokenEnv} {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment thread internal/auth/device.go Outdated
TokenType: tr.TokenType,
IssuedAt: now,
Scopes: scopes,
}, "", nil

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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
@edumansky

Copy link
Copy Markdown
Contributor Author

Codex review follow-up

Codex (gpt-6-astra, reasoning effort: high) reviewed 0c202e2. It was asked to check correctness and also look for duplication and poor factoring. All 7 findings were confirmed against the code, and all are fixed in b3be81f.

# Finding Outcome Regression test
P1 Purge both token stores independently Fixed: purge reads the keyring and the file whatever $WEBEX_TOKEN_STORE says TestPurgeWriteTokensChecksEveryStore
P2 Fallback-token precedence when the keyring recovers Fixed: loading returns the copy with the newest IssuedAt TestLoadTokenPrefersNewestCopy
P2 Protected operations run after lock timeout Fixed: a timeout returns an error; the callback runs only when locking is impossible TestWithFileLockTimesOutWithoutRunning
P2 Device-login deadline not applied to HTTP requests Fixed: requests are bounded by the code's expiry; authorization request has a 30s timeout TestPollDeviceTokenStopsStalledRequestAtExpiry
P2 Rotated env refresh token lost when caching fails Fixed: the entry is kept in memory for the process TestEnvRefreshKeepsRotatedTokenWithoutCache
P3 auth status hides env auth without a stored user Fixed: env credentials are reported first TestAuthStatusReportsEnvTokenWithoutStoredLogin
P3 Duplicated token-response construction Fixed: code, device, and refresh grants share tokenResponse.storedToken/grantedScopes Refactor; existing tests cover it

Not taken: Codex also suggested returning a refresher from ResolveToken so the commands don't rebuild it for each token source. That changes how cmd/root.go wires refreshers and is beyond these fixes.

Checks: each new test failed before the fix and passes after it. go vet ./..., go test ./... and gofmt -l (repo-wide) are clean.

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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant