profiles

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jun 21, 2026 License: MIT Imports: 11 Imported by: 0

Documentation

Overview

Package profiles implements ccwrap's named provider/model profiles (profiles.json) — the foundation of the Provider Profiles feature.

Local-trust credential philosophy: a profile MAY carry an inline secret (Auth.Key) OR an env reference (Auth.KeyEnv). ccwrap reads the file; it NEVER rewrites secrets back into it. No keychain is mandated. The one hard invariant is the product's function, not a user restriction: every profile activation still passes the preflight hidden-auth / fail-closed validation, an invalid/unresolvable profile refuses to launch, and credential VALUES never leave the proxy boundary (never to HTML/JS, /recent, or any control response).

Absent file => behavior is exactly today's (inherit-env); the zero-touch first launch is unchanged.

Package profiles validation.

validate.go owns:

  • ValidationError + ParseErrors types (multi-error report)
  • ErrValidationFailed sentinel
  • exported Validate(*File, sourceLabel) error
  • private validate(*File, source) *ParseErrors called by Parse
  • private scrubURL helper (modeled on internal/preflight/safeview.go::stripUserinfo — duplicated here because supervisor->profiles cycle blocks direct reuse)

Index

Constants

View Source
const InheritEnv = "inherit-env"

InheritEnv is the sentinel "default" value meaning "no profile — resolve from the ambient environment exactly as ccwrap does today".

View Source
const OfficialProfileName = "official"

OfficialProfileName is the reserved name for the auto-created "official Anthropic" profile. EnsureOfficialProfile creates an entry with this name on first launch; users may customize or delete it, but restart restores presence (not content).

Variables

View Source
var ErrValidationFailed = errors.New("profiles.json validation failed")

ErrValidationFailed is a sentinel for errors.Is matching against any *ParseErrors with at least one ValidationError.

Functions

func DefaultPath

func DefaultPath(stateDir string) string

DefaultPath returns the profiles.json location. ccwrap has no general user-config dir; the persistent convention ccwrap already uses is app.Paths.StateDir (see internal/app/paths.go: the CA lives at filepath.Join(stateDir, "certs")). profiles.json sits beside it at filepath.Join(stateDir, "profiles.json"). Honors CCWRAP_STATE_DIR via the caller passing the resolved app.Paths.StateDir.

func EnsureOfficialProfile

func EnsureOfficialProfile(stateDir string) error

EnsureOfficialProfile guarantees that the profiles file at the canonical path contains an "official" entry:

  • File doesn't exist → create {default: official, profiles: {official}}
  • File exists, no official entry → add official; do NOT touch Default
  • File exists, official entry present → no-op (preserve customizations)
  • File has Default == InheritEnv (legacy) → migrate Default to "official"

Uses a raw JSON parse path (NOT Load) for the existing-file case so legacy files with Default=inherit-env (which Validate rejects) can still be migrated. Atomic write via OverwriteFile (or WriteFile for fresh install) ensures the post-state passes Validate.

Returns error only on I/O failures. Idempotent: safe to call on every launch.

func Lock

func Lock(stateDir string) (unlock func(), err error)

Lock acquires an exclusive, cross-process advisory lock guarding the load → mutate → OverwriteFile sequence on profiles.json. Callers MUST hold it across the whole sequence and release the returned unlock func (defer it) once the write completes.

Why this is needed: profiles.json is a single file shared by every ccwrap process — concurrent `ccwrap` supervisors and the `ccwrap profile` CLI all read-modify-write it. The supervisor's in-process profileFileMu (internal/supervisor/server.go) only serializes goroutines within one process; it cannot see another process. Without this lock two processes each Load the same snapshot, mutate independently, and the second OverwriteFile clobbers the first — a silently lost update.

