Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -346,6 +346,12 @@ Personal overrides, gitignored by default:
| `strategy_options.summarize.enabled` | `true`, `false` | Auto-generate AI summaries at commit time |
| `telemetry` | `true`, `false` | Send anonymous usage statistics to Posthog |

### Authentication Token Storage

By default, `entire login` stores the CLI API token in your operating system keyring. For CI, set `ENTIRE_AUTH_TOKEN` to a CLI API token. For headless machines that need persistent login, set `ENTIRE_SECRETS_PATH` to an absolute path before running `entire login`; the file is plaintext JSON written with `0600` permissions. Pass `--no-keyring` to `entire login` if you want the command to fail closed when `ENTIRE_SECRETS_PATH` is missing instead of silently falling back to the OS keyring.

`ENTIRE_AUTH_TOKEN` takes precedence over tokens stored in `ENTIRE_SECRETS_PATH`, and the file store takes precedence over the OS keyring. `ENTIRE_AUTH_TOKEN` only applies when the API origin is the production default (`https://entire.io`); custom origins set via `ENTIRE_API_BASE_URL` must use the per-origin file or keyring stores so a stray override can't leak a prod bearer to a staging endpoint. `ENTIRE_CHECKPOINT_TOKEN` is separate and only applies to checkpoint Git fetch/push operations.

### Agent Hook Configuration

Each agent stores its hook configuration in its own directory. When you run `entire enable`, hooks are installed in the appropriate location for each selected agent:
Expand Down
107 changes: 88 additions & 19 deletions cmd/entire/cli/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,32 +96,51 @@ func defaultListTokens(ctx context.Context, token string) ([]api.Token, error) {
}

