From 38fc9657932bedf2e4b937fe4b2671e5cd36f7e5 Mon Sep 17 00:00:00 2001 From: Ed Umansky Date: Fri, 9 Oct 2026 15:51:12 -0600 Subject: [PATCH 1/7] Add headless device login and harden install and update Login: - `webex login --device` uses the Webex OAuth device grant and prints "Open and enter " (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 Claude-Session: https://claude.ai/code/session_01EdRf9uewJ53wofcX185zGC --- README.md | 34 ++- cmd/auth.go | 86 +++++++- cmd/login.go | 163 +++++++++++++-- cmd/login_test.go | 33 +++ cmd/logout.go | 2 +- cmd/postinstall.go | 205 ++++++++++-------- cmd/root.go | 11 +- cmd/skillupdate.go | 120 ++++++----- cmd/skillupdate_test.go | 40 ++++ cmd/update.go | 233 +++++++++++++++++---- cmd/update_test.go | 211 +++++++++++++++++++ cmd/version.go | 2 + go.mod | 8 +- go.sum | 16 ++ install.ps1 | 88 ++++++-- install.sh | 103 +++++++-- internal/auth/device.go | 192 +++++++++++++++++ internal/auth/device_test.go | 187 +++++++++++++++++ internal/auth/envrefresh.go | 99 +++++++++ internal/auth/headless.go | 15 ++ internal/auth/keyring.go | 34 +-- internal/auth/lock.go | 44 ++++ internal/auth/lock_unix.go | 24 +++ internal/auth/lock_windows.go | 27 +++ internal/auth/main_test.go | 22 ++ internal/auth/oauth.go | 51 ++++- internal/auth/resolve.go | 109 ++++++++-- internal/auth/store.go | 241 ++++++++++++++++++++++ internal/auth/store_test.go | 194 +++++++++++++++++ site/src/content/docs/agent-skill.mdx | 6 +- site/src/content/docs/getting-started.mdx | 23 ++- site/src/content/docs/installation.mdx | 33 ++- skill/SKILL.md | 11 +- skill/embed.go | 29 +++ skill/embed_test.go | 15 ++ 35 files changed, 2409 insertions(+), 302 deletions(-) create mode 100644 cmd/login_test.go create mode 100644 cmd/skillupdate_test.go create mode 100644 cmd/update_test.go create mode 100644 internal/auth/device.go create mode 100644 internal/auth/device_test.go create mode 100644 internal/auth/envrefresh.go create mode 100644 internal/auth/headless.go create mode 100644 internal/auth/lock.go create mode 100644 internal/auth/lock_unix.go create mode 100644 internal/auth/lock_windows.go create mode 100644 internal/auth/main_test.go create mode 100644 internal/auth/store.go create mode 100644 internal/auth/store_test.go create mode 100644 skill/embed.go create mode 100644 skill/embed_test.go diff --git a/README.md b/README.md index 7961ede..0398cdf 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,12 @@ irm https://raw.githubusercontent.com/Cloverhound/webex-cli/main/install.ps1 | i Or download from [Releases](https://github.com/Cloverhound/webex-cli/releases). +The installers verify the download against the release's `checksums.txt` and find the latest version without the GitHub API. For CI or sandboxed agents: + +- Pin a version with `WEBEX_CLI_VERSION=0.16.0` (or `sh -s -- -v 0.16.0`); `webex update` honors it too. +- Set `INSTALL_DIR` to install somewhere other than `~/.local/bin` (Windows: `%LOCALAPPDATA%\webex-cli`). +- Without a terminal, the installer does not prompt: it prints the PATH line to add and installs the agent skill for detected agents. Run `webex post-install --yes` to accept the defaults (including editing your shell profile), `--no-skills` to skip skills, or set `WEBEX_CLI_NONINTERACTIVE=1` to turn prompts off. + ## Quick Start ```bash @@ -128,22 +134,38 @@ See the [full refresh inventory](docs/command-inventory.md), [every renamed comm ## Authentication - **OAuth PKCE flow** — `webex login` opens a browser, no client secret needed on the user side -- **OS keyring storage** — tokens stored securely in macOS Keychain / Linux keyring / Windows Credential Manager +- **Device login** — `webex login --device` prints a URL and code to approve from any browser; used automatically over SSH, in CI, on Linux without a display, or when no browser opens +- **OS keyring storage** — tokens stored securely in macOS Keychain / Linux keyring / Windows Credential Manager, with a plain-text file fallback when no keyring is available - **Auto-refresh** — expired tokens are refreshed automatically - **Multi-user** — log in with multiple Webex accounts and switch between them ```bash -webex login # Login (opens browser) +webex login # Login (opens browser, or device login when headless) +webex login --device # Login by approving a code from any browser +webex login --browser # Force the browser flow webex login --read-only # Login with read scopes only and turn on read-only mode webex logout # Remove stored tokens -webex auth status # Show current user and token status +webex auth status # Show current user, token status, and token store webex auth list # List all authenticated users webex auth switch # Switch default user webex auth set-org # Set a persistent org override webex auth clear-org # Clear the org override +webex auth export # Print the refresh token for $WEBEX_REFRESH_TOKEN ``` -Token resolution order: `--token` flag > `$WEBEX_TOKEN` env var > OS keyring. +Token resolution order: `--token` flag > `$WEBEX_TOKEN` env var > `$WEBEX_REFRESH_TOKEN` env var > stored login. + +### Headless and Unattended Use + +Device login needs no local browser: `webex login` shows `Open and enter ` (and a QR code on a terminal), and you approve from a browser on any device. With `--output json`, the URL, code, and expiry are printed as JSON on stdout so an agent can relay them. `WEBEX_LOGIN_MODE=device|browser` picks the flow without a flag. A custom integration needs the redirect URIs `https://oauth-helper-{a,r,k,d}.wbx2.com/helperservice/v1/actions/device/callback` for device login. + +Without an OS keyring (containers, SSH sessions, CI), tokens are saved in plain text to `$XDG_CONFIG_HOME/webex-cli/credentials.json` (`~/.config/webex-cli/` by default, `%APPDATA%\webex-cli\` on Windows) with mode `0600`. `WEBEX_TOKEN_STORE=keyring|file` forces a store; `webex auth status` shows which one holds the token. + +For CI and unattended agents where nobody can approve a code: + +- `WEBEX_TOKEN` takes a personal access token (12 hours) for short jobs. +- `WEBEX_REFRESH_TOKEN` takes a refresh token; the CLI mints access tokens from it as needed. Create one by logging in on your laptop and running `webex auth export`. Set `WEBEX_CLIENT_ID` and `WEBEX_CLIENT_SECRET` if the token came from your own integration. Webex refresh tokens last about 90 days from last use. The CLI caches the access token and any rotated refresh token in the credentials file when it is writable; otherwise each process mints a new access token. +- For organization-level automation, prefer a [service app](https://developer.webex.com/docs/service-apps) or a bot token over a person's login. ### Read-only Mode @@ -151,7 +173,7 @@ Token resolution order: `--token` flag > `$WEBEX_TOKEN` env var > OS keyring. - Stored logins with write access are deleted from the keyring, because any program running as you can read it. - `PUT`, `PATCH`, `DELETE`, and uploads are refused before they are sent. `POST` is allowed only for a fixed list of query endpoints that take their filters in the request body, such as `cc search` and recording queries. -- `--token` and `$WEBEX_TOKEN` are refused, and `auth switch`, `--user`, and folder defaults accept only users who logged in with `--read-only`. +- `--token`, `$WEBEX_TOKEN`, and `$WEBEX_REFRESH_TOKEN` are refused, and `auth switch`, `--user`, and folder defaults accept only users who logged in with `--read-only`. - `webex mcp serve` does not register `webex_write`. Leaving read-only mode requires `webex login` from an interactive terminal, with a confirmation prompt. `WEBEX_READ_ONLY=1` turns the same checks on for one process and never turns them off. If your OAuth integration lacks some of the default read scopes, set your own list with `webex config set read-only-scopes ""`. @@ -297,7 +319,7 @@ A set of skill files in `skill/` enables AI coding agents (Claude Code, Claude C Each sub-skill also contains an auto-generated **Command Reference** section (updated by `make codegen`) that lists every command and its flags, keeping documentation in sync with the API as Postman collections change. -`webex post-install` offers to install all skill files automatically. They are also updated by `webex update`. +The skill files are embedded in the binary, so the installed skill always matches the installed version. `webex post-install` offers to install them, and `webex update` updates installed copies to match the new binary. See the [docs](https://cloverhound.github.io/webex-cli/agent-skill/) for manual setup instructions. diff --git a/cmd/auth.go b/cmd/auth.go index e4aa9b5..27bef06 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -13,7 +13,9 @@ import ( "github.com/Cloverhound/webex-cli/internal/auth" "github.com/Cloverhound/webex-cli/internal/config" "github.com/Cloverhound/webex-cli/internal/localconfig" + "github.com/charmbracelet/huh" "github.com/spf13/cobra" + "golang.org/x/term" ) var authCmd = &cobra.Command{ @@ -51,11 +53,18 @@ var authStatusCmd = &cobra.Command{ return nil } + for _, env := range []string{"WEBEX_TOKEN", auth.RefreshTokenEnv} { + if os.Getenv(env) != "" { + fmt.Printf("Env: $%s is set and overrides the stored login below\n", env) + break + } + } + userInfo := cfg.Users[email] - tok, err := auth.LoadToken(email) + tok, store, err := auth.LoadTokenWithStore(email) if err != nil { fmt.Printf("User: %s\n", email) - fmt.Println("Status: token not found in keyring") + fmt.Println("Status: token not found in the keyring or credentials file") return nil } @@ -77,7 +86,11 @@ var authStatusCmd = &cobra.Command{ fmt.Printf("Org override: %s\n", cfg.DefaultOrgID) } - fmt.Printf("Source: keyring\n") + if store == auth.StoreFile { + fmt.Printf("Store: file, plain text (%s)\n", auth.CredentialsPath()) + } else { + fmt.Println("Store: OS keyring") + } switch { case cfg.ReadOnly: @@ -121,6 +134,71 @@ var authStatusCmd = &cobra.Command{ }, } +var authExportCmd = &cobra.Command{ + Use: "export [email]", + Short: "Print a stored refresh token for use as $WEBEX_REFRESH_TOKEN", + Long: `Prints the refresh token of a stored login (the default user unless an email is +given) so a login made on one machine can seed a CI secret or an unattended +agent through $WEBEX_REFRESH_TOKEN. Anyone holding the token can act as that +user until it expires (about 90 days after its last use), so treat it as a password. + +The token is printed alone on stdout. Confirm at the prompt, or pass --yes +when there is no terminal.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + yes, _ := cmd.Flags().GetBool("yes") + + cfg, err := appconfig.Load() + if err != nil { + return fmt.Errorf("loading config: %w", err) + } + email := cfg.DefaultUser + if len(args) > 0 { + email = args[0] + } + if email == "" { + return fmt.Errorf("no authenticated user — run: webex login") + } + tok, err := auth.LoadToken(email) + if err != nil { + return fmt.Errorf("no stored login for %s — run: webex login", email) + } + if tok.RefreshToken == "" || tok.IsRefreshExpired() { + return fmt.Errorf("the stored login for %s has no usable refresh token — run: webex login", email) + } + + if !yes { + if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stderr.Fd())) { + return fmt.Errorf("refusing to print a refresh token without confirmation; pass --yes") + } + var ok bool + err := huh.NewForm(huh.NewGroup( + huh.NewConfirm(). + Title(fmt.Sprintf("Print the refresh token for %s?", email)). + Description("Anyone with this token can act as you until it expires."). + Affirmative("Print it"). + Negative("Cancel"). + Value(&ok), + )).WithOutput(os.Stderr).Run() + if err != nil { + return err + } + if !ok { + return fmt.Errorf("cancelled") + } + } + + if tok.ClientID != "" && tok.ClientID != appconfig.DefaultClientID { + fmt.Fprintf(os.Stderr, "This login uses its own OAuth integration; also set $%s and $%s.\n", + auth.ClientIDEnv, auth.ClientSecretEnv) + } + fmt.Fprintf(os.Stderr, "Webex may rotate refresh tokens on use. If this machine and the consumer of the export\n"+ + "both refresh it, one of them can be signed out; prefer a separate login for each.\n") + fmt.Println(tok.RefreshToken) + return nil + }, +} + var authListCmd = &cobra.Command{ Use: "list", Short: "List all authenticated users", @@ -358,6 +436,8 @@ func init() { authCmd.AddCommand(authTokenCmd) authCmd.AddCommand(authStatusCmd) authCmd.AddCommand(authListCmd) + authExportCmd.Flags().Bool("yes", false, "Print without asking for confirmation") + authCmd.AddCommand(authExportCmd) authCmd.AddCommand(authSwitchCmd) authCmd.AddCommand(authSetOrgCmd) authCmd.AddCommand(authClearOrgCmd) diff --git a/cmd/login.go b/cmd/login.go index a16ae6a..01371cb 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -1,29 +1,66 @@ package cmd import ( + "encoding/json" + "errors" "fmt" + "io" "os" "path/filepath" + "runtime" + "strings" + "time" "github.com/Cloverhound/webex-cli/internal/appconfig" "github.com/Cloverhound/webex-cli/internal/auth" "github.com/Cloverhound/webex-cli/internal/localconfig" "github.com/Cloverhound/webex-cli/internal/readonly" "github.com/charmbracelet/huh" + "github.com/mdp/qrterminal/v3" "github.com/spf13/cobra" + "golang.org/x/term" ) +const loginModeEnv = "WEBEX_LOGIN_MODE" + var loginCmd = &cobra.Command{ Use: "login", Short: "Log in to Webex via OAuth", - Long: `Opens a browser for Webex OAuth login. Stores tokens in the OS keyring for the authenticated user. + Long: `Logs in to Webex with OAuth and stores the tokens for the authenticated user. + +On a desktop, login opens a browser. With --device, or automatically over SSH, +in CI, on Linux without a display, or when no browser can be opened, login +prints a URL and a code to approve from any browser instead. Set +$` + loginModeEnv + `=device or =browser to choose the flow without a flag. +With --output json, the device code is printed as JSON on stdout. + +Tokens are stored in the OS keyring. When no keyring is available they are +stored in plain text in ` + "`~/.config/webex-cli/credentials.json`" + ` (mode 0600); +set $` + auth.TokenStoreEnv + `=keyring or =file to choose the store. With --read-only, the login requests only read scopes and turns on read-only -mode: stored logins with write access are deleted from the keyring, write -requests are refused, and only read-only logins can be used. Leaving read-only -mode requires a plain 'webex login' from an interactive terminal.`, +mode: stored logins with write access are deleted, write requests are refused, +and only read-only logins can be used. Leaving read-only mode requires a plain +'webex login' from an interactive terminal.`, RunE: func(cmd *cobra.Command, args []string) error { readOnlyLogin, _ := cmd.Flags().GetBool("read-only") + deviceFlag, _ := cmd.Flags().GetBool("device") + browserFlag, _ := cmd.Flags().GetBool("browser") + format, _ := cmd.Flags().GetString("output") + jsonOut := cmd.Flags().Changed("output") && format == "json" + + envHeadless := auth.HeadlessReason(os.Getenv, runtime.GOOS) + mode, headless, err := chooseLoginMode(deviceFlag, browserFlag, os.Getenv(loginModeEnv), envHeadless) + if err != nil { + return err + } + + // Machine-readable output owns stdout; everything for people goes to stderr. + var msg io.Writer = os.Stdout + if jsonOut { + msg = os.Stderr + } + interactive := !jsonOut && term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd())) cfg, err := appconfig.Load() if err != nil { @@ -38,14 +75,35 @@ mode requires a plain 'webex login' from an interactive terminal.`, if err := readonly.ValidateScopes(scopes); err != nil { return fmt.Errorf("read-only-scopes config: %w", err) } - fmt.Println("Read-only login: stored logins with write access will be removed.") + fmt.Fprintln(msg, "Read-only login: stored logins with write access will be removed.") } else if readOnlyMode(cfg) { if err := confirmLeaveReadOnly(); err != nil { return err } } - result, err := auth.Login(clientID, clientSecret, scopes) + deviceLogin := func() (*auth.LoginResult, error) { + return auth.DeviceLogin(clientID, clientSecret, scopes, func(dc *auth.DeviceCode) { + showDeviceCode(dc, jsonOut) + }) + } + + var result *auth.LoginResult + switch mode { + case "device": + if headless != "" { + fmt.Fprintf(os.Stderr, "Using device login (%s). Use --browser to force the browser flow.\n", headless) + } + result, err = deviceLogin() + case "browser": + result, err = auth.Login(clientID, clientSecret, scopes, false) + default: + result, err = auth.Login(clientID, clientSecret, scopes, true) + if errors.Is(err, auth.ErrNoBrowser) { + fmt.Fprintf(os.Stderr, "Browser login unavailable (%v); using device login.\n", err) + result, err = deviceLogin() + } + } if err != nil { return err } @@ -58,14 +116,14 @@ mode requires a plain 'webex login' from an interactive terminal.`, // Offer to store OAuth credentials with this user account before saving the token. // This allows each user to have independent client credentials for token refresh. - if clientID != "" { + if clientID != "" && interactive { promptStoreCredentials(&result.Token, clientID, clientSecret) } - // Store token in keyring if err := auth.SaveToken(result.Email, &result.Token); err != nil { return fmt.Errorf("saving token: %w", err) } + _, store, _ := auth.LoadTokenWithStore(result.Email) // Update config cfg.AddUser(result.Email, result.DisplayName, result.OrgID, result.OrgName) @@ -84,27 +142,96 @@ mode requires a plain 'webex login' from an interactive terminal.`, if result.OrgName != "" { orgInfo = fmt.Sprintf(" — %s", result.OrgName) } - fmt.Printf("Logged in as %s (%s)%s\n", result.DisplayName, result.Email, orgInfo) + fmt.Fprintf(msg, "Logged in as %s (%s)%s\n", result.DisplayName, result.Email, orgInfo) if readOnlyLogin { - fmt.Println("Read-only mode is on.") + fmt.Fprintln(msg, "Read-only mode is on.") for _, email := range purged { - fmt.Printf("Removed write-capable login: %s\n", email) - } - if purgeErr != nil { - return purgeErr + fmt.Fprintf(msg, "Removed write-capable login: %s\n", email) } } - fmt.Println() + if jsonOut { + _ = json.NewEncoder(os.Stdout).Encode(map[string]any{ + "status": "logged_in", + "email": result.Email, + "display_name": result.DisplayName, + "org_id": result.OrgID, + "org_name": result.OrgName, + "read_only": readOnlyLogin, + "token_store": store, + }) + } + if purgeErr != nil { + return purgeErr + } // Offer to associate this user with the current folder - if cwd, err := os.Getwd(); err == nil { - promptFolderAssociation(result.Email, cwd) + if interactive { + fmt.Println() + if cwd, err := os.Getwd(); err == nil { + promptFolderAssociation(result.Email, cwd) + } } return nil }, } +// chooseLoginMode returns "device", "browser" or "auto" (browser, falling back +// to device when no browser can be used), plus the reason when the environment +// looks headless. +func chooseLoginMode(device, browser bool, envMode, headless string) (mode, reason string, err error) { + if device && browser { + return "", "", fmt.Errorf("--device and --browser cannot be used together") + } + switch { + case device: + return "device", "", nil + case browser: + return "browser", "", nil + } + switch strings.ToLower(strings.TrimSpace(envMode)) { + case "": + case "device": + return "device", "", nil + case "browser": + return "browser", "", nil + default: + return "", "", fmt.Errorf("$%s must be \"device\" or \"browser\", not %q", loginModeEnv, envMode) + } + if headless != "" { + return "device", headless, nil + } + return "auto", "", nil +} + +// showDeviceCode tells the user how to approve the login. The first line is +// meant to be relayed word for word by an agent running the CLI on the user's +// behalf; the user approves in their own browser, not where the CLI runs, so +// no browser is opened here. +func showDeviceCode(dc *auth.DeviceCode, jsonOut bool) { + link := dc.VerificationURIComplete + if link == "" { + link = dc.VerificationURI + } + if jsonOut { + _ = json.NewEncoder(os.Stdout).Encode(map[string]any{ + "status": "pending", + "verification_uri": dc.VerificationURI, + "verification_uri_complete": dc.VerificationURIComplete, + "user_code": dc.UserCode, + "expires_in": dc.ExpiresIn, + "expires_at": time.Now().Add(time.Duration(dc.ExpiresIn) * time.Second).UTC().Format(time.RFC3339), + }) + } + + fmt.Fprintf(os.Stderr, "Open %s and enter %s\n", dc.VerificationURI, dc.UserCode) + if term.IsTerminal(int(os.Stderr.Fd())) { + fmt.Fprintln(os.Stderr, "Or scan this code with a phone:") + qrterminal.GenerateHalfBlock(link, qrterminal.L, os.Stderr) + } + fmt.Fprintf(os.Stderr, "Waiting for approval (the code expires in %d minutes)...\n", (dc.ExpiresIn+59)/60) +} + func promptStoreCredentials(tok *auth.StoredToken, clientID, clientSecret string) { var choice string form := huh.NewForm( @@ -172,5 +299,7 @@ func promptFolderAssociation(email, dir string) { func init() { loginCmd.Flags().Bool("read-only", false, "Log in with read-only scopes and turn on read-only mode") + loginCmd.Flags().Bool("device", false, "Log in with a code approved from any browser (no local browser needed)") + loginCmd.Flags().Bool("browser", false, "Force the local browser login even when no browser is detected") rootCmd.AddCommand(loginCmd) } diff --git a/cmd/login_test.go b/cmd/login_test.go new file mode 100644 index 0000000..20c9786 --- /dev/null +++ b/cmd/login_test.go @@ -0,0 +1,33 @@ +package cmd + +import "testing" + +func TestChooseLoginMode(t *testing.T) { + cases := []struct { + name string + device, browser bool + env, headless string + want string + wantErr bool + }{ + {name: "desktop default", want: "auto"}, + {name: "headless default", headless: "SSH session", want: "device"}, + {name: "device flag", device: true, want: "device"}, + {name: "browser flag beats headless", browser: true, headless: "SSH session", want: "browser"}, + {name: "env device", env: "device", want: "device"}, + {name: "env browser beats headless", env: "Browser", headless: "CI environment", want: "browser"}, + {name: "flag beats env", device: true, env: "browser", want: "device"}, + {name: "both flags", device: true, browser: true, wantErr: true}, + {name: "bad env", env: "qr", wantErr: true}, + } + for _, c := range cases { + got, _, err := chooseLoginMode(c.device, c.browser, c.env, c.headless) + if (err != nil) != c.wantErr { + t.Errorf("%s: err = %v", c.name, err) + continue + } + if got != c.want { + t.Errorf("%s: mode = %q, want %q", c.name, got, c.want) + } + } +} diff --git a/cmd/logout.go b/cmd/logout.go index 33d67cf..76e3429 100644 --- a/cmd/logout.go +++ b/cmd/logout.go @@ -11,7 +11,7 @@ import ( var logoutCmd = &cobra.Command{ Use: "logout [email]", Short: "Log out and remove stored credentials", - Long: "Removes stored tokens from the OS keyring. Without arguments, removes the default user. Specify an email to remove a specific user, or --all to remove all users.", + Long: "Removes stored tokens from the OS keyring and the credentials file. Without arguments, removes the default user. Specify an email to remove a specific user, or --all to remove all users.", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { allFlag, _ := cmd.Flags().GetBool("all") diff --git a/cmd/postinstall.go b/cmd/postinstall.go index cd2d58d..2fabc8f 100644 --- a/cmd/postinstall.go +++ b/cmd/postinstall.go @@ -4,31 +4,40 @@ import ( "archive/zip" "bytes" "fmt" - "io" - "net/http" "os" "path/filepath" "runtime" + "strconv" "strings" + "github.com/Cloverhound/webex-cli/skill" "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" "github.com/spf13/cobra" + "golang.org/x/term" ) -const skillBaseURL = "https://raw.githubusercontent.com/Cloverhound/webex-cli/main/skill/" +const coworkName = "Claude Cowork" + +const nonInteractiveEnv = "WEBEX_CLI_NONINTERACTIVE" + +// isTerminal is a variable so tests can simulate a terminal. +var isTerminal = func(f *os.File) bool { return term.IsTerminal(int(f.Fd())) } -var skillSubPaths = []string{ - "SKILL.md", - "admin/SKILL.md", - "calling/SKILL.md", - "cc/SKILL.md", - "device/SKILL.md", - "meetings/SKILL.md", - "messaging/SKILL.md", +// setupOptions controls how post-install and skill updates answer questions. +type setupOptions struct { + prompt bool // ask with interactive forms + assumeYes bool // without prompts, take the recommended action + skills bool // install or update agent skills } -const coworkName = "Claude Cowork" +// newSetupOptions prompts only on a terminal: installers and agents run +// post-install with no TTY, where a form would fail or hang. +func newSetupOptions(assumeYes, noSkills bool) setupOptions { + nonInteractive, _ := strconv.ParseBool(os.Getenv(nonInteractiveEnv)) + prompt := !assumeYes && !nonInteractive && isTerminal(os.Stdin) && isTerminal(os.Stdout) + return setupOptions{prompt: prompt, assumeYes: assumeYes, skills: !noSkills} +} type agentPlatform struct { Name string @@ -45,7 +54,21 @@ var agentPlatforms = []agentPlatform{ var postInstallCmd = &cobra.Command{ Use: "post-install", Short: "Run post-installation setup (PATH, agent skills)", + Long: `Adds the install directory to PATH and installs the Webex agent skill. + +Without a terminal, or with --yes or $WEBEX_CLI_NONINTERACTIVE=1, nothing is +asked: the skill is installed for detected agents (Claude Code, Codex, Cursor). +Without --yes, the PATH line to add is printed instead of editing shell files.`, RunE: func(cmd *cobra.Command, args []string) error { + assumeYes, _ := cmd.Flags().GetBool("yes") + noSkills, _ := cmd.Flags().GetBool("no-skills") + skillsOnly, _ := cmd.Flags().GetBool("skills-only") + opts := newSetupOptions(assumeYes, noSkills) + + if skillsOnly { + return checkSkillUpdates(opts) + } + execPath, err := os.Executable() if err != nil { return fmt.Errorf("detecting executable path: %w", err) @@ -59,7 +82,7 @@ var postInstallCmd = &cobra.Command{ return err } } else { - if err := setupUnixPath(installDir); err != nil { + if err := setupUnixPath(installDir, opts); err != nil { return err } } @@ -67,7 +90,7 @@ var postInstallCmd = &cobra.Command{ } // Step 2: Agent skill installation - if err := setupAgentSkills(); err != nil { + if err := setupAgentSkills(opts); err != nil { return err } @@ -84,7 +107,7 @@ func dirInPath(dir string) bool { return false } -func setupUnixPath(installDir string) error { +func setupUnixPath(installDir string, opts setupOptions) error { shell := filepath.Base(os.Getenv("SHELL")) var rcFile string switch shell { @@ -105,29 +128,35 @@ func setupUnixPath(installDir string) error { } } - var choice string - form := huh.NewForm( - huh.NewGroup( - huh.NewSelect[string](). - Title(fmt.Sprintf("%s is not in your PATH", installDir)). - Description( - fmt.Sprintf( - "The webex binary was installed to %s, but your shell\n"+ - "can't find it yet. Adding it to %s will make the\n"+ - "\"webex\" command available in all new terminal sessions.", - installDir, filepath.Base(rcFile), - ), - ). - Options( - huh.NewOption(fmt.Sprintf("Yes — add to %s", filepath.Base(rcFile)), "yes"), - huh.NewOption("No — I'll do it myself", "no"), - ). - Value(&choice), - ), - ) - - if err := form.Run(); err != nil { - return nil + choice := "no" + if opts.assumeYes { + choice = "yes" + } else if opts.prompt { + form := huh.NewForm( + huh.NewGroup( + huh.NewSelect[string](). + Title(fmt.Sprintf("%s is not in your PATH", installDir)). + Description( + fmt.Sprintf( + "The webex binary was installed to %s, but your shell\n"+ + "can't find it yet. Adding it to %s will make the\n"+ + "\"webex\" command available in all new terminal sessions.", + installDir, filepath.Base(rcFile), + ), + ). + Options( + huh.NewOption(fmt.Sprintf("Yes — add to %s", filepath.Base(rcFile)), "yes"), + huh.NewOption("No — I'll do it myself", "no"), + ). + Value(&choice), + ), + ) + + if err := form.Run(); err != nil { + return nil + } + } else { + fmt.Printf("%s is not in your PATH.\n", installDir) } if choice == "yes" { @@ -159,55 +188,59 @@ func setupWindowsPath(installDir string) error { return nil } -func setupAgentSkills() error { +func setupAgentSkills(opts setupOptions) error { + if !opts.skills { + return nil + } home, err := os.UserHomeDir() if err != nil { return fmt.Errorf("detecting home directory: %w", err) } - var options []huh.Option[string] - for _, p := range agentPlatforms { - label := p.Name - if p.Name == coworkName { - label += " (saves ZIP to ~/Downloads for manual upload)" - } - opt := huh.NewOption(label, p.Name) - if agentDetected(home, p) { - opt = opt.Selected(true) + var selected []string + if opts.prompt { + var options []huh.Option[string] + for _, p := range agentPlatforms { + label := p.Name + if p.Name == coworkName { + label += " (saves ZIP to ~/Downloads for manual upload)" + } + opt := huh.NewOption(label, p.Name) + if agentDetected(home, p) { + opt = opt.Selected(true) + } + options = append(options, opt) } - options = append(options, opt) - } - var selected []string - form := huh.NewForm( - huh.NewGroup( - huh.NewMultiSelect[string](). - Title("Install Webex skill for AI coding agents?"). - Description( - "The Webex skill lets AI coding agents (Claude Code, Codex, Cursor)\n"+ - "query and manage your Webex environment using natural language.\n"+ - "Detected agents are pre-selected.", - ). - Options(options...). - Value(&selected), - ), - ) - - if err := form.Run(); err != nil { - return nil + form := huh.NewForm( + huh.NewGroup( + huh.NewMultiSelect[string](). + Title("Install Webex skill for AI coding agents?"). + Description( + "The Webex skill lets AI coding agents (Claude Code, Codex, Cursor)\n"+ + "query and manage your Webex environment using natural language.\n"+ + "Detected agents are pre-selected.", + ). + Options(options...). + Value(&selected), + ), + ) + + if err := form.Run(); err != nil { + return nil + } + } else { + selected = detectedSkillAgents(home) } if len(selected) == 0 { return nil } - fmt.Print("Downloading Webex skill...") - skillFiles, err := downloadSkillFiles() + skillFiles, err := skill.Files() if err != nil { - fmt.Println(" failed") - return fmt.Errorf("downloading skill: %w", err) + return fmt.Errorf("reading embedded skill: %w", err) } - fmt.Println(" ok") for _, name := range selected { for _, p := range agentPlatforms { @@ -260,24 +293,16 @@ func agentDetected(home string, p agentPlatform) bool { return err == nil && info.IsDir() } -func downloadSkillFiles() (map[string][]byte, error) { - files := make(map[string][]byte) - for _, subPath := range skillSubPaths { - resp, err := http.Get(skillBaseURL + subPath) - if err != nil { - return nil, fmt.Errorf("downloading %s: %w", subPath, err) - } - body, err := io.ReadAll(resp.Body) - resp.Body.Close() - if err != nil { - return nil, fmt.Errorf("reading %s: %w", subPath, err) - } - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("%s: HTTP %d", subPath, resp.StatusCode) +// detectedSkillAgents lists the detected agents that install from a skill +// directory. Cowork is left out because it needs a manual ZIP upload. +func detectedSkillAgents(home string) []string { + var names []string + for _, p := range agentPlatforms { + if p.SkillDir != "" && agentDetected(home, p) { + names = append(names, p.Name) } - files[subPath] = body } - return files, nil + return names } func printCoworkInstructions(zipPath string) { @@ -341,5 +366,9 @@ func installSkill(dest string, content []byte) error { } func init() { + postInstallCmd.Flags().BoolP("yes", "y", false, "Do not prompt; add the PATH line and install skills for detected agents") + postInstallCmd.Flags().Bool("no-skills", false, "Skip agent skill installation") + postInstallCmd.Flags().Bool("skills-only", false, "Only check installed agent skills against this binary's skill") + postInstallCmd.Flags().MarkHidden("skills-only") rootCmd.AddCommand(postInstallCmd) } diff --git a/cmd/root.go b/cmd/root.go index cb1b568..37bb447 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -93,9 +93,12 @@ var rootCmd = &cobra.Command{ config.SetToken(result.Token) - // Wire up token refresher for keyring-based auth - if result.Source == auth.SourceKeyring && result.UserEmail != "" { - config.TokenRefresher = auth.MakeRefresher(result.UserEmail, cfg) + // Wire up token refresher for refreshable sources + switch { + case result.Source == auth.SourceKeyring && result.UserEmail != "": + config.TokenRefresher = auth.MakeRefresher(result.UserEmail, result.Token, cfg) + case result.Source == auth.SourceEnvRefresh: + config.TokenRefresher = auth.MakeEnvRefresher(result.Token, cfg) } // Organization: --organization flag > config default org > resolved user's org > default user's org. @@ -203,7 +206,7 @@ func hideOrgFlags(cmd *cobra.Command) { } func init() { - rootCmd.PersistentFlags().String("token", "", "Webex API token (overrides keyring)") + rootCmd.PersistentFlags().String("token", "", "Webex API token (overrides stored login)") rootCmd.PersistentFlags().String("output", "json", "Output format: json, table, csv, raw") rootCmd.PersistentFlags().Bool("debug", false, "Enable debug logging of HTTP requests") rootCmd.PersistentFlags().Bool("paginate", false, "Auto-paginate list results") diff --git a/cmd/skillupdate.go b/cmd/skillupdate.go index e212afd..52ac9cf 100644 --- a/cmd/skillupdate.go +++ b/cmd/skillupdate.go @@ -6,10 +6,17 @@ import ( "os" "path/filepath" + "github.com/Cloverhound/webex-cli/skill" "github.com/charmbracelet/huh" ) -func checkSkillUpdates() error { +// checkSkillUpdates compares installed agent skills with the skill embedded in +// this binary. Without prompts, outdated skills are updated and agents without +// the skill are left alone, unless opts.assumeYes is set. +func checkSkillUpdates(opts setupOptions) error { + if !opts.skills { + return nil + } home, err := os.UserHomeDir() if err != nil { return err @@ -21,13 +28,10 @@ func checkSkillUpdates() error { Status string // "outdated", "new" } - fmt.Print("Checking for skill updates...") - latest, err := downloadSkillFiles() + latest, err := skill.Files() if err != nil { - fmt.Println(" failed") - return err + return fmt.Errorf("reading embedded skill: %w", err) } - fmt.Println(" ok") var actions []skillAction for _, p := range agentPlatforms { @@ -35,19 +39,13 @@ func checkSkillUpdates() error { continue // skip Cowork (no filesystem path) } skillDir := filepath.Join(home, p.SkillDir) - rootPath := filepath.Join(skillDir, "SKILL.md") - current, err := os.ReadFile(rootPath) - if err == nil { - outdated := !bytes.Equal(current, latest["SKILL.md"]) - if !outdated { - for subPath := range latest { - if subPath == "SKILL.md" { - continue - } - if _, serr := os.Stat(filepath.Join(skillDir, filepath.FromSlash(subPath))); serr != nil { - outdated = true - break - } + if _, err := os.Stat(filepath.Join(skillDir, "SKILL.md")); err == nil { + outdated := false + for subPath, content := range latest { + current, rerr := os.ReadFile(filepath.Join(skillDir, filepath.FromSlash(subPath))) + if rerr != nil || !bytes.Equal(current, content) { + outdated = true + break } } if outdated { @@ -63,48 +61,58 @@ func checkSkillUpdates() error { return nil } - // Step 1: Ask if user wants to review skill updates - var proceed bool - confirmForm := huh.NewForm( - huh.NewGroup( - huh.NewConfirm(). - Title("Agent skill updates available"). - Description(fmt.Sprintf("%d agent skill(s) can be updated or installed.", len(actions))). - Affirmative("Review changes"). - Negative("Skip"). - Value(&proceed), - ), - ) + var selected []string + if !opts.prompt { + for _, a := range actions { + if a.Status == "outdated" || opts.assumeYes { + selected = append(selected, a.Platform.Name) + } else { + fmt.Printf(" %s: skill not installed (run: webex post-install --yes)\n", a.Platform.Name) + } + } + } else { + // Step 1: Ask if user wants to review skill updates + var proceed bool + confirmForm := huh.NewForm( + huh.NewGroup( + huh.NewConfirm(). + Title("Agent skill updates available"). + Description(fmt.Sprintf("%d agent skill(s) can be updated or installed.", len(actions))). + Affirmative("Review changes"). + Negative("Skip"). + Value(&proceed), + ), + ) - if err := confirmForm.Run(); err != nil || !proceed { - return nil - } + if err := confirmForm.Run(); err != nil || !proceed { + return nil + } - // Step 2: Show multiselect with specific skills - var options []huh.Option[string] - for _, a := range actions { - label := a.Platform.Name - if a.Status == "outdated" { - label += " (update available)" - } else { - label += " (not yet installed)" + // Step 2: Show multiselect with specific skills + var options []huh.Option[string] + for _, a := range actions { + label := a.Platform.Name + if a.Status == "outdated" { + label += " (update available)" + } else { + label += " (not yet installed)" + } + options = append(options, huh.NewOption(label, a.Platform.Name).Selected(true)) } - options = append(options, huh.NewOption(label, a.Platform.Name).Selected(true)) - } - var selected []string - form := huh.NewForm( - huh.NewGroup( - huh.NewMultiSelect[string](). - Title("Skill updates available"). - Description("The following agents can be updated or have the Webex skill\ninstalled. Deselect any you want to skip."). - Options(options...). - Value(&selected), - ), - ) + form := huh.NewForm( + huh.NewGroup( + huh.NewMultiSelect[string](). + Title("Skill updates available"). + Description("The following agents can be updated or have the Webex skill\ninstalled. Deselect any you want to skip."). + Options(options...). + Value(&selected), + ), + ) - if err := form.Run(); err != nil { - return nil + if err := form.Run(); err != nil { + return nil + } } if len(selected) == 0 { diff --git a/cmd/skillupdate_test.go b/cmd/skillupdate_test.go new file mode 100644 index 0000000..2a01887 --- /dev/null +++ b/cmd/skillupdate_test.go @@ -0,0 +1,40 @@ +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/Cloverhound/webex-cli/skill" +) + +func TestCheckSkillUpdatesNonInteractive(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + + claudeDir := filepath.Join(home, ".claude", "skills", "webex-cli") + if err := os.MkdirAll(claudeDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(claudeDir, "SKILL.md"), []byte("old"), 0644); err != nil { + t.Fatal(err) + } + // Detected but never installed: left alone without --yes. + if err := os.MkdirAll(filepath.Join(home, ".codex"), 0755); err != nil { + t.Fatal(err) + } + + if err := checkSkillUpdates(setupOptions{skills: true}); err != nil { + t.Fatal(err) + } + + want, _ := skill.Files() + got, err := os.ReadFile(filepath.Join(claudeDir, "admin", "SKILL.md")) + if err != nil || string(got) != string(want["admin/SKILL.md"]) { + t.Errorf("outdated Claude Code skill not updated: %v", err) + } + if _, err := os.Stat(filepath.Join(home, ".codex", "skills", "webex-cli", "SKILL.md")); !os.IsNotExist(err) { + t.Errorf("Codex skill installed without --yes: %v", err) + } +} diff --git a/cmd/update.go b/cmd/update.go index 457d625..ce2c2d0 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -5,15 +5,19 @@ import ( "archive/zip" "bytes" "compress/gzip" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "io" "net/http" "os" + "os/exec" "path/filepath" "runtime" "strconv" "strings" + "time" "github.com/spf13/cobra" ) @@ -30,16 +34,39 @@ func init() { rootCmd.AddCommand(updateCmd) } -func runUpdate() error { - latest, err := fetchLatestVersion() - if err != nil { - return fmt.Errorf("checking latest version: %w", err) - } +// versionPinEnv pins the version that install and update use, skipping lookup. +const versionPinEnv = "WEBEX_CLI_VERSION" + +// Release locations, variables so tests can point them at a local server. The +// lookup avoids api.github.com where possible: sandboxes and shared CI hosts +// often block it or exhaust its unauthenticated rate limit, while release +// downloads and the Go module proxy stay reachable. +var ( + releaseBaseURL = "https://github.com/Cloverhound/webex-cli/releases" + goProxyLatestURL = "https://proxy.golang.org/github.com/!cloverhound/webex-cli/@latest" + githubAPILatestURL = "https://api.github.com/repos/Cloverhound/webex-cli/releases/latest" +) + +var lookupClient = &http.Client{Timeout: 30 * time.Second} +func runUpdate() error { current := Version - if !isNewer(latest, current) { - fmt.Printf("Already up to date (v%s)\n", current) - return nil + latest := strings.TrimPrefix(os.Getenv(versionPinEnv), "v") + if latest != "" { + if latest == current { + fmt.Printf("Already at pinned version v%s ($%s)\n", current, versionPinEnv) + return nil + } + } else { + var err error + latest, err = fetchLatestVersion() + if err != nil { + return fmt.Errorf("checking latest version: %w", err) + } + if !isNewer(latest, current) { + fmt.Printf("Already up to date (v%s)\n", current) + return nil + } } fmt.Printf("Updating v%s -> v%s\n", current, latest) @@ -49,44 +76,161 @@ func runUpdate() error { return fmt.Errorf("locating binary: %w", err) } - url := archiveURL(latest) - if err := downloadAndReplace(url, binPath); err != nil { + if err := downloadAndReplace(latest, binPath); err != nil { return err } fmt.Printf("Updated to v%s\n", latest) - // Check for skill updates - if err := checkSkillUpdates(); err != nil { + // Skills are embedded in the binary, so the new binary must run the check + // to install the skill that matches it. + check := exec.Command(binPath, "post-install", "--skills-only") + check.Stdin, check.Stdout, check.Stderr = os.Stdin, os.Stdout, os.Stderr + if err := check.Run(); err != nil { fmt.Printf("Warning: skill update check failed: %v\n", err) } return nil } -// fetchLatestVersion queries the GitHub releases API and returns the latest -// version string (without "v" prefix). +// fetchLatestVersion returns the latest release version (without "v"), trying +// the release checksums file, then the Go module proxy, then the GitHub API. func fetchLatestVersion() (string, error) { - resp, err := http.Get("https://api.github.com/repos/Cloverhound/webex-cli/releases/latest") + type source struct { + url string + lookup func(string) (string, error) + } + sources := []source{ + {releaseBaseURL + "/latest/download/checksums.txt", latestFromChecksums}, + {goProxyLatestURL, latestFromGoProxy}, + {githubAPILatestURL, latestFromGitHubAPI}, + } + + var failures []string + for _, s := range sources { + v, err := s.lookup(s.url) + if err == nil && v != "" { + return v, nil + } + if err == nil { + err = fmt.Errorf("no version found") + } + failures = append(failures, fmt.Sprintf(" %s: %v", s.url, err)) + } + return "", fmt.Errorf("could not determine the latest version; tried:\n%s\nPin a version instead: %s=x.y.z webex update", + strings.Join(failures, "\n"), versionPinEnv) +} + +func latestFromChecksums(url string) (string, error) { + body, err := httpGetBytes(url, nil) if err != nil { return "", err } - defer resp.Body.Close() + return versionFromChecksums(body, runtime.GOOS, runtime.GOARCH) +} - if resp.StatusCode != http.StatusOK { - return "", fmt.Errorf("GitHub API returned %s", resp.Status) +func latestFromGoProxy(url string) (string, error) { + body, err := httpGetBytes(url, nil) + if err != nil { + return "", err + } + var info struct { + Version string `json:"Version"` } + if err := json.Unmarshal(body, &info); err != nil { + return "", err + } + return strings.TrimPrefix(info.Version, "v"), nil +} +func latestFromGitHubAPI(url string) (string, error) { + header := http.Header{} + if tok := os.Getenv("GITHUB_TOKEN"); tok != "" { + header.Set("Authorization", "Bearer "+tok) + } + body, err := httpGetBytes(url, header) + if err != nil { + return "", err + } var release struct { TagName string `json:"tag_name"` } - if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + if err := json.Unmarshal(body, &release); err != nil { return "", err } - return strings.TrimPrefix(release.TagName, "v"), nil } +func httpGetBytes(url string, header http.Header) ([]byte, error) { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + for k, v := range header { + req.Header[k] = v + } + resp, err := lookupClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("HTTP %s", resp.Status) + } + return io.ReadAll(resp.Body) +} + +// archiveName matches the GoReleaser name template: +// webex-cli_{VERSION}_{OS}_{ARCH}.tar.gz (.zip on Windows). +func archiveName(version, goos, goarch string) string { + ext := "tar.gz" + if goos == "windows" { + ext = "zip" + } + return fmt.Sprintf("webex-cli_%s_%s_%s.%s", version, goos, goarch, ext) +} + +// versionFromChecksums finds the archive for goos/goarch in a checksums.txt +// and returns the version embedded in its file name. +func versionFromChecksums(data []byte, goos, goarch string) (string, error) { + ext := ".tar.gz" + if goos == "windows" { + ext = ".zip" + } + suffix := "_" + goos + "_" + goarch + ext + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) != 2 { + continue + } + name := strings.TrimPrefix(fields[1], "*") + if strings.HasPrefix(name, "webex-cli_") && strings.HasSuffix(name, suffix) { + return strings.TrimSuffix(strings.TrimPrefix(name, "webex-cli_"), suffix), nil + } + } + return "", fmt.Errorf("no %s archive listed", strings.TrimPrefix(suffix, "_")) +} + +// checksumFor returns the lowercase hex SHA-256 listed for name. +func checksumFor(data []byte, name string) (string, error) { + for _, line := range strings.Split(string(data), "\n") { + fields := strings.Fields(line) + if len(fields) == 2 && strings.TrimPrefix(fields[1], "*") == name { + return strings.ToLower(fields[0]), nil + } + } + return "", fmt.Errorf("%s is not listed in checksums.txt", name) +} + +func verifyChecksum(data []byte, want string) error { + sum := sha256.Sum256(data) + got := hex.EncodeToString(sum[:]) + if got != strings.ToLower(want) { + return fmt.Errorf("checksum mismatch: expected %s, got %s", want, got) + } + return nil +} + // isNewer returns true if latest is a higher semver than current. func isNewer(latest, current string) bool { parse := func(v string) [3]int { @@ -117,35 +261,44 @@ func executablePath() (string, error) { return filepath.EvalSymlinks(exe) } -// archiveURL builds the download URL for a given version, matching the -// GoReleaser naming template: webex-cli_{VERSION}_{OS}_{ARCH}.tar.gz (.zip on Windows). -func archiveURL(version string) string { - ext := "tar.gz" - if runtime.GOOS == "windows" { - ext = "zip" +// downloadRelease downloads the archive for version and this platform and +// verifies it against the release's checksums.txt. +func downloadRelease(version string) ([]byte, error) { + base := fmt.Sprintf("%s/download/v%s/", releaseBaseURL, version) + sums, err := httpGetBytes(base+"checksums.txt", nil) + if err != nil { + return nil, fmt.Errorf("downloading checksums.txt: %w", err) + } + name := archiveName(version, runtime.GOOS, runtime.GOARCH) + want, err := checksumFor(sums, name) + if err != nil { + return nil, err } - return fmt.Sprintf( - "https://github.com/Cloverhound/webex-cli/releases/download/v%s/webex-cli_%s_%s_%s.%s", - version, version, runtime.GOOS, runtime.GOARCH, ext, - ) -} -// downloadAndReplace downloads the archive from url, extracts the binary, and -// atomically replaces the binary at binaryPath. -func downloadAndReplace(url, binaryPath string) error { - resp, err := http.Get(url) + resp, err := http.Get(base + name) if err != nil { - return fmt.Errorf("downloading release: %w", err) + return nil, fmt.Errorf("downloading release: %w", err) } defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("download failed: %s", resp.Status) + return nil, fmt.Errorf("download failed: %s", resp.Status) } - body, err := io.ReadAll(resp.Body) if err != nil { - return fmt.Errorf("reading download: %w", err) + return nil, fmt.Errorf("reading download: %w", err) + } + if err := verifyChecksum(body, want); err != nil { + return nil, fmt.Errorf("%s: %w; refusing to install", name, err) + } + return body, nil +} + +// downloadAndReplace downloads and verifies the release archive, extracts the +// binary, and atomically replaces the binary at binaryPath. +func downloadAndReplace(version, binaryPath string) error { + body, err := downloadRelease(version) + if err != nil { + return err } var bin []byte diff --git a/cmd/update_test.go b/cmd/update_test.go new file mode 100644 index 0000000..3e905c2 --- /dev/null +++ b/cmd/update_test.go @@ -0,0 +1,211 @@ +package cmd + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "net/http" + "net/http/httptest" + "os" + "runtime" + "strings" + "testing" +) + +const sampleChecksums = `0123abcd webex-cli_0.16.0_darwin_amd64.tar.gz +4567ef01 webex-cli_0.16.0_darwin_arm64.tar.gz +89ab2345 webex-cli_0.16.0_linux_amd64.tar.gz +cdef6789 webex-cli_0.16.0_linux_arm64.tar.gz +0a1b2c3d webex-cli_0.16.0_windows_amd64.zip +` + +func TestVersionFromChecksums(t *testing.T) { + for _, tc := range []struct{ goos, goarch, want string }{ + {"linux", "amd64", "0.16.0"}, + {"darwin", "arm64", "0.16.0"}, + {"windows", "amd64", "0.16.0"}, + } { + got, err := versionFromChecksums([]byte(sampleChecksums), tc.goos, tc.goarch) + if err != nil || got != tc.want { + t.Errorf("%s/%s: got %q, %v; want %q", tc.goos, tc.goarch, got, err, tc.want) + } + } + if _, err := versionFromChecksums([]byte(sampleChecksums), "windows", "arm64"); err == nil { + t.Error("windows/arm64: expected an error for a missing archive") + } +} + +func TestChecksumFor(t *testing.T) { + got, err := checksumFor([]byte(sampleChecksums), "webex-cli_0.16.0_linux_amd64.tar.gz") + if err != nil || got != "89ab2345" { + t.Errorf("got %q, %v", got, err) + } + if _, err := checksumFor([]byte(sampleChecksums), "webex-cli_0.16.0_linux_amd64.tar"); err == nil { + t.Error("expected an error for an unlisted name") + } +} + +func TestVerifyChecksum(t *testing.T) { + data := []byte("archive bytes") + sum := sha256.Sum256(data) + good := hex.EncodeToString(sum[:]) + if err := verifyChecksum(data, strings.ToUpper(good)); err != nil { + t.Errorf("valid checksum rejected: %v", err) + } + if err := verifyChecksum([]byte("tampered"), good); err == nil { + t.Error("tampered data accepted") + } +} + +// setReleaseURLs points the release lookups at srv for the duration of a test. +func setReleaseURLs(t *testing.T, srv *httptest.Server) { + t.Helper() + oldRelease, oldProxy, oldAPI := releaseBaseURL, goProxyLatestURL, githubAPILatestURL + releaseBaseURL = srv.URL + "/releases" + goProxyLatestURL = srv.URL + "/proxy/@latest" + githubAPILatestURL = srv.URL + "/api/latest" + t.Cleanup(func() { + releaseBaseURL, goProxyLatestURL, githubAPILatestURL = oldRelease, oldProxy, oldAPI + }) +} + +func TestFetchLatestVersionFallbackChain(t *testing.T) { + archive := archiveName("0.17.0", runtime.GOOS, runtime.GOARCH) + tests := []struct { + name string + handlers map[string]http.HandlerFunc + want string + }{ + { + name: "checksums", + handlers: map[string]http.HandlerFunc{ + "/releases/latest/download/checksums.txt": func(w http.ResponseWriter, r *http.Request) { + fmt.Fprintf(w, "abc %s\n", archive) + }, + }, + want: "0.17.0", + }, + { + name: "go proxy", + handlers: map[string]http.HandlerFunc{ + "/proxy/@latest": func(w http.ResponseWriter, r *http.Request) { + fmt.Fprint(w, `{"Version":"v0.17.1"}`) + }, + }, + want: "0.17.1", + }, + { + name: "github api with token", + handlers: map[string]http.HandlerFunc{ + "/api/latest": func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Authorization") != "Bearer gh-test" { + http.Error(w, "forbidden", http.StatusForbidden) + return + } + fmt.Fprint(w, `{"tag_name":"v0.17.2"}`) + }, + }, + want: "0.17.2", + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + mux := http.NewServeMux() + for p, h := range tc.handlers { + mux.HandleFunc(p, h) + } + srv := httptest.NewServer(mux) + defer srv.Close() + setReleaseURLs(t, srv) + t.Setenv("GITHUB_TOKEN", "gh-test") + + got, err := fetchLatestVersion() + if err != nil || got != tc.want { + t.Fatalf("got %q, %v; want %q", got, err, tc.want) + } + }) + } +} + +func TestFetchLatestVersionReportsEveryURL(t *testing.T) { + srv := httptest.NewServer(http.NotFoundHandler()) + defer srv.Close() + setReleaseURLs(t, srv) + + _, err := fetchLatestVersion() + if err == nil { + t.Fatal("expected an error") + } + for _, want := range []string{"checksums.txt", "/proxy/@latest", "/api/latest", versionPinEnv + "="} { + if !strings.Contains(err.Error(), want) { + t.Errorf("error does not mention %q:\n%v", want, err) + } + } +} + +func TestDownloadReleaseVerifiesChecksum(t *testing.T) { + archive := archiveName("0.17.0", runtime.GOOS, runtime.GOARCH) + payload := []byte("real archive") + sum := sha256.Sum256(payload) + + for _, tc := range []struct { + name string + served []byte + listed bool + wantErr string + }{ + {"match", payload, true, ""}, + {"tampered", []byte("evil archive"), true, "checksum mismatch"}, + {"unlisted", payload, false, "not listed"}, + } { + t.Run(tc.name, func(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/releases/download/v0.17.0/checksums.txt", func(w http.ResponseWriter, r *http.Request) { + if tc.listed { + fmt.Fprintf(w, "%s %s\n", hex.EncodeToString(sum[:]), archive) + } + }) + mux.HandleFunc("/releases/download/v0.17.0/"+archive, func(w http.ResponseWriter, r *http.Request) { + w.Write(tc.served) + }) + srv := httptest.NewServer(mux) + defer srv.Close() + setReleaseURLs(t, srv) + + got, err := downloadRelease("0.17.0") + if tc.wantErr == "" { + if err != nil || string(got) != string(payload) { + t.Fatalf("got %q, %v", got, err) + } + return + } + if err == nil || !strings.Contains(err.Error(), tc.wantErr) { + t.Fatalf("got err %v, want %q", err, tc.wantErr) + } + }) + } +} + +func TestNewSetupOptions(t *testing.T) { + old := isTerminal + t.Cleanup(func() { isTerminal = old }) + + isTerminal = func(*os.File) bool { return true } + t.Setenv(nonInteractiveEnv, "") + if o := newSetupOptions(false, false); !o.prompt || !o.skills { + t.Errorf("terminal: %+v", o) + } + if o := newSetupOptions(true, true); o.prompt || !o.assumeYes || o.skills { + t.Errorf("--yes --no-skills: %+v", o) + } + t.Setenv(nonInteractiveEnv, "1") + if o := newSetupOptions(false, false); o.prompt { + t.Errorf("%s=1 still prompts", nonInteractiveEnv) + } + + t.Setenv(nonInteractiveEnv, "") + isTerminal = func(*os.File) bool { return false } + if o := newSetupOptions(false, false); o.prompt || o.assumeYes { + t.Errorf("no terminal: %+v", o) + } +} diff --git a/cmd/version.go b/cmd/version.go index c50571c..0fc01bb 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -17,5 +17,7 @@ var versionCmd = &cobra.Command{ } func init() { + rootCmd.Version = Version + rootCmd.SetVersionTemplate("webex-cli {{.Version}}\n") rootCmd.AddCommand(versionCmd) } diff --git a/go.mod b/go.mod index d08bfa1..8bef631 100644 --- a/go.mod +++ b/go.mod @@ -4,10 +4,14 @@ go 1.26 require ( github.com/charmbracelet/huh v1.0.0 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/mark3labs/mcp-go v0.50.0 + github.com/mdp/qrterminal/v3 v3.2.1 github.com/olekukonko/tablewriter v0.0.5 github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/spf13/cobra v1.10.2 github.com/zalando/go-keyring v0.2.6 + golang.org/x/sys v0.33.0 golang.org/x/term v0.25.0 ) @@ -19,7 +23,6 @@ require ( github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect github.com/charmbracelet/bubbletea v1.3.6 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/lipgloss v1.1.0 // indirect github.com/charmbracelet/x/ansi v0.9.3 // indirect github.com/charmbracelet/x/cellbuf v0.0.13 // indirect github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect @@ -32,7 +35,6 @@ require ( github.com/google/uuid v1.6.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mark3labs/mcp-go v0.50.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect @@ -47,6 +49,6 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/sync v0.15.0 // indirect - golang.org/x/sys v0.33.0 // indirect golang.org/x/text v0.23.0 // indirect + rsc.io/qr v0.2.0 // indirect ) diff --git a/go.sum b/go.sum index 266c02c..b26a531 100644 --- a/go.sum +++ b/go.sum @@ -45,12 +45,18 @@ github.com/danieljoos/wincred v1.2.2 h1:774zMFJrqaeYCK2W57BgAem/MLi6mtSE47MB6BOJ github.com/danieljoos/wincred v1.2.2/go.mod h1:w7w4Utbrz8lqeMbDAK0lkNJUv5sAOkFi7nd/ogr0Uh8= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= @@ -59,6 +65,10 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mark3labs/mcp-go v0.50.0 h1:kfxh8ejrBCABy5pez+rUYpsSLUKV2Yg38xBtrGRuo4U= @@ -70,6 +80,8 @@ github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+Ei github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4= +github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU= github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4= github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= @@ -87,6 +99,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= @@ -123,3 +137,5 @@ golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= +rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= diff --git a/install.ps1 b/install.ps1 index b891f02..e938ea0 100644 --- a/install.ps1 +++ b/install.ps1 @@ -1,11 +1,15 @@ # Webex CLI installer for Windows # Usage: irm https://raw.githubusercontent.com/Cloverhound/webex-cli/main/install.ps1 | iex +# Pin a version: $env:WEBEX_CLI_VERSION = "0.16.0" before running $ErrorActionPreference = "Stop" $Repo = "Cloverhound/webex-cli" $Binary = "webex.exe" -$InstallDir = "$env:LOCALAPPDATA\webex-cli" +$InstallDir = if ($env:INSTALL_DIR) { $env:INSTALL_DIR } else { "$env:LOCALAPPDATA\webex-cli" } +$Releases = "https://github.com/$Repo/releases" +$GoProxyLatest = "https://proxy.golang.org/github.com/!cloverhound/webex-cli/@latest" +$ApiLatest = "https://api.github.com/repos/$Repo/releases/latest" # Detect architecture $Arch = if ([Environment]::Is64BitOperatingSystem) { @@ -15,28 +19,88 @@ $Arch = if ([Environment]::Is64BitOperatingSystem) { return } -# Get latest version -Write-Host "Fetching latest release..." -$Release = Invoke-RestMethod "https://api.github.com/repos/$Repo/releases/latest" -$Version = $Release.tag_name -replace '^v', '' -if (-not $Version) { - Write-Error "Could not determine latest version" - return +function Get-Text($Url, $Headers = @{}) { + $resp = Invoke-WebRequest -Uri $Url -Headers $Headers -UseBasicParsing + if ($resp.Content -is [byte[]]) { + return [System.Text.Encoding]::UTF8.GetString($resp.Content) + } + return [string]$resp.Content +} + +# The lookup avoids api.github.com where possible: sandboxes and shared CI +# hosts often block it or exhaust its unauthenticated rate limit. +function Get-LatestVersion { + try { + $sums = Get-Text "$Releases/latest/download/checksums.txt" + $m = [regex]::Match($sums, "webex-cli_(\S+)_windows_${Arch}\.zip") + if ($m.Success) { return $m.Groups[1].Value } + } catch {} + try { + $info = (Get-Text $GoProxyLatest) | ConvertFrom-Json + if ($info.Version) { return $info.Version -replace '^v', '' } + } catch {} + try { + $headers = @{} + if ($env:GITHUB_TOKEN) { $headers["Authorization"] = "Bearer $env:GITHUB_TOKEN" } + $release = (Get-Text $ApiLatest $headers) | ConvertFrom-Json + if ($release.tag_name) { return $release.tag_name -replace '^v', '' } + } catch {} + return $null +} + +if ($env:WEBEX_CLI_VERSION) { + $Version = $env:WEBEX_CLI_VERSION -replace '^v', '' + Write-Host "Using pinned version: v$Version" +} else { + Write-Host "Fetching latest release..." + $Version = Get-LatestVersion + if (-not $Version) { + Write-Host "Could not determine the latest version. Tried:" + Write-Host " $Releases/latest/download/checksums.txt" + Write-Host " $GoProxyLatest" + Write-Host " $ApiLatest" + Write-Host "Pin a version instead:" + Write-Host " `$env:WEBEX_CLI_VERSION = `"x.y.z`"; irm https://raw.githubusercontent.com/$Repo/main/install.ps1 | iex" + Write-Error "Could not determine latest version" + return + } + Write-Host "Latest version: v$Version" } -Write-Host "Latest version: v$Version" # Download $ZipName = "webex-cli_${Version}_windows_${Arch}.zip" -$Url = "https://github.com/$Repo/releases/download/v$Version/$ZipName" +$Url = "$Releases/download/v$Version/$ZipName" $TmpDir = New-Item -ItemType Directory -Path (Join-Path $env:TEMP "webex-cli-install-$(Get-Random)") try { Write-Host "Downloading $Url..." - Invoke-WebRequest -Uri $Url -OutFile (Join-Path $TmpDir $ZipName) + $ZipPath = Join-Path $TmpDir $ZipName + Invoke-WebRequest -Uri $Url -OutFile $ZipPath -UseBasicParsing + + # Verify + $Sums = Get-Text "$Releases/download/v$Version/checksums.txt" + $Expected = $null + foreach ($line in $Sums -split "`n") { + $fields = $line.Trim() -split '\s+' + if ($fields.Count -eq 2 -and $fields[1].TrimStart('*') -eq $ZipName) { + $Expected = $fields[0].ToLower() + break + } + } + if (-not $Expected) { + Write-Error "$ZipName is not listed in checksums.txt; refusing to install" + return + } + $Actual = (Get-FileHash -Algorithm SHA256 -Path $ZipPath).Hash.ToLower() + if ($Actual -ne $Expected) { + Write-Error "Checksum mismatch for ${ZipName} (expected $Expected, got $Actual); refusing to install" + return + } + Write-Host "Checksum verified." # Extract - Expand-Archive -Path (Join-Path $TmpDir $ZipName) -DestinationPath $TmpDir -Force + Expand-Archive -Path $ZipPath -DestinationPath $TmpDir -Force # Install New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null diff --git a/install.sh b/install.sh index 780ef67..e6ab848 100755 --- a/install.sh +++ b/install.sh @@ -3,10 +3,26 @@ set -e # Webex CLI installer # Usage: curl -fsSL https://raw.githubusercontent.com/Cloverhound/webex-cli/main/install.sh | sh +# Pin a version: ... | sh -s -- -v 0.16.0 (or WEBEX_CLI_VERSION=0.16.0) +# Install dir: ... | INSTALL_DIR=/usr/local/bin sh REPO="Cloverhound/webex-cli" BINARY="webex" -INSTALL_DIR="${HOME}/.local/bin" +INSTALL_DIR="${INSTALL_DIR:-${HOME}/.local/bin}" +RELEASES="https://github.com/${REPO}/releases" +GOPROXY_LATEST="https://proxy.golang.org/github.com/!cloverhound/webex-cli/@latest" +API_LATEST="https://api.github.com/repos/${REPO}/releases/latest" + +VERSION="${WEBEX_CLI_VERSION:-}" +while [ $# -gt 0 ]; do + case "$1" in + -v|--version) + [ $# -ge 2 ] || { echo "Error: $1 needs a version, e.g. $1 0.16.0"; exit 1; } + VERSION="$2"; shift 2 ;; + *) echo "Unknown option: $1"; exit 1 ;; + esac +done +VERSION="${VERSION#v}" # Detect OS OS=$(uname -s | tr '[:upper:]' '[:lower:]') @@ -24,24 +40,77 @@ case "$ARCH" in *) echo "Unsupported architecture: $ARCH"; exit 1 ;; esac -# Get latest version -echo "Fetching latest release..." -VERSION=$(curl -fsSL "https://api.github.com/repos/${REPO}/releases/latest" | grep '"tag_name"' | sed -E 's/.*"v([^"]+)".*/\1/') -if [ -z "$VERSION" ]; then - echo "Error: could not determine latest version" - exit 1 +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +# The lookup avoids api.github.com where possible: sandboxes and shared CI +# hosts often block it or exhaust its unauthenticated rate limit. +version_from_checksums() { + curl -fsSL "${RELEASES}/latest/download/checksums.txt" 2>/dev/null | + sed -n "s/.*webex-cli_\([^ ]*\)_${OS}_${ARCH}\.tar\.gz\$/\1/p" | head -n 1 +} + +version_from_goproxy() { + curl -fsSL "$GOPROXY_LATEST" 2>/dev/null | + sed -n 's/.*"Version":"v\{0,1\}\([^"]*\)".*/\1/p' | head -n 1 +} + +version_from_api() { + if [ -n "${GITHUB_TOKEN:-}" ]; then + curl -fsSL -H "Authorization: Bearer ${GITHUB_TOKEN}" "$API_LATEST" 2>/dev/null + else + curl -fsSL "$API_LATEST" 2>/dev/null + fi | grep '"tag_name"' | sed -E 's/.*"v?([^"]+)".*/\1/' | head -n 1 +} + +if [ -n "$VERSION" ]; then + echo "Using pinned version: v${VERSION}" +else + echo "Fetching latest release..." + VERSION=$(version_from_checksums || true) + [ -n "$VERSION" ] || VERSION=$(version_from_goproxy || true) + [ -n "$VERSION" ] || VERSION=$(version_from_api || true) + if [ -z "$VERSION" ]; then + echo "Error: could not determine the latest version. Tried:" + echo " ${RELEASES}/latest/download/checksums.txt" + echo " ${GOPROXY_LATEST}" + echo " ${API_LATEST}" + echo "Pin a version instead:" + echo " curl -fsSL https://raw.githubusercontent.com/${REPO}/main/install.sh | WEBEX_CLI_VERSION=x.y.z sh" + exit 1 + fi + echo "Latest version: v${VERSION}" fi -echo "Latest version: v${VERSION}" # Download TARBALL="${BINARY}-cli_${VERSION}_${OS}_${ARCH}.tar.gz" -URL="https://github.com/${REPO}/releases/download/v${VERSION}/${TARBALL}" - -TMPDIR=$(mktemp -d) -trap 'rm -rf "$TMPDIR"' EXIT +URL="${RELEASES}/download/v${VERSION}/${TARBALL}" echo "Downloading ${URL}..." curl -fsSL "$URL" -o "${TMPDIR}/${TARBALL}" +curl -fsSL "${RELEASES}/download/v${VERSION}/checksums.txt" -o "${TMPDIR}/checksums.txt" + +# Verify +EXPECTED=$(awk -v f="$TARBALL" '$2 == f || $2 == "*" f { print $1; exit }' "${TMPDIR}/checksums.txt") +if [ -z "$EXPECTED" ]; then + echo "Error: ${TARBALL} is not listed in checksums.txt; refusing to install" + exit 1 +fi +if command -v sha256sum >/dev/null 2>&1; then + ACTUAL=$(sha256sum "${TMPDIR}/${TARBALL}" | awk '{ print $1 }') +elif command -v shasum >/dev/null 2>&1; then + ACTUAL=$(shasum -a 256 "${TMPDIR}/${TARBALL}" | awk '{ print $1 }') +else + echo "Error: neither sha256sum nor shasum is available to verify the download" + exit 1 +fi +if [ "$ACTUAL" != "$EXPECTED" ]; then + echo "Error: checksum mismatch for ${TARBALL}; refusing to install" + echo " expected: ${EXPECTED}" + echo " actual: ${ACTUAL}" + exit 1 +fi +echo "Checksum verified." # Extract tar -xzf "${TMPDIR}/${TARBALL}" -C "$TMPDIR" @@ -54,8 +123,14 @@ chmod +x "${INSTALL_DIR}/${BINARY}" echo "Installed ${BINARY} v${VERSION} to ${INSTALL_DIR}/${BINARY}" echo "" -# Run post-install setup (PATH configuration, agent skills) -"${INSTALL_DIR}/${BINARY}" post-install < /dev/tty +# Post-install prompts need a terminal; agents and CI run this without one, +# where post-install prints the PATH line and installs skills for detected +# agents. A post-install failure must not fail an install that succeeded. +if [ -z "${WEBEX_CLI_NONINTERACTIVE:-}" ] && [ -t 1 ] && [ -r /dev/tty ]; then + "${INSTALL_DIR}/${BINARY}" post-install < /dev/tty || true +else + WEBEX_CLI_NONINTERACTIVE=1 "${INSTALL_DIR}/${BINARY}" post-install < /dev/null || true +fi echo "" echo "Get started:" diff --git a/internal/auth/device.go b/internal/auth/device.go new file mode 100644 index 0000000..ad1670e --- /dev/null +++ b/internal/auth/device.go @@ -0,0 +1,192 @@ +package auth + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// Vars rather than consts so tests can point them at a fake server. +var ( + DeviceAuthorizeURL = "https://webexapis.com/v1/device/authorize" + DeviceTokenURL = "https://webexapis.com/v1/device/token" + + pollUnit = time.Second +) + +// DeviceHelperRedirectURIs must be registered on the integration: Webex's device +// approval page redirects to one of them, and which one depends on the region. +const DeviceHelperRedirectURIs = "https://oauth-helper-{a,r,k,d}.wbx2.com/helperservice/v1/actions/device/callback" + +// DeviceCode is the response to a device authorization request. +type DeviceCode struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + VerificationURIComplete string `json:"verification_uri_complete"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` +} + +// RequestDeviceCode starts the OAuth device grant. +func RequestDeviceCode(clientID, scopes string) (*DeviceCode, error) { + if clientID == "" { + return nil, fmt.Errorf("client ID not configured — run: webex config set client-id ") + } + resp, err := http.PostForm(DeviceAuthorizeURL, url.Values{ + "client_id": {clientID}, + "scope": {scopes}, + }) + if err != nil { + return nil, fmt.Errorf("device authorization request: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != http.StatusOK { + if strings.Contains(string(body), "redirect_uri") { + return nil, fmt.Errorf("device login failed (%d): the integration is missing a device-grant redirect URI; add %s to it.\n%s", + resp.StatusCode, DeviceHelperRedirectURIs, string(body)) + } + if resp.StatusCode == http.StatusTooManyRequests { + return nil, fmt.Errorf("device login is rate limited (retry after %s seconds)", resp.Header.Get("Retry-After")) + } + return nil, fmt.Errorf("device authorization failed (%d): %s", resp.StatusCode, string(body)) + } + + var dc DeviceCode + if err := json.Unmarshal(body, &dc); err != nil { + return nil, fmt.Errorf("parsing device authorization response: %w", err) + } + if dc.DeviceCode == "" || dc.UserCode == "" { + return nil, fmt.Errorf("device authorization response is missing the device or user code: %s", string(body)) + } + if dc.ExpiresIn <= 0 { + dc.ExpiresIn = 300 + } + if dc.Interval <= 0 { + dc.Interval = 5 + } + return &dc, nil +} + +// PollDeviceToken polls the token endpoint until the user approves the code, +// denies it, or the code expires. It follows RFC 8628: authorization_pending +// keeps polling and slow_down adds 5 seconds to the interval. +func PollDeviceToken(ctx context.Context, clientID, clientSecret string, dc *DeviceCode, scopes string) (*StoredToken, error) { + interval := time.Duration(dc.Interval) * pollUnit + deadline := time.Now().Add(time.Duration(dc.ExpiresIn) * pollUnit) + + for { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-time.After(interval): + } + if time.Now().After(deadline) { + return nil, fmt.Errorf("the login code expired before it was approved — run webex login again") + } + + tok, status, err := requestDeviceToken(clientID, clientSecret, dc.DeviceCode, scopes) + if err != nil { + return nil, err + } + switch status { + case "": + return tok, nil + case "authorization_pending": + case "slow_down": + interval += 5 * pollUnit + case "access_denied": + return nil, fmt.Errorf("the login request was denied") + case "expired_token", "invalid_grant": + return nil, fmt.Errorf("the login code expired or is no longer valid — run webex login again") + default: + return nil, fmt.Errorf("device login failed: %s", status) + } + } +} + +// requestDeviceToken makes one token request. It returns the token on success, +// or the OAuth error code for a response the caller can act on. +func requestDeviceToken(clientID, clientSecret, deviceCode, scopes string) (*StoredToken, string, error) { + form := url.Values{ + "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, + "device_code": {deviceCode}, + "client_id": {clientID}, + } + req, err := http.NewRequest(http.MethodPost, DeviceTokenURL, strings.NewReader(form.Encode())) + if err != nil { + return nil, "", err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(clientID, clientSecret) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, "", fmt.Errorf("device token request: %w", err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode == http.StatusOK { + var tr tokenResponse + if err := json.Unmarshal(body, &tr); err != nil { + return nil, "", fmt.Errorf("parsing device token response: %w", err) + } + if tr.Scope != "" { + scopes = tr.Scope + } + now := time.Now() + return &StoredToken{ + AccessToken: tr.AccessToken, + RefreshToken: tr.RefreshToken, + ExpiresAt: now.Add(time.Duration(tr.ExpiresIn) * time.Second), + TokenType: tr.TokenType, + IssuedAt: now, + Scopes: scopes, + }, "", nil + } + + var oauthErr struct { + Error string `json:"error"` + Description string `json:"error_description"` + Message string `json:"message"` + } + _ = json.Unmarshal(body, &oauthErr) + + switch { + case resp.StatusCode == http.StatusPreconditionRequired: + // Webex answers 428 while the user has not approved yet. + return nil, "authorization_pending", nil + case resp.StatusCode == http.StatusTooManyRequests: + return nil, "slow_down", nil + case oauthErr.Error != "": + return nil, oauthErr.Error, nil + } + return nil, "", fmt.Errorf("device token request failed (%d): %s", resp.StatusCode, string(body)) +} + +// DeviceLogin runs the device grant. show is called once with the code for the +// user to approve; the call then blocks until approval, denial or expiry. +func DeviceLogin(clientID, clientSecret, scopes string, show func(*DeviceCode)) (*LoginResult, error) { + if clientSecret == "" { + return nil, fmt.Errorf("client secret not configured — run: webex config set client-secret ") + } + dc, err := RequestDeviceCode(clientID, scopes) + if err != nil { + return nil, err + } + show(dc) + + tok, err := PollDeviceToken(context.Background(), clientID, clientSecret, dc, scopes) + if err != nil { + return nil, err + } + return completeLogin(tok) +} diff --git a/internal/auth/device_test.go b/internal/auth/device_test.go new file mode 100644 index 0000000..33dc4b2 --- /dev/null +++ b/internal/auth/device_test.go @@ -0,0 +1,187 @@ +package auth + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "sync/atomic" + "testing" +) + +// fakeDeviceToken serves the device token endpoint, answering with responses +// in order and repeating the last one. +func fakeDeviceToken(t *testing.T, responses []func(http.ResponseWriter)) (*int32, func()) { + t.Helper() + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + user, pass, ok := r.BasicAuth() + if !ok || user != "cid" || pass != "secret" { + t.Errorf("basic auth = %q/%q/%v, want cid/secret", user, pass, ok) + } + if err := r.ParseForm(); err != nil { + t.Fatal(err) + } + if r.Form.Get("grant_type") != "urn:ietf:params:oauth:grant-type:device_code" || r.Form.Get("device_code") != "dev" { + t.Errorf("unexpected form %v", r.Form) + } + n := atomic.AddInt32(&calls, 1) + i := int(n) - 1 + if i >= len(responses) { + i = len(responses) - 1 + } + responses[i](w) + })) + old := DeviceTokenURL + DeviceTokenURL = srv.URL + return &calls, func() { DeviceTokenURL = old; srv.Close() } +} + +func respond(status int, body string) func(http.ResponseWriter) { + return func(w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + w.Write([]byte(body)) + } +} + +var granted = respond(200, `{"access_token":"at","refresh_token":"rt","expires_in":3600,"token_type":"Bearer","scope":"spark:all"}`) + +func testCode(expiresIn int) *DeviceCode { + return &DeviceCode{DeviceCode: "dev", UserCode: "123456", ExpiresIn: expiresIn, Interval: 1} +} + +func TestPollDeviceTokenPendingThenGranted(t *testing.T) { + calls, done := fakeDeviceToken(t, []func(http.ResponseWriter){ + respond(428, `{"message":"authorization pending"}`), + respond(400, `{"error":"authorization_pending"}`), + granted, + }) + defer done() + + tok, err := PollDeviceToken(context.Background(), "cid", "secret", testCode(1000), "requested") + if err != nil { + t.Fatal(err) + } + if tok.AccessToken != "at" || tok.RefreshToken != "rt" || tok.Scopes != "spark:all" { + t.Errorf("token = %+v", tok) + } + if *calls != 3 { + t.Errorf("calls = %d, want 3", *calls) + } +} + +func TestPollDeviceTokenSlowDownAddsFiveIntervals(t *testing.T) { + _, done := fakeDeviceToken(t, []func(http.ResponseWriter){ + respond(400, `{"error":"slow_down"}`), + granted, + }) + defer done() + + // With a 1-unit interval and a 5-unit expiry, the poll after slow_down + // (at 1+6 units) lands past the deadline. + _, err := PollDeviceToken(context.Background(), "cid", "secret", testCode(5), "") + if err == nil || !strings.Contains(err.Error(), "expired") { + t.Fatalf("err = %v, want expiry after slow_down", err) + } +} + +func TestPollDeviceTokenDenied(t *testing.T) { + _, done := fakeDeviceToken(t, []func(http.ResponseWriter){respond(400, `{"error":"access_denied"}`)}) + defer done() + + _, err := PollDeviceToken(context.Background(), "cid", "secret", testCode(1000), "") + if err == nil || !strings.Contains(err.Error(), "denied") { + t.Fatalf("err = %v, want denied", err) + } +} + +func TestPollDeviceTokenExpiredGrant(t *testing.T) { + _, done := fakeDeviceToken(t, []func(http.ResponseWriter){respond(400, `{"error":"expired_token"}`)}) + defer done() + + _, err := PollDeviceToken(context.Background(), "cid", "secret", testCode(1000), "") + if err == nil || !strings.Contains(err.Error(), "expired") { + t.Fatalf("err = %v, want expired", err) + } +} + +func TestPollDeviceTokenTimesOut(t *testing.T) { + calls, done := fakeDeviceToken(t, []func(http.ResponseWriter){respond(428, `{}`)}) + defer done() + + _, err := PollDeviceToken(context.Background(), "cid", "secret", testCode(20), "") + if err == nil || !strings.Contains(err.Error(), "expired") { + t.Fatalf("err = %v, want expiry", err) + } + if *calls == 0 { + t.Error("expected at least one poll before expiry") + } +} + +func TestRequestDeviceCodeRedirectMismatch(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(400) + w.Write([]byte(`{"message":"redirect_uri_mismatch"}`)) + })) + defer srv.Close() + old := DeviceAuthorizeURL + DeviceAuthorizeURL = srv.URL + defer func() { DeviceAuthorizeURL = old }() + + _, err := RequestDeviceCode("cid", "spark:all") + if err == nil || !strings.Contains(err.Error(), "oauth-helper-") { + t.Fatalf("err = %v, want a hint naming the helper redirect URIs", err) + } +} + +func TestRequestDeviceCodeDefaults(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + r.ParseForm() + if r.Form.Get("client_id") != "cid" || r.Form.Get("scope") != "spark:all" { + t.Errorf("form = %v", r.Form) + } + w.Write([]byte(`{"device_code":"dev","user_code":"123456","verification_uri":"https://v"}`)) + })) + defer srv.Close() + old := DeviceAuthorizeURL + DeviceAuthorizeURL = srv.URL + defer func() { DeviceAuthorizeURL = old }() + + dc, err := RequestDeviceCode("cid", "spark:all") + if err != nil { + t.Fatal(err) + } + if dc.ExpiresIn != 300 || dc.Interval != 5 { + t.Errorf("defaults = %d/%d, want 300/5", dc.ExpiresIn, dc.Interval) + } +} + +func TestHeadlessReason(t *testing.T) { + env := func(kv ...string) func(string) string { + m := map[string]string{} + for i := 0; i < len(kv); i += 2 { + m[kv[i]] = kv[i+1] + } + return func(k string) string { return m[k] } + } + cases := []struct { + name string + getenv func(string) string + goos string + want bool + }{ + {"mac desktop", env(), "darwin", false}, + {"ssh", env("SSH_CONNECTION", "1 2 3 4"), "darwin", true}, + {"ssh tty", env("SSH_TTY", "/dev/pts/0"), "darwin", true}, + {"ci", env("CI", "true"), "windows", true}, + {"linux no display", env(), "linux", true}, + {"linux x11", env("DISPLAY", ":0"), "linux", false}, + {"linux wayland", env("WAYLAND_DISPLAY", "wayland-0"), "linux", false}, + } + for _, c := range cases { + if got := HeadlessReason(c.getenv, c.goos) != ""; got != c.want { + t.Errorf("%s: headless = %v, want %v", c.name, got, c.want) + } + } +} diff --git a/internal/auth/envrefresh.go b/internal/auth/envrefresh.go new file mode 100644 index 0000000..16d287f --- /dev/null +++ b/internal/auth/envrefresh.go @@ -0,0 +1,99 @@ +package auth + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "time" +) + +// Environment variables for non-interactive auth from a refresh token. +const ( + RefreshTokenEnv = "WEBEX_REFRESH_TOKEN" + ClientIDEnv = "WEBEX_CLIENT_ID" + ClientSecretEnv = "WEBEX_CLIENT_SECRET" +) + +// envRefreshEntry caches the access token minted from $WEBEX_REFRESH_TOKEN, and +// the rotated refresh token, so each CLI call does not mint a new one. +type envRefreshEntry struct { + Token StoredToken `json:"token"` + Email string `json:"email,omitempty"` + OrgID string `json:"org_id,omitempty"` +} + +// The cache is keyed by a hash of the env value so a changed secret starts fresh +// and the original value never lands on disk. +func envRefreshKey(refreshToken string) string { + sum := sha256.Sum256([]byte(refreshToken)) + return "env-refresh:" + hex.EncodeToString(sum[:8]) +} + +// EnvRefreshAccessToken returns an access token for the refresh token in +// $WEBEX_REFRESH_TOKEN. A cached access token is reused until it expires or +// equals stale, the token the API just rejected. The cache lives in the +// credentials file whatever $WEBEX_TOKEN_STORE says, since CI and containers +// rarely have a keyring. +func EnvRefreshAccessToken(envRefresh, clientID, clientSecret, stale string) (token, email, orgID string, err error) { + key := envRefreshKey(envRefresh) + var entry *envRefreshEntry + + lockErr := withFileLock("refresh.lock", func() error { + cached := loadEnvRefreshEntry(key) + if cached != nil && !cached.Token.IsExpired() && cached.Token.AccessToken != stale { + entry = cached + return nil + } + + refreshToken := envRefresh + if cached != nil && cached.Token.RefreshToken != "" { + refreshToken = cached.Token.RefreshToken + } + tok, err := RefreshAccessToken(clientID, clientSecret, &StoredToken{RefreshToken: refreshToken, IssuedAt: time.Now()}) + if err != nil && refreshToken != envRefresh { + // The cached refresh token may have been revoked while the secret was + // updated in place; the env value is the source of truth. + tok, err = RefreshAccessToken(clientID, clientSecret, &StoredToken{RefreshToken: envRefresh, IssuedAt: time.Now()}) + } + if err != nil { + return fmt.Errorf("$%s refresh failed: %w", RefreshTokenEnv, err) + } + if tok.RefreshToken == "" { + tok.RefreshToken = refreshToken + } + + entry = &envRefreshEntry{Token: *tok} + if cached != nil { + entry.Email, entry.OrgID = cached.Email, cached.OrgID + } + if entry.OrgID == "" { + if id, err := fetchIdentity(tok.AccessToken); err == nil { + entry.Email, entry.OrgID = id.email, id.orgID + } + } + if data, err := json.Marshal(entry); err == nil { + if err := fileSet(key, string(data)); err != nil { + fmt.Fprintf(os.Stderr, "Warning: could not cache the token from $%s (%v); the rotated refresh token is lost when this process exits\n", RefreshTokenEnv, err) + } + } + return nil + }) + if lockErr != nil { + return "", "", "", lockErr + } + return entry.Token.AccessToken, entry.Email, entry.OrgID, nil +} + +func loadEnvRefreshEntry(key string) *envRefreshEntry { + data, err := fileGet(key) + if err != nil { + return nil + } + var e envRefreshEntry + if json.Unmarshal([]byte(data), &e) != nil { + return nil + } + return &e +} diff --git a/internal/auth/headless.go b/internal/auth/headless.go new file mode 100644 index 0000000..5895845 --- /dev/null +++ b/internal/auth/headless.go @@ -0,0 +1,15 @@ +package auth + +// HeadlessReason returns why this environment probably has no browser for the +// OAuth redirect to reach, or "" when the browser flow should work. +func HeadlessReason(getenv func(string) string, goos string) string { + switch { + case getenv("SSH_CONNECTION") != "" || getenv("SSH_TTY") != "": + return "SSH session" + case getenv("CI") != "": + return "CI environment" + case goos == "linux" && getenv("DISPLAY") == "" && getenv("WAYLAND_DISPLAY") == "": + return "no graphical display" + } + return "" +} diff --git a/internal/auth/keyring.go b/internal/auth/keyring.go index b4b7b2f..87faa64 100644 --- a/internal/auth/keyring.go +++ b/internal/auth/keyring.go @@ -4,13 +4,11 @@ import ( "encoding/json" "fmt" "time" - - "github.com/zalando/go-keyring" ) const serviceName = "webex-cli" -// StoredToken represents OAuth tokens persisted in the OS keyring. +// StoredToken represents OAuth tokens persisted in the token store. type StoredToken struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` @@ -33,33 +31,37 @@ func (t *StoredToken) IsRefreshExpired() bool { return time.Now().After(t.IssuedAt.Add(90 * 24 * time.Hour)) } -// SaveToken stores a token in the OS keyring keyed by email. +// SaveToken stores a token keyed by email, in the OS keyring when one is +// available and in the credentials file otherwise. func SaveToken(email string, tok *StoredToken) error { data, err := json.Marshal(tok) if err != nil { return fmt.Errorf("marshaling token: %w", err) } - return keyring.Set(serviceName, email, string(data)) + return storeSet(email, string(data)) } -// LoadToken retrieves a token from the OS keyring for the given email. +// LoadToken retrieves the token stored for email. func LoadToken(email string) (*StoredToken, error) { - data, err := keyring.Get(serviceName, email) + tok, _, err := LoadTokenWithStore(email) + return tok, err +} + +// LoadTokenWithStore retrieves the token stored for email and names the store +// that held it ("keyring" or "file"). +func LoadTokenWithStore(email string) (*StoredToken, string, error) { + data, store, err := storeGet(email) if err != nil { - return nil, fmt.Errorf("loading token for %s: %w", email, err) + return nil, "", fmt.Errorf("loading token for %s: %w", email, err) } var tok StoredToken if err := json.Unmarshal([]byte(data), &tok); err != nil { - return nil, fmt.Errorf("parsing stored token: %w", err) + return nil, "", fmt.Errorf("parsing stored token: %w", err) } - return &tok, nil + return &tok, store, nil } -// DeleteToken removes a token from the OS keyring. +// DeleteToken removes the token for email from every store that holds it. func DeleteToken(email string) error { - err := keyring.Delete(serviceName, email) - if err == keyring.ErrNotFound { - return nil - } - return err + return storeDelete(email) } diff --git a/internal/auth/lock.go b/internal/auth/lock.go new file mode 100644 index 0000000..626884b --- /dev/null +++ b/internal/auth/lock.go @@ -0,0 +1,44 @@ +package auth + +import ( + "fmt" + "os" + "path/filepath" + "time" +) + +var lockTimeout = 30 * time.Second + +// withFileLock runs fn while holding an exclusive lock on the named file in +// CredentialsDir, so parallel CLI processes serialize token writes and refreshes. +// If the lock cannot be taken (read-only home, timeout), fn runs anyway: a rare +// race is better than refusing to work. +func withFileLock(name string, fn func() error) error { + dir := CredentialsDir() + if err := os.MkdirAll(dir, 0700); err != nil { + return fn() + } + f, err := os.OpenFile(filepath.Join(dir, name), os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + return fn() + } + defer f.Close() + + deadline := time.Now().Add(lockTimeout) + for { + locked, err := tryLock(f) + if err != nil { + return fn() + } + if locked { + break + } + if time.Now().After(deadline) { + fmt.Fprintf(os.Stderr, "Warning: timed out waiting for %s; continuing without it\n", f.Name()) + return fn() + } + time.Sleep(50 * time.Millisecond) + } + defer unlock(f) + return fn() +} diff --git a/internal/auth/lock_unix.go b/internal/auth/lock_unix.go new file mode 100644 index 0000000..235c6e5 --- /dev/null +++ b/internal/auth/lock_unix.go @@ -0,0 +1,24 @@ +//go:build !windows + +package auth + +import ( + "errors" + "os" + "syscall" +) + +func tryLock(f *os.File) (bool, error) { + err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB) + if err == nil { + return true, nil + } + if errors.Is(err, syscall.EWOULDBLOCK) { + return false, nil + } + return false, err +} + +func unlock(f *os.File) { + _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) +} diff --git a/internal/auth/lock_windows.go b/internal/auth/lock_windows.go new file mode 100644 index 0000000..f85d137 --- /dev/null +++ b/internal/auth/lock_windows.go @@ -0,0 +1,27 @@ +//go:build windows + +package auth + +import ( + "errors" + "os" + + "golang.org/x/sys/windows" +) + +func tryLock(f *os.File) (bool, error) { + ol := new(windows.Overlapped) + err := windows.LockFileEx(windows.Handle(f.Fd()), + windows.LOCKFILE_EXCLUSIVE_LOCK|windows.LOCKFILE_FAIL_IMMEDIATELY, 0, 1, 0, ol) + if err == nil { + return true, nil + } + if errors.Is(err, windows.ERROR_LOCK_VIOLATION) { + return false, nil + } + return false, err +} + +func unlock(f *os.File) { + _ = windows.UnlockFileEx(windows.Handle(f.Fd()), 0, 1, 0, new(windows.Overlapped)) +} diff --git a/internal/auth/main_test.go b/internal/auth/main_test.go new file mode 100644 index 0000000..7875e5a --- /dev/null +++ b/internal/auth/main_test.go @@ -0,0 +1,22 @@ +package auth + +import ( + "os" + "testing" + "time" +) + +// Every test gets a scratch credentials directory so none touches the real one. +func TestMain(m *testing.M) { + dir, err := os.MkdirTemp("", "webex-auth-test-") + if err != nil { + panic(err) + } + os.Setenv("XDG_CONFIG_HOME", dir) + os.Unsetenv(TokenStoreEnv) + os.Unsetenv(RefreshTokenEnv) + pollUnit = time.Millisecond + code := m.Run() + os.RemoveAll(dir) + os.Exit(code) +} diff --git a/internal/auth/oauth.go b/internal/auth/oauth.go index 232a87c..ba712ef 100644 --- a/internal/auth/oauth.go +++ b/internal/auth/oauth.go @@ -7,22 +7,38 @@ import ( "encoding/base64" "encoding/hex" "encoding/json" + "errors" "fmt" "io" "net" "net/http" "net/url" + "os" "time" "github.com/pkg/browser" ) +// openBrowser is a var so tests can avoid launching a real browser. +var openBrowser = func(u string) error { + // pkg/browser copies the launcher's output to ours, which would corrupt + // JSON on stdout. + browser.Stdout = io.Discard + browser.Stderr = io.Discard + return browser.OpenURL(u) +} + const ( AuthorizeURL = "https://webexapis.com/v1/authorize" - TokenURL = "https://webexapis.com/v1/access_token" RedirectURI = "http://localhost:8085/callback" ) +// Vars rather than consts so tests can point them at a fake server. +var ( + TokenURL = "https://webexapis.com/v1/access_token" + peopleMeURL = "https://webexapis.com/v1/people/me" +) + // LoginResult contains the token and identity info from a successful login. type LoginResult struct { Token StoredToken @@ -32,8 +48,14 @@ type LoginResult struct { OrgName string } +// ErrNoBrowser reports that the browser flow cannot run here: the callback +// port is unavailable or no browser could be opened. +var ErrNoBrowser = errors.New("browser login unavailable") + // Login runs the OAuth PKCE flow: opens browser, waits for callback, exchanges code, fetches identity. -func Login(clientID, clientSecret, scopes string) (*LoginResult, error) { +// With failFast, it returns an error wrapping ErrNoBrowser instead of waiting +// when the browser cannot be opened, so the caller can switch to device login. +func Login(clientID, clientSecret, scopes string, failFast bool) (*LoginResult, error) { if clientID == "" { return nil, fmt.Errorf("client ID not configured — run: webex config set client-id ") } @@ -51,7 +73,7 @@ func Login(clientID, clientSecret, scopes string) (*LoginResult, error) { listener, err := net.Listen("tcp", "127.0.0.1:8085") if err != nil { - return nil, fmt.Errorf("starting callback server: %w", err) + return nil, fmt.Errorf("%w: starting callback server: %v", ErrNoBrowser, err) } mux := http.NewServeMux() @@ -94,10 +116,18 @@ func Login(clientID, clientSecret, scopes string) (*LoginResult, error) { url.QueryEscape(challenge), ) - fmt.Println("Opening browser for Webex login...") - fmt.Println("If browser doesn't open, visit:") - fmt.Println(authURL) - _ = browser.OpenURL(authURL) + if failFast { + if err := openBrowser(authURL); err != nil { + return nil, fmt.Errorf("%w: %v", ErrNoBrowser, err) + } + fmt.Fprintln(os.Stderr, "Opened browser for Webex login. If it didn't open, visit:") + fmt.Fprintln(os.Stderr, authURL) + } else { + fmt.Fprintln(os.Stderr, "Opening browser for Webex login...") + fmt.Fprintln(os.Stderr, "If browser doesn't open, visit:") + fmt.Fprintln(os.Stderr, authURL) + _ = openBrowser(authURL) + } // Wait for callback var code string @@ -114,8 +144,11 @@ func Login(clientID, clientSecret, scopes string) (*LoginResult, error) { if err != nil { return nil, err } + return completeLogin(tok) +} - // Fetch identity +// completeLogin looks up the identity and org behind a newly issued token. +func completeLogin(tok *StoredToken) (*LoginResult, error) { identity, err := fetchIdentity(tok.AccessToken) if err != nil { return nil, fmt.Errorf("fetching identity: %w", err) @@ -237,7 +270,7 @@ type identityInfo struct { } func fetchIdentity(accessToken string) (*identityInfo, error) { - req, _ := http.NewRequest("GET", "https://webexapis.com/v1/people/me", nil) + req, _ := http.NewRequest("GET", peopleMeURL, nil) req.Header.Set("Authorization", "Bearer "+accessToken) resp, err := http.DefaultClient.Do(req) diff --git a/internal/auth/resolve.go b/internal/auth/resolve.go index 5b066ce..6f01b68 100644 --- a/internal/auth/resolve.go +++ b/internal/auth/resolve.go @@ -2,6 +2,8 @@ package auth import ( "fmt" + "os" + "sync" "github.com/Cloverhound/webex-cli/internal/appconfig" ) @@ -10,10 +12,11 @@ import ( type TokenSource int const ( - SourceNone TokenSource = iota - SourceFlag // --token flag - SourceEnv // $WEBEX_TOKEN - SourceKeyring // OS keyring + SourceNone TokenSource = iota + SourceFlag // --token flag + SourceEnv // $WEBEX_TOKEN + SourceKeyring // stored login (OS keyring or credentials file) + SourceEnvRefresh // $WEBEX_REFRESH_TOKEN ) func (s TokenSource) String() string { @@ -23,12 +26,24 @@ func (s TokenSource) String() string { case SourceEnv: return "environment" case SourceKeyring: - return "keyring" + return "stored login" + case SourceEnvRefresh: + return "environment refresh token" default: return "none" } } +// EnvCredentials returns the client credentials for $WEBEX_REFRESH_TOKEN: +// $WEBEX_CLIENT_ID and $WEBEX_CLIENT_SECRET when set, else the configured ones. +func EnvCredentials(cfg *appconfig.Config) (clientID, clientSecret string) { + clientID, clientSecret = os.Getenv(ClientIDEnv), os.Getenv(ClientSecretEnv) + if clientID == "" { + return cfg.EffectiveClientID(), cfg.EffectiveClientSecret() + } + return clientID, clientSecret +} + // ResolveResult contains the resolved token and metadata. type ResolveResult struct { Token string @@ -40,14 +55,17 @@ type ResolveResult struct { // ResolveToken determines the access token using the priority chain: // 1. --token flag // 2. $WEBEX_TOKEN env -// 3. Keyring (resolve user from --user flag → $WEBEX_USER → config default) +// 3. $WEBEX_REFRESH_TOKEN env +// 4. Stored login (resolve user from --user flag → $WEBEX_USER → config default) // -// In read-only mode only keyring tokens from `webex login --read-only` are accepted. +// In read-only mode only stored tokens from `webex login --read-only` are accepted. func ResolveToken(flagToken, envToken, userFlag, envUser string, cfg *appconfig.Config, readOnly bool) (*ResolveResult, error) { + envRefresh := os.Getenv(RefreshTokenEnv) + // Webex has no public token introspection, so the scopes of a token the CLI // did not obtain itself cannot be checked. - if readOnly && (flagToken != "" || envToken != "") { - return nil, fmt.Errorf("read-only mode does not accept --token or $WEBEX_TOKEN; use a stored read-only login") + if readOnly && (flagToken != "" || envToken != "" || envRefresh != "") { + return nil, fmt.Errorf("read-only mode does not accept --token, $WEBEX_TOKEN or $%s; use a stored read-only login", RefreshTokenEnv) } // 1. Explicit --token flag @@ -60,7 +78,17 @@ func ResolveToken(flagToken, envToken, userFlag, envUser string, cfg *appconfig. return &ResolveResult{Token: envToken, Source: SourceEnv}, nil } - // 3. Keyring lookup + // 3. Refresh token from the environment + if envRefresh != "" { + clientID, clientSecret := EnvCredentials(cfg) + token, email, orgID, err := EnvRefreshAccessToken(envRefresh, clientID, clientSecret, "") + if err != nil { + return nil, err + } + return &ResolveResult{Token: token, Source: SourceEnvRefresh, UserEmail: email, OrgID: orgID}, nil + } + + // 4. Stored login email := userFlag if email == "" { email = envUser @@ -86,16 +114,11 @@ func ResolveToken(flagToken, envToken, userFlag, envUser string, cfg *appconfig. // Auto-refresh if expired if tok.IsExpired() { - clientID, clientSecret := effectiveCredentials(tok, cfg) - refreshed, err := RefreshAccessToken(clientID, clientSecret, tok) + refreshed, err := refreshStored(email, cfg, tok.AccessToken) if err != nil { return nil, fmt.Errorf("token expired for %s and refresh failed: %w\nRun: webex login", email, err) } tok = refreshed - // Persist the refreshed token - if saveErr := SaveToken(email, tok); saveErr != nil { - fmt.Printf("Warning: could not save refreshed token: %v\n", saveErr) - } } // Determine org ID from config @@ -113,22 +136,64 @@ func ResolveToken(flagToken, envToken, userFlag, envUser string, cfg *appconfig. } // MakeRefresher returns a callback that refreshes the token for the given user. -func MakeRefresher(email string, cfg *appconfig.Config) func() (string, error) { +// current is the access token in use; the callback is called after the API rejects it. +func MakeRefresher(email, current string, cfg *appconfig.Config) func() (string, error) { + var mu sync.Mutex return func() (string, error) { - tok, err := LoadToken(email) + mu.Lock() + defer mu.Unlock() + refreshed, err := refreshStored(email, cfg, current) + if err != nil { + return "", err + } + current = refreshed.AccessToken + return current, nil + } +} + +// MakeEnvRefresher is MakeRefresher for a token minted from $WEBEX_REFRESH_TOKEN. +func MakeEnvRefresher(current string, cfg *appconfig.Config) func() (string, error) { + var mu sync.Mutex + return func() (string, error) { + mu.Lock() + defer mu.Unlock() + clientID, clientSecret := EnvCredentials(cfg) + token, _, _, err := EnvRefreshAccessToken(os.Getenv(RefreshTokenEnv), clientID, clientSecret, current) if err != nil { return "", err } + current = token + return current, nil + } +} + +// refreshStored refreshes the stored token for email under a lock shared by all +// CLI processes. Webex refresh tokens may be single-use, so when another process +// has already refreshed (the stored access token is no longer stale), its +// result is reused instead of refreshing again. +func refreshStored(email string, cfg *appconfig.Config, stale string) (*StoredToken, error) { + var out *StoredToken + err := withFileLock("refresh.lock", func() error { + tok, err := LoadToken(email) + if err != nil { + return err + } + if !tok.IsExpired() && tok.AccessToken != stale { + out = tok + return nil + } clientID, clientSecret := effectiveCredentials(tok, cfg) refreshed, err := RefreshAccessToken(clientID, clientSecret, tok) if err != nil { - return "", err + return err } if saveErr := SaveToken(email, refreshed); saveErr != nil { - fmt.Printf("Warning: could not save refreshed token: %v\n", saveErr) + fmt.Fprintf(os.Stderr, "Warning: could not save refreshed token: %v\n", saveErr) } - return refreshed.AccessToken, nil - } + out = refreshed + return nil + }) + return out, err } // effectiveCredentials returns the client ID and secret to use for token refresh. diff --git a/internal/auth/store.go b/internal/auth/store.go new file mode 100644 index 0000000..331b5d5 --- /dev/null +++ b/internal/auth/store.go @@ -0,0 +1,241 @@ +package auth + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + + "github.com/zalando/go-keyring" +) + +// TokenStoreEnv forces a token store: "keyring" or "file". Unset, the keyring +// is used and the credentials file is the fallback when the keyring fails. +const TokenStoreEnv = "WEBEX_TOKEN_STORE" + +const ( + StoreKeyring = "keyring" + StoreFile = "file" +) + +var errNotFound = errors.New("token not found") + +// forcedStore returns the store named by $WEBEX_TOKEN_STORE, or "" for automatic selection. +func forcedStore() (string, error) { + switch v := strings.ToLower(strings.TrimSpace(os.Getenv(TokenStoreEnv))); v { + case "", "auto": + return "", nil + case StoreKeyring, StoreFile: + return v, nil + default: + return "", fmt.Errorf("$%s must be %q or %q, not %q", TokenStoreEnv, StoreKeyring, StoreFile, v) + } +} + +func storeGet(key string) (string, string, error) { + forced, err := forcedStore() + if err != nil { + return "", "", err + } + if forced != StoreFile { + data, kerr := keyring.Get(serviceName, key) + if kerr == nil { + return data, StoreKeyring, nil + } + if forced == StoreKeyring { + return "", "", kerr + } + } + data, err := fileGet(key) + if err != nil { + return "", "", err + } + return data, StoreFile, nil +} + +func storeSet(key, data string) error { + forced, err := forcedStore() + if err != nil { + return err + } + if forced == StoreFile { + return fileSetUser(key, data) + } + kerr := keyring.Set(serviceName, key, data) + if kerr == nil || forced == StoreKeyring { + if kerr == nil { + // A copy left in the file from an earlier fallback would shadow nothing + // but would keep a stale secret on disk. + _ = fileDelete(key) + } + return kerr + } + if err := fileSetUser(key, data); err != nil { + return fmt.Errorf("keyring unavailable (%v) and file store failed: %w", kerr, err) + } + return nil +} + +func storeDelete(key string) error { + forced, err := forcedStore() + if err != nil { + return err + } + var kerr error + if forced != StoreFile { + if kerr = keyring.Delete(serviceName, key); errors.Is(kerr, keyring.ErrNotFound) { + kerr = nil + } + if forced == StoreKeyring { + return kerr + } + } + hadFile := false + if _, err := fileGet(key); err == nil { + hadFile = true + } + if err := fileDelete(key); err != nil { + return err + } + // Without a keyring, every delete fails there; that only matters when the + // token was not in the file. + if hadFile { + return nil + } + return kerr +} + +// CredentialsDir returns the directory for the credentials file and lock files: +// $XDG_CONFIG_HOME/webex-cli, %APPDATA%\webex-cli on Windows, or ~/.config/webex-cli. +func CredentialsDir() string { + if d := os.Getenv("XDG_CONFIG_HOME"); d != "" { + return filepath.Join(d, "webex-cli") + } + if runtime.GOOS == "windows" { + if d := os.Getenv("APPDATA"); d != "" { + return filepath.Join(d, "webex-cli") + } + } + home, _ := os.UserHomeDir() + return filepath.Join(home, ".config", "webex-cli") +} + +// CredentialsPath returns the path of the plain-text token file. +func CredentialsPath() string { + return filepath.Join(CredentialsDir(), "credentials.json") +} + +type credentialsFile struct { + Tokens map[string]string `json:"tokens"` +} + +func readCredentials() (*credentialsFile, error) { + cf := &credentialsFile{Tokens: map[string]string{}} + data, err := os.ReadFile(CredentialsPath()) + if err != nil { + if os.IsNotExist(err) { + return cf, nil + } + return nil, err + } + if err := json.Unmarshal(data, cf); err != nil { + return nil, fmt.Errorf("parsing %s: %w", CredentialsPath(), err) + } + if cf.Tokens == nil { + cf.Tokens = map[string]string{} + } + return cf, nil +} + +func fileGet(key string) (string, error) { + cf, err := readCredentials() + if err != nil { + return "", err + } + data, ok := cf.Tokens[key] + if !ok { + return "", errNotFound + } + return data, nil +} + +func fileSet(key, data string) error { + return withFileLock("credentials.lock", func() error { + cf, err := readCredentials() + if err != nil { + return err + } + cf.Tokens[key] = data + return writeCredentials(cf) + }) +} + +// fileSetUser saves a login to the file and warns the first time the file is +// created, since it holds tokens in plain text. +func fileSetUser(key, data string) error { + _, statErr := os.Stat(CredentialsPath()) + if err := fileSet(key, data); err != nil { + return err + } + if os.IsNotExist(statErr) { + fmt.Fprintf(os.Stderr, "Warning: tokens are stored in plain text at %s (mode 0600)\n", CredentialsPath()) + } + return nil +} + +func fileDelete(key string) error { + if _, err := os.Stat(CredentialsPath()); os.IsNotExist(err) { + return nil + } + return withFileLock("credentials.lock", func() error { + cf, err := readCredentials() + if err != nil { + return err + } + if _, ok := cf.Tokens[key]; !ok { + return nil + } + delete(cf.Tokens, key) + return writeCredentials(cf) + }) +} + +// writeCredentials replaces the file through a rename so a reader never sees a +// partial write. +func writeCredentials(cf *credentialsFile) error { + dir := CredentialsDir() + if err := os.MkdirAll(dir, 0700); err != nil { + return fmt.Errorf("creating %s: %w", dir, err) + } + data, err := json.MarshalIndent(cf, "", " ") + if err != nil { + return err + } + tmp, err := os.CreateTemp(dir, "credentials-*.tmp") + if err != nil { + return err + } + tmpPath := tmp.Name() + if err := tmp.Chmod(0600); err != nil && runtime.GOOS != "windows" { + tmp.Close() + os.Remove(tmpPath) + return err + } + if _, err := tmp.Write(data); err != nil { + tmp.Close() + os.Remove(tmpPath) + return err + } + if err := tmp.Close(); err != nil { + os.Remove(tmpPath) + return err + } + if err := os.Rename(tmpPath, CredentialsPath()); err != nil { + os.Remove(tmpPath) + return err + } + return nil +} diff --git a/internal/auth/store_test.go b/internal/auth/store_test.go new file mode 100644 index 0000000..279d71e --- /dev/null +++ b/internal/auth/store_test.go @@ -0,0 +1,194 @@ +package auth + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "runtime" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/zalando/go-keyring" +) + +// noKeyring simulates a host without a keyring service, such as Linux without D-Bus. +func noKeyring(t *testing.T) { + t.Helper() + keyring.MockInitWithError(errors.New("dbus: no session bus")) + t.Cleanup(func() { os.Remove(CredentialsPath()) }) +} + +func TestStoreFallsBackToFile(t *testing.T) { + noKeyring(t) + + tok := &StoredToken{AccessToken: "at", RefreshToken: "rt", ExpiresAt: time.Now().Add(time.Hour)} + if err := SaveToken("a@example.com", tok); err != nil { + t.Fatal(err) + } + got, store, err := LoadTokenWithStore("a@example.com") + if err != nil { + t.Fatal(err) + } + if store != StoreFile || got.AccessToken != "at" { + t.Errorf("store=%s token=%+v", store, got) + } + + if runtime.GOOS != "windows" { + info, err := os.Stat(CredentialsPath()) + if err != nil { + t.Fatal(err) + } + if perm := info.Mode().Perm(); perm != 0600 { + t.Errorf("credentials mode = %o, want 600", perm) + } + dirInfo, _ := os.Stat(CredentialsDir()) + if perm := dirInfo.Mode().Perm(); perm != 0700 { + t.Errorf("credentials dir mode = %o, want 700", perm) + } + } + + if err := DeleteToken("a@example.com"); err != nil { + t.Fatalf("delete with no keyring: %v", err) + } + if _, err := LoadToken("a@example.com"); err == nil { + t.Error("token still loadable after delete") + } +} + +func TestStoreForcedKeyringDoesNotFallBack(t *testing.T) { + noKeyring(t) + t.Setenv(TokenStoreEnv, "keyring") + + if err := SaveToken("a@example.com", &StoredToken{AccessToken: "at"}); err == nil { + t.Fatal("expected keyring error with WEBEX_TOKEN_STORE=keyring") + } + if _, err := os.Stat(CredentialsPath()); !os.IsNotExist(err) { + t.Error("credentials file written despite WEBEX_TOKEN_STORE=keyring") + } +} + +func TestStoreForcedFileSkipsKeyring(t *testing.T) { + keyring.MockInit() + t.Setenv(TokenStoreEnv, "file") + t.Cleanup(func() { os.Remove(CredentialsPath()) }) + + if err := SaveToken("a@example.com", &StoredToken{AccessToken: "at"}); err != nil { + t.Fatal(err) + } + if _, err := keyring.Get(serviceName, "a@example.com"); err == nil { + t.Error("token written to keyring despite WEBEX_TOKEN_STORE=file") + } + if _, store, err := LoadTokenWithStore("a@example.com"); err != nil || store != StoreFile { + t.Errorf("store=%s err=%v", store, err) + } +} + +func TestStoreRejectsUnknownStore(t *testing.T) { + t.Setenv(TokenStoreEnv, "vault") + if err := SaveToken("a@example.com", &StoredToken{}); err == nil { + t.Error("expected error for unknown WEBEX_TOKEN_STORE") + } +} + +// fakeTokenEndpoint counts refresh requests and answers each with a fresh token. +func fakeTokenEndpoint(t *testing.T) *int32 { + t.Helper() + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/token": + n := atomic.AddInt32(&calls, 1) + r.ParseForm() + if r.Form.Get("grant_type") != "refresh_token" { + t.Errorf("grant_type = %q", r.Form.Get("grant_type")) + } + // Hold the response so parallel callers overlap. + time.Sleep(50 * time.Millisecond) + json.NewEncoder(w).Encode(map[string]any{ + "access_token": "at-" + string(rune('0'+n)), + "refresh_token": "rt-" + string(rune('0'+n)), + "expires_in": 3600, + }) + case "/me": + w.Write([]byte(`{"emails":["svc@example.com"],"orgId":"org-1"}`)) + } + })) + oldToken, oldMe := TokenURL, peopleMeURL + TokenURL, peopleMeURL = srv.URL+"/token", srv.URL+"/me" + t.Cleanup(func() { TokenURL, peopleMeURL = oldToken, oldMe; srv.Close() }) + return &calls +} + +func TestParallelRefreshRefreshesOnce(t *testing.T) { + noKeyring(t) + calls := fakeTokenEndpoint(t) + + expired := &StoredToken{AccessToken: "old", RefreshToken: "rt-0", ExpiresAt: time.Now().Add(-time.Hour), IssuedAt: time.Now()} + if err := SaveToken("a@example.com", expired); err != nil { + t.Fatal(err) + } + cfg := testConfig("a@example.com") + + var wg sync.WaitGroup + results := make([]string, 4) + for i := range results { + wg.Add(1) + go func(i int) { + defer wg.Done() + tok, err := refreshStored("a@example.com", cfg, "old") + if err != nil { + t.Error(err) + return + } + results[i] = tok.AccessToken + }(i) + } + wg.Wait() + + if *calls != 1 { + t.Errorf("refresh requests = %d, want 1", *calls) + } + for _, r := range results { + if r != "at-1" { + t.Errorf("results = %v, want all at-1", results) + break + } + } + stored, _ := LoadToken("a@example.com") + if stored.RefreshToken != "rt-1" { + t.Errorf("stored refresh token = %q, want rt-1", stored.RefreshToken) + } +} + +func TestEnvRefreshCachesAndRotates(t *testing.T) { + noKeyring(t) + calls := fakeTokenEndpoint(t) + + tok, email, org, err := EnvRefreshAccessToken("env-rt", "cid", "secret", "") + if err != nil { + t.Fatal(err) + } + if tok != "at-1" || email != "svc@example.com" || org != "org-1" { + t.Errorf("got %s %s %s", tok, email, org) + } + + // A second process reuses the cached access token. + tok, _, _, err = EnvRefreshAccessToken("env-rt", "cid", "secret", "") + if err != nil || tok != "at-1" || *calls != 1 { + t.Fatalf("second call: tok=%s calls=%d err=%v", tok, *calls, err) + } + + // After a 401, it refreshes with the rotated token, not the env value. + tok, _, org, err = EnvRefreshAccessToken("env-rt", "cid", "secret", "at-1") + if err != nil || tok != "at-2" || org != "org-1" { + t.Fatalf("forced refresh: tok=%s org=%s err=%v", tok, org, err) + } + entry := loadEnvRefreshEntry(envRefreshKey("env-rt")) + if entry == nil || entry.Token.RefreshToken != "rt-2" { + t.Errorf("cached entry = %+v, want rotated refresh token rt-2", entry) + } +} diff --git a/site/src/content/docs/agent-skill.mdx b/site/src/content/docs/agent-skill.mdx index 0b71d7a..e78944d 100644 --- a/site/src/content/docs/agent-skill.mdx +++ b/site/src/content/docs/agent-skill.mdx @@ -7,11 +7,13 @@ The Webex CLI includes a [skill](https://agentskills.io) that enables AI coding ## Automatic Setup -The installer and `webex post-install` command will offer to install the skill for detected agents (Claude Code, Claude Cowork, OpenAI Codex, Cursor) via an interactive menu. Skills are also kept up to date when you run `webex update`. +The installer and `webex post-install` command will offer to install the skill for detected agents (Claude Code, Claude Cowork, OpenAI Codex, Cursor) via an interactive menu. Without a terminal, or with `webex post-install --yes`, the skill is installed for detected agents without prompts (Cowork is skipped because it needs a manual upload). + +The skill files are embedded in the `webex` binary, so installing them needs no network access and the skill always matches the installed version. `webex update` updates installed copies to match the new binary. ## Manual Setup -If you prefer to install manually, download the skill into the correct folder for your coding agent: +If you prefer to install manually, download the skill into the correct folder for your coding agent. Replace `main` in the URLs with your installed version's tag (for example `v0.16.0`) to match your binary: ### Claude Code diff --git a/site/src/content/docs/getting-started.mdx b/site/src/content/docs/getting-started.mdx index 2d0af61..8a5c850 100644 --- a/site/src/content/docs/getting-started.mdx +++ b/site/src/content/docs/getting-started.mdx @@ -8,9 +8,12 @@ description: Get up and running with the Webex CLI. The CLI uses OAuth with tokens stored in your OS keyring. ```bash -# Login (opens browser) +# Login (opens browser, or device login when there is no browser) webex login +# Login by approving a code from any browser +webex login --device + # Check auth status webex auth status @@ -27,6 +30,22 @@ You can also use a token directly: webex --token "$WEBEX_TOKEN" calling people list ``` +### Headless login and unattended use + +Over SSH, in CI, in containers, on Linux without a display, or when no browser can be opened, `webex login` switches to device login. It prints a line such as `Open https://… and enter 123456`, plus a QR code on a terminal. Approve the code from a browser on any device. Use `--browser` to force the browser flow, or set `WEBEX_LOGIN_MODE=device` or `browser`. With `--output json`, the URL, code, and expiry are printed as JSON on stdout, so an AI agent can show them to you. + +If you use your own OAuth integration, register these redirect URIs on it for device login: `https://oauth-helper-{a,r,k,d}.wbx2.com/helperservice/v1/actions/device/callback` (four URIs, one per letter). + +When no OS keyring is available, tokens are saved in plain text to `~/.config/webex-cli/credentials.json` (or `$XDG_CONFIG_HOME/webex-cli/`, or `%APPDATA%\webex-cli\` on Windows) with mode `0600`. Set `WEBEX_TOKEN_STORE=keyring` or `file` to force a store. `webex auth status` shows the store in use. + +For CI and unattended agents, where nobody can approve a code: + +- `WEBEX_TOKEN` takes a personal access token, which lasts 12 hours. +- `WEBEX_REFRESH_TOKEN` takes a refresh token, from which the CLI mints access tokens as needed. Log in once on your own machine and run `webex auth export` to print one. If it came from your own integration, also set `WEBEX_CLIENT_ID` and `WEBEX_CLIENT_SECRET`. Refresh tokens last about 90 days from their last use. The CLI caches the access token and any rotated refresh token in the credentials file when it can write there. +- For organization-level automation, use a service app or a bot token instead of a person's login. + +Token resolution order: `--token` > `WEBEX_TOKEN` > `WEBEX_REFRESH_TOKEN` > stored login. + ### Read-only mode Use read-only mode when an AI agent or script should be able to read your Webex data but not change it. @@ -39,7 +58,7 @@ This login requests only read scopes (`webex config get read-only-scopes` lists - Stored logins with write access are deleted from the OS keyring. Other programs running as you can read the keyring, so leaving those logins there would let them bypass read-only mode. - `PUT`, `PATCH`, `DELETE`, and uploads are refused before they are sent. `POST` is allowed only for a fixed list of query endpoints that take their filters in the request body, such as `cc search` and recording queries. Webex also rejects writes because the token has no write scopes. -- `--token` and `$WEBEX_TOKEN` are refused, because the CLI cannot check the scopes of a token it did not create. +- `--token`, `$WEBEX_TOKEN`, and `$WEBEX_REFRESH_TOKEN` are refused, because the CLI cannot check the scopes of a token it did not create. - `auth switch`, `--user`, and folder defaults work only for users who logged in with `--read-only`. - `webex mcp serve` does not register the `webex_write` tool. diff --git a/site/src/content/docs/installation.mdx b/site/src/content/docs/installation.mdx index 6686971..fa33ed1 100644 --- a/site/src/content/docs/installation.mdx +++ b/site/src/content/docs/installation.mdx @@ -10,14 +10,41 @@ description: How to install the Webex CLI. curl -fsSL https://raw.githubusercontent.com/Cloverhound/webex-cli/main/install.sh | sh ``` -This downloads the latest release and installs it to `/usr/local/bin/webex`. +This downloads the latest release, verifies it, and installs it to `~/.local/bin/webex`. **Windows (PowerShell):** ```powershell irm https://raw.githubusercontent.com/Cloverhound/webex-cli/main/install.ps1 | iex ``` -This downloads the latest release and installs it to `%LOCALAPPDATA%\webex-cli\webex.exe`, and adds it to your user PATH. +This downloads the latest release, verifies it, installs it to `%LOCALAPPDATA%\webex-cli\webex.exe`, and adds it to your user PATH. + +## How the installer works + +The installers and `webex update` find the latest version in this order, so they work where `api.github.com` is blocked or rate-limited: + +1. A pinned version: `WEBEX_CLI_VERSION`, or `install.sh -v `. +2. The latest release's `checksums.txt` (`github.com/Cloverhound/webex-cli/releases/latest/download/checksums.txt`). +3. The Go module proxy (`proxy.golang.org`). +4. The GitHub API, using `GITHUB_TOKEN` when it is set. + +Every download is checked against the release's SHA-256 `checksums.txt`. On a mismatch, or if the archive is not listed, nothing is installed. + +## CI, containers, and AI agents + +```bash +# Pin a version and install to a custom directory +curl -fsSL https://raw.githubusercontent.com/Cloverhound/webex-cli/main/install.sh \ + | WEBEX_CLI_VERSION=0.16.0 INSTALL_DIR=/usr/local/bin sh +``` + +When there is no terminal, the installer does not prompt. `webex post-install` prints the PATH line to add and installs the agent skill for detected agents (Claude Code, Codex, Cursor). You can also run it yourself: + +| Option | Effect | +|--------|--------| +| `webex post-install --yes` | No prompts; adds the PATH line to your shell profile and installs skills for detected agents | +| `webex post-install --no-skills` | Skips agent skill installation | +| `WEBEX_CLI_NONINTERACTIVE=1` | Turns off prompts even in a terminal | ## Manual Download @@ -45,5 +72,5 @@ Requires Go 1.23+. ## Verify Installation ```bash -webex version +webex version # or: webex --version ``` diff --git a/skill/SKILL.md b/skill/SKILL.md index efde0e1..b5b4432 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -29,19 +29,22 @@ webex auth set-folder-default # Set default user for current folder webex auth clear-folder-default # Remove folder default webex auth set-org # Set a persistent org override (partner admins) webex auth clear-org # Clear the org override -webex login # OAuth login (opens browser) +webex login # OAuth login (opens browser, or device login when headless) +webex login --device --output json # Device login; prints the URL and code as JSON webex logout [email] # Remove stored credentials ``` -If not logged in, use `--token ` or set `$WEBEX_TOKEN`. +If not logged in, use `--token ` or set `$WEBEX_TOKEN` or `$WEBEX_REFRESH_TOKEN`. + +**Logging in from an agent:** In a sandbox, over SSH, or wherever no browser can reach the CLI, run `webex login --device --output json` in the background. Its first JSON line has `verification_uri` and `user_code`, and the same text appears on stderr as `Open and enter `. Relay that line to the user word for word, and tell them the code expires in about 5 minutes. The user approves in their own browser. The command exits after approval and prints a second JSON line with `"status":"logged_in"`. **Per-folder defaults:** Different folders can be associated with different Webex users via `auth set-folder-default`. When a folder default is set, that user's credentials are used automatically when running commands from that directory. This is useful when different project folders connect to different Webex orgs. -Token resolution order: `--token` flag > `WEBEX_TOKEN` env var > `--user` flag > `WEBEX_USER` env var > folder default (`.webex-cli/config.json`) > global default > OS keyring. +Token resolution order: `--token` flag > `WEBEX_TOKEN` env var > `WEBEX_REFRESH_TOKEN` env var > `--user` flag > `WEBEX_USER` env var > folder default (`.webex-cli/config.json`) > global default > OS keyring. Org resolution order: `--organization` flag > `auth set-org` override > login user's home org. -**Read-only mode:** When `webex auth status` shows `Mode: read-only`, the stored login has read scopes only. Write requests fail with `read-only mode`, `--token`/`$WEBEX_TOKEN` are refused, and only users marked `(read-only)` in `webex auth list` can be used. Do not try to leave read-only mode or work around it; tell the user which change you need and let them decide. Leaving it requires the user to run `webex login` in an interactive terminal. +**Read-only mode:** When `webex auth status` shows `Mode: read-only`, the stored login has read scopes only. Write requests fail with `read-only mode`, `--token`/`$WEBEX_TOKEN`/`$WEBEX_REFRESH_TOKEN` are refused, and only users marked `(read-only)` in `webex auth list` can be used. Do not try to leave read-only mode or work around it; tell the user which change you need and let them decide. Leaving it requires the user to run `webex login` in an interactive terminal. ## Command Structure diff --git a/skill/embed.go b/skill/embed.go new file mode 100644 index 0000000..761bc63 --- /dev/null +++ b/skill/embed.go @@ -0,0 +1,29 @@ +// Package skill embeds the agent skill files so an installed binary always +// carries the skill that matches its own commands. +package skill + +import ( + "embed" + "io/fs" +) + +//go:embed SKILL.md */SKILL.md +var embedded embed.FS + +// Files returns every embedded skill file keyed by its slash-separated path +// relative to the skill directory, such as "SKILL.md" or "admin/SKILL.md". +func Files() (map[string][]byte, error) { + files := make(map[string][]byte) + err := fs.WalkDir(embedded, ".", func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + data, err := embedded.ReadFile(path) + if err != nil { + return err + } + files[path] = data + return nil + }) + return files, err +} diff --git a/skill/embed_test.go b/skill/embed_test.go new file mode 100644 index 0000000..3ad08cb --- /dev/null +++ b/skill/embed_test.go @@ -0,0 +1,15 @@ +package skill + +import "testing" + +func TestFilesIncludesEveryArea(t *testing.T) { + files, err := Files() + if err != nil { + t.Fatal(err) + } + for _, p := range []string{"SKILL.md", "admin/SKILL.md", "calling/SKILL.md", "cc/SKILL.md", "device/SKILL.md", "meetings/SKILL.md", "messaging/SKILL.md"} { + if len(files[p]) == 0 { + t.Errorf("missing or empty %s", p) + } + } +} From f0eb8d3026b6b67f3147b526f7da4284abfc2e98 Mon Sep 17 00:00:00 2001 From: Ed Umansky Date: Fri, 9 Oct 2026 16:05:08 -0600 Subject: [PATCH 2/7] Publish the CLI to npm as per-platform binary packages `@cloverhound/webex-cli` is a small Node shim whose optionalDependencies are one `@cloverhound/webex-cli--` 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 Claude-Session: https://claude.ai/code/session_01EdRf9uewJ53wofcX185zGC --- .github/workflows/release.yml | 18 ++++++ .gitignore | 5 +- AGENTS.md | 7 +++ README.md | 7 +++ cmd/update.go | 10 ++++ cmd/update_npm_test.go | 18 ++++++ go.mod | 3 +- go.sum | 2 + main.go | 3 + npm/publish.sh | 19 ++++++ npm/stage.mjs | 80 ++++++++++++++++++++++++++ npm/webex-cli/README.md | 21 +++++++ npm/webex-cli/bin/webex.js | 67 +++++++++++++++++++++ npm/webex-cli/package.json | 20 +++++++ site/src/content/docs/installation.mdx | 11 ++++ 15 files changed, 288 insertions(+), 3 deletions(-) create mode 100644 cmd/update_npm_test.go create mode 100755 npm/publish.sh create mode 100755 npm/stage.mjs create mode 100644 npm/webex-cli/README.md create mode 100755 npm/webex-cli/bin/webex.js create mode 100644 npm/webex-cli/package.json diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b14db5d..1878e17 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,10 +7,13 @@ on: permissions: contents: write + id-token: write # npm provenance jobs: release: runs-on: ubuntu-latest + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} steps: - uses: actions/checkout@v4 with: @@ -30,3 +33,18 @@ jobs: WEBEX_CLIENT_SECRET: ${{ secrets.WEBEX_CLIENT_SECRET }} WEBEX_SCOPES: ${{ secrets.WEBEX_SCOPES }} WEBEX_READ_ONLY_SCOPES: ${{ secrets.WEBEX_READ_ONLY_SCOPES }} + + # Skipped until the NPM_TOKEN secret exists. + - uses: actions/setup-node@v4 + if: env.NPM_TOKEN != '' + with: + node-version: "22" + registry-url: "https://registry.npmjs.org" + + - name: Publish npm packages + if: env.NPM_TOKEN != '' + run: | + node npm/stage.mjs --dist dist --out npm/build + sh npm/publish.sh npm/build + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.gitignore b/.gitignore index 2ebf4c1..f920cc3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,11 @@ # Build artifacts -webex -webex-cli +/webex +/webex-cli *.exe dist/ webex-mcp.dxt webex.skill +npm/build/ # Codegen intermediates codegen/api_outline.json diff --git a/AGENTS.md b/AGENTS.md index 50e4f20..7c8e45b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,6 +56,13 @@ Releases are handled by a GitHub Actions workflow triggered by pushing a tag. Do **not** run `goreleaser` locally — it requires secrets (e.g. `WEBEX_CLIENT_ID`) that are only available in CI. Version and OAuth defaults are injected via ldflags. +When the `NPM_TOKEN` secret is set, the same workflow publishes the npm packages: +`npm/stage.mjs` turns goreleaser's `dist/` into `@cloverhound/webex-cli` plus one +`@cloverhound/webex-cli--` package per binary, and `npm/publish.sh` +publishes them with provenance. To check the packaging without secrets, run +`goreleaser build --snapshot --clean` with dummy `WEBEX_CLIENT_*` values, then +`node npm/stage.mjs` and `npm pack` the folders in `npm/build/`. + ```bash git tag v0.X.0 git push origin v0.X.0 diff --git a/README.md b/README.md index 0398cdf..80c5dc9 100644 --- a/README.md +++ b/README.md @@ -16,8 +16,15 @@ curl -fsSL https://raw.githubusercontent.com/Cloverhound/webex-cli/main/install. irm https://raw.githubusercontent.com/Cloverhound/webex-cli/main/install.ps1 | iex ``` +**npm (any platform with Node.js 18+):** +```bash +npm install -g @cloverhound/webex-cli # or: npx -y @cloverhound/webex-cli +``` + Or download from [Releases](https://github.com/Cloverhound/webex-cli/releases). +The npm package installs only the prebuilt binary for your platform, with no install script and no GitHub download, so it works in sandboxes that allow only package registries. Update it with npm rather than `webex update`. + The installers verify the download against the release's `checksums.txt` and find the latest version without the GitHub API. For CI or sandboxed agents: - Pin a version with `WEBEX_CLI_VERSION=0.16.0` (or `sh -s -- -v 0.16.0`); `webex update` honors it too. diff --git a/cmd/update.go b/cmd/update.go index ce2c2d0..85be759 100644 --- a/cmd/update.go +++ b/cmd/update.go @@ -50,6 +50,10 @@ var ( var lookupClient = &http.Client{Timeout: 30 * time.Second} func runUpdate() error { + if binPath, err := executablePath(); err == nil && installedByNPM(binPath) { + return fmt.Errorf("this webex was installed with npm; update it with: npm install -g @cloverhound/webex-cli@latest (npx fetches the latest version on its own)") + } + current := Version latest := strings.TrimPrefix(os.Getenv(versionPinEnv), "v") if latest != "" { @@ -252,6 +256,12 @@ func isNewer(latest, current string) bool { return false } +// installedByNPM reports whether binPath lives in an npm package. Replacing it +// in place would leave npm's records out of step with the file on disk. +func installedByNPM(binPath string) bool { + return strings.Contains(strings.ReplaceAll(binPath, `\`, "/"), "/node_modules/@cloverhound/webex-cli-") +} + // executablePath returns the resolved path to the running binary. func executablePath() (string, error) { exe, err := os.Executable() diff --git a/cmd/update_npm_test.go b/cmd/update_npm_test.go new file mode 100644 index 0000000..125de4c --- /dev/null +++ b/cmd/update_npm_test.go @@ -0,0 +1,18 @@ +package cmd + +import "testing" + +func TestInstalledByNPM(t *testing.T) { + cases := map[string]bool{ + "/usr/local/lib/node_modules/@cloverhound/webex-cli-darwin-arm64/bin/webex": true, + `C:\Users\a\AppData\Roaming\npm\node_modules\@cloverhound\webex-cli-win32-x64\bin\webex.exe`: true, + "/home/a/.npm/_npx/1a2b/node_modules/@cloverhound/webex-cli-linux-x64/bin/webex": true, + "/home/a/.local/bin/webex": false, + "/opt/node_modules/other/webex": false, + } + for path, want := range cases { + if got := installedByNPM(path); got != want { + t.Errorf("installedByNPM(%q) = %v, want %v", path, got, want) + } + } +} diff --git a/go.mod b/go.mod index 8bef631..bac1903 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/Cloverhound/webex-cli -go 1.26 +go 1.26.0 require ( github.com/charmbracelet/huh v1.0.0 @@ -11,6 +11,7 @@ require ( github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c github.com/spf13/cobra v1.10.2 github.com/zalando/go-keyring v0.2.6 + golang.org/x/crypto/x509roots/fallback v0.0.0-20260721213823-31914c699bfc golang.org/x/sys v0.33.0 golang.org/x/term v0.25.0 ) diff --git a/go.sum b/go.sum index b26a531..4a4c9e9 100644 --- a/go.sum +++ b/go.sum @@ -121,6 +121,8 @@ github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT0 github.com/zalando/go-keyring v0.2.6 h1:r7Yc3+H+Ux0+M72zacZoItR3UDxeWfKTcabvkI8ua9s= github.com/zalando/go-keyring v0.2.6/go.mod h1:2TCrxYrbUNYfNS/Kgy/LSrkSQzZ5UPVH85RwfczwvcI= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto/x509roots/fallback v0.0.0-20260721213823-31914c699bfc h1:3XmhiDY3RbxvcWtMLw6gtuiFSrjmhqRHiXP/hWNgQ04= +golang.org/x/crypto/x509roots/fallback v0.0.0-20260721213823-31914c699bfc/go.mod h1:+UoQFNBq2p2wO+Q6ddVtYc25GZ6VNdOMyyrd4nrqrKs= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= diff --git a/main.go b/main.go index 5621912..dbd55ee 100644 --- a/main.go +++ b/main.go @@ -11,6 +11,9 @@ import ( _ "github.com/Cloverhound/webex-cli/cmd/meetings" _ "github.com/Cloverhound/webex-cli/cmd/mcp" _ "github.com/Cloverhound/webex-cli/cmd/messaging" + + // Used only when the OS has no CA bundle, as in slim container images. + _ "golang.org/x/crypto/x509roots/fallback" ) func main() { diff --git a/npm/publish.sh b/npm/publish.sh new file mode 100755 index 0000000..ed38100 --- /dev/null +++ b/npm/publish.sh @@ -0,0 +1,19 @@ +#!/bin/sh +# Publishes the packages staged by npm/stage.mjs: platform packages first, so +# the wrapper never points at versions that are missing from the registry. +# Versions already on the registry are skipped, so a failed release can be rerun. +set -eu + +OUT="${1:-npm/build}" +VERSION=$(node -p "require('./${OUT}/webex-cli/package.json').version") +TAG=latest +case "$VERSION" in *-*) TAG=next ;; esac + +for dir in "$OUT"/webex-cli-* "$OUT/webex-cli"; do + name=$(node -p "require('./${dir}/package.json').name") + if npm view "${name}@${VERSION}" version >/dev/null 2>&1; then + echo "Skipping ${name}@${VERSION}: already published" + continue + fi + npm publish "$dir" --access public --provenance --tag "$TAG" +done diff --git a/npm/stage.mjs b/npm/stage.mjs new file mode 100755 index 0000000..0d8d6b1 --- /dev/null +++ b/npm/stage.mjs @@ -0,0 +1,80 @@ +#!/usr/bin/env node +// Builds the npm packages from a goreleaser dist directory: +// one @cloverhound/webex-cli-- package per binary, and the +// @cloverhound/webex-cli wrapper that depends on all of them. +// +// Usage: node npm/stage.mjs [--dist dist] [--out npm/build] [--version X.Y.Z] + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { parseArgs } from "node:util"; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const { values: args } = parseArgs({ + options: { + dist: { type: "string", default: "dist" }, + out: { type: "string", default: path.join(here, "build") }, + version: { type: "string" }, + }, +}); + +const OS = { darwin: "darwin", linux: "linux", windows: "win32" }; +const CPU = { amd64: "x64", arm64: "arm64" }; +const EXPECTED = Object.keys(OS).length * Object.keys(CPU).length; + +const readJSON = (p) => JSON.parse(fs.readFileSync(p, "utf8")); +const writeJSON = (p, v) => fs.writeFileSync(p, JSON.stringify(v, null, 2) + "\n"); + +const version = (args.version ?? readJSON(path.join(args.dist, "metadata.json")).version).replace(/^v/, ""); +const template = readJSON(path.join(here, "webex-cli", "package.json")); + +const binaries = readJSON(path.join(args.dist, "artifacts.json")).filter( + (a) => a.type === "Binary" && OS[a.goos] && CPU[a.goarch], +); +if (binaries.length !== EXPECTED) { + throw new Error(`expected ${EXPECTED} binaries in ${args.dist}/artifacts.json, found ${binaries.length}`); +} + +fs.rmSync(args.out, { recursive: true, force: true }); + +const platformPackages = {}; +for (const a of binaries) { + const os = OS[a.goos]; + const cpu = CPU[a.goarch]; + const name = `${template.name}-${os}-${cpu}`; + const dir = path.join(args.out, `webex-cli-${os}-${cpu}`); + const exe = a.goos === "windows" ? "webex.exe" : "webex"; + + fs.mkdirSync(path.join(dir, "bin"), { recursive: true }); + fs.copyFileSync(a.path, path.join(dir, "bin", exe)); + fs.chmodSync(path.join(dir, "bin", exe), 0o755); + writeJSON(path.join(dir, "package.json"), { + name, + version, + description: `The ${os}-${cpu} binary for ${template.name}`, + license: template.license, + homepage: template.homepage, + repository: template.repository, + os: [os], + cpu: [cpu], + files: ["bin"], + preferUnplugged: true, + }); + fs.writeFileSync( + path.join(dir, "README.md"), + `# ${name}\n\nThe ${os}-${cpu} binary for [${template.name}](https://www.npmjs.com/package/${template.name}). Install that package instead.\n`, + ); + platformPackages[name] = version; +} + +const mainDir = path.join(args.out, "webex-cli"); +fs.cpSync(path.join(here, "webex-cli"), mainDir, { recursive: true }); +fs.chmodSync(path.join(mainDir, "bin", "webex.js"), 0o755); +writeJSON(path.join(mainDir, "package.json"), { + ...template, + version, + optionalDependencies: Object.fromEntries(Object.entries(platformPackages).sort()), +}); + +console.log(`Staged ${template.name}@${version} and ${binaries.length} platform packages in ${args.out}`); diff --git a/npm/webex-cli/README.md b/npm/webex-cli/README.md new file mode 100644 index 0000000..464826a --- /dev/null +++ b/npm/webex-cli/README.md @@ -0,0 +1,21 @@ +# @cloverhound/webex-cli + +The [Webex CLI](https://github.com/Cloverhound/webex-cli) packaged for npm. It manages Webex Admin, Calling, Contact Center, Devices, Meetings, and Messaging APIs from the command line, and runs as an MCP server for AI agents. + +```bash +npx -y @cloverhound/webex-cli login --device +npx -y @cloverhound/webex-cli calling people list +``` + +Or install it globally: + +```bash +npm install -g @cloverhound/webex-cli +webex --version +``` + +npm installs only the prebuilt binary for your platform (macOS, Linux, or Windows on x64 or arm64) through an optional dependency. No install script runs and nothing is downloaded from GitHub, so the package works in sandboxes that allow the npm registry but block other hosts. + +To run it as an MCP server, use `npx -y @cloverhound/webex-cli@ mcp serve` as the server command. + +See the [documentation](https://cloverhound.github.io/webex-cli/) for login options, including device login for machines without a browser. diff --git a/npm/webex-cli/bin/webex.js b/npm/webex-cli/bin/webex.js new file mode 100755 index 0000000..ad07c7b --- /dev/null +++ b/npm/webex-cli/bin/webex.js @@ -0,0 +1,67 @@ +#!/usr/bin/env node +"use strict"; + +// Runs the webex binary from the platform package npm installed alongside this +// one (an optionalDependency selected by its os/cpu fields). + +const { spawn } = require("node:child_process"); +const fs = require("node:fs"); + +const SUPPORTED = new Set([ + "darwin-arm64", + "darwin-x64", + "linux-arm64", + "linux-x64", + "win32-arm64", + "win32-x64", +]); + +function fail(message) { + process.stderr.write(`webex-cli: ${message}\n`); + process.exit(1); +} + +function binaryPath() { + const key = `${process.platform}-${process.arch}`; + if (!SUPPORTED.has(key)) { + fail(`no build for ${key}. See https://github.com/Cloverhound/webex-cli#install for other install methods.`); + } + const pkg = `@cloverhound/webex-cli-${key}`; + const exe = process.platform === "win32" ? "webex.exe" : "webex"; + let bin; + try { + bin = require.resolve(`${pkg}/bin/${exe}`); + } catch { + fail(`${pkg} is not installed. Reinstall without --omit=optional or --no-optional.`); + } + if (process.platform !== "win32") { + try { + fs.accessSync(bin, fs.constants.X_OK); + } catch { + // Some package managers drop the executable bit when unpacking. + try { + fs.chmodSync(bin, 0o755); + } catch {} + } + } + return bin; +} + +const child = spawn(binaryPath(), process.argv.slice(2), { stdio: "inherit" }); + +// MCP hosts stop servers with a signal; pass it on so the binary is not orphaned. +for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) { + process.on(signal, () => { + if (!child.killed) child.kill(signal); + }); +} + +child.on("error", (err) => fail(err.message)); +child.on("exit", (code, signal) => { + if (signal) { + process.removeAllListeners(signal); + process.kill(process.pid, signal); + } else { + process.exit(code ?? 1); + } +}); diff --git a/npm/webex-cli/package.json b/npm/webex-cli/package.json new file mode 100644 index 0000000..5145e2b --- /dev/null +++ b/npm/webex-cli/package.json @@ -0,0 +1,20 @@ +{ + "name": "@cloverhound/webex-cli", + "version": "0.0.0", + "description": "CLI and MCP server for Webex Admin, Calling, Contact Center, Devices, Meetings, and Messaging APIs", + "license": "MIT", + "homepage": "https://github.com/Cloverhound/webex-cli", + "repository": { + "type": "git", + "url": "git+https://github.com/Cloverhound/webex-cli.git" + }, + "keywords": ["webex", "cli", "mcp", "cisco", "contact-center"], + "bin": { + "webex": "bin/webex.js" + }, + "files": ["bin"], + "engines": { + "node": ">=18" + }, + "optionalDependencies": {} +} diff --git a/site/src/content/docs/installation.mdx b/site/src/content/docs/installation.mdx index fa33ed1..51a0e54 100644 --- a/site/src/content/docs/installation.mdx +++ b/site/src/content/docs/installation.mdx @@ -19,6 +19,15 @@ irm https://raw.githubusercontent.com/Cloverhound/webex-cli/main/install.ps1 | i This downloads the latest release, verifies it, installs it to `%LOCALAPPDATA%\webex-cli\webex.exe`, and adds it to your user PATH. +**npm (any platform with Node.js 18+):** +```bash +npm install -g @cloverhound/webex-cli +# or run without installing +npx -y @cloverhound/webex-cli --version +``` + +npm installs only the prebuilt binary for your platform (macOS, Linux, or Windows on x64 or arm64), through an optional dependency. No install script runs and nothing is downloaded from GitHub, so this works in sandboxes that allow the npm registry but block other hosts. Update an npm install with npm, not `webex update`. + ## How the installer works The installers and `webex update` find the latest version in this order, so they work where `api.github.com` is blocked or rate-limited: @@ -38,6 +47,8 @@ curl -fsSL https://raw.githubusercontent.com/Cloverhound/webex-cli/main/install. | WEBEX_CLI_VERSION=0.16.0 INSTALL_DIR=/usr/local/bin sh ``` +Where only package registries are reachable, use npm instead. To run the MCP server from an agent's MCP configuration without installing anything first, use `npx -y @cloverhound/webex-cli@ mcp serve` as the command. Pin the version so the server matches the skill you installed. + When there is no terminal, the installer does not prompt. `webex post-install` prints the PATH line to add and installs the agent skill for detected agents (Claude Code, Codex, Cursor). You can also run it yourself: | Option | Effect | From e572293330f65ee638152887321cfc1b7366c597 Mon Sep 17 00:00:00 2001 From: Ed Umansky Date: Fri, 9 Oct 2026 16:18:22 -0600 Subject: [PATCH 3/7] Start the MCP server without a login and prefer npm trusted publishing `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 Claude-Session: https://claude.ai/code/session_01EdRf9uewJ53wofcX185zGC --- .github/workflows/release.yml | 11 +++++------ AGENTS.md | 9 +++++---- cmd/root.go | 4 ++++ 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1878e17..d8ed022 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -7,13 +7,11 @@ on: permissions: contents: write - id-token: write # npm provenance + id-token: write # npm trusted publishing and provenance jobs: release: runs-on: ubuntu-latest - env: - NPM_TOKEN: ${{ secrets.NPM_TOKEN }} steps: - uses: actions/checkout@v4 with: @@ -34,16 +32,17 @@ jobs: WEBEX_SCOPES: ${{ secrets.WEBEX_SCOPES }} WEBEX_READ_ONLY_SCOPES: ${{ secrets.WEBEX_READ_ONLY_SCOPES }} - # Skipped until the NPM_TOKEN secret exists. - uses: actions/setup-node@v4 - if: env.NPM_TOKEN != '' with: node-version: "22" registry-url: "https://registry.npmjs.org" + # npm uses trusted publishing (OIDC) when the packages are configured for + # it, and falls back to NPM_TOKEN otherwise. The first publish needs the + # token, because trusted publishing can only be set up on existing packages. - name: Publish npm packages - if: env.NPM_TOKEN != '' run: | + npm install -g npm@11.6.2 node npm/stage.mjs --dist dist --out npm/build sh npm/publish.sh npm/build env: diff --git a/AGENTS.md b/AGENTS.md index 7c8e45b..8ec0d89 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,10 +56,11 @@ Releases are handled by a GitHub Actions workflow triggered by pushing a tag. Do **not** run `goreleaser` locally — it requires secrets (e.g. `WEBEX_CLIENT_ID`) that are only available in CI. Version and OAuth defaults are injected via ldflags. -When the `NPM_TOKEN` secret is set, the same workflow publishes the npm packages: -`npm/stage.mjs` turns goreleaser's `dist/` into `@cloverhound/webex-cli` plus one -`@cloverhound/webex-cli--` package per binary, and `npm/publish.sh` -publishes them with provenance. To check the packaging without secrets, run +The same workflow publishes the npm packages: `npm/stage.mjs` turns goreleaser's +`dist/` into `@cloverhound/webex-cli` plus one `@cloverhound/webex-cli--` +package per binary, and `npm/publish.sh` publishes them with provenance. It +authenticates with npm trusted publishing (configured per package on npmjs.com +for `release.yml`), or with the `NPM_TOKEN` secret when that is not set up. To check the packaging without secrets, run `goreleaser build --snapshot --clean` with dummy `WEBEX_CLIENT_*` values, then `node npm/stage.mjs` and `npm pack` the folders in `npm/build/`. diff --git a/cmd/root.go b/cmd/root.go index 37bb447..46f0e4a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -247,6 +247,10 @@ func skipAuth(cmd *cobra.Command) bool { switch top.Name() { case "login", "logout", "config", "version", "update", "post-install", "help", "completion": return true + case "mcp": + // Each tool call runs the CLI in a child process that resolves its own + // token, so the server can start before anyone has logged in. + return true case "auth": // set-org validates the org against the API; token prints it. return cmd.Name() != "set-org" && cmd.Name() != "token" From bdd941c70b90e0a3d10b8b37e55664c55356fd25 Mon Sep 17 00:00:00 2001 From: Ed Umansky Date: Fri, 9 Oct 2026 22:40:40 -0600 Subject: [PATCH 4/7] Keep read-only token purging correct with the file token store 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 Claude-Session: https://claude.ai/code/session_01EdRf9uewJ53wofcX185zGC --- internal/auth/readonly.go | 13 +++++++------ internal/auth/store.go | 9 ++++++++- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/internal/auth/readonly.go b/internal/auth/readonly.go index 583bee7..0cd0f2e 100644 --- a/internal/auth/readonly.go +++ b/internal/auth/readonly.go @@ -9,7 +9,7 @@ import ( "github.com/zalando/go-keyring" ) -// HasReadOnlyToken reports whether the keyring holds a read-only token for email. +// HasReadOnlyToken reports whether the token store holds a read-only token for email. func HasReadOnlyToken(email string) bool { tok, err := LoadToken(email) return err == nil && tok.ReadOnly @@ -17,14 +17,15 @@ func HasReadOnlyToken(email string) bool { // PurgeWriteTokens deletes every stored token that is not read-only and removes // its user from cfg. It returns the removed emails, sorted. Read-only mode -// depends on this: the keyring is readable by any process running as the user, -// so a write-capable token left there would bypass the CLI's checks. An entry -// that cannot be read or decoded is deleted too, since it may hold write access. +// depends on this: the keyring and credentials file are readable by any process +// running as the user, so a write-capable token left there would bypass the +// CLI's checks. An entry that cannot be read or decoded is deleted too, since it +// may hold write access. func PurgeWriteTokens(cfg *appconfig.Config) ([]string, error) { var removed, failed []string for _, email := range cfg.UserEmails() { tok, err := LoadToken(email) - if errors.Is(err, keyring.ErrNotFound) || (err == nil && tok.ReadOnly) { + if errors.Is(err, keyring.ErrNotFound) || errors.Is(err, errNotFound) || (err == nil && tok.ReadOnly) { continue } if err := DeleteToken(email); err != nil { @@ -37,7 +38,7 @@ func PurgeWriteTokens(cfg *appconfig.Config) ([]string, error) { sort.Strings(removed) if len(failed) > 0 { sort.Strings(failed) - return removed, fmt.Errorf("could not remove write-capable tokens for %v from the keyring; remove them manually", failed) + return removed, fmt.Errorf("could not remove write-capable tokens for %v from the token store; remove them manually", failed) } return removed, nil } diff --git a/internal/auth/store.go b/internal/auth/store.go index 331b5d5..92b053c 100644 --- a/internal/auth/store.go +++ b/internal/auth/store.go @@ -40,8 +40,10 @@ func storeGet(key string) (string, string, error) { if err != nil { return "", "", err } + var kerr error if forced != StoreFile { - data, kerr := keyring.Get(serviceName, key) + var data string + data, kerr = keyring.Get(serviceName, key) if kerr == nil { return data, StoreKeyring, nil } @@ -50,6 +52,11 @@ func storeGet(key string) (string, string, error) { } } data, err := fileGet(key) + // A keyring that failed for any reason but a missing entry may still hold the + // token, so "not found" would be a guess; read-only purging relies on that. + if errors.Is(err, errNotFound) && kerr != nil && !errors.Is(kerr, keyring.ErrNotFound) { + return "", "", fmt.Errorf("not in the credentials file, and the keyring could not be read: %w", kerr) + } if err != nil { return "", "", err } From 0c202e2fc57b8fb9ae199aa4a4a4db7cba92f50d Mon Sep 17 00:00:00 2001 From: Ed Umansky Date: Fri, 9 Oct 2026 22:58:07 -0600 Subject: [PATCH 5/7] Tidy the headless login and read-only mode integration - 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 Claude-Session: https://claude.ai/code/session_01EdRf9uewJ53wofcX185zGC --- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 7 ++- AGENTS.md | 14 +++-- README.md | 4 +- cmd/auth.go | 59 +++++++++++++-------- cmd/login.go | 26 ++++++---- cmd/logout.go | 5 +- cmd/postinstall.go | 18 ++----- cmd/prompt.go | 38 ++++++++++++++ cmd/readonly.go | 18 ++----- cmd/root.go | 35 +++++++------ internal/auth/envrefresh.go | 9 +++- internal/auth/oauth.go | 1 - internal/auth/readonly.go | 9 ++-- internal/auth/readonly_test.go | 23 +++++++++ internal/auth/resolve.go | 6 +-- internal/auth/store.go | 62 +++++++++++++++++------ internal/auth/{keyring.go => token.go} | 9 +++- internal/config/config.go | 8 +-- internal/search/query.go | 6 +-- main.go | 2 +- site/src/content/docs/getting-started.mdx | 4 +- site/src/content/docs/index.mdx | 2 +- site/src/content/docs/installation.mdx | 2 +- skill/SKILL.md | 4 +- 25 files changed, 249 insertions(+), 124 deletions(-) create mode 100644 cmd/prompt.go rename internal/auth/{keyring.go => token.go} (87%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6800e51..192338d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: - uses: actions/setup-go@v5 with: - go-version: "1.23" + go-version-file: go.mod - run: go build ./... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d8ed022..6c40762 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,7 @@ jobs: - uses: actions/setup-go@v5 with: - go-version: "1.23" + go-version-file: go.mod - uses: goreleaser/goreleaser-action@v6 with: @@ -37,9 +37,8 @@ jobs: node-version: "22" registry-url: "https://registry.npmjs.org" - # npm uses trusted publishing (OIDC) when the packages are configured for - # it, and falls back to NPM_TOKEN otherwise. The first publish needs the - # token, because trusted publishing can only be set up on existing packages. + # npm uses trusted publishing (OIDC), configured per package on npmjs.com + # for this workflow, and falls back to NPM_TOKEN only when OIDC is not set up. - name: Publish npm packages run: | npm install -g npm@11.6.2 diff --git a/AGENTS.md b/AGENTS.md index 8ec0d89..8d47636 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,9 +60,12 @@ The same workflow publishes the npm packages: `npm/stage.mjs` turns goreleaser's `dist/` into `@cloverhound/webex-cli` plus one `@cloverhound/webex-cli--` package per binary, and `npm/publish.sh` publishes them with provenance. It authenticates with npm trusted publishing (configured per package on npmjs.com -for `release.yml`), or with the `NPM_TOKEN` secret when that is not set up. To check the packaging without secrets, run -`goreleaser build --snapshot --clean` with dummy `WEBEX_CLIENT_*` values, then -`node npm/stage.mjs` and `npm pack` the folders in `npm/build/`. +for `release.yml`), or with the `NPM_TOKEN` secret when that is not set up. +Tags with a prerelease suffix (`v0.X.0-rc.1`) publish under the `next` dist-tag. + +To check the packaging without secrets, run `goreleaser build --snapshot --clean` +with dummy `WEBEX_CLIENT_*` values, then `node npm/stage.mjs` and `npm pack` the +folders in `npm/build/`. ```bash git tag v0.X.0 @@ -76,10 +79,11 @@ gh release edit v0.X.0 --title "v0.X.0" --notes "## Improvements - `cmd/root.go` — Root cobra command, global flags (token, org, output, debug, dry-run, paginate, 429 retries, region) - `cmd/auth.go` — Auth subcommand (status, list, switch, set-org, clear-org) -- `cmd/login.go` / `cmd/logout.go` — OAuth PKCE flow +- `cmd/login.go` / `cmd/logout.go` — OAuth PKCE and device login - `cmd/mcp.go` / `cmd/mcp/` — MCP serve/stop, tools, resources, usage log - `cmd/update.go` / `cmd/postinstall.go` / `cmd/skillupdate.go` — self-update and skill install -- `internal/auth/` — Token storage (OS keyring), OAuth, org resolution +- `internal/auth/` — Token storage (OS keyring, credentials file fallback), OAuth, device login, org resolution +- `npm/` — npm wrapper package, staging, and publish scripts - `internal/client/` — HTTP client, request builder, pagination, 429 Retry-After - `internal/appconfig/` — Config file (`~/.webex-cli/config.json`) - `internal/config/` — Process-local flags (token, org, debug, dry-run, retry) diff --git a/README.md b/README.md index 80c5dc9..44dff7e 100644 --- a/README.md +++ b/README.md @@ -142,7 +142,7 @@ See the [full refresh inventory](docs/command-inventory.md), [every renamed comm - **OAuth PKCE flow** — `webex login` opens a browser, no client secret needed on the user side - **Device login** — `webex login --device` prints a URL and code to approve from any browser; used automatically over SSH, in CI, on Linux without a display, or when no browser opens -- **OS keyring storage** — tokens stored securely in macOS Keychain / Linux keyring / Windows Credential Manager, with a plain-text file fallback when no keyring is available +- **Token storage** — tokens stored securely in macOS Keychain / Linux keyring / Windows Credential Manager, with a plain-text file fallback when no keyring is available - **Auto-refresh** — expired tokens are refreshed automatically - **Multi-user** — log in with multiple Webex accounts and switch between them @@ -178,7 +178,7 @@ For CI and unattended agents where nobody can approve a code: `webex login --read-only` requests only read scopes, so Webex itself rejects writes made with the token. Use it when an AI agent should read but not change your Webex data. While read-only mode is on: -- Stored logins with write access are deleted from the keyring, because any program running as you can read it. +- Stored logins with write access are deleted from the keyring and credentials file, because any program running as you can read them. - `PUT`, `PATCH`, `DELETE`, and uploads are refused before they are sent. `POST` is allowed only for a fixed list of query endpoints that take their filters in the request body, such as `cc search` and recording queries. - `--token`, `$WEBEX_TOKEN`, and `$WEBEX_REFRESH_TOKEN` are refused, and `auth switch`, `--user`, and folder defaults accept only users who logged in with `--read-only`. - `webex mcp serve` does not register `webex_write`. diff --git a/cmd/auth.go b/cmd/auth.go index 27bef06..c6551fb 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "fmt" "io" "net/http" @@ -13,9 +14,7 @@ import ( "github.com/Cloverhound/webex-cli/internal/auth" "github.com/Cloverhound/webex-cli/internal/config" "github.com/Cloverhound/webex-cli/internal/localconfig" - "github.com/charmbracelet/huh" "github.com/spf13/cobra" - "golang.org/x/term" ) var authCmd = &cobra.Command{ @@ -54,17 +53,26 @@ var authStatusCmd = &cobra.Command{ } for _, env := range []string{"WEBEX_TOKEN", auth.RefreshTokenEnv} { - if os.Getenv(env) != "" { + if os.Getenv(env) == "" { + continue + } + if readOnlyMode(cfg) { + fmt.Printf("Env: $%s is set; read-only mode refuses it, so commands fail until it is unset\n", env) + } else { fmt.Printf("Env: $%s is set and overrides the stored login below\n", env) - break } + break } userInfo := cfg.Users[email] tok, store, err := auth.LoadTokenWithStore(email) if err != nil { - fmt.Printf("User: %s\n", email) - fmt.Println("Status: token not found in the keyring or credentials file") + fmt.Printf("User: %s\n", email) + if errors.Is(err, auth.ErrTokenNotFound) { + fmt.Println("Status: token not found in the keyring or credentials file") + } else { + fmt.Printf("Status: %v\n", err) + } return nil } @@ -137,10 +145,14 @@ var authStatusCmd = &cobra.Command{ var authExportCmd = &cobra.Command{ Use: "export [email]", Short: "Print a stored refresh token for use as $WEBEX_REFRESH_TOKEN", - Long: `Prints the refresh token of a stored login (the default user unless an email is -given) so a login made on one machine can seed a CI secret or an unattended -agent through $WEBEX_REFRESH_TOKEN. Anyone holding the token can act as that -user until it expires (about 90 days after its last use), so treat it as a password. + Long: `Prints the refresh token of a stored login so a login made on one machine can +seed a CI secret or an unattended agent through $WEBEX_REFRESH_TOKEN. The user +is the email given, else the one --user, $WEBEX_USER, the folder default, or the +default user selects. Anyone holding the token can act as that user until it +expires (about 90 days after its last use), so treat it as a password. + +In read-only mode only read-only logins can be exported, and the consumer must +not be in read-only mode, which refuses $WEBEX_REFRESH_TOKEN. The token is printed alone on stdout. Confirm at the prompt, or pass --yes when there is no terminal.`, @@ -152,9 +164,15 @@ when there is no terminal.`, if err != nil { return fmt.Errorf("loading config: %w", err) } + flagUser, envUser := requestedUser(cmd) email := cfg.DefaultUser - if len(args) > 0 { + switch { + case len(args) > 0: email = args[0] + case flagUser != "": + email = flagUser + case envUser != "": + email = envUser } if email == "" { return fmt.Errorf("no authenticated user — run: webex login") @@ -163,23 +181,22 @@ when there is no terminal.`, if err != nil { return fmt.Errorf("no stored login for %s — run: webex login", email) } + // The command skips token resolution, which is where read-only mode + // normally rejects write-capable logins. + if readOnlyMode(cfg) && !tok.ReadOnly { + return fmt.Errorf("read-only mode: %s has no read-only login; only read-only logins can be exported", email) + } if tok.RefreshToken == "" || tok.IsRefreshExpired() { return fmt.Errorf("the stored login for %s has no usable refresh token — run: webex login", email) } if !yes { - if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stderr.Fd())) { + if !canPrompt() { return fmt.Errorf("refusing to print a refresh token without confirmation; pass --yes") } - var ok bool - err := huh.NewForm(huh.NewGroup( - huh.NewConfirm(). - Title(fmt.Sprintf("Print the refresh token for %s?", email)). - Description("Anyone with this token can act as you until it expires."). - Affirmative("Print it"). - Negative("Cancel"). - Value(&ok), - )).WithOutput(os.Stderr).Run() + ok, err := confirm(fmt.Sprintf("Print the refresh token for %s?", email), + "Anyone with this token can act as you until it expires.", + "Print it", "Cancel") if err != nil { return err } diff --git a/cmd/login.go b/cmd/login.go index 01371cb..4f35fed 100644 --- a/cmd/login.go +++ b/cmd/login.go @@ -18,7 +18,6 @@ import ( "github.com/charmbracelet/huh" "github.com/mdp/qrterminal/v3" "github.com/spf13/cobra" - "golang.org/x/term" ) const loginModeEnv = "WEBEX_LOGIN_MODE" @@ -60,7 +59,7 @@ and only read-only logins can be used. Leaving read-only mode requires a plain if jsonOut { msg = os.Stderr } - interactive := !jsonOut && term.IsTerminal(int(os.Stdin.Fd())) && term.IsTerminal(int(os.Stdout.Fd())) + interactive := !jsonOut && canPrompt() cfg, err := appconfig.Load() if err != nil { @@ -120,10 +119,10 @@ and only read-only logins can be used. Leaving read-only mode requires a plain promptStoreCredentials(&result.Token, clientID, clientSecret) } - if err := auth.SaveToken(result.Email, &result.Token); err != nil { + store, err := auth.SaveTokenWithStore(result.Email, &result.Token) + if err != nil { return fmt.Errorf("saving token: %w", err) } - _, store, _ := auth.LoadTokenWithStore(result.Email) // Update config cfg.AddUser(result.Email, result.DisplayName, result.OrgID, result.OrgName) @@ -150,7 +149,7 @@ and only read-only logins can be used. Leaving read-only mode requires a plain } } if jsonOut { - _ = json.NewEncoder(os.Stdout).Encode(map[string]any{ + out := map[string]any{ "status": "logged_in", "email": result.Email, "display_name": result.DisplayName, @@ -158,7 +157,14 @@ and only read-only logins can be used. Leaving read-only mode requires a plain "org_name": result.OrgName, "read_only": readOnlyLogin, "token_store": store, - }) + } + if readOnlyLogin { + out["removed_logins"] = purged + if purgeErr != nil { + out["purge_error"] = purgeErr.Error() + } + } + _ = json.NewEncoder(os.Stdout).Encode(out) } if purgeErr != nil { return purgeErr @@ -225,7 +231,7 @@ func showDeviceCode(dc *auth.DeviceCode, jsonOut bool) { } fmt.Fprintf(os.Stderr, "Open %s and enter %s\n", dc.VerificationURI, dc.UserCode) - if term.IsTerminal(int(os.Stderr.Fd())) { + if isTerminal(os.Stderr) { fmt.Fprintln(os.Stderr, "Or scan this code with a phone:") qrterminal.GenerateHalfBlock(link, qrterminal.L, os.Stderr) } @@ -239,7 +245,7 @@ func promptStoreCredentials(tok *auth.StoredToken, clientID, clientSecret string huh.NewSelect[string](). Title("Save OAuth credentials with this account?"). Description( - "Storing credentials per-account lets each user refresh tokens independently,\n" + + "Storing credentials per-account lets each user refresh tokens independently,\n"+ "even when multiple accounts are configured with different OAuth apps.", ). Options( @@ -248,7 +254,7 @@ func promptStoreCredentials(tok *auth.StoredToken, clientID, clientSecret string ). Value(&choice), ), - ) + ).WithOutput(os.Stderr) if err := form.Run(); err != nil { return @@ -282,7 +288,7 @@ func promptFolderAssociation(email, dir string) { ). Value(&choice), ), - ) + ).WithOutput(os.Stderr) if err := form.Run(); err != nil { return diff --git a/cmd/logout.go b/cmd/logout.go index 76e3429..c4f7fe1 100644 --- a/cmd/logout.go +++ b/cmd/logout.go @@ -11,7 +11,7 @@ import ( var logoutCmd = &cobra.Command{ Use: "logout [email]", Short: "Log out and remove stored credentials", - Long: "Removes stored tokens from the OS keyring and the credentials file. Without arguments, removes the default user. Specify an email to remove a specific user, or --all to remove all users.", + Long: "Removes stored tokens from the OS keyring and the credentials file. Without arguments, removes the default user. Specify an email to remove a specific user, or --all to remove all users and any tokens cached from $WEBEX_REFRESH_TOKEN.", Args: cobra.MaximumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { allFlag, _ := cmd.Flags().GetBool("all") @@ -27,6 +27,9 @@ var logoutCmd = &cobra.Command{ fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not remove token for %s: %v\n", email, err) } } + if err := auth.DeleteEnvRefreshCache(); err != nil { + fmt.Fprintf(cmd.ErrOrStderr(), "Warning: could not remove cached $%s tokens: %v\n", auth.RefreshTokenEnv, err) + } cfg.Users = make(map[string]appconfig.UserInfo) cfg.DefaultUser = "" if err := cfg.Save(); err != nil { diff --git a/cmd/postinstall.go b/cmd/postinstall.go index 2fabc8f..451c7cc 100644 --- a/cmd/postinstall.go +++ b/cmd/postinstall.go @@ -7,23 +7,16 @@ import ( "os" "path/filepath" "runtime" - "strconv" "strings" "github.com/Cloverhound/webex-cli/skill" "github.com/charmbracelet/huh" "github.com/charmbracelet/lipgloss" "github.com/spf13/cobra" - "golang.org/x/term" ) const coworkName = "Claude Cowork" -const nonInteractiveEnv = "WEBEX_CLI_NONINTERACTIVE" - -// isTerminal is a variable so tests can simulate a terminal. -var isTerminal = func(f *os.File) bool { return term.IsTerminal(int(f.Fd())) } - // setupOptions controls how post-install and skill updates answer questions. type setupOptions struct { prompt bool // ask with interactive forms @@ -31,11 +24,10 @@ type setupOptions struct { skills bool // install or update agent skills } -// newSetupOptions prompts only on a terminal: installers and agents run -// post-install with no TTY, where a form would fail or hang. +// newSetupOptions prompts only when a person can answer. These forms are drawn +// on stdout, so it must be a terminal too. func newSetupOptions(assumeYes, noSkills bool) setupOptions { - nonInteractive, _ := strconv.ParseBool(os.Getenv(nonInteractiveEnv)) - prompt := !assumeYes && !nonInteractive && isTerminal(os.Stdin) && isTerminal(os.Stdout) + prompt := !assumeYes && canPrompt() && isTerminal(os.Stdout) return setupOptions{prompt: prompt, assumeYes: assumeYes, skills: !noSkills} } @@ -217,8 +209,8 @@ func setupAgentSkills(opts setupOptions) error { huh.NewMultiSelect[string](). Title("Install Webex skill for AI coding agents?"). Description( - "The Webex skill lets AI coding agents (Claude Code, Codex, Cursor)\n"+ - "query and manage your Webex environment using natural language.\n"+ + "The Webex skill lets AI coding agents (Claude Code, Codex, Cursor)\n" + + "query and manage your Webex environment using natural language.\n" + "Detected agents are pre-selected.", ). Options(options...). diff --git a/cmd/prompt.go b/cmd/prompt.go new file mode 100644 index 0000000..7659659 --- /dev/null +++ b/cmd/prompt.go @@ -0,0 +1,38 @@ +package cmd + +import ( + "os" + "strconv" + + "github.com/charmbracelet/huh" + "golang.org/x/term" +) + +const nonInteractiveEnv = "WEBEX_CLI_NONINTERACTIVE" + +// isTerminal is a variable so tests can simulate a terminal. +var isTerminal = func(f *os.File) bool { return term.IsTerminal(int(f.Fd())) } + +// canPrompt reports whether a person can answer a form. Forms are drawn on +// stderr, so stdout can be piped or carry JSON. Installers and agents usually +// run without a terminal, where a form would fail or hang. +func canPrompt() bool { + if off, _ := strconv.ParseBool(os.Getenv(nonInteractiveEnv)); off { + return false + } + return isTerminal(os.Stdin) && isTerminal(os.Stderr) +} + +// confirm asks a yes/no question on stderr. +func confirm(title, description, yes, no string) (bool, error) { + var ok bool + err := huh.NewForm(huh.NewGroup( + huh.NewConfirm(). + Title(title). + Description(description). + Affirmative(yes). + Negative(no). + Value(&ok), + )).WithOutput(os.Stderr).Run() + return ok, err +} diff --git a/cmd/readonly.go b/cmd/readonly.go index 9ea9896..f458858 100644 --- a/cmd/readonly.go +++ b/cmd/readonly.go @@ -6,8 +6,6 @@ import ( "strconv" "github.com/Cloverhound/webex-cli/internal/appconfig" - "github.com/charmbracelet/huh" - "golang.org/x/term" ) const readOnlyEnv = "WEBEX_READ_ONLY" @@ -31,21 +29,13 @@ func confirmLeaveReadOnly() error { if readOnlyEnvSet() { return fmt.Errorf("$%s is set; unset it to log in with write access", readOnlyEnv) } - if !term.IsTerminal(int(os.Stdin.Fd())) || !term.IsTerminal(int(os.Stdout.Fd())) { + if !canPrompt() { return fmt.Errorf("read-only mode is on; leaving it requires 'webex login' from an interactive terminal") } - var leave bool - err := huh.NewForm( - huh.NewGroup( - huh.NewConfirm(). - Title("Leave read-only mode?"). - Description("This logs in with write access. Only continue if you started this login yourself."). - Affirmative("Leave read-only mode"). - Negative("Cancel"). - Value(&leave), - ), - ).Run() + leave, err := confirm("Leave read-only mode?", + "This logs in with write access. Only continue if you started this login yourself.", + "Leave read-only mode", "Cancel") if err != nil { return err } diff --git a/cmd/root.go b/cmd/root.go index 46f0e4a..703f7fa 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -17,9 +17,9 @@ import ( ) var rootCmd = &cobra.Command{ - Use: "webex", - Short: "Webex CLI — manage Webex APIs", - Long: `A command-line interface for Webex APIs — Admin, Calling, Contact Center, Devices, Meetings, and Messaging.`, + Use: "webex", + Short: "Webex CLI — manage Webex APIs", + Long: `A command-line interface for Webex APIs — Admin, Calling, Contact Center, Devices, Meetings, and Messaging.`, SilenceUsage: true, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { // Debug mode (set early so auth debug works) @@ -74,17 +74,7 @@ var rootCmd = &cobra.Command{ // Resolve token flagToken, _ := cmd.Flags().GetString("token") envToken := os.Getenv("WEBEX_TOKEN") - userFlag, _ := cmd.Flags().GetString("user") - envUser := os.Getenv("WEBEX_USER") - - // Check local folder config before falling back to global default - if userFlag == "" && envUser == "" { - if cwd, err := os.Getwd(); err == nil { - if lcfg, err := localconfig.Load(cwd); err == nil && lcfg != nil && lcfg.User != "" { - envUser = lcfg.User - } - } - } + userFlag, envUser := requestedUser(cmd) result, err := auth.ResolveToken(flagToken, envToken, userFlag, envUser, cfg, readOnly) if err != nil { @@ -95,7 +85,7 @@ var rootCmd = &cobra.Command{ // Wire up token refresher for refreshable sources switch { - case result.Source == auth.SourceKeyring && result.UserEmail != "": + case result.Source == auth.SourceStored && result.UserEmail != "": config.TokenRefresher = auth.MakeRefresher(result.UserEmail, result.Token, cfg) case result.Source == auth.SourceEnvRefresh: config.TokenRefresher = auth.MakeEnvRefresher(result.Token, cfg) @@ -147,6 +137,21 @@ var rootCmd = &cobra.Command{ }, } +// requestedUser returns the --user flag, and otherwise $WEBEX_USER or the +// folder default. Both empty means the configured default user. +func requestedUser(cmd *cobra.Command) (flagUser, envUser string) { + flagUser, _ = cmd.Flags().GetString("user") + envUser = os.Getenv("WEBEX_USER") + if flagUser == "" && envUser == "" { + if cwd, err := os.Getwd(); err == nil { + if lcfg, err := localconfig.Load(cwd); err == nil && lcfg != nil { + envUser = lcfg.User + } + } + } + return flagUser, envUser +} + // setRegion validates a region value and stores it for region-specific hosts. func setRegion(region string) error { if region == "" { diff --git a/internal/auth/envrefresh.go b/internal/auth/envrefresh.go index 16d287f..263f63d 100644 --- a/internal/auth/envrefresh.go +++ b/internal/auth/envrefresh.go @@ -24,11 +24,18 @@ type envRefreshEntry struct { OrgID string `json:"org_id,omitempty"` } +const envRefreshKeyPrefix = "env-refresh:" + // The cache is keyed by a hash of the env value so a changed secret starts fresh // and the original value never lands on disk. func envRefreshKey(refreshToken string) string { sum := sha256.Sum256([]byte(refreshToken)) - return "env-refresh:" + hex.EncodeToString(sum[:8]) + return envRefreshKeyPrefix + hex.EncodeToString(sum[:8]) +} + +// DeleteEnvRefreshCache removes every cached token minted from $WEBEX_REFRESH_TOKEN. +func DeleteEnvRefreshCache() error { + return fileDeletePrefix(envRefreshKeyPrefix) } // EnvRefreshAccessToken returns an access token for the refresh token in diff --git a/internal/auth/oauth.go b/internal/auth/oauth.go index ba712ef..96d24da 100644 --- a/internal/auth/oauth.go +++ b/internal/auth/oauth.go @@ -344,4 +344,3 @@ func generateState() string { rand.Read(buf) return hex.EncodeToString(buf) } - diff --git a/internal/auth/readonly.go b/internal/auth/readonly.go index 0cd0f2e..a686041 100644 --- a/internal/auth/readonly.go +++ b/internal/auth/readonly.go @@ -6,7 +6,6 @@ import ( "sort" "github.com/Cloverhound/webex-cli/internal/appconfig" - "github.com/zalando/go-keyring" ) // HasReadOnlyToken reports whether the token store holds a read-only token for email. @@ -20,12 +19,13 @@ func HasReadOnlyToken(email string) bool { // depends on this: the keyring and credentials file are readable by any process // running as the user, so a write-capable token left there would bypass the // CLI's checks. An entry that cannot be read or decoded is deleted too, since it -// may hold write access. +// may hold write access, and so is the $WEBEX_REFRESH_TOKEN cache, which read-only +// mode never uses. func PurgeWriteTokens(cfg *appconfig.Config) ([]string, error) { var removed, failed []string for _, email := range cfg.UserEmails() { tok, err := LoadToken(email) - if errors.Is(err, keyring.ErrNotFound) || errors.Is(err, errNotFound) || (err == nil && tok.ReadOnly) { + if errors.Is(err, ErrTokenNotFound) || (err == nil && tok.ReadOnly) { continue } if err := DeleteToken(email); err != nil { @@ -40,5 +40,8 @@ func PurgeWriteTokens(cfg *appconfig.Config) ([]string, error) { sort.Strings(failed) return removed, fmt.Errorf("could not remove write-capable tokens for %v from the token store; remove them manually", failed) } + if err := DeleteEnvRefreshCache(); err != nil { + return removed, fmt.Errorf("could not remove cached $%s tokens from %s: %w", RefreshTokenEnv, CredentialsPath(), err) + } return removed, nil } diff --git a/internal/auth/readonly_test.go b/internal/auth/readonly_test.go index 102d0e7..fd08a4d 100644 --- a/internal/auth/readonly_test.go +++ b/internal/auth/readonly_test.go @@ -2,6 +2,7 @@ package auth import ( "errors" + "os" "strings" "testing" "time" @@ -84,6 +85,28 @@ func TestPurgeWriteTokensDeletesUnreadableEntries(t *testing.T) { } } +func TestPurgeWriteTokensDeletesEnvRefreshCache(t *testing.T) { + keyring.MockInit() + t.Cleanup(func() { os.Remove(CredentialsPath()) }) + cached := envRefreshKey("env-rt") + if err := fileSet(cached, `{"token":{"refresh_token":"rotated"}}`); err != nil { + t.Fatal(err) + } + if err := fileSet("file@example.com", `{"read_only":true}`); err != nil { + t.Fatal(err) + } + + if _, err := PurgeWriteTokens(testConfig("file@example.com")); err != nil { + t.Fatal(err) + } + if _, err := fileGet(cached); !errors.Is(err, ErrTokenNotFound) { + t.Errorf("env refresh cache still in the credentials file: %v", err) + } + if !HasReadOnlyToken("file@example.com") { + t.Error("read-only login in the credentials file was removed") + } +} + func TestPurgeWriteTokensReportsKeyringErrors(t *testing.T) { keyring.MockInitWithError(errors.New("keychain locked")) defer keyring.MockInit() diff --git a/internal/auth/resolve.go b/internal/auth/resolve.go index 6f01b68..99f1366 100644 --- a/internal/auth/resolve.go +++ b/internal/auth/resolve.go @@ -15,7 +15,7 @@ const ( SourceNone TokenSource = iota SourceFlag // --token flag SourceEnv // $WEBEX_TOKEN - SourceKeyring // stored login (OS keyring or credentials file) + SourceStored // stored login (OS keyring or credentials file) SourceEnvRefresh // $WEBEX_REFRESH_TOKEN ) @@ -25,7 +25,7 @@ func (s TokenSource) String() string { return "flag" case SourceEnv: return "environment" - case SourceKeyring: + case SourceStored: return "stored login" case SourceEnvRefresh: return "environment refresh token" @@ -129,7 +129,7 @@ func ResolveToken(flagToken, envToken, userFlag, envUser string, cfg *appconfig. return &ResolveResult{ Token: tok.AccessToken, - Source: SourceKeyring, + Source: SourceStored, UserEmail: email, OrgID: orgID, }, nil diff --git a/internal/auth/store.go b/internal/auth/store.go index 92b053c..b9e747a 100644 --- a/internal/auth/store.go +++ b/internal/auth/store.go @@ -21,7 +21,8 @@ const ( StoreFile = "file" ) -var errNotFound = errors.New("token not found") +// ErrTokenNotFound means no store holds a token for the key. +var ErrTokenNotFound = errors.New("token not found") // forcedStore returns the store named by $WEBEX_TOKEN_STORE, or "" for automatic selection. func forcedStore() (string, error) { @@ -48,13 +49,16 @@ func storeGet(key string) (string, string, error) { return data, StoreKeyring, nil } if forced == StoreKeyring { + if errors.Is(kerr, keyring.ErrNotFound) { + return "", "", ErrTokenNotFound + } return "", "", kerr } } data, err := fileGet(key) // A keyring that failed for any reason but a missing entry may still hold the // token, so "not found" would be a guess; read-only purging relies on that. - if errors.Is(err, errNotFound) && kerr != nil && !errors.Is(kerr, keyring.ErrNotFound) { + if errors.Is(err, ErrTokenNotFound) && kerr != nil && !errors.Is(kerr, keyring.ErrNotFound) { return "", "", fmt.Errorf("not in the credentials file, and the keyring could not be read: %w", kerr) } if err != nil { @@ -63,27 +67,32 @@ func storeGet(key string) (string, string, error) { return data, StoreFile, nil } -func storeSet(key, data string) error { +// storeSet saves data and returns the store that now holds it. +func storeSet(key, data string) (string, error) { forced, err := forcedStore() if err != nil { - return err + return "", err } if forced == StoreFile { - return fileSetUser(key, data) + return StoreFile, fileSetUser(key, data) } kerr := keyring.Set(serviceName, key, data) - if kerr == nil || forced == StoreKeyring { - if kerr == nil { - // A copy left in the file from an earlier fallback would shadow nothing - // but would keep a stale secret on disk. - _ = fileDelete(key) - } - return kerr + if kerr == nil { + // A copy left in the file from an earlier fallback would shadow nothing + // but would keep a stale secret on disk. + _ = fileDelete(key) + return StoreKeyring, nil + } + if forced == StoreKeyring { + return "", kerr } if err := fileSetUser(key, data); err != nil { - return fmt.Errorf("keyring unavailable (%v) and file store failed: %w", kerr, err) + return "", fmt.Errorf("keyring unavailable (%v) and file store failed: %w", kerr, err) } - return nil + // 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 } func storeDelete(key string) error { @@ -164,11 +173,34 @@ func fileGet(key string) (string, error) { } data, ok := cf.Tokens[key] if !ok { - return "", errNotFound + return "", ErrTokenNotFound } return data, nil } +// fileDeletePrefix removes every file entry whose key starts with prefix. +func fileDeletePrefix(prefix string) error { + if _, err := os.Stat(CredentialsPath()); os.IsNotExist(err) { + return nil + } + return withFileLock("credentials.lock", func() error { + cf, err := readCredentials() + if err != nil { + return err + } + n := len(cf.Tokens) + for key := range cf.Tokens { + if strings.HasPrefix(key, prefix) { + delete(cf.Tokens, key) + } + } + if len(cf.Tokens) == n { + return nil + } + return writeCredentials(cf) + }) +} + func fileSet(key, data string) error { return withFileLock("credentials.lock", func() error { cf, err := readCredentials() diff --git a/internal/auth/keyring.go b/internal/auth/token.go similarity index 87% rename from internal/auth/keyring.go rename to internal/auth/token.go index 87faa64..3590a41 100644 --- a/internal/auth/keyring.go +++ b/internal/auth/token.go @@ -34,9 +34,16 @@ func (t *StoredToken) IsRefreshExpired() bool { // SaveToken stores a token keyed by email, in the OS keyring when one is // available and in the credentials file otherwise. func SaveToken(email string, tok *StoredToken) error { + _, err := SaveTokenWithStore(email, tok) + return err +} + +// SaveTokenWithStore stores a token like SaveToken and names the store that +// now holds it ("keyring" or "file"). +func SaveTokenWithStore(email string, tok *StoredToken) (string, error) { data, err := json.Marshal(tok) if err != nil { - return fmt.Errorf("marshaling token: %w", err) + return "", fmt.Errorf("marshaling token: %w", err) } return storeSet(email, string(data)) } diff --git a/internal/config/config.go b/internal/config/config.go index 702b5c2..557b4b9 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -19,10 +19,10 @@ var ( ) func SetToken(t string) { token = t } -func Token() string { return token } +func Token() string { return token } -func SetMaxRetry(n int) { maxRetry = n } -func MaxRetry() int { return maxRetry } +func SetMaxRetry(n int) { maxRetry = n } +func MaxRetry() int { return maxRetry } func SetMaxRetryTimer(secs int) { maxRetryTimer = secs } func MaxRetryTimer() int { return maxRetryTimer } @@ -55,7 +55,7 @@ func SetOrgID(id string) { orgIDBase64 = EncodeOrgID(uuid) } -func OrgID() string { return orgID } +func OrgID() string { return orgID } func OrgIDBase64() string { return orgIDBase64 } const ( diff --git a/internal/search/query.go b/internal/search/query.go index 6256d57..e94e4f1 100644 --- a/internal/search/query.go +++ b/internal/search/query.go @@ -42,9 +42,9 @@ type Params struct { TimeComparator string Filter string // pre-built GraphQL filter string (from BuildFilter) Fields string // raw GraphQL field selection (overrides default) - Cursor string - PageSize int - CurrentPage int + Cursor string + PageSize int + CurrentPage int Aggregations []Aggregation Interval string Timezone string diff --git a/main.go b/main.go index dbd55ee..095efc9 100644 --- a/main.go +++ b/main.go @@ -8,8 +8,8 @@ import ( _ "github.com/Cloverhound/webex-cli/cmd/calling" _ "github.com/Cloverhound/webex-cli/cmd/cc" _ "github.com/Cloverhound/webex-cli/cmd/device" - _ "github.com/Cloverhound/webex-cli/cmd/meetings" _ "github.com/Cloverhound/webex-cli/cmd/mcp" + _ "github.com/Cloverhound/webex-cli/cmd/meetings" _ "github.com/Cloverhound/webex-cli/cmd/messaging" // Used only when the OS has no CA bundle, as in slim container images. diff --git a/site/src/content/docs/getting-started.mdx b/site/src/content/docs/getting-started.mdx index 8a5c850..d9e003c 100644 --- a/site/src/content/docs/getting-started.mdx +++ b/site/src/content/docs/getting-started.mdx @@ -5,7 +5,7 @@ description: Get up and running with the Webex CLI. ## Authentication -The CLI uses OAuth with tokens stored in your OS keyring. +The CLI uses OAuth with tokens stored in your OS keyring, or in a credentials file when no keyring is available. ```bash # Login (opens browser, or device login when there is no browser) @@ -56,7 +56,7 @@ webex login --read-only This login requests only read scopes (`webex config get read-only-scopes` lists them) and turns on read-only mode: -- Stored logins with write access are deleted from the OS keyring. Other programs running as you can read the keyring, so leaving those logins there would let them bypass read-only mode. +- Stored logins with write access are deleted from the OS keyring and the credentials file. Other programs running as you can read both, so leaving those logins there would let them bypass read-only mode. - `PUT`, `PATCH`, `DELETE`, and uploads are refused before they are sent. `POST` is allowed only for a fixed list of query endpoints that take their filters in the request body, such as `cc search` and recording queries. Webex also rejects writes because the token has no write scopes. - `--token`, `$WEBEX_TOKEN`, and `$WEBEX_REFRESH_TOKEN` are refused, because the CLI cannot check the scopes of a token it did not create. - `auth switch`, `--user`, and folder defaults work only for users who logged in with `--read-only`. diff --git a/site/src/content/docs/index.mdx b/site/src/content/docs/index.mdx index 030b628..471872c 100644 --- a/site/src/content/docs/index.mdx +++ b/site/src/content/docs/index.mdx @@ -36,7 +36,7 @@ import { Card, CardGrid } from '@astrojs/starlight/components'; Manage rooms, messages, memberships, teams, webhooks, Hybrid Data Security, and attachment actions via the Messaging API. - Browser-based OAuth login with tokens stored securely in your OS keyring. Multi-user support. + Browser or device-code OAuth login with tokens stored securely in your OS keyring. Multi-user support. Use the included skill to let Claude Code, Codex, Cursor, or any coding agent query and manage your Webex environment. diff --git a/site/src/content/docs/installation.mdx b/site/src/content/docs/installation.mdx index 51a0e54..80071ff 100644 --- a/site/src/content/docs/installation.mdx +++ b/site/src/content/docs/installation.mdx @@ -78,7 +78,7 @@ cd webex-cli go build -o webex . ``` -Requires Go 1.23+. +Requires Go 1.26+. ## Verify Installation diff --git a/skill/SKILL.md b/skill/SKILL.md index b5b4432..0f39a90 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -19,7 +19,7 @@ webex ## Authentication -The CLI supports OAuth login with tokens stored in the OS keyring. +The CLI supports OAuth login with tokens stored in the OS keyring, or in a credentials file when no keyring is available. ```bash webex auth status # Show current user, org, token expiry @@ -40,7 +40,7 @@ If not logged in, use `--token ` or set `$WEBEX_TOKEN` or `$WEBEX_REFRESH **Per-folder defaults:** Different folders can be associated with different Webex users via `auth set-folder-default`. When a folder default is set, that user's credentials are used automatically when running commands from that directory. This is useful when different project folders connect to different Webex orgs. -Token resolution order: `--token` flag > `WEBEX_TOKEN` env var > `WEBEX_REFRESH_TOKEN` env var > `--user` flag > `WEBEX_USER` env var > folder default (`.webex-cli/config.json`) > global default > OS keyring. +Token resolution order: `--token` flag > `WEBEX_TOKEN` env var > `WEBEX_REFRESH_TOKEN` env var > `--user` flag > `WEBEX_USER` env var > folder default (`.webex-cli/config.json`) > global default > stored login. Org resolution order: `--organization` flag > `auth set-org` override > login user's home org. From b3be81fe09442d0758180b6a382796c6bbde3ca3 Mon Sep 17 00:00:00 2001 From: Ed Umansky Date: Sat, 10 Oct 2026 00:00:46 -0600 Subject: [PATCH 6/7] Address Codex review of the token store and device login - 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 Claude-Session: https://claude.ai/code/session_01EdRf9uewJ53wofcX185zGC --- cmd/auth.go | 21 ++++++--- cmd/auth_test.go | 46 ++++++++++++++++++++ internal/auth/device.go | 57 +++++++++++++----------- internal/auth/device_test.go | 25 +++++++++++ internal/auth/envrefresh.go | 27 +++++++++++- internal/auth/lock.go | 9 ++-- internal/auth/lock_test.go | 38 ++++++++++++++++ internal/auth/oauth.go | 54 ++++++++++++----------- internal/auth/readonly.go | 39 ++++++++++++----- internal/auth/readonly_test.go | 24 +++++++++++ internal/auth/store.go | 79 +++++++++++++++++++++++++--------- internal/auth/store_test.go | 73 +++++++++++++++++++++++++++++++ internal/auth/token.go | 33 ++++++++++++-- 13 files changed, 426 insertions(+), 99 deletions(-) create mode 100644 cmd/auth_test.go create mode 100644 internal/auth/lock_test.go diff --git a/cmd/auth.go b/cmd/auth.go index c6551fb..afe14e2 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -47,23 +47,30 @@ var authStatusCmd = &cobra.Command{ } email := cfg.DefaultUser - if email == "" { - fmt.Println("No authenticated user. Run: webex login") - return nil - } - + envSet := false for _, env := range []string{"WEBEX_TOKEN", auth.RefreshTokenEnv} { if os.Getenv(env) == "" { continue } - if readOnlyMode(cfg) { + envSet = true + switch { + case readOnlyMode(cfg): fmt.Printf("Env: $%s is set; read-only mode refuses it, so commands fail until it is unset\n", env) - } else { + case email == "": + fmt.Printf("Env: $%s is set; commands authenticate with it\n", env) + default: fmt.Printf("Env: $%s is set and overrides the stored login below\n", env) } break } + if email == "" { + if !envSet { + fmt.Println("No authenticated user. Run: webex login") + } + return nil + } + userInfo := cfg.Users[email] tok, store, err := auth.LoadTokenWithStore(email) if err != nil { diff --git a/cmd/auth_test.go b/cmd/auth_test.go new file mode 100644 index 0000000..6b8c6c0 --- /dev/null +++ b/cmd/auth_test.go @@ -0,0 +1,46 @@ +package cmd + +import ( + "io" + "os" + "strings" + "testing" + + "github.com/Cloverhound/webex-cli/internal/auth" +) + +func captureStdout(t *testing.T, fn func()) string { + t.Helper() + r, w, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + old := os.Stdout + os.Stdout = w + fn() + os.Stdout = old + w.Close() + out, _ := io.ReadAll(r) + return string(out) +} + +func TestAuthStatusReportsEnvTokenWithoutStoredLogin(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("USERPROFILE", home) + t.Setenv(readOnlyEnv, "") + t.Setenv("WEBEX_TOKEN", "") + t.Setenv(auth.RefreshTokenEnv, "rt") + + out := captureStdout(t, func() { + if err := authStatusCmd.RunE(authStatusCmd, nil); err != nil { + t.Error(err) + } + }) + if !strings.Contains(out, "$"+auth.RefreshTokenEnv+" is set") { + t.Errorf("status output does not mention the env token:\n%s", out) + } + if strings.Contains(out, "No authenticated user") { + t.Errorf("status claims no user although commands authenticate from the env:\n%s", out) + } +} diff --git a/internal/auth/device.go b/internal/auth/device.go index ad1670e..b8fdb62 100644 --- a/internal/auth/device.go +++ b/internal/auth/device.go @@ -3,6 +3,7 @@ package auth import ( "context" "encoding/json" + "errors" "fmt" "io" "net/http" @@ -19,6 +20,10 @@ var ( pollUnit = time.Second ) +const deviceRequestTimeout = 30 * time.Second + +var errDeviceCodeExpired = errors.New("the login code expired before it was approved — run webex login again") + // DeviceHelperRedirectURIs must be registered on the integration: Webex's device // approval page redirects to one of them, and which one depends on the region. const DeviceHelperRedirectURIs = "https://oauth-helper-{a,r,k,d}.wbx2.com/helperservice/v1/actions/device/callback" @@ -38,10 +43,15 @@ func RequestDeviceCode(clientID, scopes string) (*DeviceCode, error) { if clientID == "" { return nil, fmt.Errorf("client ID not configured — run: webex config set client-id ") } - resp, err := http.PostForm(DeviceAuthorizeURL, url.Values{ - "client_id": {clientID}, - "scope": {scopes}, - }) + ctx, cancel := context.WithTimeout(context.Background(), deviceRequestTimeout) + defer cancel() + form := url.Values{"client_id": {clientID}, "scope": {scopes}} + req, err := http.NewRequestWithContext(ctx, http.MethodPost, DeviceAuthorizeURL, strings.NewReader(form.Encode())) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + resp, err := http.DefaultClient.Do(req) if err != nil { return nil, fmt.Errorf("device authorization request: %w", err) } @@ -80,20 +90,30 @@ func RequestDeviceCode(clientID, scopes string) (*DeviceCode, error) { // keeps polling and slow_down adds 5 seconds to the interval. func PollDeviceToken(ctx context.Context, clientID, clientSecret string, dc *DeviceCode, scopes string) (*StoredToken, error) { interval := time.Duration(dc.Interval) * pollUnit - deadline := time.Now().Add(time.Duration(dc.ExpiresIn) * pollUnit) + parent := ctx + // Bounding every request by the code's expiry keeps a stalled connection from + // outliving the code. + ctx, cancel := context.WithTimeout(ctx, time.Duration(dc.ExpiresIn)*pollUnit) + defer cancel() + expired := func() error { + if parent.Err() != nil { + return parent.Err() + } + return errDeviceCodeExpired + } for { select { case <-ctx.Done(): - return nil, ctx.Err() + return nil, expired() case <-time.After(interval): } - if time.Now().After(deadline) { - return nil, fmt.Errorf("the login code expired before it was approved — run webex login again") - } - tok, status, err := requestDeviceToken(clientID, clientSecret, dc.DeviceCode, scopes) + tok, status, err := requestDeviceToken(ctx, clientID, clientSecret, dc.DeviceCode, scopes) if err != nil { + if ctx.Err() != nil { + return nil, expired() + } return nil, err } switch status { @@ -114,13 +134,13 @@ func PollDeviceToken(ctx context.Context, clientID, clientSecret string, dc *Dev // requestDeviceToken makes one token request. It returns the token on success, // or the OAuth error code for a response the caller can act on. -func requestDeviceToken(clientID, clientSecret, deviceCode, scopes string) (*StoredToken, string, error) { +func requestDeviceToken(ctx context.Context, clientID, clientSecret, deviceCode, scopes string) (*StoredToken, string, error) { form := url.Values{ "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, "device_code": {deviceCode}, "client_id": {clientID}, } - req, err := http.NewRequest(http.MethodPost, DeviceTokenURL, strings.NewReader(form.Encode())) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, DeviceTokenURL, strings.NewReader(form.Encode())) if err != nil { return nil, "", err } @@ -139,18 +159,7 @@ func requestDeviceToken(clientID, clientSecret, deviceCode, scopes string) (*Sto if err := json.Unmarshal(body, &tr); err != nil { return nil, "", fmt.Errorf("parsing device token response: %w", err) } - if tr.Scope != "" { - scopes = tr.Scope - } - now := time.Now() - return &StoredToken{ - AccessToken: tr.AccessToken, - RefreshToken: tr.RefreshToken, - ExpiresAt: now.Add(time.Duration(tr.ExpiresIn) * time.Second), - TokenType: tr.TokenType, - IssuedAt: now, - Scopes: scopes, - }, "", nil + return tr.storedToken(tr.grantedScopes(scopes)), "", nil } var oauthErr struct { diff --git a/internal/auth/device_test.go b/internal/auth/device_test.go index 33dc4b2..bc500d2 100644 --- a/internal/auth/device_test.go +++ b/internal/auth/device_test.go @@ -7,6 +7,7 @@ import ( "strings" "sync/atomic" "testing" + "time" ) // fakeDeviceToken serves the device token endpoint, answering with responses @@ -51,6 +52,30 @@ func testCode(expiresIn int) *DeviceCode { return &DeviceCode{DeviceCode: "dev", UserCode: "123456", ExpiresIn: expiresIn, Interval: 1} } +func TestPollDeviceTokenStopsStalledRequestAtExpiry(t *testing.T) { + release := make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + select { + case <-release: + case <-time.After(5 * time.Second): + } + })) + defer srv.Close() + defer close(release) + old := DeviceTokenURL + DeviceTokenURL = srv.URL + defer func() { DeviceTokenURL = old }() + + start := time.Now() + _, err := PollDeviceToken(context.Background(), "cid", "secret", testCode(100), "spark:all") + if err == nil || !strings.Contains(err.Error(), "expired") { + t.Errorf("err = %v, want the code to expire", err) + } + if elapsed := time.Since(start); elapsed > 2*time.Second { + t.Errorf("polling took %s; the stalled request was not cut off at expiry", elapsed) + } +} + func TestPollDeviceTokenPendingThenGranted(t *testing.T) { calls, done := fakeDeviceToken(t, []func(http.ResponseWriter){ respond(428, `{"message":"authorization pending"}`), diff --git a/internal/auth/envrefresh.go b/internal/auth/envrefresh.go index 263f63d..41f472f 100644 --- a/internal/auth/envrefresh.go +++ b/internal/auth/envrefresh.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "os" + "sync" "time" ) @@ -26,6 +27,13 @@ type envRefreshEntry struct { const envRefreshKeyPrefix = "env-refresh:" +// envRefreshMemo holds this process's latest entry per key, so a rotated refresh +// token outlives a failed write to the credentials file until the process exits. +var ( + envRefreshMu sync.Mutex + envRefreshMemo = map[string]*envRefreshEntry{} +) + // The cache is keyed by a hash of the env value so a changed secret starts fresh // and the original value never lands on disk. func envRefreshKey(refreshToken string) string { @@ -35,6 +43,9 @@ func envRefreshKey(refreshToken string) string { // DeleteEnvRefreshCache removes every cached token minted from $WEBEX_REFRESH_TOKEN. func DeleteEnvRefreshCache() error { + envRefreshMu.Lock() + clear(envRefreshMemo) + envRefreshMu.Unlock() return fileDeletePrefix(envRefreshKeyPrefix) } @@ -80,6 +91,9 @@ func EnvRefreshAccessToken(envRefresh, clientID, clientSecret, stale string) (to entry.Email, entry.OrgID = id.email, id.orgID } } + envRefreshMu.Lock() + envRefreshMemo[key] = entry + envRefreshMu.Unlock() if data, err := json.Marshal(entry); err == nil { if err := fileSet(key, string(data)); err != nil { fmt.Fprintf(os.Stderr, "Warning: could not cache the token from $%s (%v); the rotated refresh token is lost when this process exits\n", RefreshTokenEnv, err) @@ -93,14 +107,23 @@ func EnvRefreshAccessToken(envRefresh, clientID, clientSecret, stale string) (to return entry.Token.AccessToken, entry.Email, entry.OrgID, nil } +// loadEnvRefreshEntry returns the newer of the cached entry in the credentials +// file, which other processes may have updated, and this process's own. func loadEnvRefreshEntry(key string) *envRefreshEntry { + envRefreshMu.Lock() + memo := envRefreshMemo[key] + envRefreshMu.Unlock() + data, err := fileGet(key) if err != nil { - return nil + return memo } var e envRefreshEntry if json.Unmarshal([]byte(data), &e) != nil { - return nil + return memo + } + if memo != nil && memo.Token.IssuedAt.After(e.Token.IssuedAt) { + return memo } return &e } diff --git a/internal/auth/lock.go b/internal/auth/lock.go index 626884b..c6319d7 100644 --- a/internal/auth/lock.go +++ b/internal/auth/lock.go @@ -11,8 +11,10 @@ var lockTimeout = 30 * time.Second // withFileLock runs fn while holding an exclusive lock on the named file in // CredentialsDir, so parallel CLI processes serialize token writes and refreshes. -// If the lock cannot be taken (read-only home, timeout), fn runs anyway: a rare -// race is better than refusing to work. +// When locking is impossible (read-only home, no lock support), fn runs anyway, +// since no other process can hold the lock either. When another process holds +// it past the timeout, fn does not run: it could reuse a single-use refresh +// token or overwrite that process's write. func withFileLock(name string, fn func() error) error { dir := CredentialsDir() if err := os.MkdirAll(dir, 0700); err != nil { @@ -34,8 +36,7 @@ func withFileLock(name string, fn func() error) error { break } if time.Now().After(deadline) { - fmt.Fprintf(os.Stderr, "Warning: timed out waiting for %s; continuing without it\n", f.Name()) - return fn() + return fmt.Errorf("timed out after %s waiting for %s; another webex process is still using it", lockTimeout, f.Name()) } time.Sleep(50 * time.Millisecond) } diff --git a/internal/auth/lock_test.go b/internal/auth/lock_test.go new file mode 100644 index 0000000..d9a6235 --- /dev/null +++ b/internal/auth/lock_test.go @@ -0,0 +1,38 @@ +package auth + +import ( + "testing" + "time" +) + +func TestWithFileLockTimesOutWithoutRunning(t *testing.T) { + old := lockTimeout + lockTimeout = 100 * time.Millisecond + t.Cleanup(func() { lockTimeout = old }) + + held, release, done := make(chan struct{}), make(chan struct{}), make(chan struct{}) + go func() { + defer close(done) + _ = withFileLock("test.lock", func() error { + close(held) + <-release + return nil + }) + }() + <-held + + ran := false + err := withFileLock("test.lock", func() error { + ran = true + return nil + }) + close(release) + <-done + + if err == nil { + t.Error("expected a timeout error while another holder has the lock") + } + if ran { + t.Error("callback ran without the lock") + } +} diff --git a/internal/auth/oauth.go b/internal/auth/oauth.go index 96d24da..c288814 100644 --- a/internal/auth/oauth.go +++ b/internal/auth/oauth.go @@ -200,18 +200,11 @@ func RefreshAccessToken(clientID, clientSecret string, tok *StoredToken) (*Store // A refresh keeps the original grant, so the scopes and the per-account // credentials carry over unchanged. - now := time.Now() - return &StoredToken{ - AccessToken: tokenResp.AccessToken, - RefreshToken: tokenResp.RefreshToken, - ExpiresAt: now.Add(time.Duration(tokenResp.ExpiresIn) * time.Second), - TokenType: tokenResp.TokenType, - IssuedAt: now, - ClientID: tok.ClientID, - ClientSecret: tok.ClientSecret, - Scopes: tok.Scopes, - ReadOnly: tok.ReadOnly, - }, nil + refreshed := tokenResp.storedToken(tok.Scopes) + refreshed.ClientID = tok.ClientID + refreshed.ClientSecret = tok.ClientSecret + refreshed.ReadOnly = tok.ReadOnly + return refreshed, nil } type tokenResponse struct { @@ -222,6 +215,28 @@ type tokenResponse struct { Scope string `json:"scope"` } +// grantedScopes prefers the scopes Webex reports granting and otherwise +// records the requested ones. +func (tr tokenResponse) grantedScopes(requested string) string { + if tr.Scope != "" { + return tr.Scope + } + return requested +} + +// storedToken converts a token response issued now. +func (tr tokenResponse) storedToken(scopes string) *StoredToken { + now := time.Now() + return &StoredToken{ + AccessToken: tr.AccessToken, + RefreshToken: tr.RefreshToken, + ExpiresAt: now.Add(time.Duration(tr.ExpiresIn) * time.Second), + TokenType: tr.TokenType, + IssuedAt: now, + Scopes: scopes, + } +} + func exchangeCode(clientID, clientSecret, code, verifier, scopes string) (*StoredToken, error) { form := url.Values{ "grant_type": {"authorization_code"}, @@ -247,20 +262,7 @@ func exchangeCode(clientID, clientSecret, code, verifier, scopes string) (*Store if err := json.Unmarshal(body, &tokenResp); err != nil { return nil, fmt.Errorf("parsing token response: %w", err) } - // Prefer the granted scopes when Webex reports them; otherwise record the request. - if tokenResp.Scope != "" { - scopes = tokenResp.Scope - } - - now := time.Now() - return &StoredToken{ - AccessToken: tokenResp.AccessToken, - RefreshToken: tokenResp.RefreshToken, - ExpiresAt: now.Add(time.Duration(tokenResp.ExpiresIn) * time.Second), - TokenType: tokenResp.TokenType, - IssuedAt: now, - Scopes: scopes, - }, nil + return tokenResp.storedToken(tokenResp.grantedScopes(scopes)), nil } type identityInfo struct { diff --git a/internal/auth/readonly.go b/internal/auth/readonly.go index a686041..e2723a6 100644 --- a/internal/auth/readonly.go +++ b/internal/auth/readonly.go @@ -15,25 +15,42 @@ func HasReadOnlyToken(email string) bool { } // PurgeWriteTokens deletes every stored token that is not read-only and removes -// its user from cfg. It returns the removed emails, sorted. Read-only mode -// depends on this: the keyring and credentials file are readable by any process -// running as the user, so a write-capable token left there would bypass the -// CLI's checks. An entry that cannot be read or decoded is deleted too, since it -// may hold write access, and so is the $WEBEX_REFRESH_TOKEN cache, which read-only -// mode never uses. +// users left without a login from cfg. It returns the removed emails, sorted. +// Read-only mode depends on this: the keyring and credentials file are readable +// by any process running as the user, so a write-capable token left in either +// would bypass the CLI's checks. Both stores are checked whatever +// $WEBEX_TOKEN_STORE selects. An entry that cannot be decoded is deleted too, +// since it may hold write access, and so is the $WEBEX_REFRESH_TOKEN cache, +// which read-only mode never uses. func PurgeWriteTokens(cfg *appconfig.Config) ([]string, error) { var removed, failed []string for _, email := range cfg.UserEmails() { - tok, err := LoadToken(email) - if errors.Is(err, ErrTokenNotFound) || (err == nil && tok.ReadOnly) { + copies, err := readCopies(email, []string{StoreKeyring, StoreFile}) + if errors.Is(err, ErrTokenNotFound) { continue } - if err := DeleteToken(email); err != nil { + if err != nil { failed = append(failed, email) continue } - cfg.RemoveUser(email) - removed = append(removed, email) + kept, ok := 0, true + for _, c := range copies { + if tok, err := decodeToken(c.data); err == nil && tok.ReadOnly { + kept++ + continue + } + if err := deleteFrom(c.store, email); err != nil { + ok = false + } + } + if !ok { + failed = append(failed, email) + continue + } + if kept == 0 { + cfg.RemoveUser(email) + removed = append(removed, email) + } } sort.Strings(removed) if len(failed) > 0 { diff --git a/internal/auth/readonly_test.go b/internal/auth/readonly_test.go index fd08a4d..a709f62 100644 --- a/internal/auth/readonly_test.go +++ b/internal/auth/readonly_test.go @@ -85,6 +85,30 @@ func TestPurgeWriteTokensDeletesUnreadableEntries(t *testing.T) { } } +func TestPurgeWriteTokensChecksEveryStore(t *testing.T) { + keyring.MockInit() + t.Cleanup(func() { os.Remove(CredentialsPath()) }) + t.Setenv(TokenStoreEnv, StoreFile) + if err := keyring.Set(serviceName, "ro@example.com", `{"access_token":"write"}`); err != nil { + t.Fatal(err) + } + saveTestToken(t, "ro@example.com", true) + cfg := testConfig("ro@example.com") + + if _, err := PurgeWriteTokens(cfg); err != nil { + t.Fatal(err) + } + if _, err := keyring.Get(serviceName, "ro@example.com"); !errors.Is(err, keyring.ErrNotFound) { + t.Errorf("write-capable keyring copy survived: %v", err) + } + if !HasReadOnlyToken("ro@example.com") { + t.Error("read-only file copy was removed") + } + if _, ok := cfg.Users["ro@example.com"]; !ok { + t.Error("user with a read-only login was removed from config") + } +} + func TestPurgeWriteTokensDeletesEnvRefreshCache(t *testing.T) { keyring.MockInit() t.Cleanup(func() { os.Remove(CredentialsPath()) }) diff --git a/internal/auth/store.go b/internal/auth/store.go index b9e747a..e96d7d6 100644 --- a/internal/auth/store.go +++ b/internal/auth/store.go @@ -36,35 +36,72 @@ func forcedStore() (string, error) { } } -func storeGet(key string) (string, string, error) { +// selectedStores returns the stores $WEBEX_TOKEN_STORE allows, keyring first. +func selectedStores() ([]string, error) { forced, err := forcedStore() if err != nil { - return "", "", err + return nil, err + } + if forced != "" { + return []string{forced}, nil } + return []string{StoreKeyring, StoreFile}, nil +} + +// storedCopy is the value one store holds for a key. +type storedCopy struct { + store string + data string +} + +// readCopies returns the copies of key held by each of stores. A missing entry +// is not an error. A keyring that fails for any other reason may still hold the +// key, so its error is returned when no store produced a copy: reporting "not +// found" would be a guess, and read-only purging relies on that. +func readCopies(key string, stores []string) ([]storedCopy, error) { + var copies []storedCopy var kerr error - if forced != StoreFile { + for _, store := range stores { var data string - data, kerr = keyring.Get(serviceName, key) - if kerr == nil { - return data, StoreKeyring, nil - } - if forced == StoreKeyring { - if errors.Is(kerr, keyring.ErrNotFound) { - return "", "", ErrTokenNotFound + var err error + if store == StoreKeyring { + data, err = keyring.Get(serviceName, key) + if errors.Is(err, keyring.ErrNotFound) { + continue + } + if err != nil { + kerr = err + continue + } + } else { + data, err = fileGet(key) + if errors.Is(err, ErrTokenNotFound) { + continue + } + if err != nil { + return nil, err } - return "", "", kerr } + copies = append(copies, storedCopy{store: store, data: data}) } - data, err := fileGet(key) - // A keyring that failed for any reason but a missing entry may still hold the - // token, so "not found" would be a guess; read-only purging relies on that. - if errors.Is(err, ErrTokenNotFound) && kerr != nil && !errors.Is(kerr, keyring.ErrNotFound) { - return "", "", fmt.Errorf("not in the credentials file, and the keyring could not be read: %w", kerr) + if len(copies) == 0 { + if kerr != nil { + return nil, fmt.Errorf("the keyring could not be read: %w", kerr) + } + return nil, ErrTokenNotFound } - if err != nil { - return "", "", err + return copies, nil +} + +// deleteFrom removes key from one store. +func deleteFrom(store, key string) error { + if store == StoreFile { + return fileDelete(key) + } + if err := keyring.Delete(serviceName, key); err != nil && !errors.Is(err, keyring.ErrNotFound) { + return err } - return data, StoreFile, nil + return nil } // storeSet saves data and returns the store that now holds it. @@ -89,8 +126,8 @@ func storeSet(key, data string) (string, error) { if err := fileSetUser(key, data); err != nil { return "", fmt.Errorf("keyring unavailable (%v) and file store failed: %w", kerr, err) } - // The keyring is read first, so an older entry left there would shadow this - // token once the keyring works again. + // An older keyring entry would be a stale secret. Loading prefers the newest + // copy, so this matters only for cleanup and may fail while the keyring is down. _ = keyring.Delete(serviceName, key) return StoreFile, nil } diff --git a/internal/auth/store_test.go b/internal/auth/store_test.go index 279d71e..36a3476 100644 --- a/internal/auth/store_test.go +++ b/internal/auth/store_test.go @@ -3,9 +3,11 @@ package auth import ( "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" "os" + "path/filepath" "runtime" "sync" "sync/atomic" @@ -164,6 +166,77 @@ func TestParallelRefreshRefreshesOnce(t *testing.T) { } } +func TestLoadTokenPrefersNewestCopy(t *testing.T) { + keyring.MockInit() + t.Cleanup(func() { os.Remove(CredentialsPath()) }) + encode := func(at string, issued time.Time) string { + data, _ := json.Marshal(StoredToken{AccessToken: at, IssuedAt: issued}) + return string(data) + } + if err := keyring.Set(serviceName, "a@example.com", encode("stale", time.Now().Add(-time.Hour))); err != nil { + t.Fatal(err) + } + if err := fileSet("a@example.com", encode("fresh", time.Now())); err != nil { + t.Fatal(err) + } + + got, store, err := LoadTokenWithStore("a@example.com") + if err != nil { + t.Fatal(err) + } + if got.AccessToken != "fresh" || store != StoreFile { + t.Errorf("loaded %q from %s, want fresh from file", got.AccessToken, store) + } +} + +func TestEnvRefreshKeepsRotatedTokenWithoutCache(t *testing.T) { + keyring.MockInit() + // A regular file where the credentials directory belongs makes every write fail. + blocker := filepath.Join(t.TempDir(), "blocker") + if err := os.WriteFile(blocker, nil, 0600); err != nil { + t.Fatal(err) + } + t.Setenv("XDG_CONFIG_HOME", blocker) + + var mu sync.Mutex + valid := map[string]bool{"env-rt-nocache": true} + issued := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/me" { + w.Write([]byte(`{"emails":["svc@example.com"],"orgId":"org-1"}`)) + return + } + r.ParseForm() + mu.Lock() + defer mu.Unlock() + rt := r.Form.Get("refresh_token") + if !valid[rt] { + http.Error(w, `{"error":"invalid_grant"}`, http.StatusBadRequest) + return + } + // Webex rotates the refresh token, and the old one stops working. + delete(valid, rt) + issued++ + next := fmt.Sprintf("rt-%d", issued) + valid[next] = true + json.NewEncoder(w).Encode(map[string]any{ + "access_token": fmt.Sprintf("at-%d", issued), "refresh_token": next, "expires_in": 3600, + }) + })) + oldToken, oldMe := TokenURL, peopleMeURL + TokenURL, peopleMeURL = srv.URL+"/token", srv.URL+"/me" + t.Cleanup(func() { TokenURL, peopleMeURL = oldToken, oldMe; srv.Close() }) + + tok, _, _, err := EnvRefreshAccessToken("env-rt-nocache", "cid", "secret", "") + if err != nil || tok != "at-1" { + t.Fatalf("first refresh: tok=%s err=%v", tok, err) + } + tok, _, _, err = EnvRefreshAccessToken("env-rt-nocache", "cid", "secret", "at-1") + if err != nil || tok != "at-2" { + t.Fatalf("refresh after a 401: tok=%s err=%v; want at-2 from the rotated refresh token", tok, err) + } +} + func TestEnvRefreshCachesAndRotates(t *testing.T) { noKeyring(t) calls := fakeTokenEndpoint(t) diff --git a/internal/auth/token.go b/internal/auth/token.go index 3590a41..b4b28e9 100644 --- a/internal/auth/token.go +++ b/internal/auth/token.go @@ -55,17 +55,42 @@ func LoadToken(email string) (*StoredToken, error) { } // LoadTokenWithStore retrieves the token stored for email and names the store -// that held it ("keyring" or "file"). +// that held it ("keyring" or "file"). When both stores hold a copy, as after a +// save fell back to the file while the keyring was down, the newest one wins. func LoadTokenWithStore(email string) (*StoredToken, string, error) { - data, store, err := storeGet(email) + stores, err := selectedStores() + if err != nil { + return nil, "", err + } + copies, err := readCopies(email, stores) if err != nil { return nil, "", fmt.Errorf("loading token for %s: %w", email, err) } + var best *StoredToken + var bestStore string + var parseErr error + for _, c := range copies { + tok, err := decodeToken(c.data) + if err != nil { + parseErr = err + continue + } + if best == nil || tok.IssuedAt.After(best.IssuedAt) { + best, bestStore = tok, c.store + } + } + if best == nil { + return nil, "", parseErr + } + return best, bestStore, nil +} + +func decodeToken(data string) (*StoredToken, error) { var tok StoredToken if err := json.Unmarshal([]byte(data), &tok); err != nil { - return nil, "", fmt.Errorf("parsing stored token: %w", err) + return nil, fmt.Errorf("parsing stored token: %w", err) } - return &tok, store, nil + return &tok, nil } // DeleteToken removes the token for email from every store that holds it. From 1a1371b8aad307eb2e830ade87c8c33d1a526e25 Mon Sep 17 00:00:00 2001 From: Ed Umansky Date: Thu, 22 Oct 2026 20:19:25 -0600 Subject: [PATCH 7/7] Block auth export over MCP and clean every token store - MCP: refuse "auth export" from both tools so a client cannot read the refresh token without a person approving it. - Read-only purge: report a keyring that cannot be read even when the credentials file holds a copy, unless WEBEX_TOKEN_STORE=file. - Logout: delete the keyring and file copies whatever WEBEX_TOKEN_STORE says. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VqKdravxEAp9yQu31tuAR3 --- cmd/mcp/dispatcher.go | 25 ++++++++++++++------- cmd/mcp/dispatcher_test.go | 17 +++++++++++++++ internal/auth/readonly.go | 25 ++++++++++++++++----- internal/auth/readonly_test.go | 25 +++++++++++++++++++++ internal/auth/store.go | 40 +++++++++++++++++++--------------- internal/auth/store_test.go | 24 ++++++++++++++++++++ internal/auth/token.go | 2 +- 7 files changed, 127 insertions(+), 31 deletions(-) diff --git a/cmd/mcp/dispatcher.go b/cmd/mcp/dispatcher.go index d150877..c60096c 100644 --- a/cmd/mcp/dispatcher.go +++ b/cmd/mcp/dispatcher.go @@ -18,6 +18,7 @@ import ( var blockedPrefixes = []string{ "login", "logout", + "auth export", "auth switch", "auth set-org", "auth clear-org", @@ -121,14 +122,10 @@ func makeDispatchHandler(lg *usageLogger, readOnly bool) func(context.Context, m args := strings.Fields(command) - // Blocklist check — join up to 2 tokens to cover two-word prefixes like "auth switch". - prefix := strings.Join(args[:min(2, len(args))], " ") - for _, blocked := range blockedPrefixes { - if strings.HasPrefix(prefix, blocked) { - return mcplib.NewToolResultText(fmt.Sprintf( - "Error: command '%s' is not permitted via the MCP server.", blocked, - )), nil - } + if blocked := blockedCommand(args); blocked != "" { + return mcplib.NewToolResultText(fmt.Sprintf( + "Error: command '%s' is not permitted via the MCP server.", blocked, + )), nil } // Injection guard: reject tokens containing shell-special characters. @@ -219,6 +216,18 @@ func makeDispatchHandler(lg *usageLogger, readOnly bool) func(context.Context, m } } +// blockedCommand returns the blocked prefix that args start with, or "". Up to +// two tokens are joined to cover two-word prefixes like "auth switch". +func blockedCommand(args []string) string { + prefix := strings.Join(args[:min(2, len(args))], " ") + for _, blocked := range blockedPrefixes { + if strings.HasPrefix(prefix, blocked) { + return blocked + } + } + return "" +} + // actionToken returns the action verb from the command args. // For "calling people list", it returns "list" (positional index 2). // For two-token commands like "auth status", it returns "status" (positional index 1). diff --git a/cmd/mcp/dispatcher_test.go b/cmd/mcp/dispatcher_test.go index b882dd8..92fa8bb 100644 --- a/cmd/mcp/dispatcher_test.go +++ b/cmd/mcp/dispatcher_test.go @@ -1,11 +1,28 @@ package mcp import ( + "strings" "testing" "github.com/mark3labs/mcp-go/server" ) +func TestBlockedCommand(t *testing.T) { + cases := map[string]string{ + "auth export": "auth export", + "auth export --yes": "auth export", + "login": "login", + "auth status": "", + "calling people list": "", + "cc recordings export x": "", + } + for command, want := range cases { + if got := blockedCommand(strings.Fields(command)); got != want { + t.Errorf("blockedCommand(%q) = %q, want %q", command, got, want) + } + } +} + func TestReadOnlyServerOmitsWriteTool(t *testing.T) { for _, readOnly := range []bool{false, true} { s := server.NewMCPServer("test", "0", server.WithToolCapabilities(false)) diff --git a/internal/auth/readonly.go b/internal/auth/readonly.go index e2723a6..afb1849 100644 --- a/internal/auth/readonly.go +++ b/internal/auth/readonly.go @@ -19,18 +19,26 @@ func HasReadOnlyToken(email string) bool { // Read-only mode depends on this: the keyring and credentials file are readable // by any process running as the user, so a write-capable token left in either // would bypass the CLI's checks. Both stores are checked whatever -// $WEBEX_TOKEN_STORE selects. An entry that cannot be decoded is deleted too, +// $WEBEX_TOKEN_STORE selects, and a keyring that cannot be read is a failure +// unless that setting is "file". An entry that cannot be decoded is deleted too, // since it may hold write access, and so is the $WEBEX_REFRESH_TOKEN cache, // which read-only mode never uses. func PurgeWriteTokens(cfg *appconfig.Config) ([]string, error) { var removed, failed []string + var firstErr error for _, email := range cfg.UserEmails() { copies, err := readCopies(email, []string{StoreKeyring, StoreFile}) - if errors.Is(err, ErrTokenNotFound) { - continue + if errors.Is(err, errKeyringUnreadable) && keyringOptional() { + err = nil } - if err != nil { + if err != nil && !errors.Is(err, ErrTokenNotFound) { failed = append(failed, email) + if firstErr == nil { + firstErr = err + } + continue + } + if len(copies) == 0 { continue } kept, ok := 0, true @@ -41,6 +49,9 @@ func PurgeWriteTokens(cfg *appconfig.Config) ([]string, error) { } if err := deleteFrom(c.store, email); err != nil { ok = false + if firstErr == nil { + firstErr = err + } } } if !ok { @@ -55,7 +66,11 @@ func PurgeWriteTokens(cfg *appconfig.Config) ([]string, error) { sort.Strings(removed) if len(failed) > 0 { sort.Strings(failed) - return removed, fmt.Errorf("could not remove write-capable tokens for %v from the token store; remove them manually", failed) + hint := "remove them manually" + if errors.Is(firstErr, errKeyringUnreadable) { + hint += fmt.Sprintf(", or set $%s=file if this system has no keyring", TokenStoreEnv) + } + return removed, fmt.Errorf("could not remove write-capable tokens for %v from the token store (%v); %s", failed, firstErr, hint) } if err := DeleteEnvRefreshCache(); err != nil { return removed, fmt.Errorf("could not remove cached $%s tokens from %s: %w", RefreshTokenEnv, CredentialsPath(), err) diff --git a/internal/auth/readonly_test.go b/internal/auth/readonly_test.go index a709f62..fed121b 100644 --- a/internal/auth/readonly_test.go +++ b/internal/auth/readonly_test.go @@ -151,6 +151,31 @@ func TestPurgeWriteTokensReportsKeyringErrors(t *testing.T) { } } +func TestPurgeWriteTokensReportsKeyringErrorsBesideFileCopy(t *testing.T) { + keyring.MockInitWithError(errors.New("keychain locked")) + defer keyring.MockInit() + t.Cleanup(func() { os.Remove(CredentialsPath()) }) + if err := fileSet("ro@example.com", `{"read_only":true}`); err != nil { + t.Fatal(err) + } + + _, err := PurgeWriteTokens(testConfig("ro@example.com")) + if err == nil || !strings.Contains(err.Error(), "keychain locked") { + t.Errorf("err = %v, want the keyring error although the file holds a copy", err) + } +} + +func TestPurgeWriteTokensIgnoresMissingKeyringWithFileStore(t *testing.T) { + noKeyring(t) + defer keyring.MockInit() + t.Setenv(TokenStoreEnv, StoreFile) + saveTestToken(t, "ro@example.com", true) + + if _, err := PurgeWriteTokens(testConfig("ro@example.com", "gone@example.com")); err != nil { + t.Errorf("purge failed with $%s=file and no keyring: %v", TokenStoreEnv, err) + } +} + func TestResolveTokenReadOnly(t *testing.T) { keyring.MockInit() cfg := testConfig("ro@example.com", "rw@example.com") diff --git a/internal/auth/store.go b/internal/auth/store.go index e96d7d6..3dfa1b7 100644 --- a/internal/auth/store.go +++ b/internal/auth/store.go @@ -54,10 +54,14 @@ type storedCopy struct { data string } +// errKeyringUnreadable means the keyring failed for a reason other than a +// missing entry, so it may still hold the key. +var errKeyringUnreadable = errors.New("the keyring could not be read") + // readCopies returns the copies of key held by each of stores. A missing entry -// is not an error. A keyring that fails for any other reason may still hold the -// key, so its error is returned when no store produced a copy: reporting "not -// found" would be a guess, and read-only purging relies on that. +// is not an error, and no copies at all is ErrTokenNotFound. A keyring failure +// is returned as errKeyringUnreadable together with any copies the file holds: +// loading can use those, but read-only purging must not assume the keyring is empty. func readCopies(key string, stores []string) ([]storedCopy, error) { var copies []storedCopy var kerr error @@ -70,7 +74,7 @@ func readCopies(key string, stores []string) ([]storedCopy, error) { continue } if err != nil { - kerr = err + kerr = fmt.Errorf("%w: %w", errKeyringUnreadable, err) continue } } else { @@ -84,15 +88,23 @@ func readCopies(key string, stores []string) ([]storedCopy, error) { } copies = append(copies, storedCopy{store: store, data: data}) } + if kerr != nil { + return copies, kerr + } if len(copies) == 0 { - if kerr != nil { - return nil, fmt.Errorf("the keyring could not be read: %w", kerr) - } return nil, ErrTokenNotFound } return copies, nil } +// keyringOptional reports whether keyring failures can be ignored when cleaning +// up: with $WEBEX_TOKEN_STORE=file the user has said this host's keyring is not +// in use, and on a host without one every keyring call fails. +func keyringOptional() bool { + forced, err := forcedStore() + return err == nil && forced == StoreFile +} + // deleteFrom removes key from one store. func deleteFrom(store, key string) error { if store == StoreFile { @@ -132,20 +144,14 @@ func storeSet(key, data string) (string, error) { return StoreFile, nil } +// storeDelete removes key from both stores whatever $WEBEX_TOKEN_STORE says, +// since a copy saved under an earlier setting stays loadable. func storeDelete(key string) error { forced, err := forcedStore() if err != nil { return err } - var kerr error - if forced != StoreFile { - if kerr = keyring.Delete(serviceName, key); errors.Is(kerr, keyring.ErrNotFound) { - kerr = nil - } - if forced == StoreKeyring { - return kerr - } - } + kerr := deleteFrom(StoreKeyring, key) hadFile := false if _, err := fileGet(key); err == nil { hadFile = true @@ -155,7 +161,7 @@ func storeDelete(key string) error { } // Without a keyring, every delete fails there; that only matters when the // token was not in the file. - if hadFile { + if forced == StoreFile || (hadFile && forced != StoreKeyring) { return nil } return kerr diff --git a/internal/auth/store_test.go b/internal/auth/store_test.go index 36a3476..d0050f8 100644 --- a/internal/auth/store_test.go +++ b/internal/auth/store_test.go @@ -89,6 +89,30 @@ func TestStoreForcedFileSkipsKeyring(t *testing.T) { } } +func TestDeleteTokenRemovesEveryCopy(t *testing.T) { + for _, forced := range []string{"", StoreKeyring, StoreFile} { + keyring.MockInit() + t.Cleanup(func() { os.Remove(CredentialsPath()) }) + t.Setenv(TokenStoreEnv, forced) + if err := keyring.Set(serviceName, "a@example.com", `{"access_token":"k"}`); err != nil { + t.Fatal(err) + } + if err := fileSet("a@example.com", `{"access_token":"f"}`); err != nil { + t.Fatal(err) + } + + if err := DeleteToken("a@example.com"); err != nil { + t.Fatalf("store %q: %v", forced, err) + } + if _, err := keyring.Get(serviceName, "a@example.com"); !errors.Is(err, keyring.ErrNotFound) { + t.Errorf("store %q: keyring copy survived: %v", forced, err) + } + if _, err := fileGet("a@example.com"); !errors.Is(err, ErrTokenNotFound) { + t.Errorf("store %q: file copy survived: %v", forced, err) + } + } +} + func TestStoreRejectsUnknownStore(t *testing.T) { t.Setenv(TokenStoreEnv, "vault") if err := SaveToken("a@example.com", &StoredToken{}); err == nil { diff --git a/internal/auth/token.go b/internal/auth/token.go index b4b28e9..365a7e7 100644 --- a/internal/auth/token.go +++ b/internal/auth/token.go @@ -63,7 +63,7 @@ func LoadTokenWithStore(email string) (*StoredToken, string, error) { return nil, "", err } copies, err := readCopies(email, stores) - if err != nil { + if len(copies) == 0 { return nil, "", fmt.Errorf("loading token for %s: %w", email, err) } var best *StoredToken