Documentation
¶
Overview ¶
harness.go answers one question about a dispatch: once `cloop run` is going on the far side, will the binary it execs be there.
Why this is not the same question as the three refusals beside it ¶
Start already refuses a workspace the agent cannot fetch, a sandbox mode it is too old to honour, and a hypervisor the machine does not have. All three are about the *boundary* the payload runs inside. None of them is about the payload, and the payload is what was broken: a device advertising `harnesses: ["cloop"]` — no `claude` — was sent a claudecode project on every dispatch, and failed each time with
✗ Provider error: claude CLI start error: exec: "claude": executable file not found in $PATH
That line arrives after the hub has leased a GitHub credential, written the broker's audit row, shipped the material to the device's tmpfs and provisioned a workspace directory — roughly half a second of work whose only product is an error naming neither the executor, nor the provider that needed the binary, nor the fact that the hub knew both.
It did know both. AgentCapabilities.Harnesses has carried the device's inventory since the protocol had a hello frame, and its own doc comment says "a device without the harness a project needs should not be sent its work". executor.Requirements has carried a Harnesses field with a ConstraintHarness refusal ready to fire. The two were never connected: nothing in the tree ever populated Requirements.Harnesses except a sandbox.yaml asking for `git`, so the constraint was unreachable for the provider's own CLI.
The gate is the sandbox mode, and it is the whole subtlety ¶
AgentCapabilities describes the device's host — what the agent found on its own PATH at hello. That is the right inventory to check exactly when the payload will run on that host, and the wrong one as soon as it will not: in container mode the harness comes out of the image, which is precisely the configuration that *fixes* a device with no `claude` on it. Checking the device's PATH there would refuse the remedy along with the fault, and an operator following the message below would find it stopped working the moment they complied with it.
So: host mode asks the device, container mode asks nothing. The image is not interrogated because nothing in the protocol can interrogate it — the agent reports its own PATH, not the PATH of an image it may not even have pulled yet — and inventing a "probably fine" for that case is how the pre-v9 virtualization check got its own "unknown is not no" rule.
Package remote implements the control-plane half of cloop's remote executor: a driver that satisfies executor.Executor by proxying frames to a cloop agent running on another machine.
The defining constraint is direction. Edge devices, laptops, and build boxes generally sit behind NAT or a firewall that permits outbound connections and nothing inbound. A control plane cannot dial them. So the agent dials *out*, holds one long-lived WebSocket to the control plane, and the control plane pushes work down that already-established connection. Everything else in the protocol follows from that inversion:
- the control plane never knows an agent's address, only its identity;
- liveness is the agent's job (heartbeats), because the control plane has no way to probe;
- reconnects are routine, not exceptional, so log streaming has to be resumable rather than restartable.
proto.go defines the wire format. It is deliberately a separate file from the transport: both the control plane (pkg/executor/remote) and the device (pkg/executor/agent) encode against these types, and a protocol change that only compiles on one side is the bug class this split is meant to prevent.
Versioning ¶
Every frame carries V. The control plane accepts any version in [MinProtocolVersion, ProtocolVersion] and tells the agent which version the session settled on in the Welcome frame. Agents older than the floor are rejected at hello with a legible error rather than failing later on a field they do not understand. Frames are JSON objects with an open payload, so adding a field is backward compatible; removing one or changing its meaning requires a version bump.
Authentication ¶
The connection is authenticated once, at the HTTP upgrade, by a bearer credential (see enroll.go). Individual frames are not separately signed: over an authenticated, ordered, TLS-protected channel a per-frame MAC keyed by the same secret proves nothing the handshake did not already prove, and pretending otherwise would be security theatre.
What frames *are* subject to is authorization. The session binds the connection to exactly one agent identity, and every inbound frame is checked against it: an agent may only report on handles the control plane dispatched to that same agent. A frame naming another agent's handle is dropped and the connection closed, so a compromised agent cannot forge status for or steal logs from its peers.
Index ¶
- Constants
- Variables
- func CleanUSBInventory(devs []executor.USBDevice) []executor.USBDevice
- func EnrollmentRemediation(err error) string
- func HeartbeatDeadline() time.Duration
- func MintBundle(store Store, opts MintOptions) (Bundle, EnrollmentRecord, error)
- func NegotiateVersion(peerVersion int) (int, error)
- func NewPipe(buffer int) (Conn, Conn)
- func Revoke(store Store, id string, now time.Time) (kind string, err error)
- func StoredBuildSequence(raw []byte) int
- func SupportsAttach(version int) bool
- func SupportsBranchBundle(version int) bool
- func SupportsDenialLog(version int) bool
- func SupportsDiskLimit(version int) bool
- func SupportsEgressProxy(version int) bool
- func SupportsEgressRules(version int) bool
- func SupportsForwardedDevices(version int) bool
- func SupportsGitCA(version int) bool
- func SupportsGrantRevocation(version int) bool
- func SupportsHarnessInstall(version int) bool
- func SupportsIsolationProbe(version int) bool
- func SupportsNodeCA(version int) bool
- func SupportsNodeCABundle(version int) bool
- func SupportsProjectResult(version int) bool
- func SupportsProjectSeed(version int) bool
- func SupportsResumeTerminate(version int) bool
- func SupportsRevocation(version int) bool
- func SupportsSandboxMode(version int) bool
- func SupportsSecretFiles(version int) bool
- func SupportsSecretRefresh(version int) bool
- func SupportsUpgrade(version int) bool
- func SupportsVirtualExecutor(version int) bool
- func SupportsVirtualizationProbe(version int) bool
- func SupportsWorkspaceProvisioning(version int) bool
- func SupportsWriteBack(version int) bool
- func ValidVirtualID(id string) bool
- func ValidateSecretFiles(files []SecretFile) error
- type AcceptOptions
- type AgentCapabilities
- type AgentRecord
- type AttachClosePayload
- type AttachDataPayload
- type AttachOpenPayload
- type AttachOpenedPayload
- type AttachResizePayload
- type BranchChunkPayload
- type Bundle
- type ByePayload
- type Conn
- type DenialsPayload
- type DeviceClosePayload
- type DeviceDataPayload
- type DeviceOpenPayload
- type DeviceOpenedPayload
- type DeviceRegistry
- type DeviceStream
- func (d *DeviceStream) Abort(reason string)
- func (d *DeviceStream) Close() error
- func (d *DeviceStream) Deliver(data []byte)
- func (d *DeviceStream) Done() <-chan struct{}
- func (d *DeviceStream) Finish(reason string)
- func (d *DeviceStream) ID() string
- func (d *DeviceStream) Read(p []byte) (int, error)
- func (d *DeviceStream) Reason() string
- func (d *DeviceStream) Write(p []byte) (int, error)
- type EnrollmentRecord
- type ErrorPayload
- type Executor
- func (e *Executor) Abandon(ctx context.Context, handleID, reason string) error
- func (e *Executor) AddHandleRedactions(handleID string, values ...string) bool
- func (e *Executor) AgentCapabilities() AgentCapabilities
- func (e *Executor) AgentInventory() (AgentCapabilities, bool)
- func (e *Executor) AgentProtocol() int
- func (e *Executor) AgentVersion() string
- func (e *Executor) Attach(ctx context.Context, req executor.AttachRequest) (executor.AttachConn, error)
- func (e *Executor) AttachHandleStore(store executor.HandleStore)
- func (e *Executor) BuildSequence() int
- func (e *Executor) CanTakeForwardedDevices(kinds []string) error
- func (e *Executor) Capabilities() executor.Capabilities
- func (e *Executor) ConnStatus() string
- func (e *Executor) Connected() bool
- func (e *Executor) DenialLogStatus() (bool, string)
- func (e *Executor) DeviceProtocol() int
- func (e *Executor) EgressPosture() executor.EgressPosture
- func (e *Executor) ExplainBranchBundleRefusal() string
- func (e *Executor) ExplainDiskLimitRefusal() string
- func (e *Executor) HandleStatuses(ctx context.Context) ([]executor.Status, error)
- func (e *Executor) Handles() []string
- func (e *Executor) HealthCheck(ctx context.Context) error
- func (e *Executor) HeldWorkspaceCredential(handleID string) (executor.HeldWorkspaceCredential, bool)
- func (e *Executor) HoldsLease(leaseID string) bool
- func (e *Executor) ID() string
- func (e *Executor) IsolationRuntime() (string, bool)
- func (e *Executor) Kind() string
- func (e *Executor) Leases() []string
- func (e *Executor) LogGapped(handleID string) bool
- func (e *Executor) Name() string
- func (e *Executor) PinnedResultBytes() int64
- func (e *Executor) Preflight() PreflightReport
- func (e *Executor) ProbeIsolation(ctx context.Context) (executor.IsolationProbe, error)
- func (e *Executor) ProjectResult(handleID string) (executor.ProjectResult, error)
- func (e *Executor) ProtocolVersion() int
- func (e *Executor) RefreshInventory(ctx context.Context) (AgentCapabilities, error)
- func (e *Executor) RefreshSecretFiles(ctx context.Context, req executor.SecretRefreshRequest) executor.SecretRefreshReport
- func (e *Executor) RequestHarnessInstall(ctx context.Context, harness, reason string) (HarnessInstallOutcome, error)
- func (e *Executor) RequestUpgrade(ctx context.Context, req UpgradeRequest) (UpgradeOutcome, error)
- func (e *Executor) Revocations() []RevokeResult
- func (e *Executor) RevokeLease(ctx context.Context, p RevokePayload) RevokeResult
- func (e *Executor) SecretRefreshShortfall() string
- func (e *Executor) Signal(ctx context.Context, handleID string, sig executor.Signal) error
- func (e *Executor) Start(ctx context.Context, spec executor.Spec) (executor.Handle, error)
- func (e *Executor) Status(ctx context.Context, handleID string) (executor.Status, error)
- func (e *Executor) Stream(ctx context.Context, handleID string) (<-chan executor.LogLine, error)
- func (e *Executor) SupportsRevocation() bool
- func (e *Executor) SupportsSecretRefresh() bool
- func (e *Executor) SupportsWorkspaceProvisioning() bool
- func (e *Executor) SupportsWriteBack() bool
- func (e *Executor) UpdateChannel() string
- func (e *Executor) WriteBackBundle(handleID string) ([]byte, error)
- type ExpiredLease
- type ExpiredLeaseSweeper
- type Finding
- type Frame
- type FrameType
- type HarnessInstallOutcome
- type HarnessInstalledPayload
- type HeartbeatAckPayload
- type HeartbeatPayload
- type HelloPayload
- type Hub
- func (h *Hub) Deregister(agentID string)
- func (h *Hub) Executor(agentID string) (*Executor, bool)
- func (h *Hub) Executors() []*Executor
- func (h *Hub) LeaseHolders(leaseID string) []string
- func (h *Hub) RefreshLease(ctx context.Context, req executor.SecretRefreshRequest) []RefreshResult
- func (h *Hub) Restore() error
- func (h *Hub) Revocations() []RevokeResult
- func (h *Hub) Revoke(agentID string) error
- func (h *Hub) RevokeExecutorLeases(ctx context.Context, executorID, reason string, action RevokeAction) []RevokeResult
- func (h *Hub) RevokeLease(ctx context.Context, p RevokePayload) []RevokeResult
- func (h *Hub) ServeHTTP(w http.ResponseWriter, r *http.Request)
- func (h *Hub) StartLeaseJanitor(ctx context.Context, interval time.Duration, sweep ExpiredLeaseSweeper) (stop func())
- type HubOptions
- type InstallHarnessPayload
- type InventoryPayload
- type InventoryRequestPayload
- type IsolationEvidencePayload
- type IsolationProbeRequest
- type LogAckPayload
- type LogChunkPayload
- type MintOptions
- type Options
- type PreflightReport
- type ProjectResultPayload
- type RedeemOptions
- type RefreshResult
- type ResultBudget
- type ResultBudgetUsage
- type ResultChunkPayload
- type ResultPayload
- type ResumeAck
- type ResumeAction
- type ResumeHandle
- type RevokeAction
- type RevokePayload
- type RevokeResult
- type RevokeState
- type RevokedPayload
- type SecretFile
- type SecretRefreshPayload
- type SecretRefreshedPayload
- type Session
- func (s *Session) AgentID() string
- func (s *Session) AgentProtocol() int
- func (s *Session) AgentVersion() string
- func (s *Session) Capabilities() AgentCapabilities
- func (s *Session) Close() error
- func (s *Session) Done() <-chan struct{}
- func (s *Session) LastSeen() time.Time
- func (s *Session) Version() int
- type SignalPayload
- type StartPayload
- type StartedPayload
- type StatusPayload
- type StatusReqPayload
- type Store
- type UpgradeOutcome
- type UpgradePayload
- type UpgradeRequest
- type UpgradingPayload
- type Virtual
- func (v *Virtual) Abandon(ctx context.Context, handleID, reason string) error
- func (v *Virtual) AgentVersion() string
- func (v *Virtual) Attach(ctx context.Context, req executor.AttachRequest) (executor.AttachConn, error)
- func (v *Virtual) CanTakeForwardedDevices(kinds []string) error
- func (v *Virtual) Capabilities() executor.Capabilities
- func (v *Virtual) DenialLogStatus() (bool, string)
- func (v *Virtual) EgressPosture() executor.EgressPosture
- func (v *Virtual) ExplainBranchBundleRefusal() string
- func (v *Virtual) ExplainDiskLimitRefusal() string
- func (v *Virtual) ExplainEgressScopeRefusal() string
- func (v *Virtual) HandleStatuses(ctx context.Context) ([]executor.Status, error)
- func (v *Virtual) HealthCheck(ctx context.Context) error
- func (v *Virtual) HeldWorkspaceCredential(handleID string) (executor.HeldWorkspaceCredential, bool)
- func (v *Virtual) HoldsLease(string) bool
- func (v *Virtual) ID() string
- func (v *Virtual) IsolationRuntime() (string, bool)
- func (v *Virtual) Kind() string
- func (v *Virtual) Leases() []string
- func (v *Virtual) Name() string
- func (v *Virtual) Parent() *Executor
- func (v *Virtual) ParentID() string
- func (v *Virtual) ProbeIsolation(ctx context.Context) (executor.IsolationProbe, error)
- func (v *Virtual) ProjectResult(handleID string) (executor.ProjectResult, error)
- func (v *Virtual) Revocations() []executor.RevokeOutcome
- func (v *Virtual) RevokeLease(_ context.Context, req executor.RevokeRequest) executor.RevokeOutcome
- func (v *Virtual) Signal(ctx context.Context, handleID string, sig executor.Signal) error
- func (v *Virtual) Spec() (executor.VirtualSpec, error)
- func (v *Virtual) Start(ctx context.Context, spec executor.Spec) (executor.Handle, error)
- func (v *Virtual) Status(ctx context.Context, handleID string) (executor.Status, error)
- func (v *Virtual) Stream(ctx context.Context, handleID string) (<-chan executor.LogLine, error)
- func (v *Virtual) SupportsRevocation() bool
- func (v *Virtual) WriteBackBundle(handleID string) ([]byte, error)
- type VirtualDispatch
- type VirtualSource
- type VirtualStart
- type WelcomePayload
- type WorkspaceCredential
Constants ¶
const ( LevelOK = "ok" LevelWarn = "warn" LevelFail = "fail" )
Preflight levels, matching the container driver's vocabulary.
const ( // ProtocolVersion is the newest version this build speaks. // // v2 added the revoke frame (see TypeRevoke): the control plane can take // one secret lease back from a task that is already running, rather than // waiting for the task to exit. // // v3 added StartPayload.WorkspaceCredential: the control plane hands the // device the short-lived material for one authenticated git fetch, so an // agent can materialise a private source tree before the harness starts // instead of running it against an empty directory. // // v4 added the result frames (see TypeResultChunk and TypeResult): the // device sends back the work product — a branch and commit SHA, and for a // device with no egress the git bundle itself — so a task's edits survive // the workload that made them. // // v5 gave ResumeAck an Action (see ResumeAction). Until then the only way // the control plane could refuse a resume offer was to leave the handle out // of Welcome.ResumeAccepted, and the agent read that as "stop reporting" — // so a hub that restarted with an empty handle map refused every offer and // left every workload running on the device forever, unreadable and // unstoppable. An explicit action lets the refusal say "terminate this", // which is the only honest answer for work nobody is left to collect. // // v6 added StartPayload.SecretFiles: the *contents* of a secret lease's // credential files, so a device that shares no filesystem with the hub can // place them itself. Until then a lease's files were written into the hub's // own /dev/shm and named in Spec.Env — a delivery that means nothing on // another machine — so a repository-scoped github_pat arrived at an edge // device as an environment variable pointing at a path that did not exist // there, with no token behind it. // // v7 added the attach frames (see TypeAttachOpen): an operator's // interactive session inside a running workload, multiplexed onto the // session the device already holds open. Additive in the strictest sense — // an older agent is never sent one, because the hub checks SupportsAttach // before offering the capability at all. // // v8 added StartPayload.Sandbox: the admin's answer to whether this // executor's payloads run on the device's host or in a container on it, and // under which engine, runtime and image. Until then the device decided, and // it always decided host — while advertising the container runtimes it had // found on PATH, so the fleet view showed a containment that did not exist. // // Unlike the additive fields above, this one has a rule about *old* agents // that matters more than the field itself. A pre-v8 agent cannot honour a // container mode; it would receive the frame, ignore the unknown key, and // run the payload on its host. So the hub refuses to dispatch a // container-mode workload to a pre-v8 device rather than sending it and // hoping — see Executor.Start. Host mode and unset are sent to any version, // because for those the old behaviour and the new instruction agree. // // v9 added AgentCapabilities.Virtualization: the device's own answer to // whether it can start a VM at all. v8 let an admin name a Kata runtime for // a device, and the hub believed the name — Capabilities reported // Virtualized from the configured string alone, because nothing on the wire // could contradict it. On a device without nested virtualization that claim // is simply false, and it is false in the direction the isolation model // says must never happen: placement routes work that *required* a // hypervisor onto a machine that has none, and the run dies ~50s later // inside the sandbox with a QMP connection timeout that names neither KVM // nor the device. Kata hardcodes accel=kvm and its bundled QEMU is built // --disable-tcg, so there is no degraded mode to fall back to; the only // honest answers are "yes" and "refuse". // v10 added StartPayload.ProjectSeed: the hub's `.cloop/` — goal, // instructions, provider selection and plan — placed into the tree the // device just cloned. Until then nothing carried a cloop *project* across // a dispatch, only a source repository, so `cloop run` on the device // exited on its first line with "no cloop project found" against a // perfectly good checkout. The only way to make a remote run work at all // was to commit `.cloop/state.db` — a live SQLite file — into the user's // repository. // // Additive like v6, and gated the same way and for the same shape of // reason: a pre-v10 agent does not reject the field, it ignores it and // runs the harness in a tree with no project. See MinProjectSeedVersion. // // v11 adds the upgrade frame, which lets the control plane ask a device to // roll its own binary forward (Task 20331). Additive, and gated harder than // the rest: see MinUpgradeVersion in upgradeproto.go for why a pre-v11 // agent is refused outright rather than allowed to ignore the frame. // // v12 adds the harness-install frame, which lets the control plane ask a // device to install the harness a project needs from that harness's own // official installer (Task 20336). Additive, and gated the *softest* of // any frame here: a pre-v12 agent simply keeps the Task 20332 behaviour of // refusing a harness it does not have, so the hub skips the attempt rather // than failing on it. See MinHarnessInstallVersion in installproto.go. // // v13 adds the project_result frame (see TypeProjectResult): after a // seeded workload exits, the device reads back what the run changed — // the tasks it finished or created, its steps, journal and cost rows — and // sends it for the hub to merge into its own copy of the project (Task // 20339). v10 carried the project out and nothing carried it back, so a run // could finish its task on a device while the dashboard, which renders the // hub's database, kept showing it pending — and the next start ran it // again. Gated on the *sending* side: an older hub has no case for the // frame and would answer it with a protocol error, so an agent sends it // only on a session that negotiated v13. See MinProjectResultVersion. // // v14 adds virtual executors (Task 20345). The hello carries the device's // USB inventory, whether it can install a host-side packet filter, and the // OCI runtimes its engine has registered; the start frame gains a Virtual // section naming the sub-executor, its IP firewall and the devices its // sandbox is given; and an inventory frame pair lets the hub re-read the // hardware without waiting for a reconnect. Gated on the *hub* side: a // pre-v14 agent ignores the Virtual section like any unknown key and would // start the sandbox unfiltered and without its hardware, so the hub refuses // such a dispatch instead. See MinVirtualExecutorVersion. // // v15 adds firewall rules stored in the hub (Task 20363): Spec.EgressRules, // the firewall the hub composed for one workload from the device's rule set // and the project's, and Spec.EgressBound, the device's rule set itself, so // the agent proves the containment before installing anything. Gated on the // hub side like v14, and for the same reason: an older agent ignores both // fields and would start the sandbox under the virtual executor's firewall // alone, or under no firewall at all. See MinEgressRulesVersion. // // v16 adds shipped branches (Task 20367): the hub streams a git bundle to // the device in branch_chunk frames ahead of a start frame whose workspace // carries a branch_bundle, and the device builds the tree from it — how a // feature, a branch of a repository on the hub, reaches a remote sandbox. // The hello gains branch_bundles. Gated on the hub side: an older agent // has no handler for the chunks and would ignore the workspace's branch, // running the harness on the feature's base without any of its work. See // MinBranchBundleVersion. // // v17 adds the secret_refresh frame pair (Task 20375): the hub replaces a // credential file it already delivered to a running workload — a GitHub App // installation token re-minted before GitHub's hour ran out — and the agent // rewrites it in place in the lease directory and acknowledges. Gated on the // hub side and degraded rather than refused: an older agent has no handler // for the frame, so the hub does not send it, keeps the dispatch, and // journals that the run's token lapses an hour after dispatch on that // device. See MinSecretRefreshVersion. // // v18 adds the start frame's egress_proxy route (Task 20378): the hub's // egress proxy as this workload reaches it, which the agent's sandbox // driver opens in the workload's firewall. No frame changes; the version // says the agent's drivers honour the field. Gated on the hub side: an // older agent ignores it and would install the firewall without the // proxy in it, so the hub issues no proxy session to a firewalled sandbox // on such a device and journals why. See MinEgressProxyVersion. // // v19 makes a revoke frame naming a grant exact (Task 20403): the agent // takes back that grant's files and variables and leaves the lease's other // grants where they are. No frame changes; grant_id was always on the // wire, but an older agent's vault remembered one grant per lease, so it // either took the whole lease back or answered "not held" while the // revoked grant's file stayed on the device. Gated on the hub side: to an // older agent the hub sends the revocation for the whole lease, and says // so. See MinGrantRevocationVersion. // // v20 adds disk limits (Task 20405): the agent's container driver holds a // workload to ResourceLimits.DiskMB by sampling its workspace, and reports // a workload it stopped at the limit with Status.Outcome "disk_limit" and // the measurement. No frame changes; the status frame carries the new // fields like any other. Gated on the hub side: an older agent's container // driver refuses any disk limit outright, so the hub routes no // disk-limited spec to it, and fills in no ceiling it would refuse. See // MinDiskLimitVersion. // // v21 makes the agent honour Spec.GitCA (Task 20409): the hub's own CA, // which a git proxy serving a certificate the hub issued // (executors.git_proxy.tls: auto) chains to. The agent's provisioning // fetch and write-back push trust it for the proxy's URL, and its inner // drivers write it into the payload's sandbox and name it with // http.<url>.sslCAInfo. No frame changes — the field rides inside the // Spec. Gated on the hub side: an older agent ignores it and its // workload's git would refuse the proxy's certificate, so the hub refuses // a dispatch that would talk to the proxy and names the upgrade, rather // than fall back to delivering the forge credential. See MinGitCAVersion. // // v22 adds the device channel (Task 20430, deviceproto.go): device_open, // device_opened, device_data and device_close, which carry a device // forwarder's stream from the hub to the agent; the start frame's // forwarded_devices, naming the channels its sandbox is given; and the // hello's forwarded_usb and forwarded_l2, saying whether the device can // attach each kind. The agent attaches a USB device through vhci_hcd with // a socketpair and an L2 segment through a TAP, with no listener, and // gives it to the sandbox. Gated on the hub side: an older agent would // ignore forwarded_devices and start the sandbox without the hardware, so // a dispatch whose project holds a forwarder binding is refused there and // names the upgrade. See MinForwardedDeviceVersion. // // v23 makes the agent honour Spec.GitCA.Node (Task 20435): its inner // drivers also name the hub's CA in NODE_EXTRA_CA_CERTS, so a harness on // Node.js — Claude Code — verifies the hub's model relay on the git // proxy's listener. No frame changes; the field rides inside the Spec. // Gated on the hub side: an older agent ignores it and the harness would // refuse the relay's certificate, so the hub relays no run to such a // device — the run gets its credential the way it did before the relay, // and its journal says why. See MinNodeCAVersion. // // v24 adds the denials frame (Task 20437, denialproto.go): the agent reads // its sandboxes' packet log — every deny path of their firewalls refuses // and logs to an NFLOG group — and reports each workload's refusals by // destination, port, protocol and rule, capped, every few seconds and once // more before its terminal status. The hello gains denial_log and // denial_log_issue. Gated on the sending side, like project_result: an // agent sends the frame only on a v24 session, and an older agent's // sandboxes are filtered exactly as strictly, only unreported — the hub // says so where their refusals would be shown. See MinDenialLogVersion. // // v25 lets Spec.GitCA.Node hold two CAs (Task 20438): while the hub // rotates its CA, a sandbox dispatched after the stage trusts both, and so // does the harness's Node.js. No frame changes; an older agent's // validation refuses a Node bundle of more than one certificate, so the // hub hands such an agent the active CA alone and records that the run // trusts only it — an activation then waits for the run, or names it. See // MinNodeCABundleVersion. // // v26 verifies a runtime's isolation claim (Task 20439, isolationproto.go). // The hello gains host_kernel, the device's own kernel as the agent reads // it, and a frame pair — isolation_probe and isolation_evidence — lets the // hub ask the device to run `cloop isolation-probe` in a sandbox under a // runtime it names and send back what the kernel serving it said. The hub // compares the two: a runtime named kata that is runc reads the device's // own boot ID. Gated on the hub side: an older agent has no handler and // would never answer, so the hub does not ask, and records the device's // claim as unverified with the upgrade named. See MinIsolationProbeVersion. ProtocolVersion = 26 // MinProtocolVersion is the oldest version this build still accepts. MinProtocolVersion = 1 // MinRevocationVersion is the first version whose agents understand the // revoke frame. A v1 agent connects and runs work perfectly well; it just // cannot be trusted with material that must be revocable, so the hub // refuses to place such a workload there (see Executor.Start). // // This is a placement rule, not a connectivity rule. Refusing the *device* // would strand a fleet mid-upgrade over a capability most of its work does // not need; refusing the *workload that depends on it* fails exactly the // runs whose guarantee could not be met, and names the reason. MinRevocationVersion = 2 // MinWorkspaceVersion is the first version whose agents provision a git // workspace before launching the harness. // // It is a placement rule for the same reason MinRevocationVersion is, with // a sharper edge: an older agent does not *reject* the credential field, it // ignores it. It would answer the start frame cheerfully, run the harness // in the empty directory it created, and produce a transcript that looks // like a working run operating on no code at all — which is the precise // failure the workspace contract exists to remove. So the hub refuses to // dispatch such a workload rather than trusting the device to complain. MinWorkspaceVersion = 3 // MinWriteBackVersion is the first version whose agents return the work // product after a task finishes. // // A placement rule again, and for the sharpest version of the same reason. // An older agent ignores Spec.WriteBack exactly as it would ignore any // unknown field: it runs the harness, streams a transcript describing the // files it edited, reports success — and then the device's tree, the only // copy of that work, stays on the device. The run is indistinguishable from // one that delivered. So the hub refuses to dispatch a workload that asks // for a write-back to a device that cannot make one, rather than // discovering the loss after the work is gone. MinWriteBackVersion = 4 // MinResumeTerminateVersion is the first version whose agents understand an // explicit refusal in ResumeAck.Action. // // Unlike the three floors above this one is *not* a placement rule, because // there is nothing to refuse to place: it governs what the hub may say to an // agent that is already connected and already running work. An older agent // does not reject an unknown Action, it ignores it — and then reads the // handle's mere presence in ResumeAccepted as "keep streaming", which for a // handle the hub has forgotten means chunks the hub answers with // CodeUnknownHandle forever. So the hub omits refusals entirely below this // version, which reproduces the pre-v5 wire exactly: the agent sees the // handle missing from the list and abandons it, which is what it did before. // // The leak that behaviour causes is fixed on the *device* rather than here // (see the agent's applyResume): an upgraded agent kills an abandoned // workload whatever the hub's version, so the fix reaches a fleet through // the half that can actually stop a process. MinResumeTerminateVersion = 5 // MinSecretFilesVersion is the first version whose agents receive a secret // lease's credential files and place them where the workload will look. // // A placement rule, like MinWorkspaceVersion and for the same shape of // reason: an older agent does not reject StartPayload.SecretFiles, it // ignores it. It would answer the start cheerfully and run the harness with // an environment naming GIT_CONFIG_GLOBAL, KUBECONFIG and CLOOP_LEASE_DIR // under a directory nothing ever created — and a repository-scoped // github_pat, whose entire enforcement *is* those files, would arrive with // no token at all. git then fails to authenticate for a reason nothing in // the transcript names. // // So the hub refuses the *workload*, not the device. A v5 agent still // connects and still runs everything that needs no credential files; // stranding a fleet mid-upgrade over a capability most work does not use // would be a worse failure than the one being prevented. MinSecretFilesVersion = 6 // MinSandboxModeVersion is the first version whose agents read // StartPayload.Sandbox and choose a driver from it. // // A placement rule, like the two above, and the one whose failure mode is // least visible. An older agent handed a container mode does not error: it // ignores a JSON key it does not know and runs the harness as a host // process, reporting success. Nothing in the transcript says the boundary // the admin configured was never applied — which is precisely why this has // to be checked before dispatch rather than discovered afterwards. // // Only container mode is gated. Host and unset are what a pre-v8 agent does // anyway, so sending them to one is not a downgrade; refusing those would // strand a fleet mid-upgrade for no gain. MinSandboxModeVersion = 8 // MinVirtualizationProbeVersion is the first version whose agents report // whether the device can start a VM (AgentCapabilities.Virtualization). // // This one is a rule about *silence*, which makes it the inverse of the // floors above. They ask "may the hub send this?"; this asks "may the hub // believe a field the agent never set?". A pre-v9 agent omits // Virtualization, and the zero value of a bool is false — so reading the // field unconditionally would demote every already-deployed Kata device to // "cannot virtualize" the moment this build ships, refusing placements that // work today. That is the stranding MinProtocolVersion's comment warns // about, arriving through a default rather than a version bump. // // So absence means "unknown", not "no": below this version the hub keeps // trusting the configured runtime name exactly as v8 did, and only a device // that *did* answer can contradict it. An operator upgrading the agent is // what turns the guess into a fact. MinVirtualizationProbeVersion = 9 // MinProjectSeedVersion is the first version whose agents place the hub's // project state into the workspace before starting the harness. // // A placement rule, like MinSecretFilesVersion, and its failure is loud but // aimed at the wrong thing — which is worse than quiet. A pre-v10 agent // ignores the field, provisions a correct tree, and the harness exits // immediately with "no cloop project found (run 'cloop init' first)". An // operator reading that goes to look at the project, which is intact, and // has no reason to suspect the device. // // The workload is refused, not the device, for the reason given at // MinSecretFilesVersion: a v9 agent still runs everything that needs no // seed, and every executor that shares the hub's filesystem needs none. MinProjectSeedVersion = 10 // MinProjectResultVersion is the first version whose agents send a seeded // run's changes back in a project_result frame (Task 20339). // // Not a placement rule, and deliberately so. An older agent runs a seeded // project correctly; what it cannot do is report the outcome, and refusing // it placement would turn "the dashboard is stale after a remote run" into // "remote runs are impossible until every device upgrades". The hub instead // records, on the project's journal, that the executor is too old to say // how the run went, and names the upgrade. The rule that *is* enforced runs // the other way: an agent must not send the frame to a hub below this // version, which has no handler for it. MinProjectResultVersion = 13 // MinVirtualExecutorVersion is the first version whose agents honour // StartPayload.Virtual — a sub-executor's firewall and device list — and // report the inventory those are chosen from (Task 20345). // // A placement rule, and one with the quietest failure of any here. An // older agent handed a Virtual section ignores it and starts the sandbox // from the plain Sandbox settings: same engine, same runtime, a working // run — with no firewall on its bridge and none of the hardware it was // configured with. So the hub refuses to send one, and only a virtual // executor that asks for a firewall or a device needs it: one that only // picks an engine and a runtime is fully described by Sandbox, which v8 // agents already obey. MinVirtualExecutorVersion = 14 // MinEgressRulesVersion is the first version whose agents install // Spec.EgressRules and check them against Spec.EgressBound (Task 20363). // // Required for every dispatch that carries either field, including one // whose rules reach nothing: the agent's own containment check is the // authoritative one, and an agent that cannot make it is not handed rules it // would silently drop. MinEgressRulesVersion = 15 )
Protocol version bounds. Bump ProtocolVersion for any incompatible change; raise MinProtocolVersion only when support for old agents is genuinely dropped, since doing so strands every deployed device until it is upgraded.
const ( // HeartbeatInterval is the nominal gap between agent heartbeats. The // agent applies jitter (see JitterFraction) so that a fleet reconnecting // after a control-plane restart does not resynchronise into a thundering // herd that beats in lockstep forever after. HeartbeatInterval = 15 * time.Second // MissedHeartbeatLimit is how many consecutive intervals may elapse with // no traffic before the control plane declares the agent unreachable. // Three is chosen so a single dropped packet or a brief radio outage on // an LTE edge device does not evict a working executor. MissedHeartbeatLimit = 3 // JitterFraction is the maximum proportional deviation applied to the // heartbeat interval and to reconnect backoff, matching the ±25% jitter // pkg/provider/retry.go uses for provider calls. JitterFraction = 0.25 // WriteTimeout bounds a single frame write. A wedged TCP connection that // never drains must not block the control plane's dispatch goroutine // forever; exceeding this closes the session and the agent reconnects. WriteTimeout = 20 * time.Second )
Timing constants. These are protocol-level agreements, not tunables: both sides must derive their timeouts from the same numbers or a healthy agent gets reaped as dead.
const ( // RevokeScrub invalidates the material and lets the task keep running. RevokeScrub = executor.RevokeScrub // RevokeKill scrubs and then terminates every task holding the lease. RevokeKill = executor.RevokeKill )
const ( CodeUnauthorized = "unauthorized" // CodeVersionUnsupported: the peer's protocol version is out of range. CodeVersionUnsupported = "version_unsupported" // CodeUnknownHandle: the handle is not known to the receiver. CodeUnknownHandle = "unknown_handle" // CodeStartFailed: the workload could not be started. CodeStartFailed = "start_failed" // CodeForbiddenPath: the spec's workdir escapes the agent's root. CodeForbiddenPath = "forbidden_path" // CodeProtocol: the frame was malformed or out of sequence. CodeProtocol = "protocol" // CodeBusy: the agent is at its concurrency ceiling. CodeBusy = "busy" // CodeRevokeUnsupported: the agent's protocol version predates the // revoke frame. Distinct from CodeProtocol so the hub can report // "upgrade this device" rather than "the device is broken". CodeRevokeUnsupported = "revoke_unsupported" // CodeWriteBackFailed: the work product could not be returned. Distinct // from CodeStartFailed because the workload ran — the operator's problem // is a delivery one, and the transcript they are about to read describes // edits that did not arrive. CodeWriteBackFailed = "write_back_failed" )
Error codes carried in ErrorPayload.Code. They are matched programmatically, so unlike Message they must stay stable.
const ( StatusOnline = "online" StatusOffline = "offline" )
Executor status values mirrored into statedb.
const ( // RevokeStatePending: the frame is on its way, or is queued for an agent // that is not currently connected but is expected back. RevokeStatePending = executor.RevokeStatePending // RevokeStateRevoked: the agent acked and reported what it scrubbed. RevokeStateRevoked = executor.RevokeStateRevoked // RevokeStateUnreachable: the agent is offline, so the material is still // on the device. The revocation is queued and will be replayed, but until // then this is the honest state and the UI must show it as such. RevokeStateUnreachable = executor.RevokeStateUnreachable // RevokeStateFailed: the agent answered, and the answer was an error. RevokeStateFailed = executor.RevokeStateFailed )
const DefaultEnrollTTL = 15 * time.Minute
DefaultEnrollTTL bounds an unredeemed enrollment token. Fifteen minutes is long enough to paste a command onto a device over a slow link and short enough that a token left in shell history is inert by the time anyone finds it.
const DefaultLeaseJanitorInterval = time.Minute
DefaultLeaseJanitorInterval is how often expired leases are swept off live agents. It is a fraction of DefaultMaxLeaseTTL (15 minutes in pkg/secretbroker) so a lapsed credential is taken back within a minute of lapsing rather than at the end of the following lease period.
const HarnessImageHint = "ghcr.io/blechschmidt/cloop-harness:latest"
HarnessImageHint is the published sandbox image named when a device has no harness of its own. It carries `claude`, `cloop` and `git`, which is exactly the set a refused dispatch is missing.
A constant rather than a literal in the message because docs/ and the Fleet panel name the same image, and a hint that drifts from what the image actually is would send an operator to pull something that does not fix their problem.
const HarnessInstallTimeout = 20 * time.Minute
HarnessInstallTimeout bounds one install attempt from the hub's side.
Longer than the agent's own script timeout on purpose. The device is the authority on when to give up — it is the one that can see the download — and a hub deadline that fired first would abandon a running installer and report a failure for an install that then succeeded, leaving the fleet in a state the hub's own record disagrees with.
const InventoryTimeout = 20 * time.Second
InventoryTimeout bounds a refresh. Detection reads sysfs and asks the container engine for its runtimes, which is well under a second on a healthy device; the bound is for the one that is not.
const IsolationProbeTimeout = 3 * time.Minute
IsolationProbeTimeout bounds a probe on the device: a Kata VM's boot or a gVisor sandbox's start, and the probe itself.
const LatestVersion = "latest"
LatestVersion is the TargetVersion meaning "whatever the newest published release is", resolved by the device rather than by the hub.
Spelled as a constant rather than as the empty string so that the wire is self-describing: a packet capture showing target_version:"latest" says what was asked for, where an absent field would leave a reader guessing between "latest", "unset" and "the sender forgot".
const LogAckBytes = 32 << 10
LogAckBytes is how much output may accumulate before the control plane acknowledges it. Acking every chunk would double the frame count on a chatty run; acking too rarely would make the agent retain more unacked output than necessary. 32 KiB matches MaxLogChunkBytes, so a busy stream acks roughly every chunk while a trickle of small chunks is batched.
const MaxAttachChunk = 32 << 10
MaxAttachChunk bounds one data frame's payload.
Well under MaxFrameBytes, because the payload is base64-encoded by the JSON marshaller (a []byte field) and therefore grows by a third on the wire, and because a terminal is latency-sensitive: a 1 MiB frame is a quarter-second of head-of-line blocking on a slow uplink for output nobody can read that fast.
const MaxBranchChunkBytes = 512 << 10
MaxBranchChunkBytes is the largest slice of a shipped branch one branch_chunk frame carries. Half the frame limit, because JSON encodes []byte as base64 and the envelope needs room besides.
const MaxDeviceChunk = 32 << 10
MaxDeviceChunk bounds one device_data payload. A []byte travels base64 in JSON, so this is ~43 KiB on the wire, comfortably under MaxFrameBytes, and a USB/IP bulk URB or a jumbo Ethernet frame spans at most a few chunks.
const MaxEnrollTTL = 24 * time.Hour
MaxEnrollTTL caps --ttl. Operators reach for "just make it a week" when provisioning is awkward; that converts a single-use token into a standing credential, which is the thing this design exists to avoid.
const MaxFrameBytes = 1 << 20 // 1 MiB
MaxFrameBytes bounds a single decoded frame. This ceiling exists so a malicious or malfunctioning agent cannot make the control plane allocate unboundedly from a single read.
Two frames are large enough to need their own bound underneath this one: a log chunk, capped at MaxLogChunkBytes, and — since v6 — a start frame carrying credential files, capped at MaxSecretFilesBytes. Everything else is small.
const MaxLogChunkBytes = 32 << 10 // 32 KiB
MaxLogChunkBytes is the largest amount of workload output an agent puts in one log_chunk frame. Output beyond this is split across frames; it is never accumulated waiting for a newline, because a harness that prints a progress bar with no trailing newline still has to reach the live log panel.
const MaxResultChunkBytes = 256 << 10 // 256 KiB
MaxResultChunkBytes bounds one bundle slice.
Larger than a log chunk because a bundle is a bulk transfer with no latency requirement — nobody is watching it scroll — and well under MaxFrameBytes so base64's 4/3 expansion plus the envelope still fits with room to spare.
const MaxSecretFilesBytes = 512 << 10 // 512 KiB
MaxSecretFilesBytes bounds the total plaintext one start frame may carry in StartPayload.SecretFiles.
Before v6 a start frame was small and bounded by the Spec alone; now it can carry file contents, so it is the one frame that could plausibly approach MaxFrameBytes. The ceiling is set well below it because JSON encodes []byte as base64: 512 KiB of plaintext is ~683 KiB on the wire, which still leaves room for the Spec, the workspace credential and the envelope.
It is checked on both sides and it is not the same check as MaxFrameBytes. Hitting the frame limit produces "payload 1103241 bytes exceeds 1048576", which tells an operator nothing about which lease is too big; hitting this one names the credential files. And enforcing it on the sending side means the hub refuses a lease it could never deliver instead of dispatching a frame the device will drop.
executor.MaxSecretFileBytes (256 KiB) bounds one file; this bounds the set, which is the quantity a frame actually has to carry.
const MinAttachVersion = 7
MinAttachVersion is the first protocol version whose agents understand the attach frames. An older device runs work perfectly well and simply cannot be entered; the hub refuses the session rather than sending frames that would be logged as "unexpected" and dropped.
const MinBranchBundleVersion = 16
MinBranchBundleVersion is the first version whose agents receive a shipped branch: branch_chunk frames ahead of the start, and a tree built from them.
A placement rule like MinWorkspaceVersion, and for the reason stated at ProtocolVersion: an older agent would not fail on the workspace's branch, it would ignore it and start the harness on the base commit.
const MinDenialLogVersion = 24
MinDenialLogVersion is the first version whose agents read their sandboxes' packet log and report what it refused (Task 20437).
Neither a placement rule nor a refusal: an older agent's sandbox firewall is as strict as ever, it only cannot say what it refused. So the hub sends it nothing new and dispatches to it as before, and where a run's refusals would be shown it says the device's agent is too old to report them — and how to fix that — instead of an empty list that would read as "nothing was blocked". The rule that is enforced runs the other way: an agent sends the frame only on a session that negotiated v24.
const MinDiskLimitVersion = 20
MinDiskLimitVersion is the first version whose agents hold a container workload to ResourceLimits.DiskMB (Task 20405).
A placement rule, and not for the usual reason. An older agent does not ignore the field — its container driver refuses any disk limit with "disk quotas need a storage driver that supports them" — so the failure is loud, but it is a refused run on a device that is otherwise fine, from a limit the hub put there. So below this version the hub advertises no disk enforcement for the device, placement refuses a spec that states one, and a disk ceiling is reported as unenforced there rather than filled in for the device to turn down.
const MinEgressProxyVersion = 18
MinEgressProxyVersion is the first version whose agents open the hub's egress proxy in a workload's firewall from the route its Spec carries.
A placement rule for the firewalled case only: a sandbox with no firewall reaches the proxy over its ordinary route whatever its agent speaks, so an older device is refused a proxy session only where its firewall would drop the proxy.
const MinForwardedDeviceVersion = 22
MinForwardedDeviceVersion is the first protocol version whose agents understand the device frames. A placement rule: a dispatch whose project holds a forwarder binding is refused on an older agent, naming the upgrade, because an older agent would ignore StartPayload.ForwardedDevices and start the sandbox without the hardware the run was promised.
const MinGitCAVersion = 21
MinGitCAVersion is the first version whose agents make a workload's git — and their own provisioning and write-back git — trust Spec.GitCA for the URLs it names (Task 20409).
A placement rule for the workloads that would talk to the hub's git proxy only: one whose lease rewrites GitHub to the proxy, or whose workspace is fetched through it. Every other workload is dispatched to an older device exactly as before, without the CA.
const MinGrantRevocationVersion = 19
MinGrantRevocationVersion is the first version whose agents take back exactly the grant a revoke frame names.
Not a placement rule. An older agent still honours the frame for the whole lease, which is what the hub sends it instead: taking back more than the revoked grant costs the run credentials it was still entitled to until its next dispatch, where sending the narrowed frame could leave the revoked one on the device.
const MinHarnessInstallVersion = 12
MinHarnessInstallVersion is the first protocol version whose agents understand the install frame.
A device below it is not broken and must not be treated as such: it simply keeps the pre-20336 behaviour of refusing a harness it does not have, with the message that names the manual remedies. The hub therefore checks this gate and *skips* the attempt rather than failing on it — the opposite of MinUpgradeVersion, where being too old is itself the thing the operator asked about and so has to be reported.
const MinIsolationProbeVersion = 26
MinIsolationProbeVersion is the first protocol whose agents answer the isolation probe and report their own kernel at hello.
Gated on the hub's side: an older agent has no case for the request and would never answer it, so the hub does not ask, and records the device's runtime as unverified with the upgrade named.
const MinNodeCABundleVersion = 25
MinNodeCABundleVersion is the first protocol whose agents accept a bundle for Node.js holding two CAs: the hub's, and during a rotation of it the one replacing it or the one it replaced (Task 20438).
const MinNodeCAVersion = 23
MinNodeCAVersion is the first protocol whose agents hand the hub's CA to a harness on Node.js (Spec.GitCA.Node, Task 20435), which a run relayed through the hub's model relay on the git proxy's listener needs. A run on an older device is not relayed: it keeps the credential delivery it had.
const MinSecretRefreshVersion = 17
MinSecretRefreshVersion is the first version whose agents rewrite a running workload's credential files on request.
Not a placement rule: see the file comment.
const MinUpgradeVersion = 11
MinUpgradeVersion is the first protocol version whose agents understand the upgrade frame.
This gate is stricter than the additive ones above it, and deliberately so. A pre-v10 agent that receives a project seed ignores an unknown *field* and still runs the workload, so the hub can degrade. An agent that predates this version does not know the *frame*, and there is no degraded upgrade: it will answer with a protocol error, keep running the build it has, and the operator who clicked Upgrade would be looking at a device that reports no error and never changes. So the hub refuses before sending, with a message naming the only remedy that exists for a device this old — one manual upgrade, on the device, to a build new enough to be upgraded remotely from then on.
const ResultChunkOverhead = 64
ResultChunkOverhead is what the hub counts against the budget for each result chunk it keeps, on top of the chunk's bytes: the slice header that holds it in the assembly, that header's share of the list's growth, and the allocator's rounding of a small allocation. Without it a device sending one-byte chunks would have the hub hold some fifty bytes of memory for each byte the budget counted; with it, what is held stays within the allocator's rounding — an eighth at most — of what is counted.
const StatusUnreachable = "unreachable"
StatusUnreachable is the executors-table status written when an agent misses MissedHeartbeatLimit consecutive heartbeats. It is a distinct value from "offline" (a clean bye) so an operator can tell a device that shut down tidily apart from one that fell off the network.
const TokenFileMode os.FileMode = 0o600
TokenFileMode is the permission an enrollment file must carry. It is the same 0600 the agent's long-lived credential uses, for the same reason: the contents are a bearer credential for as long as they are unredeemed.
Variables ¶
var ( // ErrProtocol: a frame was malformed, out of range, or arrived in a state // where it makes no sense. ErrProtocol = errors.New("remote: protocol error") // ErrVersionUnsupported: the peer's protocol version does not overlap // with this build's supported range. ErrVersionUnsupported = errors.New("remote: unsupported protocol version") // ErrAgentUnreachable: no live session for this agent. Start returns it // immediately rather than queueing work for a device that may never come // back. ErrAgentUnreachable = errors.New("remote: agent is unreachable") // ErrAgentBusy: the agent is at its advertised concurrency ceiling. ErrAgentBusy = errors.New("remote: agent is at capacity") // ErrSessionClosed: the session ended while a request was in flight. ErrSessionClosed = errors.New("remote: session closed") // ErrTokenInvalid: an enrollment token is malformed, unknown, or its MAC // does not verify. ErrTokenInvalid = errors.New("remote: invalid enrollment token") // ErrTokenExpired: the enrollment token's TTL elapsed before redemption. ErrTokenExpired = errors.New("remote: enrollment token expired") // ErrTokenAlreadyUsed: the enrollment token was already redeemed. This is // the replay case, and it is deliberately distinct from ErrTokenInvalid // so an operator can tell "someone captured and replayed this token" // apart from "the device typo'd it". ErrTokenAlreadyUsed = errors.New("remote: enrollment token already redeemed") // ErrRevoked: the enrollment token or agent credential was revoked. ErrRevoked = errors.New("remote: credential revoked") // ErrCredentialInvalid: an agent credential is malformed or unknown. ErrCredentialInvalid = errors.New("remote: invalid agent credential") // ErrPathOutsideRoot: a spec's workdir escapes the agent's configured // root. Enforced on the device, because the control plane cannot know the // device's filesystem, and re-checked as a hard boundary rather than a // convention. ErrPathOutsideRoot = errors.New("remote: workdir escapes agent root") // ErrAgentNotFound: no agent with the requested ID is enrolled. ErrAgentNotFound = errors.New("remote: agent not enrolled") // ErrRevocationUnsupported: the agent speaks a protocol version older // than MinRevocationVersion, so material handed to it could never be // taken back mid-run. Placing a workload that carries revocable secrets // fails with this rather than proceeding without the guarantee. // // It wraps executor.ErrRevocationUnsupported so a caller that has no // reason to know which driver refused — the run panel reporting why a // task would not start — can match the one sentinel and still get this // driver's far more specific message. ErrRevocationUnsupported = fmt.Errorf("remote: agent does not support lease revocation: %w", executor.ErrRevocationUnsupported) // ErrWorkspaceUnsupported: the agent speaks a protocol version older than // MinWorkspaceVersion, so it would ignore the workspace credential and run // the harness against an empty tree. Placing a workload whose source has to // be cloned fails with this rather than producing a run that looks fine and // operated on no code. ErrWorkspaceUnsupported = errors.New("remote: agent does not support workspace provisioning") // ErrSecretFilesUnsupported: the agent speaks a protocol version older than // MinSecretFilesVersion, so it would ignore the credential files and run the // harness with an environment naming paths nothing ever created. Placing a // workload whose secret lease delivers files fails with this rather than // producing a run whose git authentication fails for a reason nothing names. ErrSecretFilesUnsupported = errors.New("remote: agent does not support secret credential files") // ErrProjectSeedUnsupported: the agent speaks a protocol version older than // MinProjectSeedVersion, so it would ignore the project state and run the // harness in a clone that holds a source repository but no cloop project. // Placing such a workload fails with this rather than producing a run that // exits with "no cloop project found" — a message that names the project // rather than the device that dropped it. ErrProjectSeedUnsupported = errors.New("remote: agent does not support project seeding") // ErrSandboxModeUnsupported: the agent speaks a protocol version older than // MinSandboxModeVersion, so it would ignore StartPayload.Sandbox and run the // payload as a host process — whatever containment an admin configured for // this executor. Placing a container-mode workload fails with this rather // than running it on the host and reporting success, which is the one // failure mode of this feature that nothing downstream could detect. ErrSandboxModeUnsupported = errors.New("remote: agent does not support sandbox mode selection") // ErrGitCAUnsupported: the agent speaks a protocol version older than // MinGitCAVersion, so it would ignore Spec.GitCA and run a workload whose // git reaches the hub's proxy without the CA that proxy's certificate // chains to (Task 20409). Refused rather than dispatched: the failure // would be an SSL error inside the sandbox, and the only alternative the // hub has — handing the forge credential over instead — is the one the // proxy exists to rule out. ErrGitCAUnsupported = errors.New("remote: agent cannot trust the hub's git proxy certificate") // ErrNodeCAUnsupported: the workload's harness would talk to the hub's // model relay over TLS the hub's CA certifies, and this agent predates // handing that CA to Node.js (MinNodeCAVersion). The hub decides before // dispatch not to relay such a run; this is the driver's backstop, never // a silent drop that would leave the harness refusing the relay. ErrNodeCAUnsupported = errors.New("remote: agent cannot hand the hub's CA to the harness's Node.js") // ErrNodeCABundleUnsupported: the hub's CA is being rotated, so the // bundle for the harness's Node.js holds two CAs, and this agent predates // accepting more than one (MinNodeCABundleVersion, Task 20438). The hub // hands such an agent the active CA alone before dispatch; this is the // driver's backstop, never a bundle the agent would refuse to start. ErrNodeCABundleUnsupported = errors.New("remote: agent cannot hand two hub CAs to the harness's Node.js") // ErrForwardedDevicesUnsupported: the run's project holds a device // forwarder binding (Task 20430), and this executor cannot attach the // device into a sandbox — its agent predates the device channel // (MinForwardedDeviceVersion), it runs payloads on its host with no // sandbox to give the device to, or the device cannot attach the kind. // Refused rather than dispatched: an older agent would ignore the channel // list and start the sandbox without the hardware the run was promised. ErrForwardedDevicesUnsupported = errors.New("remote: agent cannot attach a forwarded device") // executor's configured sandbox mode, so it does not know whether the // payload is supposed to be contained. Dispatch fails rather than // proceeding: the alternative is running on the host because a database was // briefly busy, which is a containment decision made by a storage fault. ErrSandboxModeUnavailable = errors.New("remote: executor sandbox configuration is unreadable") // under a Kata runtime, but the device reported that it cannot open // /dev/kvm. Dispatch fails here rather than on the device, where the same // condition costs ~50s and surfaces as a QMP socket timeout that names // neither KVM nor the executor. // // Distinct from ErrSandboxModeUnsupported because the remedy is: that one // is fixed by upgrading the agent, this one usually cannot be fixed on the // device at all — nested virtualization is the hosting hypervisor's // decision — so the useful advice is to choose a boundary the machine can // actually provide. ErrVirtualizationUnavailable = errors.New("remote: device cannot start a virtual machine") // ErrWriteBackBudget: holding what a device sent back would take this hub // past the memory it allows returned work to occupy — the executor's own // budget (executors.remote.max_pinned_writeback_bytes) or the ceiling for // every executor together (max_pinned_writeback_total_bytes). The handle's // write-back fails with the reason recorded on it, so the run's journal // says which limit and how much was held (Task 20399). // // It wraps executor.ErrWriteBackUnavailable: nothing about the work itself // is implicated, and the remedy — collect, revoke, or raise the limit — is // an operator's. ErrWriteBackBudget = fmt.Errorf("remote: returned work would exceed the hub's memory budget: %w", executor.ErrWriteBackUnavailable) // ErrLeaseNotHeld: the agent was asked to revoke a lease it is not // holding. It is reported, not raised — "the material is not here" is // the end state a revocation wants — so callers treat it as success // with a note rather than as something to retry. ErrLeaseNotHeld = errors.New("remote: agent is not holding this lease") // the device does not have the agent CLI the project's provider drives. // // The failure this replaces is the most expensive shape a dispatch has: the // hub leases a credential, the broker writes an audit row, the agent places // the material on the device's tmpfs, the workspace is provisioned — and // then `cloop run` execs a binary that is not there and the whole thing // unwinds in under a second, leaving "executable file not found in $PATH" // as the only evidence, on the far side of the link, naming neither the // executor nor the provider that asked for it. // // Gated on the sandbox mode, which is the whole subtlety: AgentCapabilities // describes the *device*, and in container mode the harness comes from the // image instead. Refusing on the device's inventory there would reject the // configuration that actually fixes this one. ErrHarnessUnavailable = errors.New("remote: device does not have the harness this project needs") )
Sentinel errors for the remote executor. Callers match with errors.Is; implementations wrap these with %w and add detail.
The distinction that matters most operationally is between ErrAgentUnreachable and everything else. A control plane that hangs waiting for a NAT'd device that went offline is indistinguishable, from the UI, from a workload that is simply slow — so Start fails fast with this specific error and the caller can say "edge-1 is offline" instead of spinning.
var ErrHarnessInstallUnsupported = errors.New("remote: agent does not support harness install")
ErrHarnessInstallUnsupported reports that a device's agent predates the install frame. It is informational rather than fatal: the caller falls back to refusing the dispatch with the message that names the manual remedies.
ErrIsolationProbeUnavailable reports a device the hub cannot probe: too old to be asked, or with no sandbox runtime to ask about.
var ErrUpgradeChannel = errors.New("remote: the device does not follow the edge channel")
ErrUpgradeChannel reports an edge target for a device that does not follow the edge channel (Task 20376). The device would refuse it too; refusing here says so before a frame is sent, and says that only the device can change it.
var ErrUpgradeLowersProtocol = errors.New("remote: upgrade would lower the device's protocol")
ErrUpgradeLowersProtocol reports an upgrade target whose binaries speak an older protocol than the device already does (Task 20371). The device's own check cannot see it — "dev+g47e68a9" cannot be ordered against "v0.0.4" — so it would install the release and lose everything the newer protocol carried. UpgradeRequest.Force overrides it, as it overrides a downgrade on the device.
var ErrUpgradeRollback = errors.New("remote: the target is earlier on main than the device's build")
ErrUpgradeRollback reports a target earlier on main than the device's build (Task 20380): its sequence is lower, or it carries none while the device's build does. The device's installer refuses such a build whoever asks, and the frame's force flag does not reach that refusal — so neither does UpgradeRequest.Force here. Only root on the device can roll it back.
var ErrUpgradeTarget = errors.New("remote: upgrade target is not a published release")
ErrUpgradeTarget reports an upgrade target that is neither LatestVersion nor a release tag — most often an unreleased hub's own version, "dev+g8b418e2", which the Upgrade dialog used to prefill. A device installs published, signed releases and nothing else, so no such request could do what it says.
var ErrUpgradeUnsupported = errors.New("remote: agent does not support remote upgrade")
ErrUpgradeUnsupported reports that a device's agent predates the upgrade frame, and so can only be moved forward by hand on the device itself.
var ErrVirtualExecutorUnsupported = errors.New("remote: device cannot apply this virtual executor's configuration")
ErrVirtualExecutorUnsupported reports a virtual executor whose firewall or device list the device cannot apply.
Functions ¶
func CleanUSBInventory ¶ added in v0.0.5
CleanUSBInventory keeps the well-formed entries of an inventory a device reported, up to the bound.
Dropping rather than refusing the whole report: a device with one odd USB gadget attached must not lose its hello — and with it every other capability — over it. What is dropped never reaches the database or the dashboard, which is the property that matters, since these strings come from device firmware.
func EnrollmentRemediation ¶ added in v0.0.2
EnrollmentRemediation returns the one-line fix for an enrollment failure, or "" when err is not one this function has advice for.
Returning "" rather than a generic fallback is deliberate: a caller can then tell "I have specific advice" from "I do not", and print its own context instead of a platitude. A remediation line that says "check your configuration" trains operators to stop reading them.
func HeartbeatDeadline ¶
HeartbeatDeadline is how long the control plane waits for traffic from an agent before marking it unreachable. It is derived from the protocol constants rather than written as a literal so the two can never drift.
func MintBundle ¶
func MintBundle(store Store, opts MintOptions) (Bundle, EnrollmentRecord, error)
MintBundle mints an enrollment token and wraps it, with the hub's pin, into the blob the operator carries to the device.
It is a thin layer over Mint rather than a replacement so the storage-facing contract (single-use, hash-only persistence, MAC-bound tokens) stays in one place and is exercised by both paths.
func NegotiateVersion ¶
NegotiateVersion returns the version two peers should speak, or an error when their supported ranges do not overlap. Taking the lower of the two advertised maxima is what lets a newer control plane keep serving older agents through a staged fleet upgrade.
func NewPipe ¶
NewPipe returns two Conns wired to each other. Frames written to one are readable from the other. Closing either closes both, matching how a real socket behaves.
func Revoke ¶
Revoke invalidates an enrollment token or an agent credential by ID.
The same command handles both because an operator responding to a leak knows the ID they were given, not which table it lives in. An enrollment ID additionally revokes the agent it produced: a token that leaked before redemption and was redeemed by an attacker must not leave that attacker's credential live.
func StoredBuildSequence ¶ added in v0.0.5
StoredBuildSequence reads the build sequence out of a device's stored capability advertisement — the JSON the hub records at every connect — for a device that is not connected now (Task 20380). It returns 0 when there is none, the advertisement predates the field, or the value is out of range.
func SupportsAttach ¶ added in v0.0.2
SupportsAttach reports whether an agent at this negotiated version can host an interactive session.
func SupportsBranchBundle ¶ added in v0.0.5
SupportsBranchBundle reports whether an agent speaking this protocol version can receive a shipped branch.
func SupportsDenialLog ¶ added in v0.0.7
SupportsDenialLog reports whether an agent speaking this version reports its sandboxes' refusals.
func SupportsDiskLimit ¶ added in v0.0.5
SupportsDiskLimit reports whether an agent speaking this protocol version holds a container workload to a disk limit.
func SupportsEgressProxy ¶ added in v0.0.5
SupportsEgressProxy reports whether an agent speaking this protocol version opens a workload's egress proxy route in its firewall.
func SupportsEgressRules ¶ added in v0.0.5
SupportsEgressRules reports whether an agent speaking this protocol version installs a workload's firewall rules and proves them against the device's.
func SupportsForwardedDevices ¶ added in v0.0.6
SupportsForwardedDevices reports whether an agent at this negotiated version can attach a forwarded device.
func SupportsGitCA ¶ added in v0.0.5
SupportsGitCA reports whether an agent speaking this protocol version honours Spec.GitCA.
func SupportsGrantRevocation ¶ added in v0.0.5
SupportsGrantRevocation reports whether an agent speaking this protocol version takes back exactly the grant a revoke frame names.
func SupportsHarnessInstall ¶ added in v0.0.3
SupportsHarnessInstall reports whether an agent speaking this protocol version can be asked to install a harness.
func SupportsIsolationProbe ¶ added in v0.0.7
SupportsIsolationProbe reports whether an agent speaking this protocol version answers the isolation probe.
func SupportsNodeCA ¶ added in v0.0.7
SupportsNodeCA reports whether an agent speaking this protocol version honours Spec.GitCA.Node.
func SupportsNodeCABundle ¶ added in v0.0.7
SupportsNodeCABundle reports whether an agent speaking this protocol version accepts a Node bundle of more than one CA.
func SupportsProjectResult ¶ added in v0.0.3
SupportsProjectResult reports whether a session at this protocol version can carry a seeded run's changes back in a project_result frame.
func SupportsProjectSeed ¶ added in v0.0.3
SupportsProjectSeed reports whether an agent speaking this protocol version places the hub's `.cloop/` project state into the workspace it provisioned.
func SupportsResumeTerminate ¶
SupportsResumeTerminate reports whether an agent speaking this protocol version acts on an explicit ResumeAck.Action rather than reading only membership of Welcome.ResumeAccepted.
func SupportsRevocation ¶
SupportsRevocation reports whether an agent speaking this protocol version honours the revoke frame.
func SupportsSandboxMode ¶ added in v0.0.3
SupportsSandboxMode reports whether an agent speaking this protocol version reads StartPayload.Sandbox and selects its driver from it, rather than always running the payload as a host process.
func SupportsSecretFiles ¶
SupportsSecretFiles reports whether an agent speaking this protocol version receives a lease's credential file contents and places them for the workload.
func SupportsSecretRefresh ¶ added in v0.0.5
SupportsSecretRefresh reports whether an agent speaking this protocol version handles the secret_refresh frame.
func SupportsUpgrade ¶ added in v0.0.3
SupportsUpgrade reports whether an agent speaking this protocol version can be asked to upgrade itself.
func SupportsVirtualExecutor ¶ added in v0.0.5
SupportsVirtualExecutor reports whether an agent speaking this protocol version applies StartPayload.Virtual and reports its hardware inventory.
func SupportsVirtualizationProbe ¶ added in v0.0.3
SupportsVirtualizationProbe reports whether an agent speaking this protocol version answers AgentCapabilities.Virtualization, and therefore whether a false in that field means "this device cannot start a VM" rather than "this agent is too old to have been asked".
func SupportsWorkspaceProvisioning ¶
SupportsWorkspaceProvisioning reports whether an agent speaking this protocol version materialises a git workspace before starting the harness.
func SupportsWriteBack ¶
SupportsWriteBack reports whether an agent speaking this protocol version returns the work product after a task finishes.
func ValidVirtualID ¶ added in v0.0.5
ValidVirtualID reports whether id can name a virtual executor.
func ValidateSecretFiles ¶
func ValidateSecretFiles(files []SecretFile) error
ValidateSecretFiles checks a start payload's credential files: each one individually, plus the aggregate size no single file can see.
Both sides call it. The hub calls it before dispatch, so a lease it could never deliver fails with a message naming the files instead of producing a frame the device silently drops; the agent calls it at decode, before a byte of it reaches a filesystem.
Types ¶
type AcceptOptions ¶
type AcceptOptions struct {
// Agent is the already-authenticated identity of the peer. The hello
// frame's AgentID is checked against it; a mismatch is fatal.
Agent AgentRecord
// Executor is the durable driver this session attaches to.
Executor *Executor
// IssuedCredential is non-empty when this connection redeemed an
// enrollment token, in which case the credential is handed to the agent
// in the welcome frame. A freshly enrolling agent does not yet know its
// own ID, so the hello identity check is relaxed for exactly this case.
IssuedCredential string
// Now overrides the clock for tests.
Now func() time.Time
// HeartbeatPoll overrides how often the watchdog checks for silence.
// Zero uses half the heartbeat interval, which keeps detection latency a
// fraction of the deadline. Tests set it small so a timeout transition
// can be exercised in milliseconds against a synthetic clock instead of
// waiting out a real 45-second deadline.
HeartbeatPoll time.Duration
}
AcceptOptions parameterises the server-side handshake.
type AgentCapabilities ¶
type AgentCapabilities struct {
OS string `json:"os"`
Arch string `json:"arch"`
// CPUs is the number of logical cores visible to the agent.
CPUs int `json:"cpus,omitempty"`
// MemoryMB is total system memory in megabytes; 0 when undetectable.
MemoryMB int `json:"memory_mb,omitempty"`
// ContainerRuntimes lists runtimes found on PATH ("docker", "podman"),
// so the control plane can route container workloads to a device that
// can actually honour them.
ContainerRuntimes []string `json:"container_runtimes,omitempty"`
// Harnesses lists agent CLIs found on PATH ("claude", "codex", ...). A
// device without the harness a project needs should not be sent its work.
Harnesses []string `json:"harnesses,omitempty"`
// WorkDirRoot is the directory the agent confines every workload to.
// Reported so an operator can see the sandbox boundary from the UI.
WorkDirRoot string `json:"workdir_root,omitempty"`
// WorkspaceProvisioning reports that this device can materialise a git
// workspace itself — it has git on PATH and a build that knows how to use
// it. Advertised rather than assumed because the failure it prevents is
// silent: a device without git would answer the start frame and run the
// harness against an empty tree.
WorkspaceProvisioning bool `json:"workspace_provisioning,omitempty"`
// WriteBack reports that this device can return a finished task's work
// product — it has git and a build that knows how to commit and bundle.
// Advertised for the same reason WorkspaceProvisioning is, and against the
// same silent failure at the other end of the run: a device that cannot
// write back does not say so, it just keeps the work.
WriteBack bool `json:"write_back,omitempty"`
// BranchBundles reports that this device can build a workload's tree from
// a branch the hub ships with the start frame (protocol v16, Task 20367).
// It needs git, like WorkspaceProvisioning and WriteBack, and is
// advertised for the same reason: without it a feature placed here would
// run on its base commit with none of its work.
BranchBundles bool `json:"branch_bundles,omitempty"`
// MaxConcurrent is the agent's ceiling on simultaneous workloads.
MaxConcurrent int `json:"max_concurrent,omitempty"`
// Virtualization reports that this device can actually start a
// hypervisor-backed sandbox: /dev/kvm exists and the agent can open it
// read-write.
//
// Advertised for the same reason WorkspaceProvisioning and WriteBack are,
// against the same shape of silent failure — but this one is the only
// capability here that the control plane would otherwise assert on the
// device's behalf. Whether a payload is virtualized was treated as purely a
// property of the runtime name an admin configured, which is true of the
// *intent* and not of the *ability*: naming kata for a device with no
// nested virtualization produced a hub that reported a hypervisor boundary
// that could never exist. Only the device can answer this, so only the
// device does.
//
// False on a v9+ agent is load-bearing: see Executor.Capabilities, which
// stops claiming Virtualized, and Executor.Start, which refuses the
// dispatch rather than letting QEMU fail inside the sandbox.
//
// This is deliberately *only* about a hypervisor. gVisor needs no /dev/kvm
// — its Sentry is a userspace kernel — so a device reporting false here can
// still serve a kernel-isolated sandbox under runsc, and KernelIsolated is
// never narrowed by it.
Virtualization bool `json:"virtualization,omitempty"`
// USBDevices is the USB hardware attached to the device, as the kernel
// enumerates it (Task 20345). It is what an admin chooses from when giving
// a virtual executor's sandboxes a device, and it is a snapshot: the hub
// re-reads it on demand with an inventory frame, and a selection is
// resolved against the device's own live view at every dispatch.
USBDevices []executor.USBDevice `json:"usb_devices,omitempty"`
// PacketFilter reports that the agent can install a host-side IP filter
// on a sandbox bridge — nft(8) is present and the agent holds
// CAP_NET_ADMIN. PacketFilterIssue says why not when it cannot.
//
// A virtual executor with a firewall is refused on a device that reports
// false, because the only alternative is a sandbox whose firewall exists
// in the control plane's database and nowhere on the machine. Below v14
// the field is absent, which the hub reads as "cannot" — there is no
// firewall-capable agent older than the field.
PacketFilter bool `json:"packet_filter,omitempty"`
PacketFilterIssue string `json:"packet_filter_issue,omitempty"`
// DenialLog reports that the agent reads its sandboxes' packet log and
// reports what their firewalls refused (Task 20437): it bound an NFLOG
// group with the CAP_NET_ADMIN its packet filter already has.
// DenialLogIssue says why not when it cannot. Absent below v24, which the
// hub reads as "cannot".
DenialLog bool `json:"denial_log,omitempty"`
DenialLogIssue string `json:"denial_log_issue,omitempty"`
// ForwardedUSB reports that the agent can attach a forwarded USB device
// on its host — vhci_hcd is loaded and the agent may write its attach
// file — and ForwardedL2 that it can create a TAP and move it into a
// container sandbox (Task 20430). The issues say why not. Absent below
// v22, which the hub reads as "cannot".
ForwardedUSB bool `json:"forwarded_usb,omitempty"`
ForwardedUSBIssue string `json:"forwarded_usb_issue,omitempty"`
ForwardedL2 bool `json:"forwarded_l2,omitempty"`
ForwardedL2Issue string `json:"forwarded_l2_issue,omitempty"`
// OCIRuntimes are the low-level runtimes the device's container engine
// has registered ("runc", "runsc", "kata"), so the dashboard can offer
// them instead of asking an admin to type a name the device may not know.
OCIRuntimes []string `json:"oci_runtimes,omitempty"`
// Labels are free-form selectors (region, site, gpu) set on the device,
// with `cloop executor agent --label`. They are the device's own claim, and
// stored with the rest of its advertisement only: the labels a grant to a
// label selector is matched against are the ones on the device's
// credential, which the hub recorded at enrollment (Task 20432), and
// nothing reads these to decide one.
Labels map[string]string `json:"labels,omitempty"`
// UpdateChannel is the channel the device installs builds from (Task
// 20376): "stable", releases only, or "edge", signed builds of commits on
// main as well. An operator on the device chooses it, with a drop-in the
// hub has no way to change; the hub reads it only to decide what the
// Upgrade dialog and the auto-update policy offer. Absent from an agent
// older than the field, which the hub reads as stable — there is no
// edge-capable agent older than it, so no protocol bump is needed: the
// field is its own capability flag.
UpdateChannel string `json:"update_channel,omitempty"`
// RemoteUpgrade reports that the device can carry out an upgrade the hub
// asks for: it has the root helper (install --remote-upgrade), or the
// agent runs as root, and cosign is installed to verify what it fetches.
// RemoteUpgradeIssue says why not when it cannot. Both absent means an
// agent older than the fields, whose answer is unknown.
RemoteUpgrade bool `json:"remote_upgrade,omitempty"`
RemoteUpgradeIssue string `json:"remote_upgrade_issue,omitempty"`
// BuildSequence is the agent build's first-parent position on main (Task
// 20380), the number the device's root helper orders installs by: it
// refuses a build whose sequence is lower than the installed one's. The
// hub reads it to show in the fleet inventory, to never offer a device a
// build it would refuse, and for hub doctor to say how far behind its own
// build a device is. Absent from a build that was not stamped with one —
// and from every agent older than the field, which the hub reads the same
// way: unknown. Informational to the hub, so no protocol bump: the
// device's own installer is what enforces the order.
BuildSequence int `json:"build_sequence,omitempty"`
// HostKernel is the device's own kernel as the agent reads it — uname,
// /proc/version, the boot ID — the reference an isolation probe's evidence
// is compared with (Task 20439, protocol v26). The probe's reply carries a
// fresh reading too; this one is for the fleet view. Absent from an older
// agent, whose runtimes the hub then cannot verify.
HostKernel *isolationprobe.Evidence `json:"host_kernel,omitempty"`
}
AgentCapabilities describes the device, so the control plane can decide whether a workload belongs here. It is a superset of executor.Capabilities: the extra fields (CPU count, memory, container runtimes, harnesses) are what a scheduler needs and an in-process driver never had to report.
func (AgentCapabilities) Executor ¶
func (c AgentCapabilities) Executor() executor.Capabilities
Executor projects the device's advertised capabilities onto the driver-independent executor.Capabilities the rest of cloop reasons about.
Isolation is always IsolationRemote and SharesHostFilesystem always false: whatever the device does internally, from the control plane's perspective the workload is on another machine and Spec.WorkDir is not a path it can read. Claiming otherwise would let host-side tooling silently look in the wrong place.
type AgentRecord ¶
type AgentRecord struct {
AgentID string `json:"agent_id"`
Name string `json:"name"`
// SecretHash is hex-encoded SHA-256 of the credential secret.
SecretHash string `json:"secret_hash"`
WorkDirRoot string `json:"workdir_root,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
CreatedAt time.Time `json:"created_at"`
LastSeen time.Time `json:"last_seen,omitempty"`
RevokedAt time.Time `json:"revoked_at,omitempty"`
// EnrollmentID records which token minted this agent, so revoking a
// leaked token can also reach the identity it produced.
EnrollmentID string `json:"enrollment_id,omitempty"`
}
AgentRecord is the control plane's durable view of one enrolled agent and its long-lived credential.
func Authenticate ¶
Authenticate verifies a long-lived agent credential and returns the agent it belongs to. It is called on every reconnect, so revoking an agent takes effect the next time it dials rather than requiring the control plane to hunt down a live session — though the session layer also drops live connections on revoke, so both paths are covered.
func Redeem ¶
func Redeem(store Store, token string, opts RedeemOptions) (credential string, agent AgentRecord, err error)
Redeem exchanges an enrollment token for a long-lived agent credential.
It is the security-critical path, so the order of checks is deliberate: format and MAC first (cheap, no storage access), then existence, then revocation, then expiry, then the secret comparison, and only then the atomic single-use claim. Verifying the secret before claiming means a wrong secret cannot burn a legitimate token; claiming atomically after means two correct-secret racers cannot both succeed.
The returned credential string is shown once and stored only as a hash.
func (AgentRecord) Revoked ¶
func (r AgentRecord) Revoked() bool
Revoked reports whether the agent's credential was revoked.
type AttachClosePayload ¶ added in v0.0.2
type AttachClosePayload struct {
SessionID string `json:"session_id"`
// Reason is a short human-readable cause, shown to the operator.
Reason string `json:"reason,omitempty"`
// ExitCode is the command's status when the device observed one. -1 means
// it did not (killed, or the session was closed from the hub end).
ExitCode int `json:"exit_code,omitempty"`
}
AttachClosePayload ends a session.
func DecodeAttachClose ¶ added in v0.0.2
func DecodeAttachClose(f Frame) (AttachClosePayload, error)
DecodeAttachClose decodes a close notice.
type AttachDataPayload ¶ added in v0.0.2
AttachDataPayload carries one chunk in either direction.
func DecodeAttachData ¶ added in v0.0.2
func DecodeAttachData(f Frame) (AttachDataPayload, error)
DecodeAttachData decodes one chunk, refusing an oversized one.
The check is here rather than at the sender because the sender is the party that might be malicious: an agent that streamed unbounded frames at a hub would be turning a debugging session into a memory-exhaustion primitive against the control plane.
type AttachOpenPayload ¶ added in v0.0.2
type AttachOpenPayload struct {
// SessionID is minted by the hub and scopes every later frame.
SessionID string `json:"session_id"`
// Command is the argv to run. Empty means the agent's default shell.
Command []string `json:"command,omitempty"`
// TTY requests a pseudo-terminal on the device.
TTY bool `json:"tty,omitempty"`
// Stdin reports that this session may deliver input. When false the agent
// must give the command no input descriptor at all — the read-only
// guarantee is enforced on the device, not by the hub declining to send.
Stdin bool `json:"stdin,omitempty"`
// Rows and Cols are the initial geometry; ignored without a TTY.
Rows uint16 `json:"rows,omitempty"`
Cols uint16 `json:"cols,omitempty"`
// Env carries extra "K=V" entries for the command (TERM, in practice).
Env []string `json:"env,omitempty"`
}
AttachOpenPayload asks the device to start an interactive command.
func DecodeAttachOpen ¶ added in v0.0.2
func DecodeAttachOpen(f Frame) (AttachOpenPayload, error)
DecodeAttachOpen decodes and validates an open request.
type AttachOpenedPayload ¶ added in v0.0.2
type AttachOpenedPayload struct {
SessionID string `json:"session_id"`
// TTY reports whether the device actually allocated a terminal. It can be
// false on a request that asked for one — a device without a pty
// implementation degrades to pipes rather than refusing — and the hub tells
// the operator instead of letting them wonder why there is no prompt.
TTY bool `json:"tty"`
}
AttachOpenedPayload answers an open request.
func DecodeAttachOpened ¶ added in v0.0.2
func DecodeAttachOpened(f Frame) (AttachOpenedPayload, error)
DecodeAttachOpened decodes an open acknowledgement.
type AttachResizePayload ¶ added in v0.0.2
type AttachResizePayload struct {
SessionID string `json:"session_id"`
Rows uint16 `json:"rows"`
Cols uint16 `json:"cols"`
}
AttachResizePayload reports terminal geometry.
func DecodeAttachResize ¶ added in v0.0.2
func DecodeAttachResize(f Frame) (AttachResizePayload, error)
DecodeAttachResize decodes a geometry report.
type BranchChunkPayload ¶ added in v0.0.5
type BranchChunkPayload struct {
// Offset is where Data starts in the bundle.
Offset int64 `json:"offset"`
// Data is the slice.
Data []byte `json:"data"`
// Total is the bundle's full size, repeated on every chunk so a device can
// refuse an oversized transfer on its first frame instead of its last.
Total int64 `json:"total"`
}
BranchChunkPayload is one slice of a shipped branch's bundle.
func DecodeBranchChunk ¶ added in v0.0.5
func DecodeBranchChunk(f Frame) (BranchChunkPayload, error)
DecodeBranchChunk decodes a slice of a shipped branch, refusing one no well-behaved hub would send.
func (BranchChunkPayload) End ¶ added in v0.0.5
func (p BranchChunkPayload) End() int64
End is the offset just past this chunk.
type Bundle ¶
type Bundle struct {
// Server is the control-plane WebSocket URL the device dials.
Server string `json:"server"`
// Token is the single-use enrollment token.
Token string `json:"token"`
// Pin is the hub's SPKI fingerprint ("sha256:<base64>"), or "" when the
// control plane has no TLS certificate configured. An empty pin is not a
// silent downgrade: the device still refuses plaintext to a non-loopback
// host, so an unpinned bundle can only be used against a server with a
// publicly-trusted certificate, or over loopback.
Pin string `json:"pin,omitempty"`
// EnrollmentID identifies the minted token for revocation.
EnrollmentID string `json:"enrollment_id"`
// Name is the operator-facing label the device will carry.
Name string `json:"name"`
// WorkDirRoot is the filesystem root the device confines workloads to.
WorkDirRoot string `json:"workdir_root,omitempty"`
// ExpiresAt is when the token stops being redeemable.
ExpiresAt time.Time `json:"expires_at"`
}
Bundle is the enrollment payload handed to a device.
It is deliberately JSON with an unpadded-base64 envelope rather than a bespoke format: an operator pastes it into a terminal, a provisioning script pipes it through a file, and both need it to survive being a single opaque word with no shell-special characters in it.
func DecodeBundle ¶
DecodeBundle parses the output of Encode.
func ReadTokenFile ¶
ReadTokenFile loads enrollment material from a file, accepting either a bundle or a bare token.
This is what lets an installed service keep the token out of its command line (see pkg/executor/install): a unit file is world-readable and `systemctl show` prints ExecStart to anyone, so the credential travels as a path and is read here instead.
Accepting both forms is not laxity — an operator with a bare token from an older hub, and a provisioning system writing the bundle the dashboard displays, should both work without the operator having to know which one they hold. The prefix makes the two unambiguous.
warning is non-empty when the file is readable by anyone but its owner. It is a warning rather than a refusal for the reason set out in the agent's credential loader: bricking a fleet because a provisioning script ran with the wrong umask is a worse outcome than the exposure, and the operator can act on a warning by revoking the token.
func (Bundle) Command ¶
Command renders the copy-pasteable device command for this bundle.
The pin is included as a flag rather than left to the bundle blob because the flag form is what an operator reads, checks against what the control plane printed, and notices when it is missing.
type ByePayload ¶
type ByePayload struct {
Reason string `json:"reason,omitempty"`
// Reconnect tells the peer whether to come back. False means the
// credential was revoked or the agent deregistered, so an agent that
// obeys it stops hammering a control plane that will never accept it.
Reconnect bool `json:"reconnect"`
}
ByePayload announces an orderly shutdown.
func DecodeBye ¶
func DecodeBye(f Frame) (ByePayload, error)
type Conn ¶
type Conn interface {
// ReadFrame blocks until a frame arrives, ctx is cancelled, or the peer
// goes away (io.EOF / net.ErrClosed).
ReadFrame(ctx context.Context) (Frame, error)
// WriteFrame sends one frame.
WriteFrame(ctx context.Context, f Frame) error
// Close terminates the connection. Idempotent.
Close(reason string) error
}
Conn is a bidirectional, ordered, message-framed connection.
Ordering is load-bearing: the log-resume design assumes that a chunk sent before a terminal status frame arrives before it, which is what lets the control plane close a log stream on terminal status without worrying about output still in flight. A transport that reorders would break that, so any future implementation must preserve message order.
Implementations must be safe for one concurrent reader and one concurrent writer. They are not required to tolerate concurrent readers or concurrent writers; the session serialises both.
type DenialsPayload ¶ added in v0.0.7
DenialsPayload is one workload's batch.
func DecodeDenials ¶ added in v0.0.7
func DecodeDenials(f Frame) (DenialsPayload, error)
DecodeDenials decodes and validates a denials frame.
type DeviceClosePayload ¶ added in v0.0.6
type DeviceClosePayload struct {
ChannelID string `json:"channel_id"`
Reason string `json:"reason,omitempty"`
}
DeviceClosePayload ends a channel.
func DecodeDeviceClose ¶ added in v0.0.6
func DecodeDeviceClose(f Frame) (DeviceClosePayload, error)
DecodeDeviceClose decodes a close notice.
type DeviceDataPayload ¶ added in v0.0.6
DeviceDataPayload carries one chunk in either direction.
func DecodeDeviceData ¶ added in v0.0.6
func DecodeDeviceData(f Frame) (DeviceDataPayload, error)
DecodeDeviceData decodes one chunk, refusing an oversized one — checked on receipt because the sender is the party that might be hostile.
type DeviceOpenPayload ¶ added in v0.0.6
type DeviceOpenPayload struct {
// ChannelID is minted by the hub and scopes every later frame.
ChannelID string `json:"channel_id"`
// Kind is executor.ForwardedUSB or executor.ForwardedL2.
Kind string `json:"kind"`
// Name is the sandbox-facing handle the device is given under.
Name string `json:"name"`
// Label describes the device, for the device's own log.
Label string `json:"label,omitempty"`
// ForwarderID and BindingID identify the forwarder and the binding that
// authorised the device, for the device's log.
ForwarderID string `json:"forwarder_id,omitempty"`
BindingID string `json:"binding_id,omitempty"`
// BusID, for USB, is the bus id the forwarder exports: what the agent's
// OP_REQ_IMPORT names.
BusID string `json:"bus_id,omitempty"`
// Interface, for L2, is the sandbox-side shape of the segment; the agent
// fills Source with the TAP it creates.
Interface *executor.HostInterface `json:"interface,omitempty"`
}
DeviceOpenPayload opens a device channel.
func DecodeDeviceOpen ¶ added in v0.0.6
func DecodeDeviceOpen(f Frame) (DeviceOpenPayload, error)
DecodeDeviceOpen decodes and validates an open request.
func (DeviceOpenPayload) Validate ¶ added in v0.0.6
func (p DeviceOpenPayload) Validate() error
Validate checks an open request as the agent receives it.
type DeviceOpenedPayload ¶ added in v0.0.6
type DeviceOpenedPayload struct {
ChannelID string `json:"channel_id"`
// For USB: the vhci port, and the bus id and node the device enumerated
// as on the agent's host.
VHCIPort int `json:"vhci_port,omitempty"`
LocalBusID string `json:"local_bus_id,omitempty"`
Node string `json:"node,omitempty"`
// For L2: the TAP the agent created.
TAP string `json:"tap,omitempty"`
}
DeviceOpenedPayload reports what the agent attached.
func DecodeDeviceOpened ¶ added in v0.0.6
func DecodeDeviceOpened(f Frame) (DeviceOpenedPayload, error)
DecodeDeviceOpened decodes an open acknowledgement.
type DeviceRegistry ¶ added in v0.0.6
type DeviceRegistry struct {
// contains filtered or unexported fields
}
DeviceRegistry is the agent's handle on a connection's device channels: the same registry the hub's Session keeps, exported for the other end.
func (*DeviceRegistry) Add ¶ added in v0.0.6
func (r *DeviceRegistry) Add(d *DeviceStream) bool
Add registers a stream, refusing past the per-connection bound.
func (*DeviceRegistry) Drain ¶ added in v0.0.6
func (r *DeviceRegistry) Drain() []*DeviceStream
Drain empties the registry and returns what it held.
func (*DeviceRegistry) Get ¶ added in v0.0.6
func (r *DeviceRegistry) Get(id string) *DeviceStream
Get looks one up.
func (*DeviceRegistry) Remove ¶ added in v0.0.6
func (r *DeviceRegistry) Remove(id string)
Remove unregisters one.
type DeviceStream ¶ added in v0.0.6
type DeviceStream struct {
// contains filtered or unexported fields
}
DeviceStream is one end of a device channel.
func NewDeviceStream ¶ added in v0.0.6
func NewDeviceStream(id string, send func(t FrameType, payload any) error, onEnd func()) *DeviceStream
NewDeviceStream builds one end of channel id. send writes one frame on the session; onEnd unregisters the stream.
func (*DeviceStream) Abort ¶ added in v0.0.6
func (d *DeviceStream) Abort(reason string)
Abort ends the stream from this side without delivering what is still queued, and tells the peer.
func (*DeviceStream) Close ¶ added in v0.0.6
func (d *DeviceStream) Close() error
Close ends the stream from this side and tells the peer. Idempotent.
func (*DeviceStream) Deliver ¶ added in v0.0.6
func (d *DeviceStream) Deliver(data []byte)
Deliver hands one inbound chunk to the stream. It never blocks: it runs on the session read loop every workload on the connection shares.
func (*DeviceStream) Done ¶ added in v0.0.6
func (d *DeviceStream) Done() <-chan struct{}
Done is closed once the stream has ended.
func (*DeviceStream) Finish ¶ added in v0.0.6
func (d *DeviceStream) Finish(reason string)
Finish ends the stream because the peer said so or the connection carrying it is gone: what already arrived is still delivered, then the reader sees reason. Nothing is sent back.
func (*DeviceStream) Read ¶ added in v0.0.6
func (d *DeviceStream) Read(p []byte) (int, error)
Read returns bytes the peer sent, then the reason the stream ended.
func (*DeviceStream) Reason ¶ added in v0.0.6
func (d *DeviceStream) Reason() string
Reason is why the stream ended, once it has.
type EnrollmentRecord ¶
type EnrollmentRecord struct {
ID string `json:"id"`
Name string `json:"name"`
// SecretHash is hex-encoded SHA-256 of the token secret.
SecretHash string `json:"secret_hash"`
WorkDirRoot string `json:"workdir_root,omitempty"`
Labels map[string]string `json:"labels,omitempty"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
CreatedBy string `json:"created_by,omitempty"`
// RedeemedAt is zero until redemption. Non-zero is what makes the token
// single-use: redemption is a conditional update that only succeeds while
// this is still zero, so two concurrent redemptions cannot both win.
RedeemedAt time.Time `json:"redeemed_at,omitempty"`
RedeemedAgentID string `json:"redeemed_agent_id,omitempty"`
RevokedAt time.Time `json:"revoked_at,omitempty"`
}
EnrollmentRecord is the control plane's durable view of one minted token. The secret itself is absent by construction — only SecretHash is persisted.
func Mint ¶
func Mint(store Store, opts MintOptions) (token string, rec EnrollmentRecord, err error)
Mint creates a single-use enrollment token, persists its hash, and returns the token string. The returned string is the only time the secret exists outside the operator's terminal — it is not recoverable from the database.
func (EnrollmentRecord) Expired ¶
func (r EnrollmentRecord) Expired(now time.Time) bool
Expired reports whether the token's TTL has elapsed as of now.
func (EnrollmentRecord) Redeemed ¶
func (r EnrollmentRecord) Redeemed() bool
Redeemed reports whether the token has already been used.
func (EnrollmentRecord) Revoked ¶
func (r EnrollmentRecord) Revoked() bool
Revoked reports whether the token was explicitly revoked.
type ErrorPayload ¶
ErrorPayload reports a request-scoped failure.
func DecodeError ¶
func DecodeError(f Frame) (ErrorPayload, error)
type Executor ¶
type Executor struct {
// contains filtered or unexported fields
}
Executor proxies executor.Executor calls to a remote agent.
func NewExecutor ¶
NewExecutor builds a remote executor for an enrolled agent. It starts disconnected: Start fails with ErrAgentUnreachable until the device dials in and a session attaches.
When Options.HandleStore is set it also rehydrates: every workload this executor ID dispatched before the process restarted is put back into the handle map, so the agent's reconnect finds a control plane that still recognises its work. That happens here, synchronously, because the agent may dial in the moment the hub's listener opens and a resume offer that races rehydration would be refused — which now means killed.
func (*Executor) Abandon ¶ added in v0.0.5
Abandon implements executor.Abandoner (Task 20396): the control plane failed handleID's session over to another executor, so the workload is no longer its project's run.
Three things, in this order. The workload is killed if the agent can still hear — after a failover it usually cannot, a device is failed over because it stopped answering. The handle becomes terminal here, with reason in its status, which closes its output stream: every watcher on the hub lets go of it, the run's credential lease with them, and its durable row is forgotten so a restarted hub does not rehydrate it. And it stays known as abandoned, so a device that comes back offering to resume it is told to terminate it (reconcileResume) rather than to carry on beside its replacement.
A signal that could not be delivered is returned, but the handle is abandoned all the same: the kill is the part a device out of reach can miss, the refusal on its return is the part that holds.
func (*Executor) AddHandleRedactions ¶ added in v0.0.5
AddHandleRedactions implements executor.HandleRedactor. An adopted handle starts with no redaction set (see adopt), so without this the hub would stream — and merge into its own copy of the project — a taken-over run's output scrubbed only by the device (Task 20382).
func (*Executor) AgentCapabilities ¶
func (e *Executor) AgentCapabilities() AgentCapabilities
AgentCapabilities reports what the device advertised, including the fields (CPU count, memory, container runtimes, harnesses) that the driver-agnostic executor.Capabilities has no room for.
func (*Executor) AgentInventory ¶
func (e *Executor) AgentInventory() (AgentCapabilities, bool)
AgentInventory returns the full capability advertisement of the connected agent, and whether there was a session to ask.
The bool matters because the zero AgentCapabilities is indistinguishable from a device that genuinely reported nothing, and rendering "0 CPUs, no harnesses" for a merely-offline device would invent a fault.
func (*Executor) AgentProtocol ¶ added in v0.0.5
AgentProtocol reports the protocol version the connected agent advertised in its hello, or 0 when it is not connected. ProtocolVersion is the negotiated one — the lower of this and the hub's — so the two differ only for a device newer than its hub; DeviceProtocol is the larger of the two.
func (*Executor) AgentVersion ¶
AgentVersion reports the cloop build the connected agent advertised, or "" if the device is not currently connected.
An empty string from a *connected* agent is meaningful in itself: it is a build from before agents reported a version at all.
func (*Executor) Attach ¶ added in v0.0.2
func (e *Executor) Attach(ctx context.Context, req executor.AttachRequest) (executor.AttachConn, error)
Attach implements executor.Attacher.
func (*Executor) AttachHandleStore ¶
func (e *Executor) AttachHandleStore(store executor.HandleStore)
AttachHandleStore installs the durable handle store after construction and rehydrates from it immediately.
It exists because of boot order rather than convenience: the hub builds an executor per enrolled agent from the enrollment store, which is open long before the state database that backs handle persistence. Calling this later is equivalent to having passed Options.HandleStore, because adoption is idempotent — a handle already in the map is left alone — so a caller that does both, or that re-attaches on a config reload, adopts each workload once.
A nil store is ignored rather than clearing the current one. "Forget how to recognise your device's workloads" is not something a caller should be able to ask for by passing a zero value, and the consequence of granting it would be that the next reconnect terminates every run in flight.
func (*Executor) BuildSequence ¶ added in v0.0.5
BuildSequence reports the connected agent build's place on main (Task 20380), or 0 when the device is not connected or its build carries none.
func (*Executor) CanTakeForwardedDevices ¶ added in v0.0.6
CanTakeForwardedDevices implements executor.ForwardedDeviceTaker for the device's own executor.
func (*Executor) Capabilities ¶
func (e *Executor) Capabilities() executor.Capabilities
Capabilities implements executor.Executor.
Workspace provisioning is reported as the *intersection* of what the device advertised and what the live session can actually carry. A device with git on PATH is no use if the connection cannot hand it a credential, and claiming otherwise would let placement route a private-repository run to an agent that would silently start the harness in an empty directory.
When there is no session the last advertised value stands, which is deliberate and matches Hub.Restore's reasoning: an enrolled device that is merely offline should place and then fail with the truthful ErrAgentUnreachable, not vanish from placement behind "no executor can provision a workspace".
func (*Executor) ConnStatus ¶
Status reports the executor-level connectivity status (not a handle's).
func (*Executor) DenialLogStatus ¶ added in v0.0.7
DenialLogStatus says whether this device reports what its sandbox firewalls refuse, and if not, the sentence shown in place of the list — the upgrade for an agent below v24, the agent's own reason for one that could not bind its packet log.
func (*Executor) DeviceProtocol ¶ added in v0.0.5
DeviceProtocol is the protocol the device's binary speaks, as far as the session shows: what the agent advertised, and never less than what was negotiated (a hand-written peer may advertise nothing). 0 when offline.
func (*Executor) EgressPosture ¶ added in v0.0.5
func (e *Executor) EgressPosture() executor.EgressPosture
EgressPosture implements executor.EgressPostured for a workload placed on the device itself.
func (*Executor) ExplainBranchBundleRefusal ¶ added in v0.0.5
ExplainBranchBundleRefusal implements executor.BranchBundleExplainer: why placement found this device unable to receive a feature's branch. Too old a protocol and a device without git have different remedies; an offline device is judged by what it last advertised, which cannot tell the two apart, so it gets the generic advice.
func (*Executor) ExplainDiskLimitRefusal ¶ added in v0.0.5
ExplainDiskLimitRefusal implements executor.DiskLimitExplainer.
func (*Executor) HandleStatuses ¶
HandleStatuses implements executor.Lister from the control plane's last-known view of the agent, without dialling the device.
Not round-tripping is the point: this feeds the Executors panel's load column, and an offline agent behind NAT would otherwise make that column block until the request timed out. The cached view is refreshed by the status frames the agent pushes as work progresses, so it is current for a connected agent and honestly stale for a disconnected one — which is what the card's status dot is already telling the operator.
func (*Executor) HealthCheck ¶
HealthCheck implements executor.Executor. It is a liveness question, and for a NAT'd device the only honest answer is "is the agent currently connected and beating", since the control plane cannot probe it.
func (*Executor) HeldWorkspaceCredential ¶ added in v0.0.5
func (e *Executor) HeldWorkspaceCredential(handleID string) (executor.HeldWorkspaceCredential, bool)
HeldWorkspaceCredential implements executor.WorkspaceCredentialHolder: the workspace credential parked on handleID for its push write-back, if any.
func (*Executor) HoldsLease ¶
HoldsLease reports whether any handle this executor tracks was started with material from leaseID.
It answers true for an executor with any *unresolved* handle too, whatever the lease — a workload rehydrated from a row that did not record its bindings might be holding this one, and nothing here can tell. That routes the revocation to RevokeLease, which reports the doubt. See executor.LeaseIndex.Holds.
func (*Executor) IsolationRuntime ¶ added in v0.0.7
IsolationRuntime implements executor.IsolationClaimer: the runtime this device's sandboxes run under, and false when its payloads run on its host.
func (*Executor) LogGapped ¶
LogGapped reports whether output was permanently lost for a handle, so a consumer can label a partial log instead of presenting it as complete.
func (*Executor) PinnedResultBytes ¶ added in v0.0.5
PinnedResultBytes reports how much returned work this executor has the hub hold right now, as its budget counts it (bundle chunks with their ResultChunkOverhead), for diagnostics and tests.
func (*Executor) Preflight ¶ added in v0.0.3
func (e *Executor) Preflight() PreflightReport
Preflight checks the device behind this executor against the sandbox the admin configured for it.
It asks only what the wire can already answer — the live session's protocol version and the capabilities the agent reported at hello — so it costs no round trip and cannot hang on an unresponsive device. That is also its limit: a finding here describes what the device said when it connected, so an operator who has just enabled nested virtualization must reconnect the agent before this says so.
func (*Executor) ProbeIsolation ¶ added in v0.0.7
ProbeIsolation implements executor.IsolationProber for the device itself.
func (*Executor) ProjectResult ¶ added in v0.0.3
func (e *Executor) ProjectResult(handleID string) (executor.ProjectResult, error)
ProjectResult implements executor.ProjectResultFetcher.
Released once: the bytes go to the caller and are dropped here, because a second merge of the same run would book its spend twice.
func (*Executor) ProtocolVersion ¶
ProtocolVersion reports the version the live session negotiated, or 0 when the agent is not connected. The Executors panel shows it so "this device is too old for revocable secrets" is visible before a run fails.
func (*Executor) RefreshInventory ¶ added in v0.0.5
func (e *Executor) RefreshInventory(ctx context.Context) (AgentCapabilities, error)
RefreshInventory asks the device to re-detect its capabilities and adopts the answer as this executor's cached inventory.
The cache is refreshed wholesale on every hello anyway, so this is the same value the device would report on its next connection, fetched early.
func (*Executor) RefreshSecretFiles ¶ added in v0.0.5
func (e *Executor) RefreshSecretFiles(ctx context.Context, req executor.SecretRefreshRequest) executor.SecretRefreshReport
RefreshSecretFiles implements executor.SecretRefresher: it sends req to the device and reports what the agent rewrote. The new content joins the hub-side redaction of every workload holding the lease first, because the log stream that carries a workload's output to the hub is scrubbed here, not on the device.
func (*Executor) RequestHarnessInstall ¶ added in v0.0.3
func (e *Executor) RequestHarnessInstall( ctx context.Context, harness, reason string, ) (HarnessInstallOutcome, error)
RequestHarnessInstall asks the device to install a harness from that harness's official installer.
The version gate returns ErrHarnessInstallUnsupported rather than a message telling the operator to go and upgrade the agent. That is deliberate and the opposite of RequestUpgrade's choice: nobody asked for an install here — it is an attempt the hub makes on its own behalf, mid-dispatch — so the useful error is the one the caller was already about to produce about the missing harness, not a second one about the agent's age layered on top of it.
func (*Executor) RequestUpgrade ¶ added in v0.0.3
func (e *Executor) RequestUpgrade(ctx context.Context, req UpgradeRequest) (UpgradeOutcome, error)
RequestUpgrade asks the device behind this executor to move to a release.
Three refusals are made before anything is sent, each naming the remedy that works rather than just the shortfall:
- A device too old to be upgraded remotely (ErrUpgradeUnsupported) has to be moved once by hand, after which it can be driven from here forever.
- A target that is not a release tag (ErrUpgradeTarget) cannot be installed this way at all: the device resolves a version through its own release channel and verifies the archive's signature, so a dev build's name has nothing behind it.
- Unless req.Force, a release whose binaries speak an older protocol than the device does (ErrUpgradeLowersProtocol). This is the check the device cannot make: a dev build cannot be ordered against a release, so its own preflight would install v0.0.4 over a v14 agent and drop it to v13.
func (*Executor) Revocations ¶
func (e *Executor) Revocations() []RevokeResult
Revocations reports this executor's revocation log for the UI.
func (*Executor) RevokeLease ¶
func (e *Executor) RevokeLease(ctx context.Context, p RevokePayload) RevokeResult
RevokeLease takes one lease's material back from this executor's agent.
It always returns a result, even on failure: the caller's next move depends on *which* failure, and an error alone cannot say whether the material is gone, in doubt, or definitely still out there. The log entry is written before the frame goes out so a revocation is never lost to a crash between the two.
Unlike the hub's own drivers this does *not* fail on an unresolved binding, and the asymmetry is deliberate. Those drivers' lease index is the only record that a workload was ever handed a credential, so doubt there is unresolvable. Here the device keeps an index of its own, in a process the hub's restart did not touch, and the frame reaches it either way — so the agent's ack is a *better* answer than anything the hub could reconstruct, and Known comes from the machine actually holding the material. What the unresolved mark still buys is the ask: HoldsLease answers true for it, so the frame is sent rather than the executor skipped.
func (*Executor) SecretRefreshShortfall ¶ added in v0.0.5
SecretRefreshShortfall explains why a refresh would not reach the device, or returns "" when it would.
func (*Executor) Signal ¶
Signal implements executor.Executor.
Signalling a handle the control plane has already seen terminate returns nil without a round trip: the desired end state already holds, and bothering an LTE-connected device to tell it so is pure cost.
func (*Executor) Start ¶
Start implements executor.Executor by dispatching the spec to the device.
It fails fast rather than queueing. A control plane that buffers work for an offline device looks identical, from the UI, to one that is merely slow — and the work may sit there until the device returns days later, by which point the run is meaningless. ErrAgentUnreachable lets the caller say "edge-1 is offline" immediately.
The named returns are what let the workspace audit's end event observe the outcome from a defer: provisioning is bracketed by two rows and the second one has to say whether the dispatch it describes worked, from whichever of this function's several exits was taken.
func (*Executor) Status ¶
Status implements executor.Executor.
When the agent is unreachable this returns the last known status with State forced to StateUnknown rather than an error. That is the documented meaning of StateUnknown, and it matters for the UI: a run whose device dropped off should render as "state unknown, last seen running" instead of vanishing behind an error banner.
func (*Executor) Stream ¶
Stream implements executor.Executor. Output arrives as log_chunk frames and is republished through the same logbus every other driver uses, so the dropped-chunk and late-subscriber semantics are identical regardless of where the workload runs.
func (*Executor) SupportsRevocation ¶
SupportsRevocation reports whether the currently attached agent honours the revoke frame. False for a disconnected executor: with no session there is nothing that could honour anything.
func (*Executor) SupportsSecretRefresh ¶ added in v0.0.5
SupportsSecretRefresh reports whether a refresh issued now would reach the device: it is connected, and its agent speaks v17.
func (*Executor) SupportsWorkspaceProvisioning ¶
SupportsWorkspaceProvisioning reports whether the currently attached agent can materialise a git workspace: it must have advertised the capability *and* speak a protocol version that can carry the credential. False for a disconnected executor, since with no session there is nothing to ask.
func (*Executor) SupportsWriteBack ¶
SupportsWriteBack reports whether the currently attached agent can return a finished task's work product.
Both halves have to hold: the session's negotiated version has to carry the result frames, and the device has to have advertised that it can produce a bundle. A device with git but an old build, and a new build on a device without git, fail in the same silent way — the work stays on the device — so neither is allowed to look like support.
func (*Executor) UpdateChannel ¶ added in v0.0.5
UpdateChannel reports the update channel the connected agent says its device follows (Task 20376): "edge" or "stable". It is "" when the device is not connected, and when its agent predates the field — which follows releases only, and also cannot be put on the channel without a newer build first, so callers treat it as stable but need not tell it to run a flag it does not have.
func (*Executor) WriteBackBundle ¶
WriteBackBundle implements executor.WriteBackFetcher.
The bytes are released to the caller and dropped here in the same breath. Holding them after the consumer has taken them would keep a bundle alive for as long as the handle is retained, which for a finished workload is long enough to matter across a fleet.
type ExpiredLease ¶
ExpiredLease is one lapsed lease the janitor should take back.
type ExpiredLeaseSweeper ¶
type ExpiredLeaseSweeper func(now time.Time) []ExpiredLease
ExpiredLeaseSweeper is the callback a janitor uses to find leases whose TTL has run out. It returns the lease IDs that should be taken back, along with a human-readable reason for the audit trail.
It is a callback rather than a lease store because leases are minted by whoever is spawning work (pkg/ui), not by this package: the hub knows which agent holds which lease, and the caller knows when each lease expires. Neither half can sweep alone.
type Finding ¶ added in v0.0.3
type Finding struct {
Name string `json:"name"`
Level string `json:"level"`
Message string `json:"message"`
Fix string `json:"fix,omitempty"`
}
Finding is one preflight check's result.
type Frame ¶
type Frame struct {
// V is the protocol version of this frame.
V int `json:"v"`
// Type discriminates Payload.
Type FrameType `json:"type"`
// ID correlates a response with its request. Empty for unsolicited
// frames such as log chunks and terminal status reports.
ID string `json:"id,omitempty"`
// Handle scopes the frame to one workload. Empty for session-level
// frames (hello, welcome, heartbeat, bye).
Handle string `json:"handle,omitempty"`
// Payload is the type-specific body, decoded by the Decode* helpers.
Payload json.RawMessage `json:"payload,omitempty"`
}
Frame is the envelope for every message in both directions.
func NewFrame ¶
NewFrame builds a frame of the current protocol version with payload marshalled. A payload that cannot be marshalled is a programming error in the caller, so it is returned rather than silently dropped.
func NewFrameAt ¶
NewFrameAt builds a frame stamped with an explicit protocol version.
Once a session has negotiated a version, every frame it sends must carry that version and not this build's maximum. The receiver validates the envelope version against its own supported range, so a v2 control plane that stamped v2 onto a frame for a v1 agent would have every frame rejected as out of range — the negotiation would be decorative. Versions above the negotiated one are clamped rather than trusted, so a caller cannot accidentally widen a session past what the peer agreed to.
func (Frame) String ¶
String renders a frame for a diagnostic, without its payload.
The omission is the point. Since protocol v3 a start frame's payload carries the brokered credential for a workspace fetch (see StartPayload.WorkspaceCredential), and since v6 the plaintext of the lease's credential files as well (StartPayload.SecretFiles) — so a single `%v` on a Frame in some future log line would write live tokens into the control plane's log and, on the device, into the agent's. What identifies a frame is its type, its correlation ID, its handle and its size; none of those is a secret, and nothing else belongs in a log line anyway.
type FrameType ¶
type FrameType string
FrameType discriminates the payload. Values are strings rather than integers so that a packet capture or a debug log is readable without a decoder ring.
const ( // TypeAttachOpen (hub → agent) asks the device to start an interactive // command inside a running workload. Correlated by Frame.ID. TypeAttachOpen FrameType = "attach_open" // TypeAttachOpened (agent → hub) answers TypeAttachOpen. TypeAttachOpened FrameType = "attach_opened" // TypeAttachData carries bytes in both directions: keystrokes from the hub, // output from the device. One type rather than two, because the payload and // the routing are identical and the direction is already implied by which // end received it. TypeAttachData FrameType = "attach_data" // TypeAttachResize (hub → agent) reports new terminal geometry. TypeAttachResize FrameType = "attach_resize" // TypeAttachClose ends a session from either end: the operator closing a // terminal, or the device reporting that the command exited. TypeAttachClose FrameType = "attach_close" )
Attach frame types. Direction is fixed for each and is not negotiable: the agent never opens a session, only answers one.
const ( // TypeDeviceOpen (hub → agent) opens a device channel for a handle about // to start. Correlated by Frame.ID; Frame.Handle names the workload. TypeDeviceOpen FrameType = "device_open" // TypeDeviceOpened (agent → hub) answers it once the device is attached // on the agent's host, or the agent answers TypeError. TypeDeviceOpened FrameType = "device_opened" // TypeDeviceData carries the forwarded device's bytes in both directions. TypeDeviceData FrameType = "device_data" // TypeDeviceClose ends a channel from either end. TypeDeviceClose FrameType = "device_close" )
Device frame types.
const ( // TypeInstallHarness (control plane → agent) asks the device to install a // named harness from that harness's official installer. Added in v12. TypeInstallHarness FrameType = "install_harness" // TypeHarnessInstalled (agent → control plane) reports the outcome. Unlike // TypeUpgrading this is a completion, not an intent; see the file comment. TypeHarnessInstalled FrameType = "harness_installed" )
const ( // TypeIsolationProbe (control plane → agent) asks the device to run the // isolation probe in a sandbox. Added in v26. TypeIsolationProbe FrameType = "isolation_probe" // TypeIsolationEvidence (agent → control plane) answers with what the // probe saw, or why it could not run. TypeIsolationEvidence FrameType = "isolation_evidence" )
const ( // TypeHello (agent → control plane) opens a session: protocol version, // agent identity, advertised capabilities, and any handles the agent is // still running from a previous connection. TypeHello FrameType = "hello" // TypeWelcome (control plane → agent) accepts a session and states the // negotiated version and heartbeat interval. TypeWelcome FrameType = "welcome" // TypeHeartbeat (agent → control plane) proves liveness and reports // which handles the agent still considers running. TypeHeartbeat FrameType = "heartbeat" // TypeHeartbeatAck (control plane → agent) confirms receipt. An agent // that stops seeing acks knows the path is broken in the return // direction, which a send-only heartbeat could not detect. TypeHeartbeatAck FrameType = "heartbeat_ack" // TypeStart (control plane → agent) dispatches a workload. TypeStart FrameType = "start" // TypeStarted (agent → control plane) answers a start with the handle or // the reason it could not run. TypeStarted FrameType = "started" // TypeSignal (control plane → agent) asks the agent to signal a handle. TypeSignal FrameType = "signal" // TypeLogChunk (agent → control plane) carries workload output with the // byte offset it starts at, which is what makes resume possible. TypeLogChunk FrameType = "log_chunk" // TypeLogAck (control plane → agent) reports the highest contiguous byte // offset durably received, releasing the agent's retained buffer. TypeLogAck FrameType = "log_ack" // TypeStatusReq (control plane → agent) requests a status refresh. TypeStatusReq FrameType = "status_req" // TypeStatus (agent → control plane) reports a handle's state; sent both // on request and unsolicited when a workload terminates. TypeStatus FrameType = "status" // TypeResultChunk (agent → control plane) carries a slice of the git // bundle holding a finished task's work product, with the byte offset it // starts at. Added in protocol v4. // // It is a separate frame from TypeLogChunk rather than a stream flag on it // because the two have opposite requirements. Log output is lossy on // purpose — a subscriber that falls behind has chunks dropped so the // workload is never blocked — while a bundle with a hole in it is not a // smaller bundle, it is not a bundle. So this one is offset-checked, // size-capped and digest-verified end to end, and a gap fails the // write-back instead of being tolerated. TypeResultChunk FrameType = "result_chunk" // TypeResult (agent → control plane) closes a write-back: the branch, the // commit SHA, the bundle's length and digest, or the reason there is // nothing to send. Added in protocol v4. // // It is what the hub waits for — chunk delivery alone can never signal // completeness, since "no more chunks" and "the link dropped" look // identical from the receiving end. // // The frame order for a finished workload is fixed and load-bearing: // result chunks, then this frame, then the terminal TypeStatus. The status // frame is what closes the log stream, and executor.Run reads Status the // instant that stream closes — so a write-back arriving after it would be // recorded on a handle whose consumer had already returned. Sending it // before means the ordering is a property of the agent's control flow // rather than of a flag both sides have to agree about. TypeResult FrameType = "result" // TypeProjectResult (agent → control plane) carries what a seeded run // changed in its project — tasks finished or created, steps, journal // events, cost rows — or the device's reason for having nothing to send. // Added in protocol v13, and sent only on a v13 session. // // It travels in the same slot as the write-back and for the same reason: // after the workload has exited, so the database it read is final, and // before the terminal TypeStatus, because that frame closes the hub's log // stream and the hub settles the run the moment the stream closes. A // result arriving later would land on a run the hub had already recorded // as having changed nothing. // // One frame rather than chunks: the document holds only what the run // changed, and the device shrinks it to fit (dropping step output first) // rather than splitting it. See executor.MaxProjectResultBytes. TypeProjectResult FrameType = "project_result" // TypeBranchChunk (control plane → agent) carries a slice of the git // bundle a start frame's workspace names in branch_bundle, with the byte // offset it starts at. Added in protocol v16 (Task 20367). // // All of a bundle's chunks are written before its start frame, on the same // connection, so they have all arrived by the time the device handles the // start; the start then names the bundle's size and digest, and the device // refuses to build the tree from anything else. Like a result chunk it is // offset-checked and bounded, because a bundle with a hole in it is not a // smaller bundle. TypeBranchChunk FrameType = "branch_chunk" // TypeRevoke (control plane → agent) takes one secret lease back from a // running task. Added in protocol v2. TypeRevoke FrameType = "revoke" // TypeRevoked (agent → control plane) acknowledges a revoke, reporting // what was actually scrubbed. The ack is what lets the UI distinguish // "revoked" from "revoke pending" from "unreachable" — a fire-and-forget // revoke could only ever claim the first. TypeRevoked FrameType = "revoked" // TypeBye (either direction) announces an orderly shutdown, so the peer // can distinguish a planned disconnect from a dropped link. TypeBye FrameType = "bye" // TypeError (either direction) reports a request-scoped failure without // tearing down the session. TypeError FrameType = "error" )
const ( // TypeSecretRefresh (control plane → agent) replaces credential files a // lease already delivered to running workloads. Added in v17. TypeSecretRefresh FrameType = "secret_refresh" // TypeSecretRefreshed (agent → control plane) reports what was rewritten. TypeSecretRefreshed FrameType = "secret_refreshed" )
const ( // TypeUpgrade (control plane → agent) asks the agent to replace its own // binary with a published release and restart. Added in protocol v11. TypeUpgrade FrameType = "upgrade" // TypeUpgrading (agent → control plane) answers an upgrade with whether // the device is going to attempt it, and why not when it is not. // // It is emphatically not a completion report: the upgrade restarts the // agent and kills this session. See the file comment. TypeUpgrading FrameType = "upgrading" )
const TypeDenials FrameType = "denials"
TypeDenials (agent → control plane) reports one workload's refusals since the last report. Unsolicited, scoped to the workload's handle. Added in v24.
type HarnessInstallOutcome ¶ added in v0.0.3
type HarnessInstallOutcome struct {
// Installed reports that the harness is runnable on the device now. True
// for AlreadyPresent too: the question was "can you run this", not "did
// you do work".
Installed bool
// AlreadyPresent marks the harness as having been there before the ask,
// which happens when two dispatches race for one device.
AlreadyPresent bool
// Harness, Path and Version describe what the device has. Path is worth
// surfacing: an install under a home directory is the thing an operator
// debugging PATH needs to see.
Harness string
Path string
Version string
// Reason explains a failure, in terms the operator can act on.
Reason string
}
HarnessInstallOutcome reports what the device ended up with.
func (HarnessInstallOutcome) Summary ¶ added in v0.0.3
func (o HarnessInstallOutcome) Summary(device string) string
Summary renders the outcome as one line for a log or a toast.
type HarnessInstalledPayload ¶ added in v0.0.3
type HarnessInstalledPayload struct {
// Installed reports that the harness is now runnable on this device. It is
// true both for a fresh install and for AlreadyPresent, because the
// question the hub asked was "can you run this now", not "did you do work".
Installed bool `json:"installed"`
// AlreadyPresent distinguishes "it was already here" from "I fetched it",
// so the hub can tell a race against a concurrent dispatch from an install
// it actually caused, and log accordingly.
AlreadyPresent bool `json:"already_present,omitempty"`
// Harness echoes what was installed, so a log line or an audit row is
// self-describing rather than needing the request beside it.
Harness string `json:"harness,omitempty"`
// Path is where the binary landed and Version what it reports. Both are
// diagnostic, and Path earns its place: the official installer puts Claude
// Code in ~/.local/bin, which is not on a service manager's default PATH,
// and an operator debugging "installed but still not found" needs to see
// the directory the agent had to add.
Path string `json:"path,omitempty"`
Version string `json:"version,omitempty"`
// Reason explains a failure. Always set when Installed is false: the
// refusals here have entirely different fixes — no installer for this
// harness, no bash on the device, no network, the script exited non-zero —
// and a bare "install failed" would make them indistinguishable in the one
// place an operator reads them.
Reason string `json:"reason,omitempty"`
}
HarnessInstalledPayload is the body of TypeHarnessInstalled: whether the harness is now present, and what the device ended up with.
func DecodeHarnessInstalled ¶ added in v0.0.3
func DecodeHarnessInstalled(f Frame) (HarnessInstalledPayload, error)
DecodeHarnessInstalled decodes an install outcome.
type HeartbeatAckPayload ¶
type HeartbeatAckPayload struct {
Seq uint64 `json:"seq"`
// ServerTime lets the agent log clock skew, which is the usual cause of
// otherwise inexplicable credential-expiry failures on edge devices with
// no RTC.
ServerTime time.Time `json:"server_time"`
}
HeartbeatAckPayload confirms a heartbeat.
func DecodeHeartbeatAck ¶
func DecodeHeartbeatAck(f Frame) (HeartbeatAckPayload, error)
type HeartbeatPayload ¶
type HeartbeatPayload struct {
// Seq increments per heartbeat, letting the control plane log gaps.
Seq uint64 `json:"seq"`
// ActiveHandles is what the agent believes is still running. The control
// plane reconciles: a handle it thinks is running but the agent has
// forgotten is resolved as failed rather than left hanging forever.
ActiveHandles []string `json:"active_handles,omitempty"`
// LoadPercent is a coarse 0-100 busy signal for scheduling; -1 unknown.
LoadPercent int `json:"load_percent,omitempty"`
}
HeartbeatPayload proves liveness.
func DecodeHeartbeat ¶
func DecodeHeartbeat(f Frame) (HeartbeatPayload, error)
type HelloPayload ¶
type HelloPayload struct {
// ProtocolVersion is the newest version the agent speaks. The control
// plane replies with the version actually negotiated.
ProtocolVersion int `json:"protocol_version"`
// AgentID is the identity the credential was issued to. It is checked
// against the authenticated identity, never trusted on its own — a frame
// claiming to be another agent is a session-fatal error.
AgentID string `json:"agent_id"`
// Name is the operator-facing label chosen at enrollment.
Name string `json:"name,omitempty"`
// AgentVersion is the cloop build running on the device, for diagnosing
// version-skew problems from the control plane.
AgentVersion string `json:"agent_version,omitempty"`
// Capabilities is what this device can run; the scheduler matches on it.
Capabilities AgentCapabilities `json:"capabilities"`
// Resume lists workloads still running from a previous connection,
// with the offset each one's log stream reached. A reconnecting agent
// uses this to hand the control plane back its in-flight work instead of
// orphaning it.
Resume []ResumeHandle `json:"resume,omitempty"`
}
HelloPayload opens a session.
func DecodeHello ¶
func DecodeHello(f Frame) (HelloPayload, error)
type Hub ¶
type Hub struct {
// contains filtered or unexported fields
}
Hub accepts agent connections and maintains the executors they back.
func NewHub ¶
func NewHub(opts HubOptions) (*Hub, error)
NewHub builds a hub. Executors for previously-enrolled agents are created lazily on first connect rather than at startup: an executor with no session would advertise itself as bindable while being unable to run anything, and Restore exists for callers that do want them present-but-offline.
func (*Hub) Deregister ¶
Deregister removes an agent's executor from the registry entirely.
Everything its handles hold for collection is let go of with it, and given back to the budget (Task 20399): nothing can reach those handles once the executor has left this hub, so bytes kept for them would be held until the process exits.
func (*Hub) LeaseHolders ¶
LeaseHolders reports which connected agents hold a lease, so a caller can tell "nobody has this" apart from "three devices have it and two are offline" before deciding what to promise the operator.
func (*Hub) RefreshLease ¶ added in v0.0.5
func (h *Hub) RefreshLease(ctx context.Context, req executor.SecretRefreshRequest) []RefreshResult
RefreshLease sends req to every connected agent holding the lease and reports each answer. Nil when no agent holds it — the ordinary case for a lease held by a hub-local executor.
func (*Hub) Restore ¶
Restore registers an offline executor for every enrolled, unrevoked agent.
This is what makes a control-plane restart survivable from the operator's point of view: without it, a project bound to edge-1 fails Resolve with "executor not registered" until the device happens to reconnect, which reads as a configuration error rather than as "the device is offline". With it, the executor exists and fails Start with ErrAgentUnreachable — the truthful and actionable error.
func (*Hub) Revocations ¶
func (h *Hub) Revocations() []RevokeResult
Revocations reports the whole fleet's revocation log for the Secrets panel.
func (*Hub) Revoke ¶
Revoke revokes an agent's credential and immediately drops its live session.
Both halves are necessary. Revoking only in storage would leave an already connected device running work until it happened to reconnect — which, for a long-lived outbound WebSocket, could be never. Dropping the session without revoking would let it reconnect a second later.
func (*Hub) RevokeExecutorLeases ¶
func (h *Hub) RevokeExecutorLeases(ctx context.Context, executorID, reason string, action RevokeAction) []RevokeResult
RevokeExecutorLeases takes back every lease an executor is holding.
This is the cordon/drain trigger. Taking a device out of rotation is an operator saying "I no longer trust this to be running my work" — most often because it is being decommissioned, has misbehaved, or is suspected compromised. Leaving it holding live credentials until its in-flight tasks happen to finish would answer that with "in a few hours".
func (*Hub) RevokeLease ¶
func (h *Hub) RevokeLease(ctx context.Context, p RevokePayload) []RevokeResult
RevokeLease takes one secret lease back from every agent holding it.
The fan-out is over executors that actually hold the lease rather than over all of them: a revoke frame to a device that was never given the credential is noise on someone's LTE uplink, and its "not known here" ack would clutter the panel with rows that mean nothing.
An empty result is not an error. It means no connected agent was holding the lease — the ordinary case for a hub-local executor, whose material the caller wipes directly. The caller decides what to say about that; this method's job is to report exactly what the remote fleet did.
func (*Hub) ServeHTTP ¶
func (h *Hub) ServeHTTP(w http.ResponseWriter, r *http.Request)
ServeHTTP upgrades an agent connection and runs its session.
Authentication is by bearer token in the Authorization header. Two token shapes are accepted and they mean different things:
- an enrollment token (clet1.…) is redeemed here, single-use: the agent receives its long-lived credential in the welcome frame;
- an agent credential (clac1.…) authenticates a reconnect.
Failures return 401 with no detail about which check failed. The agent's own error frame carries a usable message once the connection is up, but an unauthenticated caller probing this endpoint learns nothing about whether a token exists, is expired, or was already redeemed.
The Origin check runs first, ahead of any token handling. That ordering is load-bearing, not tidiness: redemption is single-use and destructive, so a cross-origin request that reached Redeem would consume the operator's enrollment token — turning a refused connection into a token that must be re-minted. Checking origin first means a rejected request changes no state.
func (*Hub) StartLeaseJanitor ¶
func (h *Hub) StartLeaseJanitor(ctx context.Context, interval time.Duration, sweep ExpiredLeaseSweeper) (stop func())
StartLeaseJanitor sweeps expired leases off live agents until ctx is done.
This is the second of the three revocation triggers, and it is the one that makes a TTL mean anything. Before it, Lease.Expired was consulted only by the caller that minted the lease: an executor handed the material simply kept it, so a fifteen-minute TTL bounded nothing for a task that ran for three hours. Sweeping *live sessions* rather than re-checking at mint time is the whole difference.
It returns a stop function so a caller that owns the hub's lifetime can shut the janitor down deterministically instead of leaking a ticker goroutine per server restart in tests.
type HubOptions ¶
type HubOptions struct {
// Store is the enrollment/credential persistence. Required.
Store Store
// Registry is where connected agents are registered as executors. Nil
// uses executor.DefaultRegistry.
Registry *executor.Registry
// OnStatusChange mirrors executor connectivity into durable storage. It
// is a callback rather than a *statedb.DB so this package stays free of
// a storage dependency, matching how the registry takes a binding lookup.
OnStatusChange func(executorID, status string, at time.Time)
// OnEnroll is called after a device successfully redeems an enrollment
// token, so the caller can write an executors-table row for it.
OnEnroll func(agent AgentRecord, caps AgentCapabilities)
// OnConnect is called after *every* successful handshake, including the
// one that enrolled the device, carrying what the agent just advertised
// about itself.
//
// It exists because OnEnroll fires once, ever. A device's build version
// and capabilities are exactly the facts that change when an operator
// upgrades it, so recording them only at enrollment meant the stored copy
// was frozen at whatever the device was running the day it joined —
// reproducing, in the storage layer, the same staleness the hardcoded
// agent version produced on the wire. An upgraded device would have gone
// on being reported as its year-old self.
//
// Distinct from OnEnroll rather than replacing it: enrollment is an
// auditable one-time event that also establishes CreatedAt and the minting
// token, and collapsing the two would either lose that or re-assert it on
// every reconnect.
OnConnect func(agent AgentRecord, caps AgentCapabilities, agentVersion string)
// OnRevokeAck receives an agent's acknowledgement of a lease revocation,
// so the caller can write the audit row. It fires for replayed
// revocations too — the ones delivered long after the operator pressed
// the button — which is the only signal that a queued revocation landed.
OnRevokeAck func(executorID, leaseID string, ack RevokedPayload)
// HandleStore persists handle identity for every executor this hub builds,
// so a restart still recognises the workloads its devices are running
// (Task 20191). Nil is the pre-Task-20191 behaviour; see
// Options.HandleStore for what that costs.
//
// One store for the whole hub rather than one per agent: rows are scoped by
// executor ID inside the store, and a per-agent factory — the shape
// WorkspaceSource needs, because a *grant* is issued to a subject — would
// invite an implementation that scoped by something else and let one
// device's executor adopt another's handles.
HandleStore executor.HandleStore
// WorkspaceSource builds the credential source for one agent's executor.
//
// It is a factory rather than a single source because a grant is issued to
// a *subject*: pkg/executor/gitcreds binds the executor ID at construction
// so that a lease taken for edge-1 can never be satisfied by a grant issued
// to edge-2. Nil leaves every executor without one, in which case a
// workload naming a grant is refused rather than dispatched to fetch a
// private repository anonymously.
WorkspaceSource func(executorID string) executor.WorkspaceCredentialSource
// SandboxSource resolves an executor's admin-configured sandbox settings:
// whether payloads run on that device's host or in a container on it, and
// under which engine, runtime and image (Task 20307).
//
// A factory taking the executor ID, like WorkspaceSource, because the
// configuration is per executor — and the inner function takes no arguments
// and returns an error, because it is re-read on every dispatch and a failed
// read must be able to refuse one. See Options.Sandbox for why that refusal
// is the only safe answer.
//
// Nil leaves every executor unconfigured, which is the pre-Task-20307
// behaviour: payloads run as host processes on the device.
SandboxSource func(executorID string) func() (executor.SandboxSettings, error)
// AutoInstallHarness reports whether this hub may ask a device to install a
// missing harness from that harness's official installer (Task 20336).
//
// Deployment-wide rather than per-executor, unlike SandboxSource. Sandbox
// mode describes one machine's containment and genuinely differs between
// devices; "may this control plane run a vendor install script on the
// machines it manages" is a policy about the deployment, and per-device
// copies of it would be a setting an operator has to remember to re-apply
// to every device they ever enroll.
//
// Nil means enabled. See Options.AutoInstallHarness.
AutoInstallHarness func() bool
// ImagePolicy reads the hub's image trust policy for every executor this
// hub builds, which enforces it on the image a project names before a
// dispatch to its device (Task 20424). See Options.ImagePolicy.
//
// Deployment-wide, like AutoInstallHarness: the policy is the hub's, and
// a device holding a copy of its own would be deciding its own
// containment.
ImagePolicy func() (imagepolicy.Policy, error)
// ImageResolver and ImageVerifier stand in for the registry and for
// cosign, for tests. Nil uses the real ones; see Options.
ImageResolver imagepolicy.Resolver
ImageVerifier imagepolicy.Verifier
// ResultBudget bounds the returned work every executor this hub builds
// may have it hold in memory (Task 20399). Nil uses the process-wide
// DefaultResultBudget, whose limits the caller sets from
// executors.remote; see Options.ResultBudget.
ResultBudget *ResultBudget
// ExternalURL is what this deployment calls itself, e.g.
// https://cloop.example.com. Its origin is always an accepted WebSocket
// Origin, which is what makes the Executors panel work when a reverse
// proxy rewrites Host so the request's own origin cannot be seen.
ExternalURL string
// AllowedOrigins lists additional browser Origins permitted to open an
// agent WebSocket, on top of the request's own and ExternalURL's. Matched
// exactly: full origins, or host[:port] meaning https (Task 20394).
AllowedOrigins []string
// ForwardedTrusted reports whether a request's X-Forwarded-Proto and
// X-Forwarded-Host are believed when working out the origin it was
// addressed to. Nil believes them from loopback only. The dashboard passes
// its own rule (loopback, ui.trusted_proxies, cluster members), so the
// two endpoints judge one request alike.
ForwardedTrusted func(*http.Request) bool
// OnOriginRefused, when set, is told of every handshake refused for its
// Origin, so the hub can count and audit it with the dashboard's own.
OnOriginRefused func(*http.Request)
// Logf receives operational messages. Nil discards them.
Logf func(format string, args ...any)
// Now overrides the clock for tests.
Now func() time.Time
}
HubOptions configures a Hub.
type InstallHarnessPayload ¶ added in v0.0.3
type InstallHarnessPayload struct {
// Harness is the key into the agent's table of official installers, e.g.
// "claude". Not a URL, not a command — see the file comment.
Harness string `json:"harness"`
// Reason is free text for the device's log, distinguishing an install
// triggered by a dispatch from one an operator asked for directly. It is
// diagnostic only and never affects what is installed.
Reason string `json:"reason,omitempty"`
}
InstallHarnessPayload is the body of TypeInstallHarness.
Read the file comment before adding a field. This frame may name which published installer to run and nothing whatsoever about how to run it.
func DecodeInstallHarness ¶ added in v0.0.3
func DecodeInstallHarness(f Frame) (InstallHarnessPayload, error)
DecodeInstallHarness decodes an install frame.
The harness name is rejected unless it is a bare token. A registry lookup already constrains it — an unknown key has no installer and the device refuses — so this is defence in depth rather than the control. It is here because the failure it prevents is silent: a name carrying a path separator or a shell metacharacter is a sign that something upstream is composing this field from user input, and the moment to fail is before a later change gives that field somewhere dangerous to land.
type InventoryPayload ¶ added in v0.0.5
type InventoryPayload struct {
Capabilities AgentCapabilities `json:"capabilities"`
}
InventoryPayload is the device's freshly detected capabilities.
func DecodeInventory ¶ added in v0.0.5
func DecodeInventory(f Frame) (InventoryPayload, error)
DecodeInventory decodes and cleans an inventory answer.
type InventoryRequestPayload ¶ added in v0.0.5
type InventoryRequestPayload struct{}
InventoryRequestPayload is the (empty) request. A struct rather than nothing because every frame carries a payload, and a future field — "only USB" — has somewhere to go.
type IsolationEvidencePayload ¶ added in v0.0.7
type IsolationEvidencePayload struct {
// Evidence is what the probe read inside the sandbox; nil when Error is
// set.
Evidence *isolationprobe.Evidence `json:"evidence,omitempty"`
// Host is the device's own kernel, read when the probe ran.
Host *isolationprobe.Evidence `json:"host,omitempty"`
// EngineKernel is the kernel release the device's container engine
// reports for its machine.
EngineKernel string `json:"engine_kernel,omitempty"`
// Engine and Image are what the sandbox ran through and in.
Engine string `json:"engine,omitempty"`
Image string `json:"image,omitempty"`
// Error is why the probe could not run.
Error string `json:"error,omitempty"`
}
IsolationEvidencePayload is the device's answer.
func DecodeIsolationEvidence ¶ added in v0.0.7
func DecodeIsolationEvidence(f Frame) (IsolationEvidencePayload, error)
DecodeIsolationEvidence decodes and bounds a device's answer. Its strings come from a sandbox's kernel and a device's engine, and are stored and rendered by the hub.
type IsolationProbeRequest ¶ added in v0.0.7
type IsolationProbeRequest struct {
// Sandbox is the executor's sandbox settings: its own for a device, a
// virtual executor's for one of those. The agent refuses anything but
// container mode — host mode has no runtime to verify.
Sandbox executor.SandboxSettings `json:"sandbox"`
}
IsolationProbeRequest names the sandbox to probe in.
func DecodeIsolationProbeRequest ¶ added in v0.0.7
func DecodeIsolationProbeRequest(f Frame) (IsolationProbeRequest, error)
DecodeIsolationProbeRequest decodes a probe request, normalizing and validating its sandbox: the agent treats it as it treats a start frame's.
type LogAckPayload ¶
type LogAckPayload struct {
// Offset is the highest contiguous byte offset the control plane has
// accepted. The agent may discard everything below it.
Offset int64 `json:"offset"`
}
LogAckPayload releases the agent's retained output buffer.
func DecodeLogAck ¶
func DecodeLogAck(f Frame) (LogAckPayload, error)
type LogChunkPayload ¶
type LogChunkPayload struct {
Stream executor.StreamName `json:"stream"`
// Offset is the byte position of Text's first byte within this handle's
// output. Offsets, not sequence numbers, are the unit of acknowledgement:
// after a reconnect the agent may re-chunk the same bytes differently, so
// only a byte position identifies "where we got to" unambiguously.
Offset int64 `json:"offset"`
Text string `json:"text"`
// Time is the device's clock when the output was read.
Time time.Time `json:"time"`
}
LogChunkPayload carries workload output.
func DecodeLogChunk ¶
func DecodeLogChunk(f Frame) (LogChunkPayload, error)
func (LogChunkPayload) End ¶
func (p LogChunkPayload) End() int64
End returns the offset just past this chunk, i.e. the next expected offset.
type MintOptions ¶
type MintOptions struct {
// Name is the operator-facing label the agent will carry.
Name string
// TTL bounds redemption. Zero uses DefaultEnrollTTL; values above
// MaxEnrollTTL are rejected rather than silently clamped, because an
// operator who asked for a week should learn that the answer is no.
TTL time.Duration
// WorkDirRoot is the filesystem root the enrolled agent will confine
// every workload to. Empty lets the device choose its own default.
WorkDirRoot string
// Labels are free-form scheduler selectors.
Labels map[string]string
// CreatedBy identifies the minting operator (OIDC subject, or "" local).
CreatedBy string
// Server is the control-plane WebSocket URL to record in the enrollment
// bundle. Not persisted: it is what the operator carries to the device,
// not something the control plane needs to remember.
Server string
// Pin is the hub's SPKI fingerprint ("sha256:<base64>") to record in the
// bundle, so the device can tell its control plane apart from anything
// else that answers the same hostname. Also not persisted.
Pin string
// Now overrides the clock for tests.
Now func() time.Time
}
MintOptions parameterises token creation.
type Options ¶
type Options struct {
// ID is the registry key. It is the agent ID, so a project bound to an
// agent stays bound across reconnects and control-plane restarts.
ID string
// Name is the operator-facing label from enrollment.
Name string
// Capabilities is what the device advertised at hello.
Capabilities AgentCapabilities
// OnStatusChange, when set, is called whenever the executor transitions
// between online/offline/unreachable. The hub uses it to mirror status
// into statedb without this package importing storage.
OnStatusChange func(executorID, status string, at time.Time)
// OnRevokeAck, when set, is called when an agent acknowledges a lease
// revocation. It is a callback for the same reason OnStatusChange is:
// the audit trail lives in storage and this package must not depend on
// it. Replayed revocations report through here too, which is the only
// way an operator learns that a queued revocation finally landed.
OnRevokeAck func(executorID, leaseID string, ack RevokedPayload)
// Workspace leases the credential a git workspace fetch needs, at dispatch
// time and for the length of one start.
//
// It is an interface (executor.WorkspaceCredentialSource) rather than the
// broker itself so this package stays free of the hub's secret store —
// pkg/executor/agent imports pkg/executor/remote, and an edge binary must
// not carry a secret database because the control plane happens to have
// one. Nil means no credential can be leased: a workload naming a grant is
// refused with the same typed error the broker would have raised, rather
// than dispatched to fetch a private repository anonymously.
Workspace executor.WorkspaceCredentialSource
// HandleStore persists handle identity so a hub that restarts still knows
// which workloads it dispatched to this device (Task 20191). See
// rehydrate.go.
//
// Nil is the pre-Task-20191 behaviour and remains supported: the driver
// dispatches, streams, signals and reconciles exactly as it did. What it
// loses is the one thing only a durable row can supply — recognising a
// reconnecting agent's resume offer after a restart. Without it every offer
// is refused, and a refusal now stops the workload, so a storeless hub
// trades a leaked process for a lost run.
//
// It is a field rather than a constructor argument because a remote
// executor is built by the hub from an enrollment record, early and
// synchronously, while the state database that backs the store is opened
// later; AttachHandleStore installs it once that has happened.
HandleStore executor.HandleStore
// Sandbox resolves this executor's admin-configured sandbox settings —
// whether payloads run on the device's host or in a container on it, and
// under which engine, runtime and image (Task 20307).
//
// A function rather than a value, consulted on every Start rather than read
// once here, because the whole point is that an admin can change it from the
// UI and have the next task honour it. A value captured at construction
// would go stale the moment the panel was used and would only refresh when
// the device happened to reconnect.
//
// It returns an error rather than just a value, and the error is fatal to
// the dispatch. That is deliberate: "I could not read whether this executor
// is supposed to be contained" must not resolve to "run it on the host".
// See statedb.SandboxSettingsFor, which is the implementation the hub
// installs, for the full argument.
//
// Nil means no configuration source — the pre-Task-20307 behaviour, and what
// a hub with no control-plane database gets. Payloads run as they did.
Sandbox func() (executor.SandboxSettings, error)
// AutoInstallHarness reports whether this hub may ask a device to install
// a missing harness from that harness's official installer (Task 20336).
//
// A function for the same reason Sandbox is one: it is an admin setting
// that must take effect on the next dispatch rather than at the next
// reconnect.
//
// Nil means enabled, and that default is the feature. The behaviour it
// replaces is a refusal telling an operator to go and install something by
// hand on a machine the fleet exists to stop hand-administering. Sites that
// do not want a vendor script run on a critical host turn it off here, and
// get the Task 20332 refusal back unchanged.
AutoInstallHarness func() bool
// ImagePolicy reads the hub's image trust policy, for the image a project
// names in its .cloop/sandbox.yaml (Task 20424). Consulted on every Start
// whose sandbox runs that image, and enforced here because nothing on the
// device enforces it: see imagepolicy.go.
//
// A function, re-read per dispatch, for the reason Sandbox is one: a
// policy an operator tightens governs the next task. An error refuses
// the dispatch — it never stands in for the zero policy, which admits
// every image.
//
// Nil means no policy source, the pre-Task-20424 behaviour of an executor
// built outside a hub. The hub always sets it.
ImagePolicy func() (imagepolicy.Policy, error)
// ImageResolver turns a tag into the digest its registry serves, for a
// policy that requires signatures. Nil asks the registry
// (imagepolicy.RegistryResolver). Tests inject their own.
ImageResolver imagepolicy.Resolver
// ImageVerifier checks a digest's signature. Nil runs cosign on the hub,
// through a verifier every executor shares. Tests inject their own.
ImageVerifier imagepolicy.Verifier
// ResultBudget bounds the returned work — write-back bundles and project
// state documents — this executor may have the hub hold in memory while
// it waits to be collected (Task 20399). Nil draws on the process-wide
// DefaultResultBudget, which is what a hub wants: the limit that keeps
// it alive is one for the whole process. Tests inject their own.
ResultBudget *ResultBudget
// Now overrides the clock for tests.
Now func() time.Time
}
Options configures a remote executor.
type PreflightReport ¶ added in v0.0.3
type PreflightReport struct {
ExecutorID string `json:"executor_id"`
Findings []Finding `json:"findings"`
}
PreflightReport is the result of checking a remote executor.
func (PreflightReport) Err ¶ added in v0.0.3
func (r PreflightReport) Err() error
Err summarises the fatal findings, or nil when there are none.
func (PreflightReport) OK ¶ added in v0.0.3
func (r PreflightReport) OK() bool
OK reports whether nothing fatal was found. Warnings do not fail a report: they describe a working deployment an operator may want to change.
type ProjectResultPayload ¶ added in v0.0.3
type ProjectResultPayload struct {
// Data is the compressed result document pkg/executor/projectseed builds
// and decodes. The hub parses it; this layer only bounds it.
Data []byte `json:"data,omitempty"`
// Err is the device's reason for sending no document — the workload
// removed its project, the database was written by a newer cloop than the
// agent's. Carried rather than dropped so the hub's journal can say why a
// run came back without results instead of only that it did.
Err string `json:"error,omitempty"`
}
ProjectResultPayload carries a seeded run's changes back to the hub (v13).
func DecodeProjectResult ¶ added in v0.0.3
func DecodeProjectResult(f Frame) (ProjectResultPayload, error)
DecodeProjectResult decodes a project_result frame, refusing one no well-behaved agent could have sent.
type RedeemOptions ¶
RedeemOptions parameterises redemption.
type RefreshResult ¶ added in v0.0.5
type RefreshResult struct {
ExecutorID string `json:"executor_id"`
Report executor.SecretRefreshReport `json:"report"`
}
RefreshResult is one executor's answer to a lease refresh.
type ResultBudget ¶ added in v0.0.5
type ResultBudget struct {
// contains filtered or unexported fields
}
ResultBudget accounts the returned work one hub process holds, per executor and in total. The zero value is not usable; call NewResultBudget.
func DefaultResultBudget ¶ added in v0.0.5
func DefaultResultBudget() *ResultBudget
DefaultResultBudget returns the process-wide budget.
func NewResultBudget ¶ added in v0.0.5
func NewResultBudget(perExecutor, total int64) *ResultBudget
NewResultBudget builds a budget with the given limits. A limit that is not positive takes its default (executor.DefaultPinnedWriteBackBytes and executor.DefaultPinnedWriteBackTotalBytes).
func (*ResultBudget) Limits ¶ added in v0.0.5
func (b *ResultBudget) Limits() (perExecutor, total int64)
Limits reports the limits in force.
func (*ResultBudget) Pinned ¶ added in v0.0.5
func (b *ResultBudget) Pinned(executorID string) int64
Pinned reports what one executor holds.
func (*ResultBudget) SetLimits ¶ added in v0.0.5
func (b *ResultBudget) SetLimits(perExecutor, total int64)
SetLimits replaces both limits. A limit that is not positive takes its default. Bytes already held stay held: a lowered limit refuses new bytes until enough has been collected or released to fit under it.
func (*ResultBudget) Usage ¶ added in v0.0.5
func (b *ResultBudget) Usage() ResultBudgetUsage
Usage reports what the budget holds now.
type ResultBudgetUsage ¶ added in v0.0.5
type ResultBudgetUsage struct {
// Pinned is every executor's bytes together.
Pinned int64
// MaxPerExecutor is the most any single executor holds.
MaxPerExecutor int64
// Executors is how many executors hold anything at all.
Executors int
// PerExecutorLimit and TotalLimit are the limits in force.
PerExecutorLimit int64
TotalLimit int64
}
ResultBudgetUsage is a snapshot of what a budget is holding.
type ResultChunkPayload ¶
type ResultChunkPayload struct {
// Offset is the byte position of Data's first byte within the bundle.
// Unlike a log chunk's offset this is not merely an acknowledgement
// token: the hub refuses a chunk that does not start exactly where the
// previous one ended, because a bundle assembled out of order or with a
// hole is corrupt in a way git will report as something else entirely.
Offset int64 `json:"offset"`
// Data is the raw slice. A []byte marshals to base64 in JSON, so no
// encoding decision is made here.
Data []byte `json:"data"`
}
ResultChunkPayload carries a slice of the work product's git bundle.
func DecodeResultChunk ¶
func DecodeResultChunk(f Frame) (ResultChunkPayload, error)
DecodeResultChunk decodes a bundle slice, refusing one that could not have come from a well-behaved agent.
The bounds are enforced at decode rather than at assembly so a malformed frame is rejected before its contents are ever appended to anything. An empty chunk is refused too: it advances nothing and would let a peer hold a write-back open indefinitely at no cost to itself.
func (ResultChunkPayload) End ¶
func (p ResultChunkPayload) End() int64
End returns the offset just past this chunk, i.e. the next expected offset.
type ResultPayload ¶
type ResultPayload struct {
// Result is the metadata: mode, branch, commit, digest, or the failure.
// It never contains bundle bytes — those arrived as chunks — for the same
// reason executor.Status does not: this struct is logged and persisted.
Result executor.WriteBackResult `json:"result"`
}
ResultPayload closes a write-back.
func DecodeResult ¶
func DecodeResult(f Frame) (ResultPayload, error)
DecodeResult decodes the frame that closes a write-back, refusing one that could not have come from a well-behaved agent.
The names in it are bounded here, at decode, because the result is kept with its handle for as long as the handle is retained: without these a result was a megabyte of whatever a device chose to call its branch, per handle (Task 20399). They are refused rather than shortened, because a shortened name can become a different valid one — 41 hex characters cut to 40 are an object name. The prose fields are a different matter and are shortened where the result is stored instead (see applyResult).
type ResumeAck ¶
type ResumeAck struct {
HandleID string `json:"handle_id"`
// FromOffset is the first byte the control plane still needs. The agent
// resends from here; anything below it was already delivered. Meaningless
// when Action is ResumeTerminate.
FromOffset int64 `json:"from_offset"`
// Action is the verdict. Empty means ResumeContinue, which is what a v4
// hub's acks decode to and is therefore the only backward-compatible
// default. Added in protocol v5.
Action ResumeAction `json:"action,omitempty"`
// Reason is operator-facing text explaining a refusal, carried into the
// device's log so "why did my task die" has an answer on the machine it
// died on and not only in the control plane's.
Reason string `json:"reason,omitempty"`
}
ResumeAck is the control plane's answer to one resume offer.
func (ResumeAck) Effective ¶
func (a ResumeAck) Effective() ResumeAction
Effective returns the action to apply, defaulting an absent or unknown value to ResumeContinue.
The default is the non-destructive one on purpose, and for the same reason RevokePayload.Effective defaults to scrub: an agent that met a control plane speaking a dialect it does not understand must fail towards keeping the work, because the workload is hours of compute and the ack is one field.
type ResumeAction ¶
type ResumeAction string
ResumeAction is the control plane's verdict on one resume offer.
It exists because absence used to be the only way to say no, and absence cannot be distinguished from an old hub, a truncated list, or a bug — so the agent's only safe reading of it was the destructive one. Naming the verdict makes the destructive path deliberate and lets it carry a reason the device can put in its own log, which is where the operator looks when a workload dies moments after the hub came back.
const ( // ResumeContinue resends this handle's output from FromOffset and carries // on. This is the default for an absent or unrecognised action: a frame // from a possibly-newer control plane must not be read as an instruction // to destroy work. ResumeContinue ResumeAction = "continue" // ResumeTerminate stops the workload. The control plane has no record of // it — most often because it restarted and this handle was never // persisted — so nothing can read its output, collect its work product or // signal it. Leaving it running would burn the device's CPU on a result // with no destination, invisibly and until someone logs into the device. ResumeTerminate ResumeAction = "terminate" )
func (ResumeAction) Valid ¶
func (a ResumeAction) Valid() bool
Valid reports whether the action is one this build knows.
type ResumeHandle ¶
type ResumeHandle struct {
HandleID string `json:"handle_id"`
StartedAt time.Time `json:"started_at"`
// LogOffset is the total number of output bytes the agent has produced
// for this handle so far. The control plane compares it against what it
// received and asks for the gap.
LogOffset int64 `json:"log_offset"`
}
ResumeHandle re-attaches one surviving workload after a reconnect.
type RevokeAction ¶
type RevokeAction = executor.RevokeAction
RevokeAction is what the agent should do about a workload still using the material being taken away: scrub or kill.
type RevokePayload ¶
type RevokePayload = executor.RevokeRequest
RevokePayload takes one lease's material back from a running agent.
func DecodeRevoke ¶
func DecodeRevoke(f Frame) (RevokePayload, error)
DecodeRevoke decodes a revoke frame. A revoke with no lease ID is rejected rather than treated as "revoke everything": a wildcard revocation is not a thing this protocol offers, and silently inventing one from a malformed frame would let a truncated write take a whole device's credentials away.
type RevokeResult ¶
type RevokeResult = executor.RevokeOutcome
RevokeResult is one executor's outcome for one lease revocation.
type RevokeState ¶
type RevokeState = executor.RevokeState
RevokeState is how far one lease's revocation has got.
type RevokedPayload ¶
type RevokedPayload = executor.RevokeReport
RevokedPayload acknowledges a revoke and reports what was actually done. It is deliberately specific rather than a bare "ok": an operator revoking a kubeconfig needs to know whether the file is gone or whether the agent merely forgot about it, and an ack that cannot distinguish the two would let a UI claim a guarantee the system did not deliver.
func DecodeRevoked ¶
func DecodeRevoked(f Frame) (RevokedPayload, error)
type SecretFile ¶
type SecretFile struct {
// LeaseID and GrantID attribute the file, so the device can tie what it
// wrote to the binding a later revoke frame will name.
LeaseID string `json:"lease_id,omitempty"`
GrantID string `json:"grant_id,omitempty"`
// Dir is the directory the workload's environment already points at. It is
// the hub's *declaration*, not an instruction: the hub has no idea what is
// writable on an edge device, so the agent picks a real directory of its
// own and relocates the environment onto it (see the agent's
// materializeSecretFiles and Spec.RelocateSecrets).
Dir string `json:"dir"`
// Name is a bare file name. Re-validated on receipt: a name with a
// separator in it is an arbitrary-file-write primitive aimed at the device.
Name string `json:"name"`
// Mode is the permission bits, as a plain uint32 because fs.FileMode's
// JSON form is an opaque integer whose high bits mean things a wire format
// should not be able to assert. Zero means 0600.
Mode uint32 `json:"mode,omitempty"`
// Content is the plaintext, base64 in JSON. This is the credential;
// everything else in this struct is bookkeeping.
Content []byte `json:"content"`
}
SecretFile is one credential file, with the bytes, on its way to a device.
It mirrors executor.SecretFile rather than reusing it because the two have opposite serialization contracts: the executor type must never appear in JSON (see its json:"-" field on Spec), and this one exists only to be marshalled. Keeping them separate makes "the bytes are on the wire" an explicit, greppable opt-in of exactly one struct instead of a property that could be reintroduced by adding a tag somewhere else.
func NewSecretFile ¶
func NewSecretFile(f executor.SecretFile) SecretFile
NewSecretFile converts a driver-facing credential file into the wire form.
func NewSecretFiles ¶
func NewSecretFiles(files []executor.SecretFile) []SecretFile
NewSecretFiles converts a set, returning nil for an empty one so the field is omitted from the frame entirely rather than encoded as an empty array.
func (SecretFile) Executor ¶
func (f SecretFile) Executor() executor.SecretFile
Executor converts the wire form into the type a driver consumes.
func (SecretFile) GoString ¶
func (f SecretFile) GoString() string
GoString mirrors String so %#v cannot print the content either.
func (SecretFile) String ¶
func (f SecretFile) String() string
String renders the file without its content, so a `%v` on a start payload — or on a slice of these, which is how they travel — cannot write a live credential into the hub's log or the device's. Marshalling is unaffected; only fmt goes through here.
The rendering is delegated to executor.SecretFile so the two redactions cannot drift apart: a future field added to one and printed there would otherwise be printed here too, unredacted.
func (SecretFile) Validate ¶
func (f SecretFile) Validate() error
Validate re-derives executor.SecretFile.Validate on the receiving side.
Re-deriving rather than trusting is the whole point. The sender validated too, but this struct crossed a process boundary and, in this system's threat model, the control plane is a party that can be compromised (see the confinement note at the top of the agent's vault.go). A device that took the hub's word for "this is a bare file name" would hand a compromised hub a write anywhere on every enrolled machine.
type SecretRefreshPayload ¶ added in v0.0.5
type SecretRefreshPayload struct {
LeaseID string `json:"lease_id"`
// Files carry the replacements, attributed and named exactly as the start
// frame's SecretFiles were: Dir is the directory the hub declared, which
// the agent maps onto the one it actually created.
Files []SecretFile `json:"files"`
Reason string `json:"reason,omitempty"`
}
SecretRefreshPayload is the refresh request on the wire.
func DecodeSecretRefresh ¶ added in v0.0.5
func DecodeSecretRefresh(f Frame) (SecretRefreshPayload, error)
DecodeSecretRefresh decodes and validates a refresh request. Validated here, on receipt, for the reason a start frame's files are: the frame crossed a process boundary from a party this device does not trust with a write anywhere but its own lease directories.
func NewSecretRefreshPayload ¶ added in v0.0.5
func NewSecretRefreshPayload(req executor.SecretRefreshRequest) SecretRefreshPayload
NewSecretRefreshPayload converts a driver-facing request into the wire form.
func (SecretRefreshPayload) GoString ¶ added in v0.0.5
func (p SecretRefreshPayload) GoString() string
GoString mirrors String.
func (SecretRefreshPayload) Request ¶ added in v0.0.5
func (p SecretRefreshPayload) Request() executor.SecretRefreshRequest
Request converts the wire form into what a driver consumes.
func (SecretRefreshPayload) String ¶ added in v0.0.5
func (p SecretRefreshPayload) String() string
String redacts the content, as SecretFile's does.
type SecretRefreshedPayload ¶ added in v0.0.5
type SecretRefreshedPayload = executor.SecretRefreshReport
SecretRefreshedPayload is the agent's report.
func DecodeSecretRefreshed ¶ added in v0.0.5
func DecodeSecretRefreshed(f Frame) (SecretRefreshedPayload, error)
DecodeSecretRefreshed decodes the agent's report.
type Session ¶
type Session struct {
// contains filtered or unexported fields
}
Session is one live connection to an enrolled agent.
func Accept ¶
Accept performs the server side of the handshake on an already-authenticated connection and returns a running Session.
Authentication happened at the HTTP upgrade; what remains here is *authorization* and version negotiation. The hello frame's AgentID is compared against the authenticated identity and a mismatch closes the connection, so a valid credential for edge-1 cannot be used to impersonate edge-2 and steal its work or forge its status.
On success the session's read loop is already running in its own goroutine.
func (*Session) AgentProtocol ¶ added in v0.0.5
AgentProtocol returns the newest protocol version the agent advertised at hello. It differs from Version when the agent is newer than this hub: the session speaks the hub's protocol, but the device's binary speaks more — which is what an upgrade must not take away (see Executor.RequestUpgrade).
func (*Session) AgentVersion ¶
AgentVersion returns the cloop build the device reported at hello, or "" if it reported none.
The hello field existed and carried a comment about "diagnosing version-skew problems from the control plane", but nothing on this side ever read it: the value was decoded and dropped on the floor. Surfacing it is what lets the hub persist it and the Executors panel flag a device that trails.
func (*Session) Capabilities ¶
func (s *Session) Capabilities() AgentCapabilities
Capabilities returns what the agent advertised at hello.
func (*Session) Done ¶
func (s *Session) Done() <-chan struct{}
Done returns a channel closed when the session ends.
type SignalPayload ¶
SignalPayload asks the agent to signal a handle.
func DecodeSignal ¶
func DecodeSignal(f Frame) (SignalPayload, error)
type StartPayload ¶
type StartPayload struct {
Spec executor.Spec `json:"spec"`
// HandleID is assigned by the control plane rather than the agent, so a
// start whose response is lost to a disconnect is still addressable: the
// control plane can ask about the handle it named, and the agent can
// treat a repeated start for a known handle as a no-op instead of
// launching the workload twice.
HandleID string `json:"handle_id"`
// WorkspaceCredential is the material for the single authenticated git
// fetch Spec.Workspace needs, when it needs one. Added in protocol v3; nil
// for a public repository or a workspace that needs no provisioning.
//
// It is a sibling of Spec rather than a field inside it, and that is a
// hard rule rather than a stylistic one: pkg/executorstore persists the
// dispatched Spec, the audit trail echoes it, and the reconcile loop
// re-reads it after a control-plane restart. A token placed anywhere in a
// Spec would be durable in three places within a second of being minted.
// Here it is one field of one frame — written to a socket, never to a disk
// — and the hub releases the lease as soon as the agent confirms the start.
WorkspaceCredential *WorkspaceCredential `json:"workspace_credential,omitempty"`
// SecretFiles carries the plaintext of the credential files this workload's
// secret lease produced, for the device to write where Spec.Env already
// says they are. Added in protocol v6; nil when the lease carries none,
// which is every workload that leases only environment variables.
//
// It is a sibling of Spec for exactly the reason WorkspaceCredential is,
// and here the rule is enforced by the type system rather than by
// convention: executor.Spec.SecretFiles is tagged json:"-" precisely so
// that it *cannot* travel inside a Spec. pkg/executorstore persists the
// dispatched Spec, the audit trail echoes it, and reconcile re-reads it
// after a control-plane restart — a credential that reached a Spec field
// would be durable in three places within a second of being minted. Here
// the bytes are one field of one frame, written to a socket and never to a
// disk, and the device wipes them when the workload ends.
//
// This is also why the remote protocol has to carry them explicitly: a
// driver that shares the hub's filesystem reads the files the broker
// already wrote, and a driver on another machine has nothing to read.
SecretFiles []SecretFile `json:"secret_files,omitempty"`
// ProjectSeed is the hub's `.cloop/` project state — gzip-compressed JSON
// in the shape state.Load already migrates from — for the device to place
// into the tree it provisions. Added in protocol v10.
//
// A sibling of Spec like the two fields above, and for the size reason
// rather than the secrecy one: executor.Spec.ProjectSeed is json:"-" so a
// few hundred kilobytes of plan are not written to the handle store, echoed
// into the audit trail and re-read by reconcile on every dispatch. Here the
// bytes are one field of one frame.
//
// []byte, so encoding/json base64s it on the wire — the payload is
// compressed binary, and it is validated on receipt before a byte of it
// reaches a filesystem.
ProjectSeed []byte `json:"project_seed,omitempty"`
// Sandbox is the admin's per-executor answer to where this payload runs on
// the device: as a host process, or in a container on that host, and under
// which engine, runtime and image. Added in protocol v8; the zero value
// means "unset" and leaves the device's own default alone.
//
// A value rather than a pointer, and sent on every start rather than
// negotiated once at hello. Both follow from the same property: this is the
// control plane *instructing* the device, not asking it. A pointer would
// make "the hub said nothing" and "the hub said default" two states the
// agent has to tell apart when they mean the same thing, and a
// hello-time negotiation would mean an admin's change in the UI took effect
// only after the device happened to reconnect — leaving a window in which
// the hub believed an executor was contained and the device did not agree.
//
// Unlike WorkspaceCredential and SecretFiles, this belongs inside no
// secret-free carve-out: it holds no credential, so it is safe for
// pkg/executorstore to persist and for the audit trail to echo. That it is
// a sibling of Spec rather than a Spec field is instead about ownership —
// Spec describes one workload, and this describes the executor every
// workload on that device gets.
Sandbox executor.SandboxSettings `json:"sandbox,omitempty"`
// Virtual is set when the workload was dispatched through a virtual
// executor with a firewall or a device list (Task 20345, protocol v14).
// Sandbox above then carries that executor's engine, runtime, image and
// network rather than the device's own.
//
// Nil for every other dispatch, and for a virtual executor that only
// picks an engine and a runtime: those are fully described by Sandbox,
// which is also what keeps such a virtual executor usable on a v8 agent.
Virtual *VirtualStart `json:"virtual,omitempty"`
// ForwardedDevices names the device channels opened for this handle that
// its sandbox is given (Task 20430, protocol v22). Each was opened by a
// device_open frame ahead of this one and answered device_opened, so the
// device is attached on the agent's host by the time this arrives; the
// agent refuses a start naming a channel it does not hold for the handle.
ForwardedDevices []string `json:"forwarded_devices,omitempty"`
}
StartPayload dispatches a workload. The Spec is the same struct host and container drivers consume; it was designed to be serializable for exactly this reason.
func DecodeStart ¶
func DecodeStart(f Frame) (StartPayload, error)
DecodeStart decodes a dispatch, refusing one whose credential files could not have come from a well-behaved hub.
The check is here rather than at the point of the write because this is the boundary: everything past it is device code that has already accepted the frame. A traversal in a file name, a mode that would make a token world-readable, or a payload too large to be a lease is rejected before the agent has decided where anything goes.
func (StartPayload) CredentialFiles ¶
func (p StartPayload) CredentialFiles() []executor.SecretFile
CredentialFiles converts a start payload's files into the type the device's placement code consumes.
It is not called SecretFiles() because that name belongs to the field; the asymmetry mirrors GitCredential() above, which is the accessor for WorkspaceCredential for the same reason.
func (StartPayload) GitCredential ¶
func (p StartPayload) GitCredential() executor.GitCredential
GitCredential converts the wire form into the type the device's provisioner consumes. A payload with no credential yields the zero value, which executor.GitCredential.Empty reports as "this fetch is unauthenticated".
type StartedPayload ¶
type StartedPayload struct {
HandleID string `json:"handle_id"`
PID int `json:"pid,omitempty"`
StartedAt time.Time `json:"started_at"`
// Error is non-empty when the workload could not be started; the control
// plane surfaces it verbatim so the operator sees the device's reason
// (missing binary, workdir outside root) rather than a generic failure.
Error string `json:"error,omitempty"`
// DiskLimit is set beside Error when the refusal was a workspace already
// over its disk limit (protocol v20, Task 20405), so the hub can answer it
// as the refusal it is — journalled, and a conflict rather than a fault —
// instead of as an opaque device error.
DiskLimit *executor.DiskLimitBreach `json:"disk_limit,omitempty"`
}
StartedPayload answers a start.
func DecodeStarted ¶
func DecodeStarted(f Frame) (StartedPayload, error)
type StatusPayload ¶
type StatusPayload struct {
Status executor.Status `json:"status"`
// FinalOffset is the total output byte count when the workload reached a
// terminal state. The control plane uses it to tell "the stream ended"
// apart from "the connection dropped mid-stream": if it has fewer bytes
// than FinalOffset it knows output is still outstanding.
FinalOffset int64 `json:"final_offset,omitempty"`
}
StatusPayload reports a handle's state.
func DecodeStatus ¶
func DecodeStatus(f Frame) (StatusPayload, error)
type StatusReqPayload ¶
type StatusReqPayload struct{}
StatusReqPayload requests a status refresh. It carries no fields today; the handle is in the envelope. It exists as a named type so adding one later does not change the frame's shape.
type Store ¶
type Store interface {
// PutEnrollment persists a newly minted token record.
PutEnrollment(rec EnrollmentRecord) error
// GetEnrollment loads a token by its public ID. Returns ErrTokenInvalid
// when no such row exists.
GetEnrollment(id string) (EnrollmentRecord, error)
// RedeemEnrollment atomically marks id redeemed by agentID. It must
// return ErrTokenAlreadyUsed if the row is already redeemed.
RedeemEnrollment(id, agentID string, at time.Time) error
// RevokeEnrollment marks a token revoked; unredeemed or not.
RevokeEnrollment(id string, at time.Time) error
// ListEnrollments returns every token record, newest first.
ListEnrollments() ([]EnrollmentRecord, error)
// PutAgent persists an agent credential record.
PutAgent(rec AgentRecord) error
// GetAgent loads an agent by ID. Returns ErrAgentNotFound when absent.
GetAgent(agentID string) (AgentRecord, error)
// RevokeAgent marks an agent's credential revoked.
RevokeAgent(agentID string, at time.Time) error
// TouchAgent records that the agent was seen at t.
TouchAgent(agentID string, at time.Time) error
// ListAgents returns every enrolled agent.
ListAgents() ([]AgentRecord, error)
}
Store is the persistence the enrollment flow needs.
It is an interface rather than a *statedb.DB so that enrollment logic — which is where the security-relevant decisions live — can be tested against an in-memory fake, including the concurrent-redemption race that a SQLite-backed test would make awkward to provoke deterministically.
RedeemEnrollment carries the atomicity requirement: implementations must perform the "is it still unredeemed?" check and the write to RedeemedAt in a single atomic step. A read-then-write implementation would let two racing devices both redeem one token and end up sharing an identity.
type UpgradeOutcome ¶ added in v0.0.3
type UpgradeOutcome struct {
// Accepted is true when the device has begun the upgrade. It is not a
// success report — see the file comment.
Accepted bool
// AlreadyCurrent marks the benign refusal: the device is already running
// the requested build and was not asked to force it.
AlreadyCurrent bool
// Reason carries the device's explanation for a refusal.
Reason string
// FromVersion and TargetVersion are the builds the device is moving
// between, as the device resolved them. TargetVersion is what "latest"
// turned out to mean.
FromVersion string
TargetVersion string
}
UpgradeOutcome reports what the device said when asked.
func (UpgradeOutcome) Summary ¶ added in v0.0.3
func (o UpgradeOutcome) Summary(device string) string
Summary renders the outcome as one line for a CLI or a toast.
It exists so the three callers (CLI, REST handler, auto-update reconciler) cannot each invent their own phrasing for the accepted-is-not-succeeded distinction and drift apart on the only part of this feature an operator actually reads.
type UpgradePayload ¶ added in v0.0.3
type UpgradePayload struct {
// TargetVersion is the release tag to move to, e.g. "v0.1.4", or
// LatestVersion to let the device resolve the newest published release.
//
// A tag, not a URL: the device turns this into bytes through its own
// release channel, and verifies the result against a pinned signing
// identity. That is the whole security model of this frame.
TargetVersion string `json:"target_version"`
// Force installs the target even when the device is already running it,
// and permits moving backwards to an older release.
//
// Downgrade is included because the operational reason to reach for this
// feature at all is often a bad release: the fleet is on a build that
// misbehaves, and the fix is to put it back on the previous one before
// diagnosing. Refusing that would leave the rollback path as "SSH to every
// device", which is the problem this frame exists to remove.
Force bool `json:"force,omitempty"`
// SettleSeconds bounds how long the device waits for its restarted service
// to come back before deciding the new binary is bad and rolling back to
// the previous one. Zero uses the installer's default.
SettleSeconds int `json:"settle_seconds,omitempty"`
// Reason is free text recorded in the device's log, distinguishing an
// operator who clicked Upgrade from the auto-update policy acting on its
// own. It is diagnostic only and never affects what is installed.
Reason string `json:"reason,omitempty"`
}
UpgradePayload is the body of TypeUpgrade.
Read the file comment before adding a field. The two fields this struct must never gain are a binary source and a verification switch.
func DecodeUpgrade ¶ added in v0.0.3
func DecodeUpgrade(f Frame) (UpgradePayload, error)
DecodeUpgrade decodes an upgrade frame.
An absent target is rejected rather than defaulted to LatestVersion. The two are not the same request: "move to the newest release" is a decision, and a frame that lost its target field in transit or was written by a caller that forgot to set it is a malformed request. Defaulting would turn the second into the first and silently roll a fleet forward on a truncated write.
type UpgradeRequest ¶ added in v0.0.3
type UpgradeRequest struct {
// TargetVersion is a release tag, or LatestVersion / "" for the newest
// published release. Empty is accepted here and normalised to
// LatestVersion before it reaches the wire — the strictness in
// DecodeUpgrade is about frames arriving from elsewhere, and imposing it
// on in-process callers would only produce a redundant error. Anything
// else is refused with ErrUpgradeTarget before a frame is sent.
//
// A caller that can resolve LatestVersion to a tag should, and send the
// tag: the device then installs exactly the release that was checked. An
// unresolved LatestVersion is judged as the newest published release this
// hub knows of (version.NewestPublishedRelease).
TargetVersion string
// Force reinstalls an identical build, and permits a downgrade — including
// one that lowers the device's protocol (ErrUpgradeLowersProtocol).
Force bool
// TargetProtocol is the protocol an edge target's manifest says it
// speaks, resolved by the caller through GitHub (Task 20376). It never
// reaches the wire — the device reads the signed manifest itself — and it
// is what lets the hub keep its promise never to lower a device's
// protocol for a build of main, which no release table describes. Zero
// for an edge target refuses it unless Force.
TargetProtocol int
// TargetProtocolIssue says why TargetProtocol is unknown, for the refusal.
TargetProtocolIssue string
// TargetSequence is an edge target's place on main, from the same
// manifest as TargetProtocol, when TargetSequenceKnown (Task 20380): zero
// then means the build carries none. Like TargetProtocol it never reaches
// the wire — the device reads the signed manifest and its binary's stamp
// itself — and it is what keeps the hub from asking a device for a build
// its installer will refuse as earlier on main. A release target's
// sequence comes from the published table instead.
TargetSequence int
TargetSequenceKnown bool
// SettleTimeout bounds the device's wait for its restarted service.
SettleTimeout time.Duration
// Reason is recorded in the device's log: who asked, and whether it was a
// person or the auto-update policy.
Reason string
}
UpgradeRequest is what a caller asks for. It is the hub-side mirror of UpgradePayload, and it carries no more than that one does on purpose: a field here would have to reach the device somehow, and the set of things the device will accept is fixed by the security argument in upgradeproto.go.
type UpgradingPayload ¶ added in v0.0.3
type UpgradingPayload struct {
// Accepted reports that the device has validated the request and is about
// to begin. It does not report success; see the file comment.
Accepted bool `json:"accepted"`
// Reason explains a refusal. Always set when Accepted is false, because a
// bare "no" leaves an operator with nothing to act on — the common
// refusals (not installed as a service, already current, no such release)
// have entirely different fixes.
Reason string `json:"reason,omitempty"`
// AlreadyCurrent distinguishes the benign refusal from the rest, so the
// hub can report "nothing to do" rather than surfacing it as a failure.
AlreadyCurrent bool `json:"already_current,omitempty"`
// FromVersion is the build the device is running now, and TargetVersion
// the one it resolved the request to — which is the newest release when
// the hub asked for LatestVersion, and is the field that tells the
// operator what "latest" turned out to mean.
FromVersion string `json:"from_version,omitempty"`
TargetVersion string `json:"target_version,omitempty"`
}
UpgradingPayload is the body of TypeUpgrading: whether the device intends to attempt the upgrade, and what it is moving between.
func DecodeUpgrading ¶ added in v0.0.3
func DecodeUpgrading(f Frame) (UpgradingPayload, error)
DecodeUpgrading decodes an upgrading acknowledgement.
type Virtual ¶ added in v0.0.5
type Virtual struct {
// contains filtered or unexported fields
}
Virtual is a sub-executor of a remote agent.
func NewVirtual ¶ added in v0.0.5
func NewVirtual(parent *Executor, id, name string, source VirtualSource) (*Virtual, error)
NewVirtual builds a virtual executor over parent. It does no I/O.
func (*Virtual) Abandon ¶ added in v0.0.5
Abandon implements executor.Abandoner: the workload runs on the device, so the device's driver gives it up.
func (*Virtual) AgentVersion ¶ added in v0.0.5
AgentVersion implements executor.BuildReporter, so a fleet build floor applies to a virtual executor exactly as it applies to its device.
func (*Virtual) Attach ¶ added in v0.0.5
func (v *Virtual) Attach(ctx context.Context, req executor.AttachRequest) (executor.AttachConn, error)
Attach implements executor.Attacher through the parent's session.
func (*Virtual) CanTakeForwardedDevices ¶ added in v0.0.6
CanTakeForwardedDevices implements executor.ForwardedDeviceTaker for a virtual executor: its sandbox, its parent's device.
func (*Virtual) Capabilities ¶ added in v0.0.5
func (v *Virtual) Capabilities() executor.Capabilities
Capabilities implements executor.Executor: the parent's, as they would be under this virtual executor's sandbox settings, narrowed by what the device can apply of its firewall.
Devices are deliberately not advertised. SupportsDevices describes whether a project's host_device *grant* can be honoured, and those grants name paths with no machine attached to them; this executor's hardware is part of its own definition, chosen from its parent's inventory by an admin, and needs no grant to reach the sandbox.
func (*Virtual) DenialLogStatus ¶ added in v0.0.7
DenialLogStatus is the parent device's: a virtual executor's sandboxes are the device's, and so is their packet log.
func (*Virtual) EgressPosture ¶ added in v0.0.5
func (v *Virtual) EgressPosture() executor.EgressPosture
EgressPosture implements executor.EgressPostured for a virtual executor: the device's rule set bounds it, and its own firewall is the next level in.
func (*Virtual) ExplainBranchBundleRefusal ¶ added in v0.0.5
ExplainBranchBundleRefusal implements executor.BranchBundleExplainer: a virtual executor receives a branch exactly as its device does.
func (*Virtual) ExplainDiskLimitRefusal ¶ added in v0.0.5
ExplainDiskLimitRefusal implements executor.DiskLimitExplainer for a virtual executor: its parent's reasons, under its own sandbox settings.
func (*Virtual) ExplainEgressScopeRefusal ¶ added in v0.0.5
ExplainEgressScopeRefusal implements executor.EgressScopeExplainer.
func (*Virtual) HandleStatuses ¶ added in v0.0.5
HandleStatuses implements executor.Lister for the workloads this virtual executor started.
func (*Virtual) HealthCheck ¶ added in v0.0.5
HealthCheck implements executor.Executor. A virtual executor is exactly as reachable as the device under it, plus one thing only it can get wrong: a definition that no longer validates.
func (*Virtual) HeldWorkspaceCredential ¶ added in v0.0.5
func (v *Virtual) HeldWorkspaceCredential(handleID string) (executor.HeldWorkspaceCredential, bool)
HeldWorkspaceCredential implements executor.WorkspaceCredentialHolder.
func (*Virtual) HoldsLease ¶ added in v0.0.5
HoldsLease implements executor.Revoker. Always false; see the file comment.
func (*Virtual) IsolationRuntime ¶ added in v0.0.7
IsolationRuntime implements executor.IsolationClaimer for a virtual executor: its own runtime, always in a container.
func (*Virtual) Leases ¶ added in v0.0.5
Leases implements executor.Revoker. Always empty; the parent holds them.
func (*Virtual) Parent ¶ added in v0.0.5
Parent is the device this virtual executor dispatches through.
func (*Virtual) ProbeIsolation ¶ added in v0.0.7
ProbeIsolation implements executor.IsolationProber for a virtual executor: the probe runs on its parent under the virtual executor's own sandbox settings.
func (*Virtual) ProjectResult ¶ added in v0.0.5
func (v *Virtual) ProjectResult(handleID string) (executor.ProjectResult, error)
ProjectResult implements executor.ProjectResultFetcher.
func (*Virtual) Revocations ¶ added in v0.0.5
func (v *Virtual) Revocations() []executor.RevokeOutcome
Revocations implements executor.Revoker. Empty; the parent's log is the one.
func (*Virtual) RevokeLease ¶ added in v0.0.5
func (v *Virtual) RevokeLease(_ context.Context, req executor.RevokeRequest) executor.RevokeOutcome
RevokeLease implements executor.Revoker by reporting "not here": the parent, which holds the material, is asked by the fan-out directly.
func (*Virtual) Spec ¶ added in v0.0.5
func (v *Virtual) Spec() (executor.VirtualSpec, error)
Spec reads and normalizes the current configuration.
func (*Virtual) SupportsRevocation ¶ added in v0.0.5
SupportsRevocation implements executor.Revoker: placement must know that a lease put here can be taken back, and it can — by the parent.
type VirtualDispatch ¶ added in v0.0.5
type VirtualDispatch struct {
// ID and Name identify the virtual executor.
ID string
Name string
// Spec is its configuration, already normalized.
Spec executor.VirtualSpec
}
VirtualDispatch is a start routed through a virtual executor: the sandbox configuration comes from it rather than from the device's own row.
type VirtualSource ¶ added in v0.0.5
type VirtualSource func() (executor.VirtualSpec, error)
VirtualSource reads a virtual executor's configuration. A function, consulted on every dispatch, for the reason Options.Sandbox is one: an admin edits the configuration in the dashboard and the next task must honour it.
type VirtualStart ¶ added in v0.0.5
type VirtualStart struct {
// ID is the virtual executor's ID. The device keys the sandbox bridge and
// its nftables table by it, so two virtual executors on one machine get
// two firewalls rather than whichever was installed last.
ID string `json:"id"`
// Name is the operator-facing label, for the device's own log.
Name string `json:"name,omitempty"`
// Firewall is the IP policy for the sandbox bridge. Nil means none.
Firewall *executor.FirewallRules `json:"firewall,omitempty"`
// Devices are resolved against the device's live USB inventory at start.
Devices []executor.DeviceSelector `json:"devices,omitempty"`
}
VirtualStart is the part of a virtual executor's configuration only the device can apply: the firewall it installs on the sandbox bridge and the hardware it resolves from its own sysfs.
func (VirtualStart) Validate ¶ added in v0.0.5
func (v VirtualStart) Validate(sandbox executor.SandboxSettings) error
Validate checks a VirtualStart as received. The device re-validates for the reason it re-validates Sandbox: an engine name becomes a program it runs and a device path becomes hardware it hands out, so "the sender checked" is not a property the receiving side may rely on.
type WelcomePayload ¶
type WelcomePayload struct {
// ProtocolVersion is the version the session settled on: the lower of
// what the two sides support. Both must encode at this version.
ProtocolVersion int `json:"protocol_version"`
// ExecutorID is the control plane's ID for this agent, which is also the
// agent's own identity and the registry key projects bind to. The three
// are deliberately one value: an operator reading a project binding, an
// executors-table row, and an agent's log should not have to correlate
// three different identifiers for the same device.
ExecutorID string `json:"executor_id"`
// Credential is the long-lived per-agent credential, sent only on the
// connection that redeemed an enrollment token. Redemption happens
// inline on the connect rather than as a separate HTTP call so a device
// needs exactly one reachable URL and one round trip to enroll.
//
// It is the single time this secret crosses the wire; the control plane
// keeps only its hash. An agent that fails to persist it must re-enroll.
Credential string `json:"credential,omitempty"`
// HeartbeatSeconds is the interval the agent must beat at. Sent rather
// than assumed so the control plane can slow down a large fleet without
// redeploying every device.
HeartbeatSeconds int `json:"heartbeat_seconds"`
// ResumeAccepted answers Hello.Resume, one entry per offer the control
// plane has a verdict for. Since v5 the verdict is explicit: an entry may
// say "resume from this offset" or "terminate this workload" (see
// ResumeAck.Action). Below v5 the hub emits accepted handles only, because
// an older agent would read a refusal's presence in this list as
// permission to keep streaming.
//
// The field name is historical and no longer literal — every entry used to
// be an acceptance. It is kept because it is a wire name, and renaming it
// would strand every deployed agent for the sake of a better noun.
//
// A handle the agent offered and that is absent here still means "the
// control plane is not tracking this". An agent that receives no entry for
// a workload must stop it rather than merely stop reporting it: nothing
// will ever read its output, collect its result, or be able to signal it.
ResumeAccepted []ResumeAck `json:"resume_accepted,omitempty"`
}
WelcomePayload accepts a session.
func DecodeWelcome ¶
func DecodeWelcome(f Frame) (WelcomePayload, error)
type WorkspaceCredential ¶
type WorkspaceCredential struct {
// Username is the HTTP basic user. Empty means the broker's GitHub
// convention, "x-access-token", which executor.GitCredential applies.
Username string `json:"username,omitempty"`
// Password is the token. This is the secret; everything else in this
// struct is bookkeeping.
Password string `json:"password"`
// ExpiresAt is the lease deadline, so a device can decline to begin a
// fetch with a credential that will lapse mid-transfer rather than failing
// halfway through with an opaque 401.
ExpiresAt time.Time `json:"expires_at,omitempty"`
// Host is the host the credential may be presented to (Task 20423): its
// grant's forge host, or the git proxy's for a session token. The hub
// chose it for exactly that host; a device scopes it there and refuses a
// workspace on any other, as executor.GitCredentialConfig does. Not
// secret. An older hub sends none, and an older agent ignores it.
Host string `json:"host,omitempty"`
}
WorkspaceCredential is HTTP basic material for one git fetch.
It deliberately carries no lease or grant identifier. The device neither renews nor releases the lease — the hub does both — so an identifier here would buy nothing and cost the ability to correlate a captured frame with a lease in the hub's audit trail.
func (WorkspaceCredential) GoString ¶
func (c WorkspaceCredential) GoString() string
GoString mirrors String so %#v cannot print the token either.
func (WorkspaceCredential) String ¶
func (c WorkspaceCredential) String() string
String renders the credential without its secret, so a `%v` on a start payload — or on this struct directly — cannot write a live token into a log. Marshalling is unaffected: only fmt goes through here.
Source Files
¶
- attach.go
- attachmux.go
- attachproto.go
- branch.go
- bundle.go
- conn.go
- denialproto.go
- devicemux.go
- deviceproto.go
- devicestream.go
- disklimit.go
- egressrules.go
- enroll.go
- errors.go
- harness.go
- hub.go
- imagepolicy.go
- install.go
- installproto.go
- inventory.go
- inventoryproto.go
- isolationproto.go
- origin.go
- preflight.go
- projectresult.go
- proto.go
- refreshproto.go
- rehydrate.go
- remediation.go
- remote.go
- resultbudget.go
- returns.go
- revoke.go
- session.go
- upgrade.go
- upgradeproto.go
- virtual.go
- workspace.go
- writeback.go