adapterkit

package
v0.20.0 Latest Latest
Warning

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

Go to latest
Published: Oct 4, 2026 License: MIT Imports: 18 Imported by: 0

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

View Source
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.

View Source
const DefaultProfileName = "default"

DefaultProfileName is the profile used when neither --profile nor BRIGADE_PROFILE names one (4.1).

View Source
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).

View Source
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.

View Source
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.

View Source
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.

View Source
const ProfileVersion = 1

ProfileVersion is the profile file schema version this binary reads and writes.

View Source
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

func CheckProfileName(name string) error

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

func ChildEnv(environ []string, computed ...string) []string

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

func ConfigDir(environ []string) (string, error)

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

func Getenv(environ []string, name string) string

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

func MkdirPrivate(dir string) error

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

func PrintError(err error) int

PrintError is WriteError on the process stdout: failures are protocol output too (4.3), and stderr stays free for diagnostics.

func PrintResult

func PrintResult(result any) int

PrintResult is WriteResult on the process stdout. It is the printer the stdout discipline of 7.3 names.

func ProfileDir

func ProfileDir(configDir, name string) (string, error)

ProfileDir returns ${configDir}/teams/<name> after validating name.

func ProfileName

func ProfileName(environ []string) string

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

func ProfilePath(configDir, name string) (string, error)

ProfilePath returns the team.json path inside ProfileDir.

func ReadInput

func ReadInput(stdin io.Reader) ([]byte, error)

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

func ReadStrict(path string) ([]byte, error)

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

func RemovePidfile(path string, content []byte) (bool, error)

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

func RunQuiet(ctx context.Context, spec QuietSpec) error

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

func SaveProfile(configDir, name string, p *Profile) error

SaveProfile validates p and writes it atomically as team.json, mode 0600, creating the 0700 profile directory chain as needed.

func SidecarPath

func SidecarPath(path string) string

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

func StateDir(environ []string) (string, error)

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

func WriteAtomic(path string, data []byte) error

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

func WriteAtomicMode(path string, data []byte, mode os.FileMode) error

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

func WriteError(w io.Writer, err error) int

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

func WritePidfile(path string, content []byte) error

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

func WriteResult(w io.Writer, result any) int

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

func LockFile(path string, timeout time.Duration) (*FileLock, error)

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.

func (*FileLock) Unlock

func (l *FileLock) Unlock() error

Unlock releases the lock and closes the sidecar descriptor. It is safe to call more than once; later calls are no-ops.

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

func LoadProfile(configDir, name string) (*Profile, error)

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.

func (*Profile) Validate

func (p *Profile) Validate() error

Validate checks the members every profile file must carry. Failures are `config` (exit 11): the file exists but this binary cannot honour it.

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).

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).

Jump to

Keyboard shortcuts

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