profiles

package
v0.0.13 Latest Latest
Warning

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

Go to latest
Published: Jul 22, 2026 License: MIT Imports: 15 Imported by: 0

Documentation

Overview

Package profiles resolves which boid daemon a CLI invocation should talk to: the local UNIX socket (today's sole, and still default, behavior) or a remote daemon over HTTPS + Bearer auth, per a named "profile" in ~/.config/boid/config.yaml (docs/plans/cli-remote-connection.md, Phase 3 PR1 "profile 基盤").

Index

Constants

View Source
const (
	SourceFlag           = "flag"
	SourceEnv            = "env"
	SourceDefaultProfile = "default_profile"
	SourceUnixFallback   = "unix-fallback"
)

Source values explain, for diagnostics, which precedence tier a ResolvedProfile came from.

View Source
const BOIDProfileEnv = "BOID_PROFILE"

BOIDProfileEnv is the environment variable checked between --profile and default_profile (decision 1, docs/plans/cli-remote-connection.md).

View Source
const ProfileFlagName = "profile"

ProfileFlagName is the --profile persistent flag cmd/root.go registers on rootCmd. Exported so cmd/root.go and this package agree on the flag name without either hard-coding a string literal the other could drift from.

Variables

This section is empty.

Functions

func ConfigPath

func ConfigPath() (string, error)

ConfigPath returns ~/.config/boid/config.yaml, honoring $XDG_CONFIG_HOME (os.UserConfigDir does this natively on Linux) — the same file `boid web set-url`/`boid web set-addr` (cmd/web.go) already read-merge- write, and the same resolution strategy internal/config.Load and internal/orchestrator.DefaultHostCommandsPath use for their own files under the same directory.

func DeleteToken

func DeleteToken(profileName string) error

DeleteToken removes the token file for profileName. A missing file is not an error — `boid logout` calls this unconditionally as part of an idempotent cleanup, so a second logout (or a logout of a profile that was hand-removed from config.yaml without ever running `boid logout`) must not fail merely because there was nothing left to delete.

func LockConfigMutation

func LockConfigMutation(cfgPath string) (release func(), err error)

LockConfigMutation takes an exclusive advisory flock on the config.yaml mutation lock (a sibling config.lock file next to cfgPath) and returns a release function that MUST be called via defer. It is the shared serialization primitive for every writer that touches config.yaml — including profiles.MutateConfig (login/logout's profile mutations) and cmd/web.go's set-url/set-addr (web.public_url / web.addr mutations). All those writers hitting the same flock file guarantees a "read → modify → write" cycle in one process cannot interleave with another's and silently lose data (the whole reason MutateConfig existed to begin with — codex PR2 review round 2).

The lock file itself is created 0600 in the config directory (which is created 0755 if missing). A caller is responsible for releasing:

release, err := profiles.LockConfigMutation(cfgPath)
if err != nil { return err }
defer release()
// … read config.yaml, mutate, atomic write …

func MutateConfig

func MutateConfig(cfgPath string, mutator func(*Config) (*Config, error)) error

MutateConfig serializes a read-modify-write cycle on cfgPath under LockConfigMutation, so two writers racing against the same config.yaml cannot lose each other's changes:

  • acquire the shared config lock (LockConfigMutation)
  • LoadConfig(cfgPath) — read the current on-disk state INSIDE the lock, so the mutator never runs against a value that was already stale by the time it was passed in
  • call mutator(cfg) to compute the new Config value; the mutator may also perform matching side-effect writes to sibling profile-scoped files (token files — the mutator's `login` caller writes the token inside this lock so no concurrent login on the SAME profile can end up with a token file whose URL disagrees with the config entry written next; codex PR2 review round 2)
  • WriteConfig(cfgPath, newCfg) atomically (temp+rename), still inside the lock, so no other process observes an intermediate state
  • release the flock on return

mutator must not retain cfg after returning — the caller is expected to derive newCfg from it (SetProfile / RemoveProfile both return copies) and let cfg fall out of scope. A nil newCfg means "no write needed" (idempotent path, e.g. logout on a profile that was already absent by the time we grabbed the lock).

func TokenPath

func TokenPath(profileName string) (string, error)

TokenPath returns the token file path for profileName. profileName is NOT re-validated here — callers (Resolve) are expected to have already run it through ValidateSlug before it ever reaches a filesystem path, so a rejected traversal-shaped name never gets this far in the first place.

func TokensDir

func TokensDir() (string, error)

TokensDir returns ~/.config/boid/tokens (decision 2), alongside ConfigPath's ~/.config/boid/config.yaml under the same XDG-resolved boid config directory.

func ValidateSlug

func ValidateSlug(name string) error

ValidateSlug reports whether name is a valid profile name. Called wherever a profile name is actually about to be used to look something up (Resolve) or written to disk (a future PR2's `boid login --profile`), not by Config's own parsing (config.go's Config/UnmarshalYAML accept arbitrary map keys so a hand-edited config.yaml with a bad key still parses — Resolve is where an invalid name becomes a clear, contextual error instead of a low-level regex complaint).

func WriteConfig

func WriteConfig(path string, cfg *Config) error

WriteConfig serializes cfg's default_profile/profiles fields into path (~/.config/boid/config.yaml) and writes it atomically (temp file in the same directory, fsynced, renamed into place — config.WriteFileAtomic), permission 0600. The parent directory is created (0755, matching the existing `boid web set-url`/`set-addr` convention in cmd/web.go) if it does not exist yet.

The single most important property of this function is what it does NOT touch: config.yaml is a file shared with internal/config.Config's own gc/web/notify/sandbox/task_ask/gateway sections (see Config's own doc comment in config.go), and a real dogfood config.yaml already has some of those. A naive "unmarshal whole file into a generic map, mutate default_profile/profiles, marshal the whole map back out" round-trip is exactly what this function must NOT do (map key/value order is not preserved by Go's map type, so every unrelated section would get silently reformatted — style-only churn on a first write, but map ordering makes even that non-deterministic across runs).

Instead this reads the file as a yaml.Node tree (loadConfigMappingNode), surgically replaces (or removes) only the "default_profile" and "profiles" entries within the top-level mapping via mapNodeSetOrRemove, and marshals that same tree back out — so every other top-level key (web, gc, gateway, ...) round-trips as the exact same node structure it was parsed from, byte-identical modulo yaml.v3's own re-indentation (which does not touch semantics — the values are unchanged Nodes, not re-derived from a lossy Go value).

func WriteToken

func WriteToken(profileName string, tok *Token) error

WriteToken serializes tok as JSON and atomically writes it to ~/.config/boid/tokens/<profileName>.json (decision 2, docs/plans/ cli-remote-connection.md: "0600、親 dir 0700"), creating the tokens/ directory (0700) first if it does not exist yet. The write goes through config.WriteFileAtomic (temp file in the same directory, fsynced, renamed into place) so a reader — including this same process's own LoadToken, or a concurrent `boid` invocation — never observes a partially written file.

Types

type Config

type Config struct {
	// DefaultProfile is used when neither --profile nor BOID_PROFILE is
	// set. Empty means "no default" — Resolve then falls back to the
	// pre-Phase-3 unix-socket default (docs/plans/cli-remote-connection.md's
	// "現行互換" contract).
	DefaultProfile string `yaml:"default_profile"`
	// Profiles maps a profile name (validated by ValidateSlug wherever a
	// name is actually selected — this type itself does not enforce it, so
	// a hand-edited config.yaml with an invalid key still parses and
	// Resolve surfaces the slug error with full context) to its connection
	// target.
	Profiles map[string]Profile `yaml:"profiles"`
}

Config is the profile-related subset of ~/.config/boid/config.yaml. config.yaml is a single shared file with several unrelated top-level sections (internal/config.Config's own gc/web/notify/sandbox/task_ask/ gateway) — see UnmarshalYAML's doc comment for why this type's decoding deliberately does NOT reject those siblings.

func LoadConfig

func LoadConfig(path string) (*Config, error)

LoadConfig reads and parses path. A missing file is not an error — it returns an empty Config, matching the documented "config.yaml が存在しない" branch of Resolve's 現行互換 fallback (docs/plans/cli-remote-connection.md).

func RemoveProfile

func RemoveProfile(cfg *Config, name string) *Config

RemoveProfile returns a copy of cfg with name's entry removed. Removing a name that is not present is a no-op (idempotent — cmd/login.go's `boid logout` relies on this to be safe to call twice).

func SetProfile

func SetProfile(cfg *Config, name string, prof Profile) *Config

SetProfile returns a copy of cfg with name's entry added or updated to prof. cfg itself is never mutated — callers (cmd/login.go) hold onto the value LoadConfig returned them for diagnostics (e.g. "already exists") and must not have it silently change underneath them.

func (*Config) UnmarshalYAML

func (c *Config) UnmarshalYAML(value *yaml.Node) error

UnmarshalYAML implements yaml.Unmarshaler. It intentionally decodes ONLY the default_profile/profiles keys and silently ignores every other top-level key in the document — mirroring internal/config.Config's own UnmarshalYAML ("Unknown legacy fields... are silently ignored"), for the same reason: config.yaml is shared by several independent loaders (gc, web, gateway, sandbox, task_ask, and now profiles), and a plain dec.KnownFields(true) applied to the whole document would make this package reject any config.yaml that ALSO configures, say, gateway.forges or web.public_url — which is not "strict validation", it is a hard crash on a file every other loader in this codebase treats as perfectly normal.

What this DOES validate strictly is the shape *within* each profiles.<name> entry: a bare yaml.Node.Decode call always starts a fresh, loose decode regardless of the Decoder that produced the node (see internal/orchestrator/host_commands_config.go's hostCommandSpecStrict doc comment for the identical gopkg.in/yaml.v3 quirk, applied there one level down for the same reason) — so each profile's raw Node is re-marshaled to its own small YAML document and re-decoded through its own KnownFields(true) Decoder below, which does catch a typo'd field (e.g. "urll:") instead of silently dropping it.

type Profile

type Profile struct {
	URL string `yaml:"url"`
}

Profile is a single named connection target from config.yaml's `profiles:` map. Deliberately does not carry a token field: the token lives in ~/.config/boid/tokens/<profile>.json (token.go), never in config.yaml — see decision 2 (docs/plans/cli-remote-connection.md).

type ResolvedProfile

type ResolvedProfile struct {
	// Name is the profile's slug, or "" for the unix-fallback case (no
	// profile was ever selected by name).
	Name string
	// URL is always a "unix://" or "https://" url.NewClient can consume
	// directly.
	URL string
	// Token is the Bearer device token for an https-scheme profile, or ""
	// for a unix-scheme one (unix never needs a token — decision 4).
	Token string
	// Source records which precedence tier decided Name (SourceFlag /
	// SourceEnv / SourceDefaultProfile / SourceUnixFallback).
	Source string
}

ResolvedProfile is the outcome of Resolve: everything client.NewClient needs to build the actual transport, plus Name/Source for diagnostics and error messages.

func Resolve

func Resolve(cmd *cobra.Command) (*ResolvedProfile, error)

Resolve determines which daemon this CLI invocation should talk to, applying the precedence chain from decision 1 (docs/plans/ cli-remote-connection.md): --profile flag > BOID_PROFILE env > default_profile > 現行互換 (unix://DefaultSocketPath()). For https-scheme profiles it also loads and validates the Bearer device token (decision 9 origin-bind check).

cmd may be nil (unit tests exercising the env/default/fallback tiers without a real cobra invocation); a nil cmd is simply treated as "--profile was not passed".

Callers that need the profile identity BEFORE running the token-load step — root's PersistentPreRunE, in particular, so a scope=local rejection (decision 6) fires *before* an unrelated missing/corrupt-token error preempts it — should call ResolveWithoutToken instead and only reach for the token when they've decided the invocation is actually going to proceed.

func ResolveWithoutToken

func ResolveWithoutToken(cmd *cobra.Command) (*ResolvedProfile, error)

ResolveWithoutToken performs the profile-selection half of Resolve (config load, precedence chain, slug validation, URL scheme validation) but deliberately stops SHORT of loading the Bearer device token. The returned ResolvedProfile has Token == "" even for an https-scheme profile that would normally need one.

This exists so root.PersistentPreRunE can enforce decision 6 (scope=local + non-unix profile → hard error) *before* a scope-independent failure — a missing/corrupt token file on the resolved https profile — has a chance to preempt it with a "run 'boid login' first" error that would mislead the caller into thinking token state is the actual problem.

func (*ResolvedProfile) IsUnix

func (rp *ResolvedProfile) IsUnix() bool

IsUnix reports whether the resolved profile targets a UNIX socket (as opposed to an https-scheme remote daemon). Handy for callers that need this discrimination BEFORE a Token has been loaded — e.g. root's PersistentPreRunE checking scope=local rejection (decision 6) against a ResolveWithoutToken result, where a token-load failure on an unrelated https profile would otherwise pre-empt the intended "ローカル専用コマンドだよ" error.

type Token

type Token struct {
	DeviceID string    `json:"device_id"`
	Token    string    `json:"token"`
	IssuedAt time.Time `json:"issued_at,omitempty"`
	// URL is the canonical origin the token was issued against (the
	// canonical_url field of POST /api/auth/device's response — see
	// internal/api/device_auth.go's deviceAuthResponse). Resolve compares
	// this byte-for-byte against the profile's own configured URL and hard-
	// errors on any mismatch (decision 9: "token は canonical origin に強く
	// bind") — never silently proceeds with a token that was issued for a
	// different origin than the one config.yaml now points the profile at.
	URL string `json:"url"`
}

Token is the on-disk shape of ~/.config/boid/tokens/<profile>.json (decision 2, docs/plans/cli-remote-connection.md). Writing this file is PR2's job (`boid login`); this package only reads and validates it.

func LoadToken

func LoadToken(profileName string) (*Token, error)

LoadToken reads and parses the token file for profileName. A missing file is reported via the plain os.ErrNotExist-wrapping error LoadToken returns unchanged (callers use os.IsNotExist / errors.Is on it) — Resolve turns that specific case into the "run 'boid login <url>' first" message from the spec, so this function does not editorialize about *why* the file is missing.

A permission looser than 0600 is logged as a warning, not a hard error (decision 2: "起動時に権限緩ければ警告") — the token itself is still usable and refusing to read it outright would turn a merely-suspicious filesystem state into an outage; a human fixing `chmod 600` is a perfectly adequate remediation once warned.

Jump to

Keyboard shortcuts

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