guest

package
v0.1.17 Latest Latest
Warning

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

Go to latest
Published: Jul 12, 2026 License: Apache-2.0 Imports: 20 Imported by: 0

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

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

func DialHostVsock(port uint32) func(ctx context.Context) (net.Conn, error)

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

func InstallSkills(dir string, uid, gid int) error

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

func NewUnixClient(path string) *Client

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

func (c *Client) DialPort(ctx context.Context, port int) (net.Conn, error)

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

func (c *Client) Exec(ctx context.Context, cmd []string) (sandbox.ExecResult, error)

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

func (c *Client) Init(ctx context.Context, in InitReq) (InitResp, error)

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

func New(opts Options) *Guest

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

func (a *Guest) ListenAndServe(ctx context.Context, path string) error

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

func (a *Guest) ListenVsockAndServe(ctx context.Context, port uint32) error

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.

Jump to

Keyboard shortcuts

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