github.com/anthropics/anthropic-sdk-go@v1.70.1/config/writers.go (about)

     1  package config
     2  
     3  import (
     4  	"encoding/json"
     5  	"fmt"
     6  	"io/fs"
     7  	"os"
     8  	"path/filepath"
     9  	"sort"
    10  	"strings"
    11  	"time"
    12  )
    13  
    14  const credentialsTypeOAuthToken = "oauth_token"
    15  
    16  // CredentialsFileVersion is the version written to credentials/<profile>.json.
    17  // Absent on read implies "1.0". Major.minor format: bump major on
    18  // backwards-incompatible shape changes, minor on additive changes.
    19  const CredentialsFileVersion = "1.0"
    20  
    21  // Credentials is the in-memory form of a credentials/<profile>.json file.
    22  // Not every field is populated for every authentication variant: federation
    23  // grants, for example, do not return a refresh token.
    24  type Credentials struct {
    25  	AccessToken  string
    26  	RefreshToken string
    27  	// ExpiresAt is the absolute expiry time of AccessToken. Nil means the
    28  	// token's lifetime is unknown to the SDK (treated as non-expiring by
    29  	// the in-memory cache).
    30  	ExpiresAt *time.Time
    31  
    32  	// Scope, OrganizationUUID, OrganizationName, and AccountEmail record
    33  	// what the current AccessToken was actually granted/minted for. They are
    34  	// written on every login and reflect the token's view of the world at
    35  	// mint time. This is distinct from [Config.OrganizationID] on the profile
    36  	// config side, which is the user's intended target org; the two may
    37  	// diverge if the user is reassigned or the token was minted before the
    38  	// profile was edited.
    39  	Scope            string
    40  	OrganizationUUID string
    41  	OrganizationName string
    42  	// AccountEmail is the email of the account that minted the token, taken
    43  	// from the /v1/oauth/token response's account.email_address.
    44  	AccountEmail string
    45  	// WorkspaceID and WorkspaceName record the workspace the token was
    46  	// bound to at mint time (when the authorization carried one). Stored
    47  	// as the tagged `wrkspc_...` form — the same format the CLI flag,
    48  	// profile config, and anthropic-workspace-id header accept. Sourced
    49  	// from the /v1/oauth/token response's workspace.id. Empty for tokens
    50  	// that aren't workspace-scoped.
    51  	WorkspaceID   string
    52  	WorkspaceName string
    53  }
    54  
    55  type credentialsWireShape struct {
    56  	Version          string `json:"version,omitempty"`
    57  	Type             string `json:"type"`
    58  	AccessToken      string `json:"access_token"`
    59  	ExpiresAt        *int64 `json:"expires_at,omitempty"`
    60  	RefreshToken     string `json:"refresh_token,omitempty"`
    61  	Scope            string `json:"scope,omitempty"`
    62  	OrganizationUUID string `json:"organization_uuid,omitempty"`
    63  	OrganizationName string `json:"organization_name,omitempty"`
    64  	AccountEmail     string `json:"account_email,omitempty"`
    65  	WorkspaceID      string `json:"workspace_id,omitempty"`
    66  	WorkspaceName    string `json:"workspace_name,omitempty"`
    67  }
    68  
    69  func (c Credentials) MarshalJSON() ([]byte, error) {
    70  	w := credentialsWireShape{
    71  		Version:          CredentialsFileVersion,
    72  		Type:             credentialsTypeOAuthToken,
    73  		AccessToken:      c.AccessToken,
    74  		RefreshToken:     c.RefreshToken,
    75  		Scope:            c.Scope,
    76  		OrganizationUUID: c.OrganizationUUID,
    77  		OrganizationName: c.OrganizationName,
    78  		AccountEmail:     c.AccountEmail,
    79  		WorkspaceID:      c.WorkspaceID,
    80  		WorkspaceName:    c.WorkspaceName,
    81  	}
    82  	if c.ExpiresAt != nil {
    83  		exp := c.ExpiresAt.Unix()
    84  		w.ExpiresAt = &exp
    85  	}
    86  	return json.Marshal(w)
    87  }
    88  
    89  // UnmarshalJSON decodes a credentials/<profile>.json file. Unknown fields
    90  // are silently tolerated per the credentials-file-format spec. A missing
    91  // "type" is treated as equivalent to "oauth_token" — this is a concession
    92  // to interop with external tooling (credential daemons, sidecars) that
    93  // may write plain bearer-token blobs without the discriminator. Every
    94  // SDK writer emits "type" on write, so missing-type files can only come
    95  // from outside the SDK; rejecting them would break valid integrations.
    96  // A type that is set but not "oauth_token" still fails loud.
    97  func (c *Credentials) UnmarshalJSON(data []byte) error {
    98  	var w credentialsWireShape
    99  	if err := json.Unmarshal(data, &w); err != nil {
   100  		return err
   101  	}
   102  	if w.Type != "" && w.Type != credentialsTypeOAuthToken {
   103  		return fmt.Errorf("credentials: unknown type %q", w.Type)
   104  	}
   105  	c.AccessToken = w.AccessToken
   106  	c.RefreshToken = w.RefreshToken
   107  	c.Scope = w.Scope
   108  	c.OrganizationUUID = w.OrganizationUUID
   109  	c.OrganizationName = w.OrganizationName
   110  	c.AccountEmail = w.AccountEmail
   111  	c.WorkspaceID = w.WorkspaceID
   112  	c.WorkspaceName = w.WorkspaceName
   113  	c.ExpiresAt = nil
   114  	if w.ExpiresAt != nil {
   115  		exp := time.Unix(*w.ExpiresAt, 0)
   116  		c.ExpiresAt = &exp
   117  	}
   118  	return nil
   119  }
   120  
   121  // DefaultDir returns the SDK's default configuration directory (the same
   122  // one [LoadConfig] reads from when ANTHROPIC_CONFIG_DIR is unset). Returns
   123  // an empty string if the platform home directory cannot be resolved — the
   124  // writers in this package surface that as an explicit error.
   125  func DefaultDir() string { return defaultConfigDir() }
   126  
   127  // configsSubdir and credentialsSubdir are the directories under the config
   128  // root that hold one JSON file per profile. Defined once here so the layout
   129  // has a single source of truth across path helpers and [ListProfiles].
   130  const (
   131  	configsSubdir     = "configs"
   132  	credentialsSubdir = "credentials"
   133  )
   134  
   135  // ProfilesDir returns the directory containing profile config JSON files.
   136  func ProfilesDir(dir string) string { return filepath.Join(dir, configsSubdir) }
   137  
   138  // CredentialsDir returns the directory containing per-profile credentials files.
   139  func CredentialsDir(dir string) string { return filepath.Join(dir, credentialsSubdir) }
   140  
   141  // ProfilePath returns the path to configs/<profile>.json under dir.
   142  func ProfilePath(dir, profile string) string {
   143  	return filepath.Join(ProfilesDir(dir), profile+".json")
   144  }
   145  
   146  // ProfileCredentialsPath returns the path to credentials/<profile>.json under dir.
   147  func ProfileCredentialsPath(dir, profile string) string {
   148  	return filepath.Join(CredentialsDir(dir), profile+".json")
   149  }
   150  
   151  // ActiveConfigPath returns the path to the active_config pointer file under dir.
   152  func ActiveConfigPath(dir string) string {
   153  	return filepath.Join(dir, "active_config")
   154  }
   155  
   156  // ListProfiles returns the names of all profiles stored under dir's configs
   157  // subdirectory, sorted for stable output. A missing configs directory is
   158  // treated as "no profiles" and returns a nil slice with no error, so callers
   159  // can enumerate a fresh config root without special-casing first run.
   160  func ListProfiles(dir string) ([]string, error) {
   161  	entries, err := os.ReadDir(ProfilesDir(dir))
   162  	if err != nil {
   163  		if os.IsNotExist(err) {
   164  			return nil, nil
   165  		}
   166  		return nil, err
   167  	}
   168  	var names []string
   169  	for _, e := range entries {
   170  		if e.IsDir() {
   171  			continue
   172  		}
   173  		name, ok := strings.CutSuffix(e.Name(), ".json")
   174  		if !ok || name == "" {
   175  			continue
   176  		}
   177  		names = append(names, name)
   178  	}
   179  	sort.Strings(names)
   180  	return names, nil
   181  }
   182  
   183  // Profile filesystem modes. Configs are non-secret and use the "public"
   184  // mode so another UID on the same host (e.g. a sidecar, or `ant profile
   185  // list` running as a different user inside a pod) can read them;
   186  // credentials are secret and use the tighter mode.
   187  //
   188  // These track the credentials-file-format spec's split rationale:
   189  // configs/ (0755 dirs, 0644 files — checkin-safe) vs credentials/
   190  // (0700 dirs, 0600 files — secrets).
   191  const (
   192  	publicDirMode  os.FileMode = 0755
   193  	publicFileMode os.FileMode = 0644
   194  	secretDirMode  os.FileMode = 0700
   195  	secretFileMode os.FileMode = 0600
   196  )
   197  
   198  // writeFileAtomic writes to a unique sibling tmp file then renames onto
   199  // the target (last-writer-wins). Tmp name is unique per write so
   200  // concurrent writers don't collide on the temp; mode is Chmod'd
   201  // explicitly so umask can't loosen secrets; tmp and parent dir are
   202  // fsync'd for crash durability; tmp is removed on any failure.
   203  func writeFileAtomic(path string, data []byte, dirMode, fileMode os.FileMode) error {
   204  	dir := filepath.Dir(path)
   205  	if err := os.MkdirAll(dir, dirMode); err != nil {
   206  		return err
   207  	}
   208  
   209  	// Base prefix is for debuggability (which target a leftover tmp belongs to).
   210  	f, err := os.CreateTemp(dir, filepath.Base(path)+".*.tmp")
   211  	if err != nil {
   212  		return err
   213  	}
   214  	tmpPath := f.Name()
   215  	// From here on, any failure must remove the tmp file.
   216  	cleanup := func(err error) error {
   217  		os.Remove(tmpPath)
   218  		return err
   219  	}
   220  	if _, err := f.Write(data); err != nil {
   221  		f.Close()
   222  		return cleanup(err)
   223  	}
   224  	// CreateTemp defaults to 0600; Chmod enforces fileMode for both paths.
   225  	if err := f.Chmod(fileMode); err != nil {
   226  		f.Close()
   227  		return cleanup(err)
   228  	}
   229  	if err := f.Sync(); err != nil {
   230  		f.Close()
   231  		return cleanup(err)
   232  	}
   233  	if err := f.Close(); err != nil {
   234  		return cleanup(err)
   235  	}
   236  	// Refuse a pre-planted symlink at the target — would let an attacker
   237  	// redirect the bearer-token write to a path of their choosing.
   238  	if fi, err := os.Lstat(path); err == nil && fi.Mode()&fs.ModeSymlink != 0 {
   239  		return cleanup(fmt.Errorf("refusing to write %s: target is a symlink", path))
   240  	}
   241  	if err := os.Rename(tmpPath, path); err != nil {
   242  		return cleanup(err)
   243  	}
   244  	// fsync the parent directory so the rename is durable across a crash.
   245  	// On Windows this is a no-op / unsupported — the Sync() call may
   246  	// return an error, which we swallow because the rename itself is
   247  	// already visible to other processes.
   248  	if d, err := os.Open(dir); err == nil {
   249  		_ = d.Sync()
   250  		d.Close()
   251  	}
   252  	return nil
   253  }
   254  
   255  // validateDirAndProfile rejects empty dirs and unsafe profile names so
   256  // callers that pass zero values or untrusted input get a clean error
   257  // instead of a path-traversal.
   258  func validateDirAndProfile(dir, profile string) error {
   259  	if dir == "" {
   260  		return fmt.Errorf("config dir is empty (DefaultDir returned empty; set ANTHROPIC_CONFIG_DIR)")
   261  	}
   262  	if err := validateProfileName(profile); err != nil {
   263  		return err
   264  	}
   265  	return nil
   266  }
   267  
   268  // SaveProfile persists cfg to configs/<profile>.json under dir. The write
   269  // is atomic (.tmp sibling + rename); the target file is mode 0644 and the
   270  // configs/ parent is 0755, matching the spec's "non-secret, checkin-safe"
   271  // positioning for config files (other UIDs on the host — a sidecar, or
   272  // the CLI running under a different user inside a pod — must be able to
   273  // read them).
   274  //
   275  // If cfg.AuthenticationInfo.CredentialsPath exactly matches the default
   276  // resolved path for this profile (i.e. it was populated by LoadConfig
   277  // defaulting the value, not set explicitly by the caller), it is cleared
   278  // on write. Otherwise a load → save round-trip would pin the profile
   279  // config file to the current $HOME via an absolute path, breaking the
   280  // "checkin-safe, relocatable" design goal.
   281  func SaveProfile(dir, profile string, cfg *Config) error {
   282  	if err := validateDirAndProfile(dir, profile); err != nil {
   283  		return fmt.Errorf("SaveProfile: %w", err)
   284  	}
   285  	if cfg == nil {
   286  		return fmt.Errorf("SaveProfile: cfg is nil")
   287  	}
   288  	if cfg.AuthenticationInfo == nil {
   289  		return fmt.Errorf("SaveProfile: cfg.AuthenticationInfo is required")
   290  	}
   291  
   292  	toWrite := *cfg
   293  	toWrite.Version = ConfigFileVersion
   294  	authCopy := *cfg.AuthenticationInfo
   295  	toWrite.AuthenticationInfo = &authCopy
   296  	if authCopy.CredentialsPath == ProfileCredentialsPath(dir, profile) {
   297  		authCopy.CredentialsPath = ""
   298  	}
   299  
   300  	body, err := json.MarshalIndent(&toWrite, "", "  ")
   301  	if err != nil {
   302  		return fmt.Errorf("SaveProfile: marshal: %w", err)
   303  	}
   304  	target := ProfilePath(dir, profile)
   305  	if err := writeFileAtomic(target, body, publicDirMode, publicFileMode); err != nil {
   306  		return fmt.Errorf("SaveProfile: write %q: %w", target, err)
   307  	}
   308  	return nil
   309  }
   310  
   311  // WriteCredentials persists creds to path atomically. The target file is
   312  // mode 0600 and the parent directory 0700. Callers typically build path
   313  // with [ProfileCredentialsPath]; the signature takes a raw path so tests
   314  // and non-profile layouts can target arbitrary locations.
   315  func WriteCredentials(path string, creds Credentials) error {
   316  	if path == "" {
   317  		return fmt.Errorf("WriteCredentials: path is empty")
   318  	}
   319  	body, err := json.MarshalIndent(creds, "", "  ")
   320  	if err != nil {
   321  		return fmt.Errorf("WriteCredentials: marshal: %w", err)
   322  	}
   323  	if err := writeFileAtomic(path, body, secretDirMode, secretFileMode); err != nil {
   324  		return fmt.Errorf("WriteCredentials: write %q: %w", path, err)
   325  	}
   326  	return nil
   327  }
   328  
   329  // SetActiveProfile writes the active_config pointer under dir so that
   330  // subsequent [LoadConfig] calls (without ANTHROPIC_PROFILE set) resolve
   331  // to profile. The pointer file sits next to configs/ and is written with
   332  // the "public" config modes.
   333  func SetActiveProfile(dir, profile string) error {
   334  	if err := validateDirAndProfile(dir, profile); err != nil {
   335  		return fmt.Errorf("SetActiveProfile: %w", err)
   336  	}
   337  	target := ActiveConfigPath(dir)
   338  	if err := writeFileAtomic(target, []byte(profile+"\n"), publicDirMode, publicFileMode); err != nil {
   339  		return fmt.Errorf("SetActiveProfile: write %q: %w", target, err)
   340  	}
   341  	return nil
   342  }
   343  
   344  // DeleteProfile removes configs/<profile>.json and credentials/<profile>.json
   345  // under dir. If active_config currently points at profile, the pointer file
   346  // is also cleared so the next [LoadConfig] call falls back to "default".
   347  // Missing files are not an error — DeleteProfile is idempotent.
   348  func DeleteProfile(dir, profile string) error {
   349  	if err := validateDirAndProfile(dir, profile); err != nil {
   350  		return fmt.Errorf("DeleteProfile: %w", err)
   351  	}
   352  
   353  	for _, p := range []string{
   354  		ProfilePath(dir, profile),
   355  		ProfileCredentialsPath(dir, profile),
   356  	} {
   357  		if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
   358  			return fmt.Errorf("DeleteProfile: remove %q: %w", p, err)
   359  		}
   360  	}
   361  
   362  	if readActiveConfigPointer(dir) == profile {
   363  		if err := os.Remove(ActiveConfigPath(dir)); err != nil && !os.IsNotExist(err) {
   364  			return fmt.Errorf("DeleteProfile: clear active_config: %w", err)
   365  		}
   366  	}
   367  	return nil
   368  }
   369  
   370  func readActiveConfigPointer(dir string) string {
   371  	data, err := os.ReadFile(ActiveConfigPath(dir))
   372  	if err != nil {
   373  		return ""
   374  	}
   375  	return strings.TrimSpace(string(data))
   376  }