The lock is held on a sidecar file under <stateDir>/locks (the same convention as the CA lock; see internal/certs/ca.go's acquireLock), NOT on profiles.json itself: OverwriteFile replaces profiles.json via an atomic rename, which swaps the inode out from under any lock held on the original file's fd. flock is associated with the open file description, so two acquisitions from the same process (each opening its own fd) also serialize — making this the cross-process complement to profileFileMu.

func OverwriteFile

func OverwriteFile(path string, f *File, sourceLabel string) error

OverwriteFile validates f and atomically replaces path with the marshaled JSON.

func Validate

func Validate(f *File, sourceLabel string) error

Validate runs semantic invariant checks against an already-parsed *File (e.g., one built in memory by the add/edit flow before write). Returns nil on clean, *ParseErrors with all violations otherwise. sourceLabel is rendered into multi-line output as "<sourceLabel> invalid: N errors" — callers brand it for context (e.g. "edit profile glm" or "preview profiles.json").

func ValidateEgressSpec

func ValidateEgressSpec(spec EgressSpec) error

ValidateEgressSpec runs the egress invariants from validateEgress against a single EgressSpec value (no surrounding Profile/File). Returns a *ParseErrors with one Item on failure, nil on success.

Used by HTTP handlers (e.g. /profile/test-egress) that need to validate a draft EgressSpec from a request body without constructing a synthetic *File. Returned ValidationError.Path is rooted at "egress.mode" / "egress.url" — callers may rewrap with a richer path if they want to expose the surrounding profile name.

func WriteFile

func WriteFile(path string, f *File) error

WriteFile atomically persists a *File to path via O_CREATE|O_EXCL — the EXCL guards against a race where profiles.json appears between the seed trigger detection (os.Stat returned NotExist) and this write. Perms are 0o600 (owner-only): profiles.json now carries the credential VALUE inline (see SeedSpec docstring), so the file is a secret-bearing surface and must match the trust posture of any other credential file on the user's machine.

On EEXIST (race lost), returns a wrapped error that satisfies errors.Is(err, fs.ErrExist) == true. WriteFile uses fmt.Errorf %w to preserve the chain so the caller can branch on the race condition.

Types

type AuthSpec

type AuthSpec struct {
	Mode   string `json:"mode"`
	Key    string `json:"key,omitempty"`
	KeyEnv string `json:"key_env,omitempty"`
}

AuthSpec is a profile's upstream-auth posture. Mode is "ccwrap_bearer" or "ccwrap_x_api_key". Key is an inline secret (permitted under local trust); KeyEnv is an env reference (recommended, not mandated). ccwrap never rewrites either back into profiles.json.

A profile that does not want ccwrap to inject auth omits the auth block entirely (Profile.Auth == nil). The pointer makes "present vs absent" a compile-time-checkable distinction.

type EgressSpec

type EgressSpec struct {
	Mode string `json:"mode"`
	URL  string `json:"url,omitempty"`
}

EgressSpec mirrors the inputs ResolveEgressFromInspection accepts. Mode is "inherit" | "direct" | "http"; URL is required for "http".

type File

type File struct {
	Default  string             `json:"default"`
	Profiles map[string]Profile `json:"profiles"`
}

File is the parsed profiles.json. Default is a profile name or the InheritEnv sentinel.

func Load

func Load(path string) (*File, error)

Load reads profiles.json. A missing file returns (nil, nil) — that is the zero-touch path: behavior is exactly today's inherit-env.

func Parse

func Parse(data []byte, source string) (*File, error)

Parse decodes profiles.json. The model_aliases submap of each profile is coerced through modelalias.ParseJSON so it shares the exact alias-coercion rules (and error messages) used everywhere else in ccwrap. The Profile.Name is injected from its map key.

func SeedFromEnv

func SeedFromEnv(spec SeedSpec) (*File, error)

SeedFromEnv builds a single-profile *File from a SeedSpec, suitable to pass to WriteFile. The resulting File has:

  • Default: spec.Name (so profile selection picks it up next launch)
  • Profiles: { spec.Name: <derived Profile> }

Auth precedence: API_KEY beats AUTH_TOKEN beats neither. Both flags false → Profile.Auth nil (ccwrap does not own auth; the upstream client's own auth flow takes over at request time).

Returns error on a malformed BaseURL (url.Parse fail), empty Name, or the reserved sentinel name "inherit-env".

func (*File) Select

func (f *File) Select(requested string) (*Profile, bool, error)

Select resolves a request to a concrete profile, implementing the selection rule. requested is:

  • "" → the persisted Default ("" Default treated as InheritEnv); InheritEnv → (nil, true, nil)
  • "inherit-env" → (nil, true, nil) explicitly
  • an exact name → that profile
  • a provider/group→ that group's default: the persisted Default if it is in the group, else the sole profile if the group has exactly one, else an ambiguous error listing the group's profiles
  • unknown → error naming the request

A nil *File (no profiles.json) with requested=="" or InheritEnv is inherit-env; with an explicit name it is an error (an explicit --profile must never silently degrade to inherit-env).

type ParseErrors

type ParseErrors struct {
	Source string
	Items  []ValidationError
}

ParseErrors wraps a multi-error report from Parse / Validate. Returned only when at least one ValidationError exists.

func (*ParseErrors) Error

func (e *ParseErrors) Error() string

func (*ParseErrors) Is

func (e *ParseErrors) Is(target error) bool

Is matches the sentinel ErrValidationFailed. Requires len(Items) > 0 — a zero-item ParseErrors (which internal code paths never produce but external constructions could) returns false to avoid misleading "validation failed but no items" matches.

type Profile

type Profile struct {
	Name            string            `json:"-"`
	Provider        string            `json:"provider"`
	BaseURL         string            `json:"base_url"`
	Auth            *AuthSpec         `json:"auth,omitempty"`
	ModelAliases    map[string]string `json:"model_aliases,omitempty"`
	UpstreamHeaders map[string]string `json:"upstream_headers,omitempty"`
	Egress          EgressSpec        `json:"egress"`
}

Profile is one named provider/model preset. Name is the map key, injected by Parse (it has no JSON tag of its own). Provider is the group label used purely for UI organization.

Auth is nullable: a nil pointer means "ccwrap does not own auth for this profile" (the upstream call goes out without an injected auth header). A non-nil pointer means ccwrap injects an auth header per its Mode/Key/KeyEnv.

func OfficialProfile

func OfficialProfile() Profile

OfficialProfile returns the canonical shape ccwrap writes when EnsureOfficialProfile creates the entry on first launch. Auth is nil — claude-code's OAuth flow owns request-time auth. BaseURL is empty so preflight.resolveAPIBase falls back to https://api.anthropic.com.

func (*Profile) BaseURLHost

func (p *Profile) BaseURLHost() string

BaseURLHost is the host[:port] of BaseURL, for the switcher row. Returns the raw trimmed BaseURL if it does not parse as a URL.

func (*Profile) EgressSummary

func (p *Profile) EgressSummary() string

EgressSummary is the switcher's one-token egress description: "inherit" | "direct" | "http <host:port>". Never a credential.

func (*Profile) Label

func (p *Profile) Label() string

Label is the switcher group/row label "name (Provider)". When Provider is empty it is just the name. Never includes credentials.

func (*Profile) ModelAliasCount

func (p *Profile) ModelAliasCount() int

ModelAliasCount is the number of configured alias rules.

func (*Profile) SafeView

func (p *Profile) SafeView() SafeCatalogItem

SafeView returns a non-secret catalog view of p suitable for emission on the browser-facing /profile/catalog endpoint. The function-local scope touches Auth.Key bytes only to set HasInlineKey; the bytes never escape.

When p.Auth is nil (ccwrap does not own auth for this profile), SafeView leaves SafeCatalogItem.Auth nil — the wire emits "auth": null (or omits the field entirely via omitempty). The popover reads item.auth as a nullable nested object; absent means "ccwrap does not inject auth header".

type SafeAuthSpec

type SafeAuthSpec struct {
	Mode         string `json:"mode"`
	HasInlineKey bool   `json:"has_inline_key,omitempty"`
	HasKeyEnv    bool   `json:"has_key_env,omitempty"`
}

SafeAuthSpec is the wire mirror of an AuthSpec without secret bytes. Mode is categorical (ccwrap_bearer / ccwrap_x_api_key — mode=passthrough is rejected at Validate time). HasInlineKey is true when Auth.Key carries a value; HasKeyEnv is true when Auth.KeyEnv names an env var. Both flags are bool — neither the secret nor the env NAME leaks.

type SafeCatalogItem

type SafeCatalogItem struct {
	Name                string            `json:"name"`
	Provider            string            `json:"provider"`
	BaseURLHost         string            `json:"base_url_host"`
	BaseURL             string            `json:"base_url,omitempty"` // full URL minus userinfo — populates popover edit form
	Auth                *SafeAuthSpec     `json:"auth,omitempty"`     // nil = ccwrap does not own auth
	ModelAliasCount     int               `json:"model_alias_count,omitempty"`
	ModelAliases        map[string]string `json:"model_aliases,omitempty"` // claude name → upstream model; populates popover edit form
	UpstreamHeaderCount int               `json:"upstream_header_count,omitempty"`
	EgressMode          string            `json:"egress_mode"`
	EgressHost          string            `json:"egress_host,omitempty"`
	EgressURL           string            `json:"egress_url,omitempty"` // full URL minus userinfo, only set when EgressMode=http
}

SafeCatalogItem is the source-of-truth catalog-wire shape. Every field is non-secret by construction:

  • BaseURLHost/EgressHost are url.Parse(...).Hostname() — userinfo-free
  • Auth (when non-nil) carries Mode + HasInlineKey + HasKeyEnv boolean flags; the env-var NAME and the secret bytes never enter this struct
  • Auth=nil expresses "ccwrap does not own auth for this profile" — the wire field is omitted (or "auth": null) instead of carrying empty mode
  • UpstreamHeaderCount is a count; header values are never emitted

control.SafeCatalogItem in internal/control mirrors this shape for the wire boundary (same JSON tags); the supervisor converts between them via a struct conversion. Keeping SafeCatalogItem in internal/profiles preserves the dependency direction (control depends on no package; the supervisor depends on both).

type SeedSpec

type SeedSpec struct {
	Name      string
	BaseURL   string
	APIKey    string // ANTHROPIC_API_KEY value at migration time; empty = not set
	AuthToken string // ANTHROPIC_AUTH_TOKEN value at migration time; empty = not set
}

SeedSpec describes the inputs used to build a seed Profile from launch env. It is populated from settings.EffectiveProviderEnvFromInspection; future `ccwrap profile init` subcommand and similar tooling populate it from their own sources.

APIKey / AuthToken carry the actual env VALUE (not just presence). The seeded Profile records the VALUE inline at Auth.Key — the profile is self-contained and survives env rotation/unset on subsequent launches. This is a deliberate doctrine evolution: the earlier design wrote only env-NAME references (Auth.KeyEnv) to avoid landing credentials on disk, but that coupling made the profile fragile (rotating the env would silently break the profile until the user noticed). The trade-off: profiles.json now contains the secret VALUE in a 0600-mode file — equivalent to the trust posture of any other 0600 credential file.

type ValidationError

type ValidationError struct {
	Path string
	Want string
	Got  string
}

ValidationError describes one semantic invariant violation. Path is a dotted field path rooted at the profiles.json file (e.g., "profiles.glm.auth.mode" or "default"). Want and Got give the user enough context to fix without consulting docs. URL-typed Got values are scrubbed by validate.go's private scrubURL() helper.

func (ValidationError) Error

func (v ValidationError) Error() string

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL