Documentation
¶
Overview ¶
Package guest implements the in-box guest and its host-side client. The guest runs inside a box (as the entrypoint, under tini) and serves the box-operation verbs over a Unix-domain control socket: Init (write per-box files and run the host-provided init script), Exec (run a command), and Dial (a data-plane verb that splices the connection to a localhost port inside the box). The host reaches the socket through a per-box bind mount, so the same client drives any backend — container today, microVM or remote VM later — without host→box bridge networking. The box's own workload is installed and started by the init script, not by the guest.
Index ¶
Constants ¶
const DefaultSkillsDir = "/home/agent/.claude/skills"
DefaultSkillsDir is where the guest installs the embedded agent skills by default: under the box user's Claude home, so the box's agent discovers them. It matches the unprivileged `agent` account the Firecracker base provisions (home /home/agent); a box using a different account overrides it with the guest's --skills-dir flag.
Variables ¶
This section is empty.
Functions ¶
func DialHostVsock ¶
DialHostVsock returns a dialer that connects to the host (CID 2) on the given AF_VSOCK port — the guest side of Firecracker's guest-initiated vsock, which the hypervisor forwards to the host Unix socket the spoke pre-listens on.
@arg port The host vsock port to connect to. @return func A dialer usable with RunBoxAPIBridge.
@testcase TestDialHostVsockDialer checks the dialer fails cleanly (or connects) without a hypervisor; the real vsock path is proven by the live TestBoxAPIOverVsock.
func InstallSkills ¶ added in v0.1.13
InstallSkills writes the guest's embedded agent skills into dir (typically DefaultSkillsDir) so the box's agent learns how to drive the box API. It is idempotent: existing files are overwritten, so a guest upgrade refreshes the skills in place. Every directory it creates and every file it writes is chowned to uid/gid when they are non-zero, so the unprivileged box user the agent runs as can read (and manage) them; a zero uid/gid (the guest running as root) leaves them root-owned. This includes the intermediate ancestors MkdirAll must create to reach dir (e.g. the box user's ~/.claude on the way to ~/.claude/skills), so the box user can also write siblings like ~/.claude/ downloads. dir must be non-empty — an empty dir is a caller signal to skip installation and is reported as an error here so the caller can decide.
@arg dir The directory to install the skill tree into; must be non-empty. @arg uid The owner uid to apply to created files and dirs; 0 leaves them root-owned. @arg gid The owner gid to apply to created files and dirs; 0 leaves them root-owned. @error error if dir is empty, the embedded tree cannot be read, or a file or directory cannot be written or chowned.
@testcase TestInstallSkillsWritesTree installs the embedded skills and finds SKILL.md at the expected path. @testcase TestInstallSkillsChownsToBoxUser applies uid/gid to the created files and dirs. @testcase TestInstallSkillsChownsCreatedAncestors chowns the ~/.claude parent created on the way to the skills dir. @testcase TestInstallSkillsOverwrites refreshes an existing skill file on a second install. @testcase TestInstallSkillsRejectsEmptyDir errors when dir is empty.
func RunBoxAPIBridge ¶
func RunBoxAPIBridge(ctx context.Context, socketPath string, dial func(ctx context.Context) (net.Conn, error), log *slog.Logger) error
RunBoxAPIBridge accepts connections on a guest Unix socket and splices each to a host-side connection opened by dial — the guest half of the microVM box-port API path. The in-box workload talks HTTP to the Unix socket (the same contract as the Docker backend, where the spoke serves the socket directly through the bind mount); here the guest forwards the raw bytes to the host over vsock, where the spoke's per-VM listener serves the same API. The bridge is a dumb pipe: it neither parses nor authenticates anything — identity is assigned host-side by which VM's vsock the bytes arrive on. It serves until ctx is cancelled or the listener fails.
@arg ctx Context whose cancellation stops the accept loop and removes the socket. @arg socketPath The in-guest Unix socket to accept box-port API connections on. @arg dial Opens the host-side connection one accepted connection is spliced to. @arg log Logger for per-connection dial failures; nil uses slog.Default. @error error if the socket cannot be created or the accept loop fails for a reason other than ctx cancellation.
@testcase TestBoxAPIBridgeSplices splices bytes both ways between a client and the dialled host. @testcase TestBoxAPIBridgeDialError closes the client connection when the host dial fails. @testcase TestBoxAPIBridgePermitsBoxUser makes the socket dir traversable and the socket 0666 so the non-root box user can reach it.
Types ¶
type Client ¶
type Client struct {
// Dial opens a new control connection to the box's guest.
Dial func(ctx context.Context) (net.Conn, error)
}
Client is the host-side handle to one box's guest. It opens a fresh control connection per call via Dial, so concurrent operations don't contend on a single connection. The backend supplies Dial via its Instance.Control: an AF_UNIX dial to the box's bind-mounted socket for the Docker backend, or a vsock CONNECT handshake over the hypervisor UDS for the Firecracker backend.
func NewUnixClient ¶
NewUnixClient returns a Client that dials the guest's Unix socket at path.
@arg path The filesystem path of the box's control socket. @return *Client A client that opens a new connection per call.
@testcase TestClientOverUnixSocket drives a guest through a unix-socket client.
func (*Client) DialPort ¶
DialPort opens a connection to a TCP port inside the box and returns it as a raw byte pipe (the guest splices it to localhost:port). The caller owns the returned connection and must close it.
@arg ctx Context for dialing the guest. @arg port The TCP port inside the box to connect to. @return net.Conn A connection spliced to the in-box port. @error error if dialing the guest fails or the guest cannot reach the port.
@testcase TestClientDialPort reaches a listener inside the box through the guest.
func (*Client) Exec ¶
Exec runs a command in the box and returns its captured result.
@arg ctx Context for the call. @arg cmd The command and arguments to run. @return sandbox.ExecResult The command's output and exit code. @error error if the call fails.
@testcase TestClientOverUnixSocket runs a command through Exec.
func (*Client) Init ¶
Init writes the box's per-create files, records its parameters, and runs the init script (if any). A file-write or already-initialised failure is a returned error; a failing init script is reported in the InitResp (ScriptFailed set), not as an error, so the caller can keep the box as a broken one to inspect.
@arg ctx Context for the call. @arg in The init request. @return InitResp The init outcome; ScriptFailed is set (with the reason and output) when the init script fails. @error error if the call fails at the transport level (dial, framing, file write, or double-init).
@testcase TestClientOverUnixSocket initialises a box through the client.
type Guest ¶
type Guest struct {
// contains filtered or unexported fields
}
Guest is the in-box guest: it serves the box-operation verbs (Init, Exec, Dial) over a control channel so the host can provision and reach the box without any host→box bridge networking. A single Guest handles one box for its lifetime; Init runs once, while Exec and Dial may run concurrently.
func New ¶
New returns a Guest configured by opts, applying defaults for any zero field.
@arg opts The guest options; zero fields take their defaults. @return *Guest A ready-to-serve guest.
@testcase TestGuestLifecycle drives a guest built by New through its verbs.
func (*Guest) ListenAndServe ¶
ListenAndServe creates the control socket at path (replacing any stale socket), restricts it to the owner, and serves connections until ctx is cancelled or the listener fails.
@arg ctx Context whose cancellation stops the accept loop and removes the socket. @arg path The filesystem path of the Unix control socket to create. @error error if the socket cannot be created or the accept loop fails for a reason other than ctx cancellation.
@testcase TestGuestLifecycle serves over a socket created by ListenAndServe.
func (*Guest) ListenVsockAndServe ¶
ListenVsockAndServe listens on the guest AF_VSOCK port and serves control connections until ctx is cancelled or the listener fails. It is the microVM transport: the host reaches this listener over the hypervisor's vsock, so no filesystem socket crosses a bind mount. The control protocol served is identical to the Unix-socket transport.
@arg ctx Context whose cancellation stops the accept loop and closes the listener. @arg port The guest AF_VSOCK port to listen on. @error error if the vsock listener cannot be created or the accept loop fails for a reason other than ctx cancellation.
@testcase TestListenVsockReturns returns promptly (an error when AF_VSOCK is unavailable, or nil once ctx is cancelled) rather than hanging.
type InitReq ¶
type InitReq struct {
Files []sandbox.InjectFile `json:"files,omitempty"`
Env []string `json:"env,omitempty"`
// CopyFiles are host files a spoke copies into every box during Init (its
// --copy flag). Unlike Files (per-box secrets the caller owns, written with the
// UID/GID they carry), these are written OWNED BY THE BOX USER regardless of the
// UID/GID they carry, so the box's workload can read and write them — a spoke
// staging config or seed data into the box without baking it into the image.
CopyFiles []sandbox.InjectFile `json:"copy_files,omitempty"`
// InitScript is an optional provisioning script run inside the box during Init,
// as the same (unprivileged) user the box's workload runs as. Empty runs
// nothing. A non-zero exit reports a broken box (see InitResp.ScriptFailed).
InitScript []byte `json:"init_script,omitempty"`
// InitScriptTimeout bounds how long the init script may run. A non-positive
// value uses the guest default (defaultInitScriptTimeout).
InitScriptTimeout time.Duration `json:"init_script_timeout,omitempty"`
}
InitReq carries everything the guest needs to provision the box: the per-box files to write, the environment for the box's processes, and an optional host-provided init script run once inside the box (with its own timeout) so a spoke can customise every box without rebuilding the image.
type InitResp ¶ added in v0.1.8
type InitResp struct {
ScriptFailed bool `json:"script_failed,omitempty"`
ScriptError string `json:"script_error,omitempty"`
ScriptOutput string `json:"script_output,omitempty"`
}
InitResp reports the outcome of Init. A file-write or already-initialised failure is a transport error (an error frame), not this payload; this payload reports the one failure the host must NOT treat as a torn-down box: a failing init script. When ScriptFailed is true the box was provisioned and is left running, and ScriptError/ScriptOutput carry the reason and the script's captured output so the host can surface a broken box the operator can inspect instead of a vanished one. The zero value (ScriptFailed false) means Init succeeded.
type Options ¶
type Options struct {
// Home overrides $HOME for the box's Exec commands and init script. Empty
// inherits the home the guest itself was started with (a real container sets
// HOME=/root); the in-process test fake sets a per-box home so concurrent boxes
// stay isolated.
Home string
// InitScriptPath is where the host-provided init script is written inside the
// box before it is run. Empty uses defaultInitScriptPath; tests override it to a
// writable temp path so they need no privileged location.
InitScriptPath string
// Credential, when non-nil, is the OS credential (uid/gid and supplementary
// groups) the guest drops to when running the box's init script and Exec
// commands. The guest itself keeps its own privileges (root, to serve the
// control channel and inject files); only those child processes run as this
// credential — so the box's own workload runs unprivileged yet can still
// escalate via sudo. Nil runs children as the guest's own user. Pair it with
// Home so the dropped processes get that user's home.
Credential *syscall.Credential
// Log records best-effort failures; nil falls back to slog.Default().
Log *slog.Logger
}
Options configure a Guest.