func runAuthStatus(ctx context.Context, w io.Writer, store tokenStore, list authTokenLister, baseURL string) error {
token, err := store.GetToken(baseURL)
info, err := store.GetTokenInfo(baseURL)
if err != nil {
return fmt.Errorf("read keychain: %w", err)
return fmt.Errorf("read auth token: %w", err)
}
if token == "" {
if info.Value == "" {
fmt.Fprintf(w, "Not logged in to %s\n", baseURL)
fmt.Fprintln(w, "Run 'entire login' to authenticate.")
return nil
}

tokens, err := list(ctx, token)
tokens, err := list(ctx, info.Value)
if err != nil {
if api.IsHTTPErrorStatus(err, http.StatusUnauthorized) {
fmt.Fprintf(w, "Token in keychain for %s is no longer valid.\n", baseURL)
fmt.Fprintln(w, "Run 'entire login' to re-authenticate.")
fmt.Fprintf(w, "Token for %s is no longer valid.\n", baseURL)
if info.Source == auth.TokenSourceEnv {
fmt.Fprintf(w, "Update or unset %s to re-authenticate.\n", auth.AuthTokenEnvVar)
} else {
fmt.Fprintln(w, "Run 'entire login' to re-authenticate.")
}
return nil
}
return fmt.Errorf("validate token: %w", err)
}

fmt.Fprintf(w, "Logged in to %s\n", baseURL)
fmt.Fprintln(w, " Token: stored in OS keychain")
fmt.Fprintf(w, " Token: %s\n", describeTokenSource(info))
fmt.Fprintf(w, " Active tokens on this account: %d\n", len(tokens))
return nil
}

func describeTokenSource(info auth.TokenInfo) string {
switch info.Source {
case auth.TokenSourceNone:
return "not configured"
case auth.TokenSourceEnv:
return "supplied by " + auth.AuthTokenEnvVar
case auth.TokenSourceFile:
return "stored in file " + info.Path
case auth.TokenSourceKeyring:
return "stored in OS keychain"
default:
return "stored"
}
}

// --- list -------------------------------------------------------------------

func newAuthListCmd() *cobra.Command {
Expand All @@ -146,7 +165,7 @@ func newAuthListCmd() *cobra.Command {
func runAuthList(ctx context.Context, w io.Writer, store tokenStore, list authTokenLister, baseURL string, jsonOut bool) error {
token, err := store.GetToken(baseURL)
if err != nil {
return fmt.Errorf("read keychain: %w", err)
return fmt.Errorf("read auth token: %w", err)
}
if token == "" {
return fmt.Errorf("not logged in to %s; run 'entire login' first", baseURL)
Expand Down Expand Up @@ -391,7 +410,7 @@ func newAuthRevokeCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "revoke [id]",
Short: "Revoke an API token by id",
Long: "Revoke a specific API token. Use --current to revoke the token used by this CLI (equivalent to 'entire logout').",
Long: "Revoke a specific API token. Use --current to revoke the token used by this CLI.",
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
id := ""
Expand All @@ -412,7 +431,7 @@ func newAuthRevokeCmd() *cobra.Command {
api.BaseURL(), id, revokeCurrent)
},
}
cmd.Flags().BoolVar(&revokeCurrent, "current", false, "Revoke the token used by this CLI and remove the local copy")
cmd.Flags().BoolVar(&revokeCurrent, "current", false, "Revoke the token used by this CLI and remove the local copy when stored locally")
addInsecureHTTPAuthFlag(cmd, &insecureHTTPAuth)
return cmd
}
Expand All @@ -431,35 +450,85 @@ func runAuthRevoke(
baseURL, id string,
current bool,
) error {
token, err := store.GetToken(baseURL)
info, err := store.GetTokenInfo(baseURL)
if err != nil {
return fmt.Errorf("read keychain: %w", err)
return fmt.Errorf("read auth token: %w", err)
}
if token == "" {
if info.Value == "" {
return fmt.Errorf("not logged in to %s; run 'entire login' first", baseURL)
}

if current {
// Revoking our own token is just logout — reuse that path so behavior
// stays identical (best-effort revoke + local delete).
return runLogout(ctx, outW, errW, store, revokeCurrent, baseURL)
return revokeCurrentToken(ctx, outW, errW, store, revokeCurrent, baseURL, info)
}

if err := revokeByID(ctx, token, id); err != nil {
if err := revokeByID(ctx, info.Value, id); err != nil {
return err
}

// The list endpoint requires bearer auth, so a 401 here means the id we
// just revoked was the same one this CLI is using — the keychain entry is
// now stale and would otherwise produce confusing 401s on every command.
if _, listErr := list(ctx, token); listErr != nil && api.IsHTTPErrorStatus(listErr, http.StatusUnauthorized) {
if _, listErr := list(ctx, info.Value); listErr != nil && api.IsHTTPErrorStatus(listErr, http.StatusUnauthorized) {
if info.Source == auth.TokenSourceEnv {
fmt.Fprintf(outW, "Revoked token %s (this was supplied by %s; unset it to stop using it locally).\n", id, auth.AuthTokenEnvVar)
return nil
}
if delErr := store.DeleteToken(baseURL); delErr != nil {
return fmt.Errorf("revoked token %s but failed to remove local copy: %w", id, delErr)
}
fmt.Fprintf(outW, "Revoked token %s (this was your local token; removed from keychain).\n", id)
fmt.Fprintf(outW, "Revoked token %s (this was your local token; removed from %s).\n", id, localTokenSourceName(info))
return nil
}

fmt.Fprintf(outW, "Revoked token %s.\n", id)
return nil
}

func revokeCurrentToken(
ctx context.Context,
outW, errW io.Writer,
store tokenStore,
revoke revokeCurrentFunc,
baseURL string,
info auth.TokenInfo,
) error {
if info.Source == auth.TokenSourceEnv {
// Env tokens have no local copy to fall back on, so a non-401 server
// failure means the token is still active — we MUST NOT print
// "Revoked" and exit zero in that case. 401 is treated as idempotent
// success (the token was already invalid server-side).
if err := revoke(ctx, info.Value); err != nil {
if !api.IsHTTPErrorStatus(err, http.StatusUnauthorized) {
return fmt.Errorf("revoke current token: %w", err)
}
}
fmt.Fprintf(outW, "Revoked current token supplied by %s. Unset it to stop using it locally.\n", auth.AuthTokenEnvVar)
return nil
}

if err := revoke(ctx, info.Value); err != nil && !api.IsHTTPErrorStatus(err, http.StatusUnauthorized) {
fmt.Fprintf(errW, "Warning: server-side token revocation failed: %v\n", err)
}
if err := store.DeleteToken(baseURL); err != nil {
return fmt.Errorf("remove auth token: %w", err)
}

fmt.Fprintln(outW, "Logged out.")
return nil
}

func localTokenSourceName(info auth.TokenInfo) string {
switch info.Source {
case auth.TokenSourceFile:
return "file"
case auth.TokenSourceKeyring:
return "keychain"
case auth.TokenSourceEnv:
return auth.AuthTokenEnvVar
case auth.TokenSourceNone:
return "local storage"
default:
return "local storage"
}
}
35 changes: 35 additions & 0 deletions cmd/entire/cli/auth/env.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package auth

import (
"os"
"strings"
)

const (
// AuthTokenEnvVar provides a read-only bearer token for non-interactive CLI auth.
AuthTokenEnvVar = "ENTIRE_AUTH_TOKEN" // #nosec G101 -- this is an environment variable name, not a credential.

// SecretsPathEnvVar points to an opt-in file-backed token store.
SecretsPathEnvVar = "ENTIRE_SECRETS_PATH"
)

// TokenSource identifies where the active auth token came from.
type TokenSource string

const (
TokenSourceNone TokenSource = ""
TokenSourceEnv TokenSource = "env"
TokenSourceFile TokenSource = "file"
TokenSourceKeyring TokenSource = "keyring"
)

// TokenInfo is a source-aware auth token lookup result.
type TokenInfo struct {
Value string
Source TokenSource
Path string
}

func envAuthToken() string {
return strings.TrimSpace(os.Getenv(AuthTokenEnvVar))
}
Loading
Loading