Documentation
¶
Overview ¶
Package pkg holds the controller's backend-independent control API: the request and response shapes a caller speaks over A2A, and the single interface every backend implements. Nothing in this package names a backend — that is the point of it.
Index ¶
- Constants
- func DefaultLocalStateFile(ctx context.Context) (string, error)
- func DefaultSecretDir() string
- func DefaultTeamVaultConfigPath() string
- func NewA2AExecutor(provider BackendProvider, backend Backend) a2asrv.AgentExecutor
- func NewA2AHandler(executor a2asrv.AgentExecutor) http.Handler
- func NewAgentCard(config AgentCardConfig) *a2a.AgentCard
- func NewAgentCardHandler(card *a2a.AgentCard) http.Handler
- func RemoveSecretFiles(ctx context.Context, dir string, workerID WorkerID) error
- func ValidateMounts(ctx context.Context, allowlist MountAllowlist, mounts []Mount) error
- func ValidateSecrets(ctx context.Context, secrets []Secret) error
- type AgentCardConfig
- type Auth
- type Backend
- type BackendProvider
- type ControlRequest
- type ControlResponse
- type CreateRequest
- type CreateResponse
- type DockerClient
- type DockerConfig
- type EgressDestination
- type KubernetesClient
- type KubernetesConfig
- type ListResponse
- type LocalConfig
- type LocalWorker
- type Mount
- type MountAllowlist
- type Operation
- type PortProber
- type ResolvedSecret
- type Secret
- type SecretMount
- type StopRequest
- type StopResponse
- type StubWorkerBackend
- type TaskName
- type TeamVaultResolver
- type WeztermClient
- type Worker
- type WorkerBackend
- func NewDockerBackend(client DockerClient, prober PortProber, config DockerConfig) WorkerBackend
- func NewKubernetesBackend(client KubernetesClient, builder WorkerResourceBuilder, templateConfig string, ...) WorkerBackend
- func NewLocalBackend(client WeztermClient, store WorkerStore, config LocalConfig) WorkerBackend
- type WorkerID
- type WorkerResourceBuilder
- type WorkerState
- type WorkerStore
Constants ¶
const ( // DefaultDockerImage is the image every worker runs. ⚠️ It is untagged, so docker // resolves it to :latest — and no :latest is published for this image, upstream or // locally, so a create that used this value verbatim would fail with docker's own // resolution error rather than anything naming the real cause. The binary therefore // requires the image to be configured and overrides this field; it survives as the // package's own placeholder, not as a value that resolves. DefaultDockerImage = "bborbe/agent-claude" // DefaultAgentNameBase prefixes the name each worker's Agent Card advertises; the worker // id is appended so two workers never advertise the same name. DefaultAgentNameBase = "claude-interactive" // DefaultAnthropicModel is the model the worker's turns run against. DefaultAnthropicModel = "MiniMax-M2.7-highspeed" // DefaultAllowedTools is the tool allow-list the worker's agent runs with. DefaultAllowedTools = "Read,Bash,Grep,Glob,Write,Edit,WebSearch,WebFetch" // DefaultPortStart and DefaultPortEnd bound the host-port search. 9090 is commonly taken // on a laptop and 9091/9092 are held by hand-started containers that are out of bounds, // so the search starts above them. DefaultPortStart = 9093 DefaultPortEnd = 9199 )
The worker container's fixed settings. They mirror the local-docker-run recipe the agent image documents, which is the command create automates.
const ( // DefaultKubernetesTemplateConfig is the Config every worker is cloned from. Cloning a // live Config keeps the image pin, resources and priority class in the one place the chart // already maintains them, so a worker never runs an image the shared pod does not. DefaultKubernetesTemplateConfig = "claude-interactive" // DefaultKubernetesIngressClassName is the ingress class every worker Ingress uses. DefaultKubernetesIngressClassName = "traefik" )
The defaults for the k8s backend's settings.
const ( // DefaultWeztermBinary is the wezterm binary's absolute path. // // ⚠️ IT IS ABSOLUTE ON PURPOSE AND A BARE NAME MUST NEVER BE SUBSTITUTED. wezterm is not // on a launchd job's PATH — it exists only inside the app bundle — and `exec.Command` // resolves a bare name with `exec.LookPath` against THIS process's PATH, never against the // child's `cmd.Env`. Prefixing the child's PATH therefore cannot repair it, and the failure // is silent: the spawn never happens and nothing says why. DefaultWeztermBinary = "/Applications/WezTerm.app/Contents/MacOS/wezterm" // DefaultLocalTasksDir is the vault directory a task file lives in, relative to the // configured vault root. It is the private-personal vault's own layout, which is the vault // a local worker serves. DefaultLocalTasksDir = "25 Tasks" )
The local backend's fixed settings.
const A2APath = "/a2a"
A2APath is the route the A2A JSON-RPC binding is served at.
const AgentCardPath = a2asrv.WellKnownAgentCardPath
AgentCardPath is the well-known path the Agent Card is served at, as the A2A spec defines it. It is the SDK's own constant, re-exported so the service's wiring reads both A2A routes from one place.
const (
// AuthorizationHeader is the request header carrying the caller's bearer credential.
AuthorizationHeader = "Authorization"
)
The bearer gate's header and scheme. A caller presents the controller's token as `Authorization: Bearer <token>`, the scheme the A2A binding and every sibling service use.
const MountNotAllowedPrefix = "mount_not_allowed: "
MountNotAllowedPrefix is the marker every refusal from this file carries. It is a stable token rather than a sentence so a caller can match on it without parsing prose, and it names the offending value after the colon — the caller's own input, never anything resolved.
const SecretContainerDir = "/run/secrets"
SecretContainerDir is the directory inside the worker where resolved secrets appear. A file per secret, named after the declared Name.
⚠️ IT IS A FILE MOUNT RATHER THAN AN ENVIRONMENT VARIABLE, and that choice is forced rather than aesthetic. An env route — `-e NAME=value` or an `--env-file` — lands the value in the container's `Config.Env`, where `docker inspect` prints it for anyone with daemon access. A bind-mounted file appears in `docker inspect` as the HOST path only, so the value stays out of both argv and the container's recorded spec.
const TeamVaultCommand = "teamvault-cli"
TeamVaultCommand is the CLI this controller shells out to. It is a separate binary rather than an in-process client on purpose: the launchd wrapper (~/.local/bin/a2a-worker-controller.sh) and claude-supervisor's scripts/mcp-start.py already resolve TeamVault this way, so one implementation of the lookup serves all three and the controller adds no second auth path to keep in step.
const TeamVaultConfigEnv = "TEAMVAULT_CONFIG"
TeamVaultConfigEnv is the only environment variable this controller reads for TeamVault. It names the teamvault-cli config file, not a credential: the CLI resolves its own authentication from that file, so the controller never holds a TeamVault password of its own.
⚠️ The NAME is teamvault-cli's, not this controller's invention, and the controller must not grow a second one. teamvault-cli already documents a precedence for this value ($TEAMVAULT_CONFIG, else ~/.config/teamvault-cli/config.json, else ~/.teamvault.json), so a private variable here would be a second configuration surface that disagrees with the CLI the moment the CLI's default moves.
Variables ¶
This section is empty.
Functions ¶
func DefaultLocalStateFile ¶ added in v0.10.0
DefaultLocalStateFile returns the ownership store's default path, i.e. ~/.local/state/a2a-worker-controller/local-workers.json.
func DefaultSecretDir ¶ added in v0.12.0
func DefaultSecretDir() string
DefaultSecretDir is the host directory resolved secret files are written into when the operator configures none. It sits beside the controller's other state, under the user's own home, so it inherits that directory's permissions rather than a world-readable temp dir.
func DefaultTeamVaultConfigPath ¶ added in v0.12.0
func DefaultTeamVaultConfigPath() string
DefaultTeamVaultConfigPath is the path teamvault-cli uses when TeamVaultConfigEnv is unset and its XDG and legacy candidates are absent. It exists so a caller can REPORT where a lookup was expected to read from without re-deriving teamvault-cli's precedence — never so this package can pass it as a flag, which would freeze a default the CLI owns.
func NewA2AExecutor ¶ added in v0.2.0
func NewA2AExecutor(provider BackendProvider, backend Backend) a2asrv.AgentExecutor
NewA2AExecutor creates the A2A executor for the control API.
provider resolves a backend name to the WorkerBackend that serves it; backend is the name the controller was configured with. Both are injected — the executor reads no global and no environment — so the same executor serves any backend, and a test drives it with a stub through the real selection wiring.
func NewA2AHandler ¶ added in v0.2.0
func NewA2AHandler(executor a2asrv.AgentExecutor) http.Handler
NewA2AHandler serves the A2A JSON-RPC binding over the given executor. The body is capped before the SDK handler sees it.
func NewAgentCard ¶ added in v0.2.0
func NewAgentCard(config AgentCardConfig) *a2a.AgentCard
NewAgentCard builds the Agent Card this service advertises. Its single supported interface is the JSON-RPC binding at config.PublicURL, which is the externally reachable address supplied as configuration — never the listen address and never a value derived from a request. The card carries no credential.
func NewAgentCardHandler ¶ added in v0.2.0
NewAgentCardHandler serves the Agent Card. The card is public by design: it advertises the endpoint and skills and carries no credential.
func RemoveSecretFiles ¶ added in v0.12.0
RemoveSecretFiles removes the directory holding one worker's secret files. It is called when the worker is stopped: the files are mounted for the container's lifetime, so they must outlive the create that wrote them, and removing them is what keeps a long-lived host from accumulating credentials for workers that no longer exist.
A missing directory is not an error — a worker created without secrets has none, and the stop that follows must not fail on that account.
func ValidateMounts ¶ added in v0.12.0
func ValidateMounts(ctx context.Context, allowlist MountAllowlist, mounts []Mount) error
ValidateMounts refuses a caller's declared mounts that this controller will not honour, and names the first offending value so the caller can fix it rather than guess.
It is called before any container is created: a refused mount means NO worker, not a worker with the mount silently missing. A worker that started without a mount it declared would look healthy and fail later at its first file access, which is the failure this ordering exists to prevent.
func ValidateSecrets ¶ added in v0.12.0
ValidateSecrets refuses a caller's declared secrets that the controller cannot resolve.
⚠️ IT CHECKS SHAPE ONLY — it never resolves a key and never sees a value. Resolution is a separate call at create time, so a malformed request is refused before the controller touches TeamVault at all, and no error raised here can carry a secret even by accident.
A repeated Name is refused rather than last-write-wins: two secrets under one name would make the worker's view of that name depend on the controller's iteration order, which is not something a caller can reason about.
Types ¶
type AgentCardConfig ¶ added in v0.2.0
type AgentCardConfig struct {
// Name names the deployed agent in the Agent Card.
Name string
// PublicURL is the externally reachable A2A endpoint the card advertises verbatim.
// It is never derived from the listen address or a request header.
PublicURL string
}
AgentCardConfig is what the A2A Agent Card advertises about this service.
type Auth ¶ added in v0.4.1
type Auth struct {
// contains filtered or unexported fields
}
Auth is the controller's authentication decision: the bearer token every gated request must present.
The zero value is not a usable default — it refuses every gated request, so a controller built without a token serves only the exempt admin paths. That is the fail-closed outcome: a decision left unmade cannot silently open a surface that creates and destroys containers. There is deliberately no opt-out value. The reference gate carries one so a deliberately unexposed consumer can state its choice; this controller has no deployment in which serving /a2a unauthenticated is correct, so the escape hatch is omitted rather than left unused.
func NewAuthToken ¶ added in v0.4.1
NewAuthToken returns the Auth that requires token as the bearer credential on every gated request. An empty token is not a usable credential: it yields the same fail-closed value as the zero Auth.
func (Auth) Wrap ¶ added in v0.4.1
Wrap returns the handler that serves next only to a request carrying this decision's bearer credential.
It wraps the whole router rather than individual routes, so /a2a and any route registered after this change are covered by construction rather than by remembering to add the gate. A refused request is answered before the wrapped handler runs, so it never reaches the executor.
type Backend ¶
type Backend string
Backend names the implementation that serves workers. Its value space belongs to the backend implementations and to the selection wiring, never to this schema: a constant naming a backend here would put that backend's identity above the seam this package exists to hold, and a second backend would have to edit the shared schema to become selectable.
const ( // BackendDocker runs each worker as a container on the local host. BackendDocker Backend = "docker" // BackendKubernetes runs each worker as a workload in the cluster. BackendKubernetes Backend = "k8s" // BackendLocal runs each worker as a WezTerm tab on the local host, started with the // vault's own launcher. It is the one backend whose worker serves no A2A endpoint: a tab // is reached through its pane, not through a URL. BackendLocal Backend = "local" )
The backend names. They live here and nowhere else: this file is the one place above the seam that names a backend, so a backend becomes selectable by editing this selection wiring alone. The shared schema (types.go) names none of them, which is what keeps a backend's identity from becoming part of the control API.
type BackendProvider ¶ added in v0.2.0
type BackendProvider interface {
// Get returns the backend serving the named backend, or an error when the name is
// not one the controller knows or its implementation is not wired.
Get(ctx context.Context, backend Backend) (WorkerBackend, error)
}
BackendProvider resolves a backend name to the WorkerBackend that serves it.
func NewBackendProvider ¶ added in v0.2.0
func NewBackendProvider( docker WorkerBackend, kubernetes WorkerBackend, local WorkerBackend, ) BackendProvider
NewBackendProvider creates the backend selection wiring.
docker, kubernetes and local are the implementations of the three backends the controller supports, in the order their names are declared above. Any of them may be nil while its implementation is not yet wired — and a backend that is not selected is passed as nil on purpose, because its settings exist only for it; Get then refuses that backend rather than handing back a nil seam that would fail on first use.
type ControlRequest ¶
type ControlRequest struct {
Operation Operation `json:"operation"`
// Create is set when Operation is OperationCreate.
Create *CreateRequest `json:"create,omitempty"`
// Stop is set when Operation is OperationStop.
Stop *StopRequest `json:"stop,omitempty"`
}
ControlRequest is one control-API call. Exactly one operation-specific field is set and it matches Operation; a request that sets none, or more than one, is refused rather than guessed at.
type ControlResponse ¶
type ControlResponse struct {
Operation Operation `json:"operation"`
Create *CreateResponse `json:"create,omitempty"`
List *ListResponse `json:"list,omitempty"`
Stop *StopResponse `json:"stop,omitempty"`
}
ControlResponse is the controller's reply to one ControlRequest. Exactly one operation-specific field is set and it matches Operation.
type CreateRequest ¶
type CreateRequest struct {
Task TaskName `json:"task"`
// Env carries extra environment for the worker, keyed by variable name. It is how a
// caller hands a worker the settings its own tooling needs — the attention store's URL
// and token, for instance.
//
// ⚠️ Every entry travels through the child environment, never in argv, because the
// controller cannot tell a credential from a plain setting and create is a public
// surface. A name colliding with one of the controller's own settings is refused rather
// than merged: silently overriding AGENT_TYPE would start a worker outside the gated
// service path, which is the setting its permission gate depends on.
Env map[string]string `json:"env,omitempty"`
// Mounts are the host paths the caller asks the controller to make reachable. Each is
// checked against the controller's allowlist before any container is created.
Mounts []Mount `json:"mounts,omitempty"`
// Secrets are the credentials the caller asks the controller to resolve and deliver.
// Only the TeamVault KEY travels here; the value is resolved controller-side.
Secrets []Secret `json:"secrets,omitempty"`
}
CreateRequest asks for a worker serving one task.
type CreateResponse ¶
type CreateResponse struct {
Worker Worker `json:"worker"`
// Token is the worker's own bearer credential. It must never be logged, echoed into a
// transcript, or written to a file.
Token string `json:"token"`
}
CreateResponse is the reply to a create. It is the only place a worker's token is ever returned: the controller keeps no copy it depends on, so a caller that loses this reply stops the worker and creates another rather than asking the controller to repeat it.
type DockerClient ¶ added in v0.3.0
type DockerClient interface {
// Run runs docker with args and the extra environment, returning its standard output.
// The extra environment is layered on the caller's own and is where a credential
// travels, so the value never reaches argv.
Run(ctx context.Context, args []string, extraEnv []string) (string, error)
}
DockerClient runs one docker CLI invocation. It is the backend's only door to the daemon and it is injected, so the backend reads no global and a test drives it with a fake that records the argv and the environment it was handed and answers with canned output — no daemon required.
func NewDockerCLIClient ¶ added in v0.3.0
func NewDockerCLIClient() DockerClient
NewDockerCLIClient creates the DockerClient that runs the docker CLI.
type DockerConfig ¶ added in v0.3.0
type DockerConfig struct {
// Image is the image every worker container runs.
Image string
// AgentNameBase prefixes the Agent Card name each worker advertises.
AgentNameBase string
// Model is the model the worker's turns run against.
Model string
// AllowedTools is the tool allow-list the worker's agent runs with.
AllowedTools string
// PortStart and PortEnd bound the host ports the backend will publish on.
PortStart int
PortEnd int
// MountAllowlist is the set of host paths a caller may ask this backend to expose. Its
// zero value allows nothing, so a backend built without it refuses every mount — see
// MountAllowlist for why the empty default is the safe one here.
MountAllowlist MountAllowlist
// TeamVault resolves a declared secret's key to its value. A nil resolver refuses any
// create that declares a secret, rather than starting a worker whose secret is missing.
TeamVault TeamVaultResolver
// SecretDir is the host directory the resolved secret files are written into before being
// bind-mounted read-only into the worker. It must be private to this controller: every file
// in it holds a credential, and one controller's workers must never be able to read
// another's.
SecretDir string
}
DockerConfig holds the docker backend's settings. It carries no credential: the token a worker is created with is minted per worker and returned once.
func DefaultDockerConfig ¶ added in v0.3.0
func DefaultDockerConfig() DockerConfig
DefaultDockerConfig is the configuration the controller uses when none is supplied. Its values are the local-docker-run recipe's own.
type EgressDestination ¶ added in v0.6.0
EgressDestination is one in-cluster destination a worker pod may reach: the pods in Namespace — all of them, or only those matching PodLabels — on TCP Port. DNS is not one of them — every worker policy allows it implicitly, because without it no other destination resolves.
PodLabels exists because a namespace-wide port is too wide on a shared namespace: in nuke's `dev`, port 9090 is served by the vault writers and by the trading gateways alike, so `dev:9090` would open both. `dev/app=vault-obsidian-personal:9090` opens the vault alone.
func ParseEgressDestinations ¶ added in v0.6.0
func ParseEgressDestinations(ctx context.Context, value string) ([]EgressDestination, error)
ParseEgressDestinations parses a comma-separated `namespace[/key=value]:port` list, the form the EGRESS_ALLOW setting takes (`dev:8788,dev/app=vault-obsidian-personal:9090`). The optional `/key=value` narrows the destination to pods carrying that label. An empty value is an empty list — a worker that may reach nothing but DNS. A malformed entry is refused rather than skipped, because a dropped entry silently denies a destination the operator meant to allow.
type KubernetesClient ¶ added in v0.5.0
type KubernetesClient interface {
// GetConfig returns the named Config.
GetConfig(ctx context.Context, name string) (*agentv1.Config, error)
// ListConfigs returns every Config matching labelSelector.
ListConfigs(ctx context.Context, labelSelector string) ([]agentv1.Config, error)
// CreateConfig creates config and returns it as stored, carrying its uid.
CreateConfig(ctx context.Context, config *agentv1.Config) (*agentv1.Config, error)
// DeleteConfig deletes the named Config. Its owned objects are garbage-collected.
DeleteConfig(ctx context.Context, name string) error
// GetSecret returns the named Secret.
GetSecret(ctx context.Context, name string) (*corev1.Secret, error)
// CreateSecret creates secret.
CreateSecret(ctx context.Context, secret *corev1.Secret) error
// CreateService creates service.
CreateService(ctx context.Context, service *corev1.Service) error
// CreateIngress creates ingress.
CreateIngress(ctx context.Context, ingress *networkingv1.Ingress) error
// CreateNetworkPolicy creates policy.
CreateNetworkPolicy(ctx context.Context, policy *networkingv1.NetworkPolicy) error
}
KubernetesClient is the k8s backend's only door to the cluster. It is injected, so the backend reads no global and a test drives it with a mock — no cluster required. Every call is scoped to the one namespace the client was built for.
func NewInClusterKubernetesClient ¶ added in v0.5.0
func NewInClusterKubernetesClient(ctx context.Context, namespace string) (KubernetesClient, error)
NewInClusterKubernetesClient creates the KubernetesClient from the pod's own service account. It is the only constructor production uses: the controller runs in the cluster it manages, so it needs no kubeconfig and holds no credential beyond its service account.
type KubernetesConfig ¶ added in v0.5.0
type KubernetesConfig struct {
// Namespace is where every worker Config, Secret, Service and Ingress is written.
Namespace string
// TemplateConfig names the Config every worker is cloned from.
TemplateConfig string
// AgentNameBase prefixes the name each worker's Agent Card advertises.
AgentNameBase string
// IngressDomain is the parent domain of every worker host, so a worker named
// a2a-worker-<id> is served at https://a2a-worker-<id>.<IngressDomain>/a2a.
IngressDomain string
// IngressClassName is the ingress class every worker Ingress uses.
IngressClassName string
// TLSSecretName names the wildcard certificate Secret covering IngressDomain.
TLSSecretName string
// WorkerPriorityClass is the PriorityClass every worker pod runs under. Empty keeps the
// template's class. It exists because a class carries its own ResourceQuota on the cluster:
// workers that inherited the template's class would share the template's pod cap, which the
// template's own pod already fills.
WorkerPriorityClass string
// EgressAllow lists the in-cluster destinations each worker's NetworkPolicy allows, on top
// of DNS. Everything else — the public internet included — is denied.
EgressAllow []EgressDestination
// TeamVault resolves a declared secret's key to its value. A nil resolver refuses any
// create that declares a secret, rather than writing a Config whose pod would start
// without the credential it was told to expect.
TeamVault TeamVaultResolver
}
KubernetesConfig holds the k8s backend's settings. It carries no credential: a worker's shared credentials are copied from its template's Secret inside the cluster, and its own token is minted per worker and returned once.
func DefaultKubernetesConfig ¶ added in v0.5.0
func DefaultKubernetesConfig() KubernetesConfig
DefaultKubernetesConfig is the k8s backend's configuration before the deployment-specific values (namespace, domain, certificate) are set.
type ListResponse ¶
type ListResponse struct {
Workers []Worker `json:"workers"`
}
ListResponse is the reply to a list.
type LocalConfig ¶ added in v0.10.0
type LocalConfig struct {
// VaultDir is the vault root the workers' task files live in. It is also the directory
// each worker's tab starts in, so the worker runs where its task lives.
VaultDir string
// ClaudeScript is the absolute path to the vault's claude_script launcher — the `cc-*`
// script every worker's tab execs.
ClaudeScript string
// WindowID names the WezTerm window a worker's tab opens in, from LOCAL_WINDOW_ID. Empty
// omits the flag entirely, and the tab then lands in the window the spawning process is
// associated with — which under launchd is the default window. It exists because the flag
// is part of the spawn this backend mirrors; the controller has no role map to resolve one
// from, so it is configured rather than derived.
WindowID string
}
LocalConfig holds the local backend's settings. It carries no credential: a tab worker authenticates nothing — the caller reaches it through its pane, not through a URL.
type LocalWorker ¶ added in v0.10.0
type LocalWorker struct {
WorkerID WorkerID `json:"worker_id"`
Task TaskName `json:"task"`
// PaneID is the WezTerm pane the worker runs in. It is the worker's only handle.
PaneID string `json:"pane_id"`
// GuiID names the WezTerm GUI instance the pane belongs to: the live GUI socket's path,
// which carries the GUI process's own pid. It is what makes PaneID an identity rather than
// a bare counter — a pane id restarts from a low number when the mux is recreated, so after
// a GUI restart a stale entry's id can name a pane this controller never opened.
GuiID string `json:"gui_id"`
// SessionID is the Claude Code session the worker's tab bound to the task, empty until
// that binding lands.
SessionID string `json:"session_id"`
// CreateSessionID is the session id the task file ALREADY CARRIED when this worker was
// created — in practice the id of an earlier session of the same task, because this
// worker's own session cannot have started before its tab did.
//
// ⚠️ IT EXISTS SO THAT AN EARLIER SESSION'S ID IS NEVER REPORTED AS THIS WORKER'S. The
// task-scoped `claude_session_id` frontmatter field is stamped by the vault when it is
// empty and LEFT ALONE on every later binding, so once a worker has been stopped and
// another created for the same task, the file still names the STOPPED worker's session —
// and nothing ever rewrites it, so that value would otherwise be permanent. The snapshot
// is what refreshSessionIDs measures against: a value that was already there when this
// worker started cannot be its own, and a value that appeared afterwards can.
CreateSessionID string `json:"create_session_id"`
}
LocalWorker is one worker the local backend owns, as the ownership store records it.
The store is this backend's resource state. It exists because a WezTerm pane carries no labels: unlike a container, a pane cannot be asked which task it serves, so the mapping from worker to pane has to be written down somewhere the controller can read back after a restart.
type Mount ¶ added in v0.12.0
type Mount struct {
// HostPath is the absolute host path to expose. It must fall inside the controller's
// configured allowlist, or the create is refused.
HostPath string `json:"host_path,omitempty"`
// Volume is a named docker volume. Refused, always, in this revision.
Volume string `json:"volume,omitempty"`
// ContainerPath is where the path appears inside the worker. Required.
ContainerPath string `json:"container_path"`
// ReadOnly asks for the mount to be read-only. Defaults to false, so a caller that wants
// a read-only mount must say so.
ReadOnly bool `json:"read_only,omitempty"`
}
Mount is one filesystem path a caller asks the controller to make reachable inside the worker. It is DECLARED rather than passed through as a raw docker `-v` argument, because a raw argument would let a caller mount any path on the host and create is a public surface.
⚠️ `Volume` EXISTS ONLY TO BE REFUSED, and that is deliberate rather than vestigial. A named docker volume is not a host path, so the allowlist below cannot govern it: a caller free to name one could mount another workload's data volume. Refusing it by name — rather than by omitting the field — is what turns a silently-ignored key into an error that names the thing the caller asked for. Go's decoder drops unknown keys, so an omitted field would have accepted `{"volume": "…"}` and mounted nothing, which reads as success.
type MountAllowlist ¶ added in v0.12.0
type MountAllowlist []string
MountAllowlist is the set of absolute host paths a caller may ask the controller to expose.
⚠️ AN EMPTY ALLOWLIST ALLOWS NOTHING, which is the opposite of the usual "unset means unrestricted" convention and is deliberate. create is a public surface, so the safe reading of an unconfigured controller is "this controller mounts nothing" — a default that mounted everything would turn a forgotten flag into an arbitrary-host-path mount on every worker.
func ParseMountAllowlist ¶ added in v0.12.0
func ParseMountAllowlist(ctx context.Context, value string) (MountAllowlist, error)
ParseMountAllowlist parses the comma-separated absolute host-path list the MOUNT_ALLOW setting takes (`/Users/bborbe/Documents/Obsidian/private-personal,/srv/data`). A malformed entry is refused rather than skipped, matching ParseEgressDestinations and for the same reason: a dropped entry silently denies a path the operator meant to allow, and the operator discovers it only when a worker cannot reach its vault.
Every entry must be absolute. A relative entry is refused rather than resolved against the controller's own working directory, because that directory is not part of the setting and a path that depends on it would mean different things on the Mac and in a pod.
func (MountAllowlist) Allows ¶ added in v0.12.0
func (a MountAllowlist) Allows(hostPath string) bool
Allows reports whether hostPath sits at or under one of the allowlisted paths.
⚠️ THE COMPARISON IS ON PATH BOUNDARIES, NOT ON RAW PREFIXES. A plain strings.HasPrefix would let an allowlist entry of `/srv/data` admit `/srv/database`, which is a different directory that the operator never named — so the descendant case is matched against the entry plus a separator. Both sides are cleaned first, because `/srv/data/` and `/srv/./data` name the same directory and a caller should not be able to slip past by spelling it differently.
type Operation ¶
type Operation string
Operation names one control-API operation. It travels as a plain string so a caller written against this schema needs no shared Go type to address the controller.
const ( // OperationCreate asks the controller for a worker serving one task. OperationCreate Operation = "create" // OperationList asks the controller for every worker it owns. OperationList Operation = "list" // OperationStop asks the controller to stop and remove one worker. OperationStop Operation = "stop" )
type PortProber ¶ added in v0.3.0
type PortProber interface {
// IsFree reports whether port can be bound on the host.
IsFree(ctx context.Context, port int) bool
}
PortProber reports whether a host port is free to bind. It is injected so the port search is testable without opening a socket.
func NewTCPPortProber ¶ added in v0.3.0
func NewTCPPortProber() PortProber
NewTCPPortProber creates the PortProber that bind-checks a port on the host.
type ResolvedSecret ¶ added in v0.12.0
ResolvedSecret is one secret after the controller has looked its value up. It exists only between resolution and the write that puts it on disk.
⚠️ ITS Value FIELD IS A CREDENTIAL. The struct is passed by value into the writer and dropped immediately; it is never stored on a backend, never logged, and never placed in an error.
type Secret ¶ added in v0.12.0
type Secret struct {
// Name is the key the worker reads the secret under. Required.
Name string `json:"name"`
// TeamVaultKey is the TeamVault lookup key the controller resolves. A short identifier
// rather than a secret, which is what makes naming it here safe.
TeamVaultKey string `json:"teamvault_key"`
}
Secret is one credential a caller asks the controller to resolve and deliver to the worker.
⚠️ THE VALUE NEVER TRAVELS IN THE REQUEST. The caller names a TeamVault key; the controller resolves it with its own credential and hands the worker the resolved value through a channel the request and the container's spec both stay clear of. That is the whole point of the shape: a caller that could send the value could also log it, and create is a public surface whose requests are recorded.
type SecretMount ¶ added in v0.12.0
type SecretMount struct {
// HostPath is the 0600 file on the host holding the value.
HostPath string
// Name is the declared name; the file appears at SecretContainerDir/<Name> in the worker.
Name string
}
SecretMount is one resolved secret as the run command needs it: the host file holding the value, and the name it appears under inside the worker. It carries no value, which is what lets it be passed around and reported without a credential travelling with it.
func WriteSecretFiles ¶ added in v0.12.0
func WriteSecretFiles( ctx context.Context, dir string, workerID WorkerID, secrets []ResolvedSecret, ) ([]SecretMount, error)
WriteSecretFiles writes one file per resolved secret under dir/<workerID>/ and returns the mounts to hand the run command, in the same order as the input.
⚠️ EVERY FILE IS 0600 AND THE DIRECTORY IS 0700. The value is a credential, and the container is not the only reader on this host — the operator's own account, any process running as them, and anything that walks the tree can read a 0644 file. The narrow mode is what makes the difference between a secret this worker can read and a secret every local process can.
A failure part-way through removes the directory it created rather than leaving a partial set on disk: a half-written secret directory is a credential store nobody will clean up, because the create that would have owned it failed.
type StopRequest ¶
type StopRequest struct {
WorkerID WorkerID `json:"worker_id"`
}
StopRequest names the worker to stop and remove.
type StopResponse ¶
type StopResponse struct {
Worker Worker `json:"worker"`
}
StopResponse is the reply to a stop. It carries the stopped worker so a caller can confirm which one the controller acted on.
type StubWorkerBackend ¶ added in v0.2.0
type StubWorkerBackend struct {
// contains filtered or unexported fields
}
StubWorkerBackend is a throwaway in-memory WorkerBackend. It exists for two reasons.
It makes the executor testable with no container, no cluster and no credential: a test drives the real executor and the real selection wiring against it. And it is the second implementation of WorkerBackend, which is what proves the seam — the executor drives it through the same interface it drives a real backend through, without knowing which one it holds.
It is absent from the selection wiring in backend-provider.go: no Backend value resolves to it. It is therefore reachable only by tests and by explicit construction, and can never serve a production request.
func NewStubWorkerBackend ¶ added in v0.2.0
func NewStubWorkerBackend() *StubWorkerBackend
NewStubWorkerBackend creates an empty in-memory backend.
func (*StubWorkerBackend) Create ¶ added in v0.2.0
func (s *StubWorkerBackend) Create( ctx context.Context, req CreateRequest, ) (*CreateResponse, error)
Create records a worker for the requested task and returns it with a token derived from its id.
⚠️ A TASK THAT ALREADY HAS A WORKER IS REFUSED, matching the contract the real backends keep. The stub's token is *derived* rather than kept, so it could answer where they cannot — but answering here would let a spec certify a reply the real backends refuse, which is the exact divergence a stub must not introduce. A stub that is easier to satisfy than the thing it stands in for tests the stub.
func (*StubWorkerBackend) List ¶ added in v0.2.0
func (s *StubWorkerBackend) List(ctx context.Context) (*ListResponse, error)
List returns every worker the stub holds, in no particular order.
func (*StubWorkerBackend) Stop ¶ added in v0.2.0
func (s *StubWorkerBackend) Stop(ctx context.Context, req StopRequest) (*StopResponse, error)
Stop removes the named worker and returns it, reporting it as stopped. A worker the stub does not hold is refused rather than silently ignored.
type TaskName ¶
type TaskName string
TaskName is the vault task a worker serves. It names the work, not the worker — a task has at most one worker, and a second create for a task that already has one is REFUSED, naming that worker, rather than answered with it.
type TeamVaultResolver ¶ added in v0.12.0
type TeamVaultResolver interface {
// Resolve returns the value for key. The returned value is a secret: it must never be
// logged, echoed into a transcript, or written to a file. An error names the KEY and the
// CLI's own stderr, never the value.
Resolve(ctx context.Context, key string) (string, error)
}
TeamVaultResolver resolves a TeamVault lookup key to the secret it names.
func NewTeamVaultResolver ¶ added in v0.12.0
func NewTeamVaultResolver() TeamVaultResolver
NewTeamVaultResolver creates the resolver that shells out to teamvault-cli.
The config path comes from TeamVaultConfigEnv when set, and is otherwise left to teamvault-cli's own default — passing no flag at all, rather than guessing a path here. That keeps one precedence rule in the system instead of two that can disagree.
type WeztermClient ¶ added in v0.10.0
type WeztermClient interface {
// GuiID returns an identifier for the GUI instance the other three calls talk to — the
// live GUI socket's path, which carries the GUI process's own pid. It differs after a GUI
// restart, and that is the whole point of recording it: a pane id on its own is a per-mux
// counter that restarts with the mux, so only a GUI id makes it interpretable.
//
// ⚠️ IT IS RESOLVED ONCE PER BACKEND CALL AND THEN PASSED TO EVERY OTHER METHOD, RATHER
// THAN RESOLVED AGAIN INSIDE EACH. The resolution picks the NEWEST live socket, so two
// resolutions within one call can name two different GUIs, and a GUI that restarts between
// them would have a pane opened in one GUI recorded under the other's identity — which is
// the single thing the recorded identity exists to prevent.
GuiID(ctx context.Context) (string, error)
// Spawn opens a tab running inner as `bash -lc inner`, in the GUI guiID names, in the
// window windowID names when it is not empty, and returns the new pane's id.
Spawn(ctx context.Context, guiID string, windowID string, inner string) (string, error)
// PaneIDs returns the id of every pane the GUI guiID names has.
PaneIDs(ctx context.Context, guiID string) ([]string, error)
// KillPane closes the pane paneID names in the GUI guiID names.
KillPane(ctx context.Context, guiID string, paneID string) error
}
WeztermClient is the local backend's only door to WezTerm. It is injected, so the backend reads no global and a test drives the real code path with a fake that records the command line it was handed — no GUI and no pane required.
func NewWeztermCLIClient ¶ added in v0.10.0
func NewWeztermCLIClient(binary string) WeztermClient
NewWeztermCLIClient creates the WeztermClient that runs the wezterm binary at binary.
binary is an absolute path — see DefaultWeztermBinary for why a name would not resolve under launchd.
type Worker ¶
type Worker struct {
WorkerID WorkerID `json:"worker_id"`
Task TaskName `json:"task"`
A2AURL string `json:"a2a_url"`
Backend Backend `json:"backend"`
State WorkerState `json:"state"`
// PaneID is the WezTerm pane the worker runs in. Empty unless the worker runs in a tab.
PaneID string `json:"pane_id"`
// SessionID is the Claude Code session the worker's tab is bound to. Empty unless the
// worker runs in a tab, and empty there too until the session's own task binding lands.
SessionID string `json:"session_id"`
}
Worker is one worker as the control API reports it. It deliberately carries no token: a worker's token is returned once by create and never again, so no listing, no log and no restart can leak one.
⚠️ PaneID AND SessionID ARE A DELIBERATE WIDENING OF THIS SHARED SCHEMA. What this file keeps out is a backend's IDENTITY: no backend is named here, so a second backend does not have to edit the schema to become selectable. These two fields are not that — they record how a worker is reached when it has no URL. A worker that runs in a WezTerm tab serves no A2A endpoint, so its pane is the only handle a caller has on it, and without these fields there would be nowhere for that handle to travel. A caller reads them when a2a_url is empty and ignores them otherwise; both are empty for a backend whose workers answer at their own URL.
type WorkerBackend ¶ added in v0.2.0
type WorkerBackend interface {
// Create starts one worker serving the requested task and returns it together with
// its own bearer token. ⚠️ A task that already has a worker is REFUSED, naming that
// worker's id, rather than answered: only one worker is ever started per task, and the
// existing worker's token cannot be returned because the backend kept no copy of it —
// so a success reply would name a worker no caller could authenticate to, which reads
// as a working worker while being unreachable. The caller's recovery is to stop the
// named worker and create again.
Create(ctx context.Context, req CreateRequest) (*CreateResponse, error)
// List returns every worker the backend owns. It is rebuilt from the backend's own
// resource state on every call, never from controller memory, so a controller
// restart loses nothing.
List(ctx context.Context) (*ListResponse, error)
// Stop stops and removes the named worker and returns it.
Stop(ctx context.Context, req StopRequest) (*StopResponse, error)
}
WorkerBackend is the single seam every backend implements. It is expressible with the control-API schema alone: it takes the schema's request shapes and returns its response shapes, so nothing above the seam names a backend and a new backend needs no change to the executor or to the HTTP surface.
⚠️ THE SCHEMA HALF OF THAT IS NO LONGER UNCONDITIONAL. Adding a backend whose workers are not reachable at a URL — the local backend, whose workers are WezTerm tabs — widened Worker with pane_id and session_id (types.go), because a tab has no A2A URL and the pane is the only handle a caller gets on it. That widening was deliberate and is recorded there; what still holds is the narrower rule, that a backend's IDENTITY never reaches this schema. A backend whose workers answer at their own URL still needs no schema change at all.
func NewDockerBackend ¶ added in v0.3.0
func NewDockerBackend(client DockerClient, prober PortProber, config DockerConfig) WorkerBackend
NewDockerBackend creates the WorkerBackend that runs each worker as a docker container.
client is the backend's only door to the daemon and prober decides which host port is free; both are injected, so the backend reads no global and a test drives it without a daemon. config carries the image, the Agent Card name prefix, the model and the host-port range.
func NewKubernetesBackend ¶ added in v0.5.0
func NewKubernetesBackend( client KubernetesClient, builder WorkerResourceBuilder, templateConfig string, teamVault TeamVaultResolver, ) WorkerBackend
NewKubernetesBackend creates the WorkerBackend that runs each worker as a per-task pod.
client is the backend's only door to the cluster and builder renders every object a worker is made of; both are injected, so a test drives the backend without a cluster. templateConfig names the Config every worker is cloned from.
func NewLocalBackend ¶ added in v0.10.0
func NewLocalBackend(client WeztermClient, store WorkerStore, config LocalConfig) WorkerBackend
NewLocalBackend creates the WorkerBackend that opens each worker as a WezTerm tab.
client is the backend's only door to WezTerm and store is its ownership record; both are injected, so the backend reads no global and a test drives it without a GUI. config carries the vault directory and the launcher every tab runs.
type WorkerID ¶
type WorkerID string
WorkerID identifies one worker the controller created. It is the value a caller passes back to stop a worker and the value list reports, so it stays stable across a controller restart: it is read back from the worker's own labels rather than minted per request and held in memory.
type WorkerResourceBuilder ¶ added in v0.5.0
type WorkerResourceBuilder interface {
// BuildConfig clones template into the Config for one worker serving task.
BuildConfig(template agentv1.Config, workerID WorkerID, task TaskName) *agentv1.Config
// BuildSecret renders the worker's credential Secret, owned by owner: every key of
// template, with the bearer replaced by token, plus one entry per declared secret.
BuildSecret(
owner agentv1.Config,
template corev1.Secret,
token string,
secrets []ResolvedSecret,
) *corev1.Secret
// BuildService renders the ClusterIP Service in front of owner's pod.
BuildService(owner agentv1.Config) *corev1.Service
// BuildIngress renders the TLS Ingress that exposes owner's Service on its own host.
BuildIngress(owner agentv1.Config) *networkingv1.Ingress
// BuildNetworkPolicy renders the egress NetworkPolicy that confines owner's pod.
BuildNetworkPolicy(owner agentv1.Config) *networkingv1.NetworkPolicy
// A2AURL is the address the worker named name advertises.
A2AURL(name string) string
}
WorkerResourceBuilder renders every Kubernetes object a k8s worker is made of. It decides what a worker looks like; it never talks to the cluster, so every method is a pure function of its arguments and the backend's configuration.
func NewWorkerResourceBuilder ¶ added in v0.5.0
func NewWorkerResourceBuilder(config KubernetesConfig) WorkerResourceBuilder
NewWorkerResourceBuilder creates the WorkerResourceBuilder that renders workers for config's namespace, ingress domain and certificate.
type WorkerState ¶
type WorkerState string
WorkerState is what list reports about a worker's lifecycle.
const ( // WorkerStateRunning means the worker exists and is reachable: at its own A2A URL for a // backend whose workers serve one, or through its pane for a tab worker, which serves none. WorkerStateRunning WorkerState = "running" // WorkerStateStopped means the worker no longer exists. It appears only in the reply to // the stop that removed it; list never returns it. WorkerStateStopped WorkerState = "stopped" // WorkerStateUnknown means the worker's backing resource exists but the controller // cannot classify it, so a caller must not read it as running. WorkerStateUnknown WorkerState = "unknown" )
type WorkerStore ¶ added in v0.10.0
type WorkerStore interface {
// List returns every worker the store holds, empty when the store does not exist yet.
List(ctx context.Context) ([]LocalWorker, error)
// Save replaces the store's contents with workers.
Save(ctx context.Context, workers []LocalWorker) error
}
WorkerStore persists the workers the local backend owns. It is injected, so the backend reads no global and a test drives it against a temporary file.
List and Save are the whole surface: the backend reads the set, changes it, and writes it back under its own mutex, so there is no per-entry mutation to get wrong.
⚠️ THE STORE IS SINGLE-WRITER BY CONTRACT, AND THAT IS A PROPERTY OF THE CONFIGURATION RATHER THAN OF THIS INTERFACE. Save replaces the file's whole contents, so two controllers pointed at one path would each overwrite the other's set: the last write wins and the other controller's workers are left running with no record naming them, which is exactly the state Stop cannot reach. The backend's mutex serializes its calls within ONE process and nothing serializes two processes, so one state file belongs to exactly one controller.
func NewJSONWorkerStore ¶ added in v0.10.0
func NewJSONWorkerStore(path string) WorkerStore
NewJSONWorkerStore creates the WorkerStore backed by the JSON file at path. A leading ~ is resolved against the home directory, so the default path can be passed through unchanged.