Documentation
¶
Overview ¶
Package adapterkit is the shared plumbing every Brigade adapter uses (plan P1-3): the bounded stdin document reader with its TTY refusal, the result printer (the only place under internal/ that may write protocol output to os.Stdout, plan 7.3), the XDG directory resolution of 3.2, the atomic 0600 file writer and the strict 0600 reader (U-10), the advisory flock helper on a sidecar file with its 10 s bound (5.1, corrected by E0-6), the O_EXCL pidfile helper with compare-then-delete removal (E0-5), and the profile file schema of 5.2.
Everything here maps failures onto internal/protocol's 4.6 taxonomy: a helper that refuses something returns a *protocol.Error whose Code selects the exit status, and the caller hands it to WriteError or PrintError. Nothing here re-declares codes, exits or envelope shapes.
Two adapterkit deliverables live elsewhere in this package's plan row and are NOT in this file set: the child spawn helper (spawn.go) and the redacting slog handler (the adapterkit/log subpackage).
stdin discipline (4.1): a command that takes an input document calls ReadInput exactly once; a command that takes no input MUST NOT read stdin at all — the harness spawns such commands with stdin ignored, and a read would block forever when a human runs the adapter by hand.
Index ¶
- Constants
- func CheckProfileName(name string) error
- func ChildEnv(environ []string, computed ...string) []string
- func ConfigDir(environ []string) (string, error)
- func Getenv(environ []string, name string) string
- func MkdirPrivate(dir string) error
- func PrintError(err error) int
- func PrintResult(result any) int
- func ProfileDir(configDir, name string) (string, error)
- func ProfileName(environ []string) string
- func ProfilePath(configDir, name string) (string, error)
- func ReadInput(stdin io.Reader) ([]byte, error)
- func ReadStrict(path string) ([]byte, error)
- func RemovePidfile(path string, content []byte) (bool, error)
- func RunQuiet(ctx context.Context, spec QuietSpec) error
- func SaveProfile(configDir, name string, p *Profile) error
- func SidecarPath(path string) string
- func StateDir(environ []string) (string, error)
- func WriteAtomic(path string, data []byte) error
- func WriteAtomicMode(path string, data []byte, mode os.FileMode) error
- func WriteError(w io.Writer, err error) int
- func WritePidfile(path string, content []byte) error
- func WriteResult(w io.Writer, result any) int
- type FileLock
- type Profile
- type QuietSpec
- type SpawnResult
- type SpawnSpec
Constants ¶
const DefaultLockTimeout = 10 * time.Second
DefaultLockTimeout is the 10 s bound of 5.1: a holder that outlives it makes the waiter fail with `unavailable` (exit 9) instead of hanging a hook or a send forever. E0-6 proved the bound live (a 13 s holder produced exactly this failure), so it is load-bearing, not decoration.
const DefaultProfileName = "default"
DefaultProfileName is the profile used when neither --profile nor BRIGADE_PROFILE names one (4.1).
const DefaultWaitDelay = 5 * time.Second
DefaultWaitDelay is how long Spawn waits after SIGTERM (or after a clean exit that left the stdout pipe open) before SIGKILL and pipe closure. 5 s matches the watcher's supervision of its adapter child (plan 6.6).
const MaxAdapterStdout = 4 << 20
MaxAdapterStdout is the cap on what a child may write to its stdout before the harness cancels it (plan 7.3): one 4.3 envelope is far smaller, so anything past 4 MiB is a runaway or an attack, not a result.
const MaxInputBytes = 1 << 20
MaxInputBytes caps the stdin document at 1 MiB (plan 4.1). The reader consumes at most one byte more than this, so a hostile or accidental multi-gigabyte stream costs bounded memory and is refused, never swallowed.
const MaxStrictBytes = 1 << 20
MaxStrictBytes caps what ReadStrict will read: every private file it serves — a profile, a credential, the adapter sidecar and registry, a seen file, a pidfile — is a few KiB at most, so anything past 1 MiB is not one of them and is refused rather than buffered.
const ProfileVersion = 1
ProfileVersion is the profile file schema version this binary reads and writes.
const SecretStoreFile = "file"
SecretStoreFile is the secret_store value for credentials kept in a file beside the profile — the only store v1 implements (5.2).
Variables ¶
This section is empty.
Functions ¶
func CheckProfileName ¶
CheckProfileName validates a profile name before it is used as a path component: 1–64 characters from [A-Za-z0-9._-], not starting with a dot. That excludes path separators, "." and "..", so a hostile --profile or BRIGADE_PROFILE value cannot traverse out of the profiles directory. The offending value is deliberately not echoed.
func ChildEnv ¶
ChildEnv builds an adapter child's environment from scratch (3.2): it keeps only the allow-listed variables of environ — PATH, HOME, TMPDIR, LANG, LC_*, XDG_*, CLAUDE_CONFIG_DIR, the proxy variables in both cases, and SSL_CERT_FILE/SSL_CERT_DIR — and appends computed last, so the harness-computed BRIGADE_PROFILE, BRIGADE_CONFIG_DIR, BRIGADE_STATE_DIR and BRIGADE_LOG_LEVEL always win (last occurrence wins, as Getenv reads and os/exec dedupes). An entry with an empty value is dropped, matching Getenv's empty-means-unset rule.
environ is the parent's os.Environ() at an entry point, or a literal in tests; inherited BRIGADE_* never survives the filter, which is why a hostile repository's `env` block cannot steer a child spawned here.
func ConfigDir ¶
ConfigDir resolves the Brigade configuration directory (3.2): $BRIGADE_CONFIG_DIR, else $XDG_CONFIG_HOME/brigade, else $HOME/.config/brigade.
A relative XDG_CONFIG_HOME is IGNORED (the XDG spec requires it, and 6.2 explains why it matters here: hooks run with cwd = project directory, so a trusted repository's settings `env` block could otherwise point the config or state directory at a path inside the project). A relative BRIGADE_CONFIG_DIR is refused with `config` rather than ignored: the harness always computes an absolute value, so a relative one is a misconfigured shell, and silently falling back to ~/.config would honour a different directory than the user named. os.UserConfigDir is not used because it answers ~/Library/Application Support on macOS.
func Getenv ¶
Getenv returns the value of name in environ, or "" when it is absent. The LAST occurrence wins, matching os/exec's dedupEnv (an appended override beats an earlier value) and cli.Context.Getenv; an empty value counts as unset, as with os.Getenv. Exported so other adapterkit files (the spawn helper's allow-list construction) share one lookup.
func MkdirPrivate ¶
MkdirPrivate creates dir and any missing parents with mode 0700, the only directory mode Brigade state and profile trees use (3.2; gosec G301 is configured to the same value). Like os.MkdirAll it leaves the modes of already-existing directories alone.
func PrintError ¶
PrintError is WriteError on the process stdout: failures are protocol output too (4.3), and stderr stays free for diagnostics.
func PrintResult ¶
PrintResult is WriteResult on the process stdout. It is the printer the stdout discipline of 7.3 names.
func ProfileDir ¶
ProfileDir returns ${configDir}/teams/<name> after validating name.
func ProfileName ¶
ProfileName resolves the profile a command acts on when no --profile flag was given: $BRIGADE_PROFILE, else "default" (4.1). The value is not validated here; CheckProfileName runs when the name becomes a path.
func ProfilePath ¶
ProfilePath returns the team.json path inside ProfileDir.
func ReadInput ¶
ReadInput reads the one JSON document a command takes on stdin (4.1): UTF-8, terminated by EOF, at most MaxInputBytes.
Refusals, all as *protocol.Error:
- stdin is a terminal → usage (exit 2). A human ran an input-taking command by hand; without this check the process would block forever waiting for typed JSON. Detected with x/term.IsTerminal, which is why entry points must pass os.Stdin itself, not a wrapper.
- more than MaxInputBytes → invalid_input (exit 3), read through io.LimitReader so the excess is never buffered.
- empty stdin → invalid_input (4.6: "stdin missing").
- a read error → invalid_input, with fixed text (the underlying error string is not echoed).
The bytes are returned unparsed; protocol.Decode owns JSON and UTF-8 validity.
func ReadStrict ¶
ReadStrict reads a file that must be private: a regular file, opened without following a symlink and without blocking on a FIFO, whose mode grants nothing to group or other, owned by the current uid, and no larger than MaxStrictBytes. Every check runs on the OPEN descriptor, so the file cannot be swapped between the check and the read. A file that fails a check is REFUSED with the `config` code (exit 11) and its content is never read — a group- or world-readable credential is treated as already leaked (U-10, E0-1), and reading it anyway would let a misconfigured install keep working silently. details.reason names the failed check: symlink, not_regular, insecure_mode, foreign_owner or too_large.
A missing file is returned as the underlying *fs.PathError (so errors.Is(err, fs.ErrNotExist) holds): whether "missing" means `config` (team.json, 4.6) or `unauthenticated` (session.json, 5.1) is the caller's mapping, not this helper's.
func RemovePidfile ¶
RemovePidfile unlinks path ONLY when its current content is byte-for-byte equal to content, and reports whether it removed the file. Compare-then-delete is REQUIRED, not a courtesy (E0-5): in the replace flow a superseded process's cleanup would otherwise delete the pidfile that by then belongs to its replacement. A missing file and a content mismatch both return (false, nil) — in either case the file is not this process's to remove, and the caller has nothing to act on. The `hold` policy's release file is its second caller (inbound.ConsumeRelease): a release the watcher has applied is deleted only if `brigade inbox release` has not rewritten it meanwhile.
func RunQuiet ¶ added in v0.12.0
RunQuiet runs one child to completion and reports whether it exited 0. It is the spawn seam for a program that speaks no protocol, beside Spawn for one that does: the same file, the same environment rule, the same SIGTERM-then-SIGKILL cancel. The error is the raw one, for the caller's redacting logger; nothing here parses, keeps or echoes output.
func SaveProfile ¶
SaveProfile validates p and writes it atomically as team.json, mode 0600, creating the 0700 profile directory chain as needed.
func SidecarPath ¶
SidecarPath names the lock sidecar for a file that WriteAtomic replaces: the lock must live on a file that is never renamed over, because a lock taken on the old inode would not protect the new one (5.1).
func StateDir ¶
StateDir resolves the Brigade state directory (3.2): $BRIGADE_STATE_DIR, else $XDG_STATE_HOME/brigade, else $HOME/.local/state/brigade. The same absolute-only rules as ConfigDir apply.
func WriteAtomic ¶
WriteAtomic writes data to path with mode 0600, atomically: a 0600 temporary file in path's own directory, write, fsync, rename over path, fsync the directory (5.1). Two concurrent writers therefore leave ONE file carrying one writer's complete content — the later rename wins — and a reader can never observe a truncated or interleaved file. A crash leaves either the old content or the new, never a partial write.
The temporary file is created by os.CreateTemp (0600 before any byte is written) and chmodded to exactly 0600 so an unusual umask cannot narrow the mode; a pre-existing world-readable file at path is REPLACED by the 0600 result, because the rename swaps the inode.
func WriteAtomicMode ¶ added in v0.2.0
WriteAtomicMode is WriteAtomic with the final mode a parameter. It exists for the one public file Brigade ever writes — the 0644 `.brigade.json` a team's administrator commits to the repository — and everything private stays on WriteAtomic's fixed 0600. The mode is applied to the temporary file before any byte lands, so no reader ever observes the destination path with a mode other than the one asked for.
func WriteError ¶
WriteError writes exactly one failing 4.3 envelope for err to w, followed by a newline, and returns the exit status of its 4.6 code.
A *protocol.Error (anywhere in err's chain) keeps its code, message and details. Anything else — including nil, which is a caller bug — becomes `internal` with the fixed message "internal error": err.Error() text is deliberately never echoed onto stdout, because an unclassified error can carry raw server text, paths or token material, and stdout reaches the model. Callers log the underlying error to stderr through the redacting logger instead.
func WritePidfile ¶
WritePidfile creates path with O_CREATE|O_EXCL, mode 0600, and writes content to it. An existing file — a live or stale predecessor — fails with an error for which errors.Is(err, fs.ErrExist) holds, and the caller decides whether to run its liveness check and replace flow (P3-5); this helper never deletes someone else's file. The content is fsynced before the create is reported successful.
func WriteResult ¶
WriteResult writes exactly one successful 4.3 envelope carrying result to w, followed by a newline, and returns the process exit status: 0, or the `internal` status when the envelope could not be marshalled or written (an exit 0 with no parseable envelope on stdout would be a lie the harness cannot detect).
Types ¶
type FileLock ¶
type FileLock struct {
// contains filtered or unexported fields
}
A FileLock is a held exclusive advisory flock. The zero value holds nothing; Unlock on it is a no-op.
func LockFile ¶
LockFile takes an exclusive advisory flock(2) on path — normally a SidecarPath — creating it 0600 when absent. It retries a non-blocking attempt every 5 ms until timeout has elapsed (at least one attempt is always made), then fails with `unavailable`. The lock is released by Unlock, or by the process dying — flock evaporates with the last open descriptor, which is the property that makes a crashed holder harmless.
type Profile ¶
type Profile struct {
// Version is the schema version; ProfileVersion for files this binary
// writes.
Version int `json:"version"`
// Adapter names the adapter that owns the profile, e.g. "supabase".
Adapter string `json:"adapter"`
// URL is the backend URL (Supabase: the project URL).
URL string `json:"url,omitzero"`
// PublishableKey is the backend's publishable API key. Publishable
// keys are configuration, not secrets (5.1).
PublishableKey string `json:"publishable_key,omitzero"`
// TeamRef is the opaque reference of the team the profile is bound
// to; empty while unbound.
TeamRef string `json:"team_ref,omitzero"`
// TeamName is the human name of that team.
TeamName string `json:"team_name,omitzero"`
// PrincipalRef is the opaque reference of the principal the profile's
// credential authenticates.
PrincipalRef string `json:"principal_ref,omitzero"`
// HumanLabel is the optional operator-chosen label (e.g. an email).
HumanLabel string `json:"human_label,omitzero"`
// SecretStore names where the credential lives; SecretStoreFile in v1.
SecretStore string `json:"secret_store,omitzero"`
// CreatedAt is when the profile was first written (RFC 3339).
CreatedAt time.Time `json:"created_at,omitzero"`
}
Profile is the 5.2 profile file schema. All fields but Version and Adapter are optional: `profile init` writes the backend pair, `team join`/`team create` fill in the team binding, and `team leave` clears it again.
func LoadProfile ¶
LoadProfile reads, parses and validates a profile file. Every failure is `config` (4.6: "profile missing, invalid or world-readable"): a missing file, a group- or world-readable file (via ReadStrict), a file that is not valid JSON (fixed message; the decoder's text is not echoed), and a file failing Validate.
type QuietSpec ¶ added in v0.12.0
type QuietSpec struct {
Argv []string
Env []string
// WaitDelay bounds how long the child may outlive its context cancel
// (SIGTERM) before SIGKILL; zero or negative means DefaultWaitDelay.
WaitDelay time.Duration
}
A QuietSpec describes one child run for its side effect alone — the message-arrival sound player or desktop notifier (cards 35 and 36) — with nothing given to it and nothing read from it: no stdin (the null device), stdout and stderr discarded. Argv and Env follow SpawnSpec's rules exactly: an argv array executed directly, never a shell, and an environment the caller built from scratch (ChildEnv) that the child never inherits.
type SpawnResult ¶
type SpawnResult struct {
// Envelope is the child's stdout, parsed and validated against 4.3.
// It may be a failing envelope: an adapter that exits with its own
// 4.6 status and a well-formed error envelope is speaking the
// protocol, not failing to — the caller reads Envelope.Error and
// ExitCode and decides. Spawn deliberately does not cross-check the
// exit status against the envelope's code.
Envelope *protocol.Envelope
// ExitCode is the child's own exit status.
ExitCode int
// WaitDelayExpired records the tolerated exec.ErrWaitDelay case: the
// child exited 0 with a valid result but its stdout pipe stayed open
// past WaitDelay (an adapter must not hand stdout to a grandchild,
// but a valid result that still arrived stands, plan 7.3).
WaitDelayExpired bool
}
A SpawnResult is a child that produced a parseable protocol envelope.
func Spawn ¶
func Spawn(ctx context.Context, spec SpawnSpec) (*SpawnResult, error)
Spawn runs one adapter child to completion and maps every way it can fail onto the 4.6 taxonomy. The returned error, when non-nil, is always a *protocol.Error:
- a missing executable (errors.Is os.ErrNotExist, or exec.ErrNotFound for a PATH search) → `unavailable`, details.reason "adapter_not_found";
- the caller's ctx expiring → `unavailable`, details.reason "timeout" (detected with ctx.Err(), never from cmd.Wait: Wait reports the SIGTERM death the cancel itself caused, plan 4.6 [verified, A.7]);
- a child killed by a signal with the context still live → `unavailable`, details.signal naming the signal;
- stdout past MaxAdapterStdout → the context is cancelled (SIGTERM, then SIGKILL after WaitDelay) and the mapping is `internal`, details.reason "stdout_overflow";
- stdout that is not one valid 4.3 envelope → `internal`.
The deadline is the caller's: pass a context bounded with context.WithTimeout (the harness applies its 3.5 budgets there).
type SpawnSpec ¶
type SpawnSpec struct {
// Argv is the child's argv; Argv[0] is the executable. It must be
// non-empty.
Argv []string
// Env is the child's entire environment, in os.Environ form, built
// from scratch by the caller — normally with [ChildEnv]. The child
// NEVER inherits this process's environment: a nil Env runs the child
// with an empty one, not the parent's (the dynamic half of the 3.2
// environment-isolation rule; forbidigo's exec.Command ban is the
// static half).
Env []string
// Stdin is the input document, fed from a bytes.Reader. A nil Stdin
// runs the child with no stdin at all (the null device): a command
// that takes no input must not be handed a pipe it could block on.
Stdin []byte
// Stderr receives the child's stderr — normally the adapter log file.
// A nil Stderr discards it. Stdout is never the caller's to redirect:
// it is the protocol channel and Spawn owns it.
Stderr io.Writer
// WaitDelay bounds how long the child may outlive its context cancel
// (SIGTERM) or its own exit with the stdout pipe still open, before
// SIGKILL and forced pipe closure. Zero or negative means
// [DefaultWaitDelay].
WaitDelay time.Duration
// Logger receives the diagnostics 7.3 requires (the tolerated
// exec.ErrWaitDelay case is logged), scalar attributes only. A nil
// Logger discards them.
Logger *slog.Logger
}
A SpawnSpec describes one adapter child process. Argv is an argv array and nothing else: Argv[0] is executed directly, no shell is ever involved, and no field of this struct is interpreted by one (plan 7.3).
Source Files
¶
Directories
¶
| Path | Synopsis |
|---|---|
|
Package log is Brigade's one redacting slog handler (plan 7.3, T12): every diagnostic line an adapter or the harness writes to stderr or a log file goes through it, because the harness captures adapter stderr at debug level and a leak here puts a credential in a 0600-but-still- on-disk log file (U-09, U-23).
|
Package log is Brigade's one redacting slog handler (plan 7.3, T12): every diagnostic line an adapter or the harness writes to stderr or a log file goes through it, because the harness captures adapter stderr at debug level and a leak here puts a credential in a 0600-but-still- on-disk log file (U-09, U-23). |