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 }