From 09a457c843ab91714c75e7a6c463b81e92e463b6 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Sat, 8 Aug 2026 19:25:23 -0700 Subject: [PATCH 1/3] feat: add Firecracker guest execution transport Introduce the bounded vsock protocol, static guest supervisor, per-run workspace image staging and recovery-safe extraction, and reusable manager lifecycle primitives while keeping runtime dispatch fail-closed. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- guest/firecracker-supervisor/build.sh | 22 + guest/firecracker-supervisor/config.go | 83 +++ guest/firecracker-supervisor/config_test.go | 35 ++ guest/firecracker-supervisor/go.mod | 5 + guest/firecracker-supervisor/main.go | 23 + guest/firecracker-supervisor/protocol.go | 411 +++++++++++++ guest/firecracker-supervisor/protocol_test.go | 141 +++++ guest/firecracker-supervisor/runtime_linux.go | 537 +++++++++++++++++ guest/firecracker-supervisor/runtime_other.go | 9 + src/firecracker-runtime-backend.ts | 8 +- src/firecracker/manager.test.ts | 190 ++++++ src/firecracker/manager.ts | 197 ++++++- src/firecracker/vsock-client.test.ts | 263 +++++++++ src/firecracker/vsock-client.ts | 464 +++++++++++++++ src/firecracker/vsock-protocol.test.ts | 110 ++++ src/firecracker/vsock-protocol.ts | 367 ++++++++++++ src/firecracker/workspace-image.test.ts | 198 +++++++ src/firecracker/workspace-image.ts | 554 ++++++++++++++++++ src/types/runtime-options.ts | 5 +- 19 files changed, 3603 insertions(+), 19 deletions(-) create mode 100755 guest/firecracker-supervisor/build.sh create mode 100644 guest/firecracker-supervisor/config.go create mode 100644 guest/firecracker-supervisor/config_test.go create mode 100644 guest/firecracker-supervisor/go.mod create mode 100644 guest/firecracker-supervisor/main.go create mode 100644 guest/firecracker-supervisor/protocol.go create mode 100644 guest/firecracker-supervisor/protocol_test.go create mode 100644 guest/firecracker-supervisor/runtime_linux.go create mode 100644 guest/firecracker-supervisor/runtime_other.go create mode 100644 src/firecracker/vsock-client.test.ts create mode 100644 src/firecracker/vsock-client.ts create mode 100644 src/firecracker/vsock-protocol.test.ts create mode 100644 src/firecracker/vsock-protocol.ts create mode 100644 src/firecracker/workspace-image.test.ts create mode 100644 src/firecracker/workspace-image.ts diff --git a/guest/firecracker-supervisor/build.sh b/guest/firecracker-supervisor/build.sh new file mode 100755 index 000000000..8d8acc678 --- /dev/null +++ b/guest/firecracker-supervisor/build.sh @@ -0,0 +1,22 @@ +#!/bin/sh +set -eu + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +GO_VERSION=go1.25.0 +VERSION=${VERSION:-dev} +OUTPUT=${OUTPUT:-"$ROOT/firecracker-supervisor"} + +actual=$(go env GOVERSION) +if [ "$actual" != "$GO_VERSION" ]; then + echo "required Go toolchain: $GO_VERSION (found $actual)" >&2 + exit 1 +fi + +cd "$ROOT" +CGO_ENABLED=0 GOOS=linux GOARCH="${GOARCH:-amd64}" \ + go build -trimpath -buildvcs=false -ldflags="-s -w -X main.version=$VERSION" -o "$OUTPUT" . +if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$OUTPUT" > "$OUTPUT.sha256" +else + shasum -a 256 "$OUTPUT" > "$OUTPUT.sha256" +fi diff --git a/guest/firecracker-supervisor/config.go b/guest/firecracker-supervisor/config.go new file mode 100644 index 000000000..81b1ef333 --- /dev/null +++ b/guest/firecracker-supervisor/config.go @@ -0,0 +1,83 @@ +package main + +import ( + "fmt" + "net" + "path/filepath" + "strconv" + "strings" +) + +type bootConfig struct { + WorkspaceDevice string + WorkspaceMount string + VsockPort uint32 + GuestIP net.IP + GuestPrefix int + Gateway net.IP + Interface string +} + +func parseBootConfig(cmdline string) (bootConfig, error) { + values := make(map[string]string) + for _, token := range strings.Fields(cmdline) { + key, value, ok := strings.Cut(token, "=") + if !ok || !strings.HasPrefix(key, "awf.") { + continue + } + if _, duplicate := values[key]; duplicate { + return bootConfig{}, fmt.Errorf("duplicate boot argument %q", key) + } + values[key] = value + } + required := []string{ + "awf.workspace-device", "awf.workspace-mount", "awf.vsock-port", + "awf.guest-ip", "awf.guest-prefix", "awf.guest-gateway", "awf.guest-interface", + } + for _, key := range required { + if values[key] == "" { + return bootConfig{}, fmt.Errorf("missing required boot argument %q", key) + } + } + port, err := strconv.ParseUint(values["awf.vsock-port"], 10, 32) + if err != nil || port == 0 { + return bootConfig{}, fmt.Errorf("invalid awf.vsock-port") + } + prefix, err := strconv.Atoi(values["awf.guest-prefix"]) + if err != nil || prefix < 0 || prefix > 32 { + return bootConfig{}, fmt.Errorf("invalid awf.guest-prefix") + } + ip := net.ParseIP(values["awf.guest-ip"]).To4() + gateway := net.ParseIP(values["awf.guest-gateway"]).To4() + if ip == nil || gateway == nil { + return bootConfig{}, fmt.Errorf("guest IP and gateway must be IPv4 addresses") + } + device := values["awf.workspace-device"] + if !strings.HasPrefix(device, "/dev/") || filepath.Clean(device) != device || strings.Contains(device, "..") { + return bootConfig{}, fmt.Errorf("invalid awf.workspace-device") + } + mount := values["awf.workspace-mount"] + if !filepath.IsAbs(mount) || filepath.Clean(mount) != mount || mount == "/" { + return bootConfig{}, fmt.Errorf("invalid awf.workspace-mount") + } + iface := values["awf.guest-interface"] + if !validInterface(iface) { + return bootConfig{}, fmt.Errorf("invalid awf.guest-interface") + } + return bootConfig{ + WorkspaceDevice: device, WorkspaceMount: mount, VsockPort: uint32(port), + GuestIP: ip, GuestPrefix: prefix, Gateway: gateway, Interface: iface, + }, nil +} + +func validInterface(name string) bool { + if name == "" || len(name) > 15 { + return false + } + for i, r := range name { + if !(r == '-' || r == '_' || r == '.' || r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || (i > 0 && r >= '0' && r <= '9')) { + return false + } + } + return true +} diff --git a/guest/firecracker-supervisor/config_test.go b/guest/firecracker-supervisor/config_test.go new file mode 100644 index 000000000..97c17b344 --- /dev/null +++ b/guest/firecracker-supervisor/config_test.go @@ -0,0 +1,35 @@ +package main + +import "testing" + +const validCmdline = "console=ttyS0 awf.workspace-device=/dev/vdb awf.workspace-mount=/workspace awf.vsock-port=1024 awf.guest-ip=192.0.2.2 awf.guest-prefix=24 awf.guest-gateway=192.0.2.1 awf.guest-interface=eth0" + +func TestParseBootConfig(t *testing.T) { + config, err := parseBootConfig(validCmdline) + if err != nil { + t.Fatalf("parseBootConfig: %v", err) + } + if config.VsockPort != 1024 || config.Interface != "eth0" || config.GuestIP.String() != "192.0.2.2" { + t.Fatalf("unexpected config: %#v", config) + } +} + +func TestParseBootConfigRejectsUnsafeValues(t *testing.T) { + cases := []string{ + "awf.workspace-device=/dev/vdb awf.workspace-mount=/workspace awf.vsock-port=0 awf.guest-ip=192.0.2.2 awf.guest-prefix=24 awf.guest-gateway=192.0.2.1 awf.guest-interface=eth0", + "awf.workspace-device=/dev/../etc/passwd awf.workspace-mount=/workspace awf.vsock-port=1 awf.guest-ip=192.0.2.2 awf.guest-prefix=24 awf.guest-gateway=192.0.2.1 awf.guest-interface=eth0", + "awf.workspace-device=/dev/vdb awf.workspace-mount=/ awf.vsock-port=1 awf.guest-ip=192.0.2.2 awf.guest-prefix=24 awf.guest-gateway=192.0.2.1 awf.guest-interface=eth0", + "awf.workspace-device=/dev/vdb awf.workspace-mount=/workspace awf.vsock-port=1 awf.guest-ip=bad awf.guest-prefix=24 awf.guest-gateway=192.0.2.1 awf.guest-interface=eth0", + } + for _, cmdline := range cases { + if _, err := parseBootConfig(cmdline); err == nil { + t.Errorf("unsafe command line accepted: %q", cmdline) + } + } +} + +func TestParseBootConfigRejectsDuplicateArguments(t *testing.T) { + if _, err := parseBootConfig(validCmdline + " awf.vsock-port=1025"); err == nil { + t.Fatal("duplicate argument accepted") + } +} diff --git a/guest/firecracker-supervisor/go.mod b/guest/firecracker-supervisor/go.mod new file mode 100644 index 000000000..5cce86d18 --- /dev/null +++ b/guest/firecracker-supervisor/go.mod @@ -0,0 +1,5 @@ +module github.com/github/gh-aw-firewall/firecracker-supervisor + +go 1.24.0 + +toolchain go1.25.0 diff --git a/guest/firecracker-supervisor/main.go b/guest/firecracker-supervisor/main.go new file mode 100644 index 000000000..602ab4e09 --- /dev/null +++ b/guest/firecracker-supervisor/main.go @@ -0,0 +1,23 @@ +// firecracker-supervisor is the minimal guest-side command supervisor. +package main + +import ( + "flag" + "fmt" + "os" +) + +var version = "dev" + +func main() { + showVersion := flag.Bool("version", false, "print version") + flag.Parse() + if *showVersion { + fmt.Println(version) + return + } + if err := runSupervisor(); err != nil { + fmt.Fprintln(os.Stderr, "firecracker-supervisor:", err) + os.Exit(1) + } +} diff --git a/guest/firecracker-supervisor/protocol.go b/guest/firecracker-supervisor/protocol.go new file mode 100644 index 000000000..e49bf1fcd --- /dev/null +++ b/guest/firecracker-supervisor/protocol.go @@ -0,0 +1,411 @@ +package main + +import ( + "bytes" + "encoding/base64" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "io" + "regexp" + "strings" +) + +const ( + ProtocolVersion = 1 + MaxFramePayloadBytes = 1 << 20 + MaxStreamChunkBytes = 64 << 10 + MaxEncodedChunkBytes = 4 * ((MaxStreamChunkBytes + 2) / 3) + MaxEnvEntries = 512 + MaxArgvEntries = 4096 + MaxStringBytes = 256 << 10 + MaxSafeInteger = int64(9_007_199_254_740_991) +) + +type errorCode string + +const ( + errorInvalidFrame errorCode = "invalid_frame" + errorProtocolVersionMismatch errorCode = "protocol_version_mismatch" + errorInvalidRequest errorCode = "invalid_request" + errorRequestInProgress errorCode = "request_in_progress" + errorRequestNotFound errorCode = "request_not_found" + errorTTYUnsupported errorCode = "tty_unsupported" + errorInternal errorCode = "internal_error" +) + +var ( + ErrFrameTooLarge = &protocolError{code: errorInvalidFrame, message: "frame exceeds 1 MiB"} + ErrInvalidFrame = &protocolError{code: errorInvalidFrame, message: "invalid frame"} + requestIDPattern = regexp.MustCompile(`^[A-Za-z0-9_.-]{1,128}$`) + envNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) +) + +type protocolError struct { + code errorCode + message string + expectedVersion *int +} + +func (e *protocolError) Error() string { return e.message } + +func (e *protocolError) Is(target error) bool { + other, ok := target.(*protocolError) + return ok && e.code == other.code +} + +// Frame is the exact JSON shape accepted by src/firecracker/vsock-protocol.ts. +type Frame struct { + Version int `json:"version"` + Type string `json:"type"` + RequestID string `json:"requestId"` + Argv []string `json:"argv,omitempty"` + Env map[string]string `json:"env,omitempty"` + Cwd string `json:"cwd,omitempty"` + UID int64 `json:"uid,omitempty"` + GID int64 `json:"gid,omitempty"` + TTY bool `json:"tty,omitempty"` + TimeoutMS *int64 `json:"timeoutMs,omitempty"` + Data *string `json:"data,omitempty"` + EOF *bool `json:"eof,omitempty"` + Columns int `json:"columns,omitempty"` + Rows int `json:"rows,omitempty"` + Reason string `json:"reason,omitempty"` + ExitCode *int `json:"exitCode,omitempty"` + Signal *string `json:"signal,omitempty"` + TimedOut bool `json:"timedOut,omitempty"` + Code errorCode `json:"code,omitempty"` + Message string `json:"message,omitempty"` + ExpectedVersion *int `json:"expectedVersion,omitempty"` + Capabilities map[string]bool `json:"capabilities,omitempty"` +} + +func newFrame(frameType, requestID string) Frame { + return Frame{Version: ProtocolVersion, Type: frameType, RequestID: requestID} +} + +func (f Frame) MarshalJSON() ([]byte, error) { + switch f.Type { + case "execute": + return json.Marshal(struct { + Version int `json:"version"` + Type string `json:"type"` + RequestID string `json:"requestId"` + Argv []string `json:"argv"` + Env map[string]string `json:"env"` + Cwd string `json:"cwd"` + UID int64 `json:"uid"` + GID int64 `json:"gid"` + TTY bool `json:"tty"` + TimeoutMS *int64 `json:"timeoutMs,omitempty"` + }{ + Version: f.Version, Type: f.Type, RequestID: f.RequestID, + Argv: f.Argv, Env: f.Env, Cwd: f.Cwd, UID: f.UID, GID: f.GID, + TTY: f.TTY, TimeoutMS: f.TimeoutMS, + }) + case "result": + return json.Marshal(struct { + Version int `json:"version"` + Type string `json:"type"` + RequestID string `json:"requestId"` + ExitCode *int `json:"exitCode"` + Signal *string `json:"signal"` + TimedOut bool `json:"timedOut"` + }{ + Version: f.Version, Type: f.Type, RequestID: f.RequestID, + ExitCode: f.ExitCode, Signal: f.Signal, TimedOut: f.TimedOut, + }) + default: + type frameAlias Frame + return json.Marshal(frameAlias(f)) + } +} + +func validateFrameKeys(payload []byte, frameType string) error { + var fields map[string]json.RawMessage + if err := json.Unmarshal(payload, &fields); err != nil { + return invalidFrame("malformed JSON: " + err.Error()) + } + allowed := map[string][]string{ + "ready": {"version", "type", "requestId", "capabilities"}, + "execute": {"version", "type", "requestId", "argv", "env", "cwd", "uid", "gid", "tty", "timeoutMs"}, + "stdout": {"version", "type", "requestId", "data"}, + "stderr": {"version", "type", "requestId", "data"}, + "stdin": {"version", "type", "requestId", "data", "eof"}, + "resize": {"version", "type", "requestId", "columns", "rows"}, + "cancel": {"version", "type", "requestId", "reason"}, + "result": {"version", "type", "requestId", "exitCode", "signal", "timedOut"}, + "error": {"version", "type", "requestId", "code", "message", "expectedVersion"}, + "shutdown": {"version", "type", "requestId"}, + "shutting_down": {"version", "type", "requestId"}, + } + names, known := allowed[frameType] + if !known { + return invalidRequest("unknown frame type") + } + for _, name := range names { + if _, ok := fields[name]; !ok { + switch frameType { + case "stdin": + continue + case "execute": + if name == "timeoutMs" { + continue + } + case "error": + if name == "expectedVersion" { + continue + } + } + return invalidRequest("missing frame property " + name) + } + } + for name := range fields { + found := false + for _, allowedName := range names { + if name == allowedName { + found = true + break + } + } + if !found { + return invalidRequest("unexpected frame property " + name) + } + } + return nil +} + +func ReadFrame(r io.Reader) (Frame, error) { + var header [4]byte + if _, err := io.ReadFull(r, header[:]); err != nil { + return Frame{}, err + } + length := binary.BigEndian.Uint32(header[:]) + if length == 0 { + return Frame{}, invalidFrame("empty payload") + } + if length > MaxFramePayloadBytes { + return Frame{}, ErrFrameTooLarge + } + payload := make([]byte, length) + if _, err := io.ReadFull(r, payload); err != nil { + return Frame{}, err + } + var frame Frame + decoder := json.NewDecoder(bytes.NewReader(payload)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&frame); err != nil { + return Frame{}, invalidFrame("malformed JSON: " + err.Error()) + } + if err := ensureEOF(decoder); err != nil { + return frame, invalidFrame("trailing JSON: " + err.Error()) + } + if err := ValidateFrame(frame); err != nil { + return frame, err + } + if err := validateFrameKeys(payload, frame.Type); err != nil { + return frame, err + } + return frame, nil +} + +func WriteFrame(w io.Writer, frame Frame) error { + if err := ValidateFrame(frame); err != nil { + return err + } + payload, err := json.Marshal(frame) + if err != nil { + return err + } + if len(payload) > MaxFramePayloadBytes { + return ErrFrameTooLarge + } + var header [4]byte + binary.BigEndian.PutUint32(header[:], uint32(len(payload))) + if err := writeFull(w, header[:]); err != nil { + return err + } + return writeFull(w, payload) +} + +func writeFull(w io.Writer, data []byte) error { + for len(data) > 0 { + count, err := w.Write(data) + if err != nil { + return err + } + if count <= 0 { + return io.ErrShortWrite + } + data = data[count:] + } + return nil +} + +func ensureEOF(decoder *json.Decoder) error { + var extra any + err := decoder.Decode(&extra) + if err == io.EOF { + return nil + } + if err == nil { + return errors.New("multiple JSON values") + } + return err +} + +func ValidateFrame(f Frame) error { + if f.Version != ProtocolVersion { + expected := ProtocolVersion + return &protocolError{ + code: errorProtocolVersionMismatch, + message: fmt.Sprintf("unsupported protocol version %d", f.Version), + expectedVersion: &expected, + } + } + if err := validateString(f.Type, "type", 64, false); err != nil { + return err + } + if err := validateRequestID(f.RequestID); err != nil { + return err + } + switch f.Type { + case "ready": + if f.RequestID != "control" { + return invalidRequest("ready requestId must be control") + } + if len(f.Capabilities) != 3 || f.Capabilities == nil { + return invalidRequest("ready requires exactly stdin, tty, and resize capabilities") + } + for _, name := range []string{"stdin", "tty", "resize"} { + if _, ok := f.Capabilities[name]; !ok { + return invalidRequest("ready is missing capability " + name) + } + } + case "execute": + if len(f.Argv) == 0 || len(f.Argv) > MaxArgvEntries { + return invalidRequest("argv must contain 1-4096 strings") + } + for _, arg := range f.Argv { + if err := validateString(arg, "argv entry", MaxStringBytes, false); err != nil { + return err + } + } + if f.Env == nil || len(f.Env) > MaxEnvEntries { + return invalidRequest("env exceeds 512 entries") + } + for name, value := range f.Env { + if len(name) > 256 || !envNamePattern.MatchString(name) { + return invalidRequest("invalid environment variable name") + } + if err := validateString(value, "environment value", MaxStringBytes, true); err != nil { + return err + } + } + if err := validateString(f.Cwd, "cwd", 4096, false); err != nil { + return err + } + if !strings.HasPrefix(f.Cwd, "/") { + return invalidRequest("cwd must be absolute") + } + if f.UID <= 0 || f.UID > MaxSafeInteger || f.GID <= 0 || f.GID > MaxSafeInteger { + return invalidRequest("uid and gid must be positive") + } + if f.TimeoutMS != nil && (*f.TimeoutMS <= 0 || *f.TimeoutMS > MaxSafeInteger) { + return invalidRequest("timeoutMs must be positive") + } + case "stdout", "stderr": + if f.Data == nil { + return invalidRequest("stream frame requires data") + } + if err := validateBase64Chunk(*f.Data); err != nil { + return err + } + case "stdin": + if f.Data == nil && (f.EOF == nil || !*f.EOF) { + return invalidRequest("stdin requires data or eof=true") + } + if f.Data != nil { + if err := validateBase64Chunk(*f.Data); err != nil { + return err + } + } + case "resize": + if f.Columns < 1 || f.Columns > 65535 || f.Rows < 1 || f.Rows > 65535 { + return invalidRequest("columns and rows must be in 1-65535") + } + case "cancel": + if err := validateString(f.Reason, "reason", 4096, false); err != nil { + return err + } + case "result": + if (f.ExitCode == nil) == (f.Signal == nil) { + return invalidRequest("result requires exactly one of exitCode or signal") + } + if f.ExitCode != nil && (*f.ExitCode < 0 || *f.ExitCode > 255) { + return invalidRequest("exitCode must be in 0-255") + } + if f.Signal != nil { + if err := validateString(*f.Signal, "signal", 64, false); err != nil { + return err + } + } + case "error": + if !validErrorCode(f.Code) { + return invalidRequest("unknown error code") + } + if err := validateString(f.Message, "message", 16<<10, false); err != nil { + return err + } + if f.ExpectedVersion != nil && *f.ExpectedVersion <= 0 { + return invalidRequest("expectedVersion must be positive") + } + case "shutdown", "shutting_down": + default: + return invalidRequest("unknown frame type " + f.Type) + } + return nil +} + +func validErrorCode(code errorCode) bool { + switch code { + case errorInvalidFrame, errorProtocolVersionMismatch, errorInvalidRequest, + errorRequestInProgress, errorRequestNotFound, errorTTYUnsupported, errorInternal: + return true + } + return false +} + +func validateRequestID(id string) error { + if !requestIDPattern.MatchString(id) { + return invalidRequest("invalid requestId") + } + return nil +} + +func validateString(value, label string, maximum int, allowEmpty bool) error { + if (!allowEmpty && value == "") || strings.IndexByte(value, 0) >= 0 || len(value) > maximum { + return invalidRequest(label + " is invalid") + } + return nil +} + +func validateBase64Chunk(encoded string) error { + if len(encoded) > MaxEncodedChunkBytes || len(encoded)%4 != 0 { + return invalidRequest("stream data is not canonical base64") + } + decoded, err := base64.StdEncoding.DecodeString(encoded) + if err != nil || base64.StdEncoding.EncodeToString(decoded) != encoded || len(decoded) > MaxStreamChunkBytes { + return invalidRequest("stream data is not canonical base64") + } + return nil +} + +func invalidFrame(message string) error { + return &protocolError{code: errorInvalidFrame, message: message} +} + +func invalidRequest(message string) error { + return &protocolError{code: errorInvalidRequest, message: message} +} diff --git a/guest/firecracker-supervisor/protocol_test.go b/guest/firecracker-supervisor/protocol_test.go new file mode 100644 index 000000000..589bf57f2 --- /dev/null +++ b/guest/firecracker-supervisor/protocol_test.go @@ -0,0 +1,141 @@ +package main + +import ( + "bytes" + "encoding/base64" + "encoding/binary" + "errors" + "testing" +) + +func TestFrameRoundTrip(t *testing.T) { + timeout := int64(1000) + input := Frame{ + Version: ProtocolVersion, Type: "execute", RequestID: "request-1", + Argv: []string{"/bin/echo", "hello"}, Env: map[string]string{"LANG": "C"}, + Cwd: "/workspace", UID: 1000, GID: 1000, TimeoutMS: &timeout, + } + var wire bytes.Buffer + if err := WriteFrame(&wire, input); err != nil { + t.Fatalf("WriteFrame: %v", err) + } + output, err := ReadFrame(&wire) + if err != nil { + t.Fatalf("ReadFrame: %v", err) + } + if output.Type != input.Type || output.RequestID != input.RequestID || output.Argv[1] != "hello" { + t.Fatalf("round trip mismatch: %#v", output) + } +} + +func TestReadFrameRejectsOversizedPayloadBeforeAllocation(t *testing.T) { + var wire bytes.Buffer + var header [4]byte + binary.BigEndian.PutUint32(header[:], MaxFramePayloadBytes+1) + wire.Write(header[:]) + _, err := ReadFrame(&wire) + if !errors.Is(err, ErrFrameTooLarge) { + t.Fatalf("expected ErrFrameTooLarge, got %v", err) + } +} + +func TestReadFrameRejectsUnknownJSONFields(t *testing.T) { + payload := []byte(`{"version":1,"type":"shutdown","unexpected":true}`) + var wire bytes.Buffer + var header [4]byte + binary.BigEndian.PutUint32(header[:], uint32(len(payload))) + wire.Write(header[:]) + wire.Write(payload) + if _, err := ReadFrame(&wire); !errors.Is(err, ErrInvalidFrame) { + t.Fatalf("expected ErrInvalidFrame, got %v", err) + } +} + +func TestValidateFrameRejectsInvalidSchemas(t *testing.T) { + cases := []Frame{ + {Version: 2, Type: "shutdown", RequestID: "shutdown"}, + {Version: ProtocolVersion, Type: "execute", RequestID: "id", Env: map[string]string{}, Cwd: "/workspace", UID: 1, GID: 1}, + {Version: ProtocolVersion, Type: "stdin", RequestID: "id", Data: stringPointer("not base64!")}, + {Version: ProtocolVersion, Type: "result", RequestID: "id"}, + {Version: ProtocolVersion, Type: "unknown", RequestID: "id"}, + } + for _, frame := range cases { + if err := ValidateFrame(frame); err == nil { + t.Errorf("ValidateFrame(%#v) unexpectedly succeeded", frame) + } + } +} + +func TestOutputChunkLimit(t *testing.T) { + data := bytes.Repeat([]byte("x"), MaxStreamChunkBytes) + encoded := base64.StdEncoding.EncodeToString(data) + if len(encoded) != MaxEncodedChunkBytes { + t.Fatalf("encoded size = %d, want %d", len(encoded), MaxEncodedChunkBytes) + } + if err := ValidateFrame(Frame{Version: ProtocolVersion, Type: "stdout", RequestID: "id", Data: &encoded}); err != nil { + t.Fatalf("valid maximum output chunk rejected: %v", err) + } + tooLarge := encoded + "AAAA" + if err := ValidateFrame(Frame{Version: ProtocolVersion, Type: "stdout", RequestID: "id", Data: &tooLarge}); err == nil { + t.Fatal("oversized output chunk accepted") + } +} + +func TestResultIncludesExplicitNull(t *testing.T) { + code := 0 + frame := newFrame("result", "request-1") + frame.ExitCode = &code + var wire bytes.Buffer + if err := WriteFrame(&wire, frame); err != nil { + t.Fatalf("WriteFrame: %v", err) + } + if !bytes.Contains(wire.Bytes(), []byte(`"signal":null`)) || !bytes.Contains(wire.Bytes(), []byte(`"exitCode":0`)) { + t.Fatalf("result lacks required explicit fields: %s", wire.Bytes()) + } +} + +func TestReadyHasExactCapabilities(t *testing.T) { + frame := newFrame("ready", "control") + frame.Capabilities = map[string]bool{"stdin": true, "tty": false, "resize": false} + if err := ValidateFrame(frame); err != nil { + t.Fatalf("valid ready rejected: %v", err) + } + frame.Capabilities["cancel"] = true + if err := ValidateFrame(frame); err == nil { + t.Fatal("ready with an extra capability accepted") + } +} + +func TestExecuteWritesRequiredFalseAndEmptyFields(t *testing.T) { + frame := newFrame("execute", "request-1") + frame.Argv = []string{"/bin/true"} + frame.Env = map[string]string{} + frame.Cwd = "/workspace" + frame.UID, frame.GID = 1000, 1000 + var wire bytes.Buffer + if err := WriteFrame(&wire, frame); err != nil { + t.Fatalf("WriteFrame: %v", err) + } + payload := wire.Bytes()[4:] + for _, required := range [][]byte{[]byte(`"env":{}`), []byte(`"tty":false`)} { + if !bytes.Contains(payload, required) { + t.Fatalf("execute lacks required property %s: %s", required, payload) + } + } +} + +func TestStdinEOFAndVersionMismatch(t *testing.T) { + eof := true + if err := ValidateFrame(Frame{ + Version: ProtocolVersion, Type: "stdin", RequestID: "request-1", EOF: &eof, + }); err != nil { + t.Fatalf("stdin eof rejected: %v", err) + } + err := ValidateFrame(Frame{Version: 2, Type: "shutdown", RequestID: "shutdown"}) + var protocol *protocolError + if !errors.As(err, &protocol) || protocol.code != errorProtocolVersionMismatch || protocol.expectedVersion == nil { + t.Fatalf("version mismatch was not typed: %v", err) + } +} + +func stringPointer(value string) *string { return &value } diff --git a/guest/firecracker-supervisor/runtime_linux.go b/guest/firecracker-supervisor/runtime_linux.go new file mode 100644 index 000000000..e34a034dd --- /dev/null +++ b/guest/firecracker-supervisor/runtime_linux.go @@ -0,0 +1,537 @@ +//go:build linux + +package main + +import ( + "context" + "encoding/base64" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "syscall" + "time" + "unsafe" +) + +const ( + afVsock = 40 + vmaddrCIDAny = ^uint32(0) + cancelGrace = 2 * time.Second + maxTimeoutMS = int64(24 * 60 * 60 * 1000) +) + +type sockaddrVM struct { + Family uint16 + Reserved uint16 + Port uint32 + CID uint32 + Zero [4]byte +} + +type vsockListener struct{ fd int } + +func listenVsock(port uint32) (*vsockListener, error) { + fd, err := syscall.Socket(afVsock, syscall.SOCK_STREAM|syscall.SOCK_CLOEXEC, 0) + if err != nil { + return nil, err + } + address := sockaddrVM{Family: afVsock, Port: port, CID: vmaddrCIDAny} + if _, _, errno := syscall.Syscall(syscall.SYS_BIND, uintptr(fd), uintptr(unsafe.Pointer(&address)), unsafe.Sizeof(address)); errno != 0 { + syscall.Close(fd) + return nil, errno + } + if err := syscall.Listen(fd, 16); err != nil { + syscall.Close(fd) + return nil, err + } + return &vsockListener{fd: fd}, nil +} + +func (l *vsockListener) Accept() (*os.File, error) { + var address sockaddrVM + length := uint32(unsafe.Sizeof(address)) + fd, _, errno := syscall.Syscall6(syscall.SYS_ACCEPT4, uintptr(l.fd), uintptr(unsafe.Pointer(&address)), uintptr(unsafe.Pointer(&length)), syscall.SOCK_CLOEXEC, 0, 0) + if errno != 0 { + return nil, errno + } + return os.NewFile(fd, "vsock-client"), nil +} + +func (l *vsockListener) Close() error { return syscall.Close(l.fd) } + +func runSupervisor() error { + if os.Getpid() == 1 { + if err := mountProc(); err != nil { + return err + } + } + cmdline, err := os.ReadFile("/proc/cmdline") + if err != nil { + return fmt.Errorf("read kernel command line: %w", err) + } + config, err := parseBootConfig(string(cmdline)) + if err != nil { + return err + } + if err := mountWorkspace(config); err != nil { + return err + } + if err := configureNetwork(config); err != nil { + return err + } + listener, err := listenVsock(config.VsockPort) + if err != nil { + return fmt.Errorf("listen on vsock: %w", err) + } + defer listener.Close() + for { + connection, err := listener.Accept() + if err != nil { + if errors.Is(err, syscall.EINTR) { + continue + } + return err + } + shutdown := serveClient(connection, config) + connection.Close() + if shutdown { + return shutdownGuest(config) + } + } +} + +func mountProc() error { + if err := os.MkdirAll("/proc", 0555); err != nil { + return fmt.Errorf("create proc mount: %w", err) + } + if err := syscall.Mount("proc", "/proc", "proc", 0, ""); err != nil && !errors.Is(err, syscall.EBUSY) { + return fmt.Errorf("mount proc: %w", err) + } + return nil +} + +func shutdownGuest(config bootConfig) error { + syscall.Sync() + if err := syscall.Unmount(config.WorkspaceMount, 0); err != nil { + return fmt.Errorf("unmount workspace: %w", err) + } + if err := syscall.Reboot(syscall.LINUX_REBOOT_CMD_POWER_OFF); err != nil { + return fmt.Errorf("power off guest: %w", err) + } + return nil +} + +func mountWorkspace(config bootConfig) error { + info, err := os.Stat(config.WorkspaceDevice) + if err != nil { + return fmt.Errorf("stat workspace device: %w", err) + } + if info.Mode()&os.ModeDevice == 0 || info.Mode()&os.ModeCharDevice != 0 { + return fmt.Errorf("workspace device is not a block device") + } + if err := os.MkdirAll(config.WorkspaceMount, 0755); err != nil { + return fmt.Errorf("create workspace mount: %w", err) + } + if err := syscall.Mount(config.WorkspaceDevice, config.WorkspaceMount, "", 0, ""); err != nil { + return fmt.Errorf("mount workspace: %w", err) + } + return nil +} + +func configureNetwork(config bootConfig) error { + ip, err := ipCommand() + if err != nil { + return err + } + env := []string{"PATH=/usr/sbin:/usr/bin:/sbin:/bin"} + run := func(args ...string) error { + command := exec.Command(ip, args...) + command.Env = env + if output, err := command.CombinedOutput(); err != nil { + return fmt.Errorf("ip %s: %w: %s", strings.Join(args, " "), err, strings.TrimSpace(string(output))) + } + return nil + } + if err := run("link", "set", "dev", config.Interface, "up"); err != nil { + return err + } + if err := run("address", "replace", config.GuestIP.String()+fmt.Sprintf("/%d", config.GuestPrefix), "dev", config.Interface); err != nil { + return err + } + return run("route", "replace", "default", "via", config.Gateway.String(), "dev", config.Interface) +} + +func ipCommand() (string, error) { + for _, path := range []string{"/sbin/ip", "/usr/sbin/ip"} { + if info, err := os.Stat(path); err == nil && !info.IsDir() && info.Mode()&0111 != 0 { + return path, nil + } + } + return "", errors.New("ip utility is required to configure guest networking") +} + +type session struct { + connection *os.File + config bootConfig + writeMu sync.Mutex + activeMu sync.Mutex + active *execution +} + +type execution struct { + requestID string + command *exec.Cmd + stdin io.WriteCloser + cancel context.CancelFunc + done chan struct{} + once sync.Once + output sync.WaitGroup + stdinMu sync.Mutex + stdinClosed bool +} + +func serveClient(connection *os.File, config bootConfig) bool { + s := &session{connection: connection, config: config} + _ = s.send(Frame{Version: ProtocolVersion, Type: "ready", RequestID: "control", Capabilities: map[string]bool{ + "stdin": true, "tty": false, "resize": false, + }}) + for { + frame, err := ReadFrame(connection) + if err != nil { + var protocol *protocolError + if errors.As(err, &protocol) { + s.sendProtocolError(safeRequestID(frame.RequestID), protocol) + } + s.stopActive() + return false + } + switch frame.Type { + case "execute": + if err := s.start(frame); err != nil { + var typed typedError + if errors.As(err, &typed) { + s.sendError(frame.RequestID, typed.code, typed.message) + } else { + s.sendError(frame.RequestID, errorInvalidRequest, err.Error()) + } + } + case "stdin": + s.writeStdin(frame) + case "cancel": + s.cancel(frame.RequestID) + case "resize": + s.sendError(frame.RequestID, errorTTYUnsupported, "TTY and resize are unsupported") + case "shutdown": + _ = s.send(newFrame("shutting_down", frame.RequestID)) + s.stopActive() + return true + default: + s.sendError(frame.RequestID, errorInvalidRequest, "frame is not accepted from the client") + } + } +} + +func (s *session) send(frame Frame) error { + s.writeMu.Lock() + defer s.writeMu.Unlock() + return WriteFrame(s.connection, frame) +} + +func (s *session) sendError(requestID string, code errorCode, message string) { + f := newFrame("error", safeRequestID(requestID)) + f.Code, f.Message = code, message + _ = s.send(f) +} + +func (s *session) sendProtocolError(requestID string, protocol *protocolError) { + f := newFrame("error", requestID) + f.Code, f.Message, f.ExpectedVersion = protocol.code, protocol.message, protocol.expectedVersion + _ = s.send(f) +} + +func safeRequestID(requestID string) string { + if requestIDPattern.MatchString(requestID) { + return requestID + } + return "control" +} + +func (s *session) start(frame Frame) error { + if frame.TTY { + return typedError{errorTTYUnsupported, "TTY is not supported by this guest"} + } + if frame.TimeoutMS != nil && *frame.TimeoutMS > maxTimeoutMS { + return fmt.Errorf("timeoutMs exceeds maximum of %d", maxTimeoutMS) + } + if frame.UID > int64(^uint32(0)) || frame.GID > int64(^uint32(0)) { + return fmt.Errorf("uid and gid must fit Linux credential limits") + } + cwd, err := resolveCWD(s.config.WorkspaceMount, frame.Cwd) + if err != nil { + return err + } + s.activeMu.Lock() + defer s.activeMu.Unlock() + if s.active != nil { + return typedError{errorRequestInProgress, "another command is already running"} + } + ctx := context.Background() + var cancel context.CancelFunc + if frame.TimeoutMS != nil { + ctx, cancel = context.WithTimeout(ctx, time.Duration(*frame.TimeoutMS)*time.Millisecond) + } else { + ctx, cancel = context.WithCancel(ctx) + } + command := exec.Command(frame.Argv[0], frame.Argv[1:]...) + command.Dir = cwd + command.Env = environment(frame.Env) + command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Credential: &syscall.Credential{Uid: uint32(frame.UID), Gid: uint32(frame.GID)}} + stdin, err := command.StdinPipe() + if err != nil { + cancel() + return err + } + stdout, err := command.StdoutPipe() + if err != nil { + cancel() + stdin.Close() + return err + } + stderr, err := command.StderrPipe() + if err != nil { + cancel() + stdin.Close() + return err + } + if err := command.Start(); err != nil { + cancel() + stdin.Close() + return err + } + execution := &execution{requestID: frame.RequestID, command: command, stdin: stdin, cancel: cancel, done: make(chan struct{})} + s.active = execution + execution.output.Add(2) + go s.streamOutput(execution, "stdout", stdout) + go s.streamOutput(execution, "stderr", stderr) + go func() { + select { + case <-ctx.Done(): + s.terminate(execution) + case <-execution.done: + } + }() + go s.wait(execution, ctx) + return nil +} + +func (s *session) writeStdin(frame Frame) { + s.activeMu.Lock() + execution := s.active + s.activeMu.Unlock() + if execution == nil || execution.requestID != frame.RequestID { + s.sendError(frame.RequestID, errorRequestNotFound, "no matching command is running") + return + } + execution.stdinMu.Lock() + defer execution.stdinMu.Unlock() + if frame.Data != nil { + data, _ := base64.StdEncoding.DecodeString(*frame.Data) + if _, err := execution.stdin.Write(data); err != nil { + s.sendError(frame.RequestID, errorInternal, "write stdin: "+err.Error()) + } + } + if frame.EOF != nil && *frame.EOF && !execution.stdinClosed { + execution.stdinClosed = true + if err := execution.stdin.Close(); err != nil { + s.sendError(frame.RequestID, errorInternal, "close stdin: "+err.Error()) + } + } +} + +func (s *session) cancel(requestID string) { + s.activeMu.Lock() + execution := s.active + s.activeMu.Unlock() + if execution == nil || execution.requestID != requestID { + s.sendError(requestID, errorRequestNotFound, "no matching command is running") + return + } + s.terminate(execution) +} + +func (s *session) stopActive() { + s.activeMu.Lock() + execution := s.active + s.activeMu.Unlock() + if execution == nil { + return + } + s.terminate(execution) + select { + case <-execution.done: + case <-time.After(cancelGrace + time.Second): + } +} + +func (s *session) terminate(execution *execution) { + select { + case <-execution.done: + return + default: + } + execution.once.Do(func() { + execution.cancel() + _ = syscall.Kill(-execution.command.Process.Pid, syscall.SIGTERM) + go func() { + select { + case <-execution.done: + case <-time.After(cancelGrace): + _ = syscall.Kill(-execution.command.Process.Pid, syscall.SIGKILL) + } + }() + }) +} + +func (s *session) wait(execution *execution, ctx context.Context) { + _ = execution.stdin.Close() + execution.output.Wait() + err := execution.command.Wait() + terminateAndReapDescendants(execution.command.Process.Pid) + // Prevent a just-cancelled context watcher from signalling a reaped PID. + execution.once.Do(func() {}) + timedOut := errors.Is(ctx.Err(), context.DeadlineExceeded) + result := newFrame("result", execution.requestID) + result.TimedOut = timedOut + if exitError, ok := err.(*exec.ExitError); ok { + status := exitError.Sys().(syscall.WaitStatus) + if status.Signaled() { + signal := signalName(status.Signal()) + result.Signal = &signal + } else { + code := status.ExitStatus() + result.ExitCode = &code + } + } else if err == nil { + code := 0 + result.ExitCode = &code + } else { + s.sendError(execution.requestID, errorInternal, "execution failed: "+err.Error()) + } + if result.ExitCode != nil || result.Signal != nil { + _ = s.send(result) + } + s.activeMu.Lock() + if s.active == execution { + s.active = nil + } + s.activeMu.Unlock() + close(execution.done) + execution.cancel() +} + +func terminateAndReapDescendants(processGroup int) { + _ = syscall.Kill(-processGroup, syscall.SIGTERM) + deadline := time.Now().Add(cancelGrace) + killed := false + for { + var status syscall.WaitStatus + pid, err := syscall.Wait4(-1, &status, syscall.WNOHANG, nil) + if pid > 0 { + continue + } + if err != nil && !errors.Is(err, syscall.ECHILD) { + return + } + if errors.Is(err, syscall.ECHILD) { + return + } + if !killed && time.Now().After(deadline) { + _ = syscall.Kill(-processGroup, syscall.SIGKILL) + killed = true + deadline = time.Now().Add(time.Second) + } + if killed && time.Now().After(deadline) { + return + } + time.Sleep(10 * time.Millisecond) + } +} + +func (s *session) streamOutput(execution *execution, frameType string, reader io.Reader) { + defer execution.output.Done() + // 48 KiB encodes to exactly 64 KiB, keeping the protocol chunk limit. + buffer := make([]byte, 48<<10) + for { + count, err := reader.Read(buffer) + if count > 0 { + frame := newFrame(frameType, execution.requestID) + data := base64.StdEncoding.EncodeToString(buffer[:count]) + frame.Data = &data + _ = s.send(frame) + } + if err != nil { + return + } + } +} + +func environment(values map[string]string) []string { + environment := make([]string, 0, len(values)) + for key, value := range values { + environment = append(environment, key+"="+value) + } + return environment +} + +func resolveCWD(workspace, cwd string) (string, error) { + if !filepath.IsAbs(cwd) { + return "", errors.New("cwd must be an absolute path under the workspace mount") + } + resolvedWorkspace, err := filepath.EvalSymlinks(workspace) + if err != nil { + return "", fmt.Errorf("resolve workspace mount: %w", err) + } + resolvedCWD, err := filepath.EvalSymlinks(cwd) + if err != nil { + return "", fmt.Errorf("resolve cwd: %w", err) + } + relative, err := filepath.Rel(resolvedWorkspace, resolvedCWD) + if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(os.PathSeparator)) { + return "", errors.New("cwd must remain under the workspace mount") + } + info, err := os.Stat(resolvedCWD) + if err != nil { + return "", err + } + if !info.IsDir() { + return "", errors.New("cwd is not a directory") + } + return resolvedCWD, nil +} + +func signalName(signal syscall.Signal) string { + if name, ok := map[syscall.Signal]string{ + syscall.SIGHUP: "SIGHUP", syscall.SIGINT: "SIGINT", syscall.SIGQUIT: "SIGQUIT", + syscall.SIGILL: "SIGILL", syscall.SIGABRT: "SIGABRT", syscall.SIGFPE: "SIGFPE", + syscall.SIGKILL: "SIGKILL", syscall.SIGSEGV: "SIGSEGV", syscall.SIGPIPE: "SIGPIPE", + syscall.SIGALRM: "SIGALRM", syscall.SIGTERM: "SIGTERM", syscall.SIGUSR1: "SIGUSR1", + syscall.SIGUSR2: "SIGUSR2", syscall.SIGCHLD: "SIGCHLD", syscall.SIGCONT: "SIGCONT", + syscall.SIGSTOP: "SIGSTOP", syscall.SIGTSTP: "SIGTSTP", syscall.SIGTTIN: "SIGTTIN", + syscall.SIGTTOU: "SIGTTOU", + }[signal]; ok { + return name + } + return fmt.Sprintf("SIG%d", signal) +} + +type typedError struct { + code errorCode + message string +} + +func (e typedError) Error() string { return e.message } diff --git a/guest/firecracker-supervisor/runtime_other.go b/guest/firecracker-supervisor/runtime_other.go new file mode 100644 index 000000000..db1370d97 --- /dev/null +++ b/guest/firecracker-supervisor/runtime_other.go @@ -0,0 +1,9 @@ +//go:build !linux + +package main + +import "errors" + +func runSupervisor() error { + return errors.New("the Firecracker guest supervisor requires Linux") +} diff --git a/src/firecracker-runtime-backend.ts b/src/firecracker-runtime-backend.ts index 88a142d2b..11033f7d6 100644 --- a/src/firecracker-runtime-backend.ts +++ b/src/firecracker-runtime-backend.ts @@ -5,7 +5,7 @@ import type { WrapperConfig } from './types'; export const FIRECRACKER_INCOMPLETE_CAPABILITY_ERROR = 'Firecracker runtime workload execution is unavailable in this preview: ' + - 'workspace image and guest command execution are not implemented'; + 'final runtime dispatch and infrastructure handoff are not integrated'; export interface FirecrackerRuntimeBackendDependencies { startInfrastructure: WorkflowDependencies['startContainers']; @@ -16,9 +16,9 @@ export interface FirecrackerRuntimeBackendDependencies { * Fail-closed backend boundary for the Firecracker control-plane preview. * * The manager primitives are intentionally not dispatched by the main workflow - * until workspace preparation and guest command execution land in later stack - * layers. FirecrackerManager separately refuses to launch without host-side - * network enforcement. + * until the final runtime-selection layer integrates infrastructure discovery, + * sanitized environment assembly, and required probes. FirecrackerManager + * separately refuses to launch without host-side network enforcement. */ export class FirecrackerRuntimeBackend implements ExternalAgentRuntimeBackend { readonly runtime = 'firecracker'; diff --git a/src/firecracker/manager.test.ts b/src/firecracker/manager.test.ts index 31c07a685..7f544ddec 100644 --- a/src/firecracker/manager.test.ts +++ b/src/firecracker/manager.test.ts @@ -3,6 +3,7 @@ import type { FirecrackerOptions } from '../types/runtime-options'; import type { FirecrackerApiClient } from './api-client'; import { FirecrackerManager, + buildSupervisorBootArgs, createFirecrackerRunPaths, type FirecrackerManagerDependencies, type FirecrackerManagerNetworkConfig, @@ -11,6 +12,8 @@ import type { FirecrackerNetworkLifecycle, FirecrackerNetworkPlan, } from './network'; +import type { FirecrackerVsockClient } from './vsock-client'; +import type { FirecrackerWorkspaceImage } from './workspace-image'; function config(overrides: Partial = {}): FirecrackerOptions { return { @@ -30,6 +33,7 @@ function processMock(): ExecaChildProcess { const child = Promise.resolve({ exitCode: 0 }) as unknown as ExecaChildProcess; Object.assign(child, { exitCode: null, + signalCode: null, killed: false, kill: jest.fn(() => { Object.assign(child, { exitCode: 0, killed: true }); @@ -64,6 +68,7 @@ function dependencies( putMachineConfig: jest.fn().mockResolvedValue(undefined), putBootSource: jest.fn().mockResolvedValue(undefined), putDrive: jest.fn().mockResolvedValue(undefined), + putVsock: jest.fn().mockResolvedValue(undefined), putNetworkInterface: jest.fn().mockResolvedValue(undefined), instanceStart: jest.fn().mockResolvedValue(undefined), } as unknown as FirecrackerApiClient; @@ -85,6 +90,8 @@ function dependencies( sleep: jest.fn().mockResolvedValue(undefined), createClient: jest.fn().mockReturnValue(client), createNetwork: jest.fn((plan) => networkLifecycle(plan)), + createWorkspaceImage: jest.fn(), + createVsockClient: jest.fn(), resolveIdentity: jest.fn().mockReturnValue({ uid: 1000, gid: 1000 }), ...overrides, }; @@ -258,6 +265,189 @@ describe('FirecrackerManager', () => { expect(cleanup).toHaveBeenCalledTimes(2); }); + it('configures the workspace drive and vsock, then extracts only after VM termination', async () => { + const order: string[] = []; + const child = processMock(); + const workspace = { + prepare: jest.fn().mockResolvedValue({ + workspaceImagePath: '/tmp/prepared-workspace.ext4', + rootfsImagePath: '/tmp/prepared-rootfs.ext4', + imageBytes: 1024, + originalManifest: new Map(), + }), + extractAfterStop: jest.fn(async () => { + order.push('extract'); + expect(child.exitCode).toBe(0); + }), + cleanup: jest.fn().mockResolvedValue(undefined), + } as unknown as FirecrackerWorkspaceImage; + const guestClient = { + connect: jest.fn().mockResolvedValue({ + version: 1, + type: 'ready', + requestId: 'control', + capabilities: { stdin: true, tty: false, resize: false }, + }), + execute: jest.fn().mockResolvedValue({ + requestId: 'command', + exitCode: 0, + signal: null, + timedOut: false, + }), + shutdown: jest.fn().mockResolvedValue(undefined), + destroy: jest.fn(), + } as unknown as FirecrackerVsockClient; + const deps = dependencies({ + launch: jest.fn().mockReturnValue(child), + createWorkspaceImage: jest.fn().mockReturnValue(workspace), + createVsockClient: jest.fn().mockReturnValue(guestClient), + }); + const manager = new FirecrackerManager( + config(), + '/tmp/awf', + deps, + 'guest', + networkConfig(), + { + workspacePath: '/workspace', + homePath: '/home/runner', + supervisorBinaryPath: '/opt/awf-supervisor', + supervisorSha256: 'a'.repeat(64), + }, + ); + + const client = await manager.start(); + expect(client.putBootSource).toHaveBeenCalledWith(expect.objectContaining({ + kernel_image_path: '/kernel', + boot_args: expect.stringContaining('init=/sbin/awf-supervisor'), + })); + expect(client.putDrive).toHaveBeenCalledWith({ + drive_id: 'workspace', + path_on_host: '/workspace.ext4', + is_root_device: false, + is_read_only: false, + }); + expect(client.putVsock).toHaveBeenCalledWith({ + guest_cid: 3, + uds_path: '/run/awf-vsock.socket', + }); + await manager.startInstance(); + expect(deps.createVsockClient).toHaveBeenCalledWith( + '/tmp/awf/firecracker-jailer/firecracker/guest/root/run/awf-vsock.socket', + 52, + 1, + ); + await expect(manager.execute({ + requestId: 'command', + argv: ['true'], + env: {}, + cwd: '/workspace', + uid: 1000, + gid: 1000, + })).resolves.toEqual(expect.objectContaining({ exitCode: 0 })); + await manager.stop(); + + expect(guestClient.shutdown).toHaveBeenCalledTimes(1); + expect(workspace.extractAfterStop).toHaveBeenCalledWith( + '/tmp/awf/firecracker-jailer/firecracker/guest/root/workspace.ext4', + ); + expect(order).toEqual(['extract']); + }); + + it('builds explicit supervisor boot networking without widening policy', () => { + const args = buildSupervisorBootArgs({ + runId: 'run', + namespaceName: 'ns', + netnsPath: '/var/run/netns/ns', + nftTableName: 'table', + infrastructureBridge: 'awfbr0', + hostVethName: 'host', + namespaceVethName: 'namespace', + tapName: 'tap', + infrastructureIp: '172.30.0.20', + infrastructureCidr: '172.30.0.0/24', + hostGatewayIp: '172.30.0.1', + guestSubnet: '100.64.0.0/30', + guestIp: '100.64.0.2', + guestGatewayIp: '100.64.0.1', + guestPrefixLength: 30, + guestMac: '02:00:00:00:00:01', + jailerUid: 1000, + jailerGid: 1000, + allowedEndpoints: [], + networkInterface: { iface_id: 'eth0', host_dev_name: 'tap' }, + }, { + workspacePath: '/workspace', + homePath: '/home/runner', + supervisorBinaryPath: '/opt/supervisor', + supervisorSha256: 'a'.repeat(64), + }); + expect(args).toContain('awf.guest-ip=100.64.0.2'); + expect(args).toContain('awf.guest-gateway=100.64.0.1'); + expect(args).toContain('awf.workspace-device=/dev/vdb'); + expect(args).not.toContain('8.8.8.8'); + }); + + it('retains the workspace and network until process termination is confirmed', async () => { + const child = Promise.resolve({ exitCode: null }) as unknown as ExecaChildProcess; + Object.assign(child, { + exitCode: null, + signalCode: null, + killed: false, + kill: jest.fn(() => { + Object.assign(child, { killed: true }); + return true; + }), + }); + const workspace = { + prepare: jest.fn().mockResolvedValue({ + workspaceImagePath: '/tmp/prepared-workspace.ext4', + rootfsImagePath: '/tmp/prepared-rootfs.ext4', + imageBytes: 1024, + originalManifest: new Map(), + }), + extractAfterStop: jest.fn().mockResolvedValue(undefined), + cleanup: jest.fn().mockResolvedValue(undefined), + } as unknown as FirecrackerWorkspaceImage; + const guestClient = { + connect: jest.fn().mockResolvedValue(undefined), + shutdown: jest.fn().mockResolvedValue(undefined), + destroy: jest.fn(), + } as unknown as FirecrackerVsockClient; + const deps = dependencies({ + launch: jest.fn().mockReturnValue(child), + createWorkspaceImage: jest.fn().mockReturnValue(workspace), + createVsockClient: jest.fn().mockReturnValue(guestClient), + }); + const manager = new FirecrackerManager( + config(), + '/tmp/awf', + deps, + 'termination', + networkConfig(), + { + workspacePath: '/workspace', + homePath: '/home/runner', + supervisorBinaryPath: '/opt/awf-supervisor', + supervisorSha256: 'a'.repeat(64), + }, + ); + await manager.start(); + await manager.startInstance(); + + await expect(manager.stop()).rejects.toThrow(/stopped before workspace\/network removal/); + const lifecycle = (deps.createNetwork as jest.Mock).mock.results[0] + .value as FirecrackerNetworkLifecycle; + expect(lifecycle.cleanup).not.toHaveBeenCalled(); + expect(workspace.extractAfterStop).not.toHaveBeenCalled(); + expect(deps.rm).not.toHaveBeenCalled(); + + Object.assign(child, { exitCode: 0 }); + await expect(manager.stop()).resolves.toBeUndefined(); + expect(workspace.extractAfterStop).toHaveBeenCalledTimes(1); + expect(lifecycle.cleanup).toHaveBeenCalledTimes(1); + }); + it('rolls back the network when typed NIC configuration fails', async () => { const client = { putMachineConfig: jest.fn().mockResolvedValue(undefined), diff --git a/src/firecracker/manager.ts b/src/firecracker/manager.ts index f18f24bfe..b443a1512 100644 --- a/src/firecracker/manager.ts +++ b/src/firecracker/manager.ts @@ -13,10 +13,24 @@ import { type FirecrackerNetworkPlan, } from './network'; import { runFirecrackerPreflight } from './preflight'; +import { + FirecrackerVsockClient, + type FirecrackerGuestExecutionRequest, + type FirecrackerGuestExecutionResult, +} from './vsock-client'; +import { + FirecrackerWorkspaceImage, + type FirecrackerWorkspaceImageConfig, +} from './workspace-image'; const API_SOCKET_NAME = 'firecracker.socket'; +const VSOCK_SOCKET_NAME = 'awf-vsock.socket'; +const WORKSPACE_IMAGE_NAME = 'workspace.ext4'; const KERNEL_JAIL_PATH = '/kernel'; const ROOTFS_JAIL_PATH = '/rootfs'; +const WORKSPACE_JAIL_PATH = '/workspace.ext4'; +const VSOCK_JAIL_PATH = `/run/${VSOCK_SOCKET_NAME}`; +export const FIRECRACKER_GUEST_VSOCK_PORT = 52; export interface FirecrackerRunPaths { runId: string; @@ -25,6 +39,8 @@ export interface FirecrackerRunPaths { apiSocketPath: string; kernelPath: string; rootfsPath: string; + workspacePath: string; + vsockSocketPath: string; } export interface FirecrackerManagerDependencies { @@ -47,6 +63,8 @@ export interface FirecrackerManagerDependencies { sleep(milliseconds: number): Promise; createClient(socketPath: string, timeoutMs: number): FirecrackerApiClient; createNetwork(plan: FirecrackerNetworkPlan): FirecrackerNetworkLifecycle; + createWorkspaceImage(config: FirecrackerWorkspaceImageConfig): FirecrackerWorkspaceImage; + createVsockClient(socketPath: string, guestPort: number, timeoutMs: number): FirecrackerVsockClient; resolveIdentity(): { uid: number; gid: number }; } @@ -56,6 +74,15 @@ export interface FirecrackerManagerNetworkConfig { controlPeer?: FirecrackerControlPeer; } +export interface FirecrackerManagerGuestConfig { + readonly workspacePath: string; + readonly homePath: string; + readonly supervisorBinaryPath: string; + readonly supervisorSha256: string; + readonly maxWorkspaceImageBytes?: number; + readonly vsockPort?: number; +} + const defaultDependencies: FirecrackerManagerDependencies = { preflight: runFirecrackerPreflight, launch: (command, args, options) => execa(command, args, options), @@ -68,6 +95,14 @@ const defaultDependencies: FirecrackerManagerDependencies = { sleep: (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)), createClient: (socketPath, timeoutMs) => new FirecrackerApiClient({ socketPath, timeoutMs }), createNetwork: (plan) => new FirecrackerNetworkManager(plan), + createWorkspaceImage: (config) => new FirecrackerWorkspaceImage(config), + createVsockClient: (socketPath, guestPort, timeoutMs) => new FirecrackerVsockClient({ + socketPath, + guestPort, + connectTimeoutMs: timeoutMs, + readTimeoutMs: Math.max(timeoutMs, 30_000), + writeTimeoutMs: timeoutMs, + }), resolveIdentity: resolveJailerIdentity, }; @@ -107,6 +142,8 @@ export function createFirecrackerRunPaths( apiSocketPath: path.join(jailRoot, 'run', API_SOCKET_NAME), kernelPath: path.join(jailRoot, KERNEL_JAIL_PATH), rootfsPath: path.join(jailRoot, ROOTFS_JAIL_PATH), + workspacePath: path.join(jailRoot, WORKSPACE_IMAGE_NAME), + vsockSocketPath: path.join(jailRoot, 'run', VSOCK_SOCKET_NAME), }; } @@ -118,15 +155,19 @@ export class FirecrackerManager { private process: ExecaChildProcess | undefined; private client: FirecrackerApiClient | undefined; private network: FirecrackerNetworkLifecycle | undefined; + private workspace: FirecrackerWorkspaceImage | undefined; + private guestClient: FirecrackerVsockClient | undefined; + private instanceStarted = false; constructor( private readonly config: FirecrackerOptions, - workDir: string, + private readonly workDir: string, private readonly dependencies: FirecrackerManagerDependencies = defaultDependencies, runId?: string, private readonly networkConfig?: FirecrackerManagerNetworkConfig, + private readonly guestConfig?: FirecrackerManagerGuestConfig, ) { - this.paths = createFirecrackerRunPaths(workDir, config.firecrackerBinary, runId); + this.paths = createFirecrackerRunPaths(this.workDir, config.firecrackerBinary, runId); } async start(): Promise { @@ -147,6 +188,27 @@ export class FirecrackerManager { }); this.network = this.dependencies.createNetwork(networkPlan); await this.network.setup(); + let rootfsSource = artifacts.rootfsPath; + let workspaceSource: string | undefined; + if (this.guestConfig) { + this.workspace = this.dependencies.createWorkspaceImage({ + runId: this.paths.runId, + workDir: this.workDir, + workspacePath: this.guestConfig.workspacePath, + homePath: this.guestConfig.homePath, + baseRootfsPath: artifacts.rootfsPath, + supervisorBinaryPath: this.guestConfig.supervisorBinaryPath, + supervisorSha256: this.guestConfig.supervisorSha256, + ...(this.guestConfig.maxWorkspaceImageBytes === undefined + ? {} + : { maxImageBytes: this.guestConfig.maxWorkspaceImageBytes }), + uid: identity.uid, + gid: identity.gid, + }); + const preparation = await this.workspace.prepare(); + rootfsSource = preparation.rootfsImagePath; + workspaceSource = preparation.workspaceImagePath; + } await this.dependencies.mkdir(this.paths.chrootBaseDir, { recursive: true, mode: 0o700, @@ -173,7 +235,10 @@ export class FirecrackerManager { await this.waitForApiSocket(); await this.stageArtifact(artifacts.kernelPath, this.paths.kernelPath, 0o400, identity); - await this.stageArtifact(artifacts.rootfsPath, this.paths.rootfsPath, 0o600, identity); + await this.stageArtifact(rootfsSource, this.paths.rootfsPath, 0o600, identity); + if (workspaceSource) { + await this.stageArtifact(workspaceSource, this.paths.workspacePath, 0o600, identity); + } this.client = this.dependencies.createClient( this.paths.apiSocketPath, @@ -185,6 +250,9 @@ export class FirecrackerManager { }); await this.client.putBootSource({ kernel_image_path: KERNEL_JAIL_PATH, + ...(this.guestConfig + ? { boot_args: buildSupervisorBootArgs(networkPlan, this.guestConfig) } + : {}), }); await this.client.putDrive({ drive_id: 'rootfs', @@ -193,6 +261,18 @@ export class FirecrackerManager { is_read_only: false, }); await this.client.putNetworkInterface(networkPlan.networkInterface); + if (this.guestConfig) { + await this.client.putDrive({ + drive_id: 'workspace', + path_on_host: WORKSPACE_JAIL_PATH, + is_root_device: false, + is_read_only: false, + }); + await this.client.putVsock({ + guest_cid: 3, + uds_path: VSOCK_JAIL_PATH, + }); + } return this.client; } catch (error) { startupError = error; @@ -212,25 +292,83 @@ export class FirecrackerManager { async startInstance(): Promise { if (!this.client) throw new Error('Firecracker API is not configured'); await this.client.instanceStart(); + this.instanceStarted = true; + if (this.guestConfig) { + this.guestClient = this.dependencies.createVsockClient( + this.paths.vsockSocketPath, + this.guestConfig.vsockPort ?? FIRECRACKER_GUEST_VSOCK_PORT, + this.config.apiTimeoutMs, + ); + await this.guestClient.connect(); + } + } + + async execute( + request: FirecrackerGuestExecutionRequest, + ): Promise { + if (!this.guestClient) { + throw new Error('Firecracker guest supervisor is not ready'); + } + return this.guestClient.execute(request); } async stop(): Promise { const errors: unknown[] = []; - if (this.process && this.process.exitCode === null && !this.process.killed) { + const instanceWasStarted = this.instanceStarted; + if (this.guestClient) { + try { + await this.guestClient.shutdown(); + } catch (error) { + errors.push(error); + this.guestClient.destroy(); + } + } + this.guestClient = undefined; + + let terminationConfirmed = !this.process || + this.process.exitCode !== null || + this.process.signalCode !== null; + if ( + this.process && + this.process.exitCode === null && + this.process.signalCode === null + ) { const child = this.process; try { - child.kill('SIGTERM', { forceKillAfterTimeout: 2_000 }); + if (!child.killed) { + child.kill('SIGTERM', { forceKillAfterTimeout: 2_000 }); + } await child; if (child.exitCode === null && child.signalCode === null) { throw new Error('Firecracker process termination was not confirmed'); } + terminationConfirmed = true; } catch (error) { + terminationConfirmed = child.exitCode !== null || child.signalCode !== null; errors.push(error); } } + if (!terminationConfirmed && this.process) { + if (errors.length === 0) { + errors.push(new Error('Firecracker process termination was not confirmed')); + } + throw new Error( + `Firecracker cleanup stopped before workspace/network removal: ` + + `${errors.map(formatError).join('; ')}`, + ); + } this.process = undefined; this.client = undefined; + if (this.workspace && instanceWasStarted) { + try { + await this.workspace.extractAfterStop(this.paths.workspacePath); + } catch (error) { + errors.push(error); + } + } + this.instanceStarted = false; + try { await this.network?.cleanup(); this.network = undefined; @@ -238,18 +376,27 @@ export class FirecrackerManager { errors.push(error); } + if (!instanceWasStarted || terminationConfirmed) { + try { + await this.dependencies.rm( + path.join( + this.paths.chrootBaseDir, + path.basename(this.config.firecrackerBinary), + this.paths.runId, + ), + { recursive: true, force: true }, + ); + } catch (error) { + errors.push(error); + } + } + try { - await this.dependencies.rm( - path.join( - this.paths.chrootBaseDir, - path.basename(this.config.firecrackerBinary), - this.paths.runId, - ), - { recursive: true, force: true }, - ); + await this.workspace?.cleanup(!instanceWasStarted); } catch (error) { errors.push(error); } + this.workspace = undefined; if (errors.length === 1) throw errors[0]; if (errors.length > 1) { @@ -295,6 +442,30 @@ export class FirecrackerManager { } } +export function buildSupervisorBootArgs( + networkPlan: FirecrackerNetworkPlan, + guestConfig: FirecrackerManagerGuestConfig, +): string { + const port = guestConfig.vsockPort ?? FIRECRACKER_GUEST_VSOCK_PORT; + if (!Number.isInteger(port) || port < 1 || port > 65_535) { + throw new Error(`Firecracker guest vsock port must be in 1-65535: ${port}`); + } + return [ + 'console=ttyS0', + 'reboot=k', + 'panic=1', + 'pci=off', + 'init=/sbin/awf-supervisor', + 'awf.workspace-device=/dev/vdb', + 'awf.workspace-mount=/workspace', + `awf.vsock-port=${port}`, + `awf.guest-ip=${networkPlan.guestIp}`, + `awf.guest-prefix=${networkPlan.guestPrefixLength}`, + `awf.guest-gateway=${networkPlan.guestGatewayIp}`, + 'awf.guest-interface=eth0', + ].join(' '); +} + function formatError(error: unknown): string { return error instanceof Error ? error.message : String(error); } diff --git a/src/firecracker/vsock-client.test.ts b/src/firecracker/vsock-client.test.ts new file mode 100644 index 000000000..59413701b --- /dev/null +++ b/src/firecracker/vsock-client.test.ts @@ -0,0 +1,263 @@ +import { promises as fs } from 'fs'; +import * as net from 'net'; +import * as os from 'os'; +import * as path from 'path'; +import { PassThrough } from 'stream'; +import { + FIRECRACKER_GUEST_PROTOCOL_VERSION, + FirecrackerFrameDecoder, + encodeFirecrackerFrame, + type FirecrackerGuestFrame, +} from './vsock-protocol'; +import { FirecrackerVsockClient } from './vsock-client'; + +async function createServer( + handler: (frame: FirecrackerGuestFrame, socket: net.Socket) => void, +): Promise<{ socketPath: string; close(): Promise }> { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'awf-vsock-')); + const socketPath = path.join(directory, 'vsock.sock'); + const server = net.createServer((socket) => { + let handshaken = false; + let handshake = Buffer.alloc(0); + const decoder = new FirecrackerFrameDecoder(); + socket.on('data', (chunk: Buffer) => { + if (!handshaken) { + handshake = Buffer.concat([handshake, chunk]); + const newline = handshake.indexOf(0x0a); + if (newline === -1) return; + expect(handshake.subarray(0, newline).toString()).toBe('CONNECT 52'); + handshaken = true; + socket.write('OK 1234\n'); + socket.write(encodeFirecrackerFrame({ + version: FIRECRACKER_GUEST_PROTOCOL_VERSION, + type: 'ready', + requestId: 'control', + capabilities: { stdin: true, tty: false, resize: false }, + })); + chunk = handshake.subarray(newline + 1); + } + for (const frame of decoder.push(chunk)) handler(frame, socket); + }); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(socketPath, resolve); + }); + return { + socketPath, + close: async () => { + await new Promise((resolve) => server.close(() => resolve())); + await fs.rm(directory, { recursive: true, force: true }); + }, + }; +} + +describe('FirecrackerVsockClient', () => { + it('streams output, stdin, and exact terminal status', async () => { + const received: FirecrackerGuestFrame[] = []; + const server = await createServer((frame, socket) => { + received.push(frame); + if (frame.type === 'execute') { + socket.write(Buffer.concat([ + encodeFirecrackerFrame({ + version: 1, + type: 'stdout', + requestId: frame.requestId, + data: Buffer.from('hello').toString('base64'), + }), + encodeFirecrackerFrame({ + version: 1, + type: 'stderr', + requestId: frame.requestId, + data: Buffer.from('warning').toString('base64'), + }), + ])); + } + if (frame.type === 'stdin' && frame.eof) { + socket.write(encodeFirecrackerFrame({ + version: 1, + type: 'result', + requestId: frame.requestId, + exitCode: 7, + signal: null, + timedOut: false, + })); + } + }); + const client = new FirecrackerVsockClient({ + socketPath: server.socketPath, + guestPort: 52, + }); + const stdout = new PassThrough(); + const stderr = new PassThrough(); + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + stdout.on('data', (chunk) => stdoutChunks.push(chunk)); + stderr.on('data', (chunk) => stderrChunks.push(chunk)); + + await client.connect(); + const resultPromise = client.execute({ + requestId: 'run-1', + argv: ['sh', '-c', 'cat'], + env: { PATH: '/usr/bin' }, + cwd: '/workspace', + uid: 1000, + gid: 1000, + stdout, + stderr, + }); + await client.writeStdin(Buffer.from('input')); + await client.endStdin(); + + await expect(resultPromise).resolves.toEqual({ + requestId: 'run-1', + exitCode: 7, + signal: null, + timedOut: false, + }); + expect(Buffer.concat(stdoutChunks).toString()).toBe('hello'); + expect(Buffer.concat(stderrChunks).toString()).toBe('warning'); + expect(received).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: 'execute', requestId: 'run-1' }), + expect.objectContaining({ type: 'stdin', requestId: 'run-1', eof: true }), + ])); + client.destroy(); + await server.close(); + }); + + it('cancels at the host deadline and deterministically returns 124', async () => { + const server = await createServer((frame, socket) => { + if (frame.type === 'cancel') { + socket.write(encodeFirecrackerFrame({ + version: 1, + type: 'result', + requestId: frame.requestId, + exitCode: null, + signal: 'SIGTERM', + timedOut: true, + })); + } + }); + const client = new FirecrackerVsockClient({ + socketPath: server.socketPath, + guestPort: 52, + cancellationGraceMs: 100, + }); + await client.connect(); + await expect(client.execute({ + argv: ['sleep', '10'], + env: {}, + cwd: '/workspace', + uid: 1000, + gid: 1000, + timeoutMs: 10, + })).resolves.toEqual(expect.objectContaining({ + exitCode: 124, + timedOut: true, + signal: 'SIGTERM', + })); + client.destroy(); + await server.close(); + }); + + it('rejects protocol errors and disconnects during execution', async () => { + const server = await createServer((frame, socket) => { + if (frame.type === 'execute') socket.destroy(); + }); + const client = new FirecrackerVsockClient({ + socketPath: server.socketPath, + guestPort: 52, + }); + await client.connect(); + await expect(client.execute({ + argv: ['true'], + env: {}, + cwd: '/workspace', + uid: 1000, + gid: 1000, + })).rejects.toThrow(/disconnected/); + await server.close(); + }); + + it('requires advertised TTY capability', async () => { + const server = await createServer(() => undefined); + const client = new FirecrackerVsockClient({ + socketPath: server.socketPath, + guestPort: 52, + }); + await client.connect(); + await expect(client.execute({ + argv: ['sh'], + env: {}, + cwd: '/workspace', + uid: 1000, + gid: 1000, + tty: true, + })).rejects.toThrow(/does not support TTY/); + client.destroy(); + await server.close(); + }); + + it('uses an acknowledged shutdown frame before closing the transport', async () => { + const server = await createServer((frame, socket) => { + if (frame.type === 'shutdown') { + socket.write(encodeFirecrackerFrame({ + version: 1, + type: 'shutting_down', + requestId: frame.requestId, + })); + } + }); + const client = new FirecrackerVsockClient({ + socketPath: server.socketPath, + guestPort: 52, + }); + await client.connect(); + await expect(client.shutdown()).resolves.toBeUndefined(); + await server.close(); + }); + + it('allows silent commands while bounding incomplete frame reads', async () => { + let execution = 0; + const server = await createServer((frame, socket) => { + if (frame.type !== 'execute') return; + execution += 1; + const result = encodeFirecrackerFrame({ + version: 1, + type: 'result', + requestId: frame.requestId, + exitCode: 0, + signal: null, + timedOut: false, + }); + if (execution === 1) { + setTimeout(() => socket.write(result), 30); + } else { + socket.write(result.subarray(0, 2)); + } + }); + const client = new FirecrackerVsockClient({ + socketPath: server.socketPath, + guestPort: 52, + readTimeoutMs: 10, + }); + await client.connect(); + await expect(client.execute({ + requestId: 'silent', + argv: ['sleep', '1'], + env: {}, + cwd: '/workspace', + uid: 1000, + gid: 1000, + })).resolves.toEqual(expect.objectContaining({ exitCode: 0 })); + await expect(client.execute({ + requestId: 'partial', + argv: ['true'], + env: {}, + cwd: '/workspace', + uid: 1000, + gid: 1000, + })).rejects.toThrow(/frame read timed out/); + await server.close(); + }); +}); diff --git a/src/firecracker/vsock-client.ts b/src/firecracker/vsock-client.ts new file mode 100644 index 000000000..42cd95013 --- /dev/null +++ b/src/firecracker/vsock-client.ts @@ -0,0 +1,464 @@ +import { randomBytes } from 'crypto'; +import * as net from 'net'; +import { constants as osConstants } from 'os'; +import type { Writable } from 'stream'; +import { + FIRECRACKER_GUEST_PROTOCOL_VERSION, + FIRECRACKER_MAX_STREAM_CHUNK_BYTES, + FirecrackerFrameDecoder, + FirecrackerProtocolError, + encodeFirecrackerFrame, + type FirecrackerErrorFrame, + type FirecrackerExecuteFrame, + type FirecrackerGuestFrame, + type FirecrackerReadyFrame, + type FirecrackerResultFrame, +} from './vsock-protocol'; + +const FIRECRACKER_VSOCK_HANDSHAKE_LIMIT = 128; + +export interface FirecrackerVsockClientOptions { + readonly socketPath: string; + readonly guestPort: number; + readonly connectTimeoutMs?: number; + readonly readTimeoutMs?: number; + readonly writeTimeoutMs?: number; + readonly cancellationGraceMs?: number; +} + +export interface FirecrackerGuestExecutionRequest { + readonly argv: readonly string[]; + readonly env: Readonly>; + readonly cwd: string; + readonly uid: number; + readonly gid: number; + readonly tty?: boolean; + readonly timeoutMs?: number; + readonly requestId?: string; + readonly stdout?: Writable; + readonly stderr?: Writable; +} + +export interface FirecrackerGuestExecutionResult { + readonly requestId: string; + readonly exitCode: number; + readonly signal: string | null; + readonly timedOut: boolean; +} + +interface PendingExecution { + readonly requestId: string; + readonly stdout?: Writable; + readonly stderr?: Writable; + readonly resolve: (result: FirecrackerGuestExecutionResult) => void; + readonly reject: (error: Error) => void; + hostTimedOut: boolean; + timeout?: NodeJS.Timeout; + cancellation?: NodeJS.Timeout; +} + +export class FirecrackerGuestError extends Error { + constructor(readonly frame: FirecrackerErrorFrame) { + super(`Firecracker guest ${frame.code}: ${frame.message}`); + this.name = 'FirecrackerGuestError'; + } +} + +/** + * Host endpoint for Firecracker's CONNECT-over-UDS vsock mapping. + */ +export class FirecrackerVsockClient { + private readonly connectTimeoutMs: number; + private readonly readTimeoutMs: number; + private readonly writeTimeoutMs: number; + private readonly cancellationGraceMs: number; + private readonly decoder = new FirecrackerFrameDecoder(); + private socket: net.Socket | undefined; + private ready: FirecrackerReadyFrame | undefined; + private pending: PendingExecution | undefined; + private handshakeComplete = false; + private handshakeBuffer = Buffer.alloc(0); + private processing = Promise.resolve(); + private readyWaiter: { + resolve: (frame: FirecrackerReadyFrame) => void; + reject: (error: Error) => void; + } | undefined; + private shutdownWaiter: { + resolve: () => void; + reject: (error: Error) => void; + } | undefined; + private frameReadTimeout: NodeJS.Timeout | undefined; + + constructor(private readonly options: FirecrackerVsockClientOptions) { + if (!Number.isInteger(options.guestPort) || options.guestPort < 1 || options.guestPort > 65_535) { + throw new Error(`Firecracker guest vsock port must be in 1-65535: ${options.guestPort}`); + } + this.connectTimeoutMs = options.connectTimeoutMs ?? 5_000; + this.readTimeoutMs = options.readTimeoutMs ?? 30_000; + this.writeTimeoutMs = options.writeTimeoutMs ?? 5_000; + this.cancellationGraceMs = options.cancellationGraceMs ?? 2_000; + } + + async connect(): Promise { + if (this.socket) throw new Error('Firecracker guest vsock client is already connected'); + const socket = net.createConnection({ path: this.options.socketPath }); + this.socket = socket; + socket.on('data', (chunk: Buffer) => this.onData(chunk)); + socket.on('error', (error) => this.fail(error)); + socket.on('close', () => this.onClose()); + await withTimeout( + new Promise((resolve, reject) => { + socket.once('connect', resolve); + socket.once('error', reject); + }), + this.connectTimeoutMs, + `Firecracker guest vsock UDS connect timed out after ${this.connectTimeoutMs}ms`, + ); + await this.writeRaw(Buffer.from(`CONNECT ${this.options.guestPort}\n`, 'ascii')); + + return withTimeout( + new Promise((resolve, reject) => { + this.readyWaiter = { resolve, reject }; + if (this.ready) { + this.readyWaiter = undefined; + resolve(this.ready); + } + }), + this.connectTimeoutMs, + `Firecracker guest readiness timed out after ${this.connectTimeoutMs}ms`, + ); + } + + execute(request: FirecrackerGuestExecutionRequest): Promise { + if (!this.ready || !this.socket) { + return Promise.reject(new Error('Firecracker guest supervisor is not ready')); + } + if (this.pending) { + return Promise.reject(new Error( + `Firecracker guest request ${this.pending.requestId} is still running`, + )); + } + if (request.tty && !this.ready.capabilities.tty) { + return Promise.reject(new Error('Firecracker guest supervisor does not support TTY execution')); + } + const requestId = request.requestId ?? + `exec-${process.pid}-${randomBytes(8).toString('hex')}`; + const frame: FirecrackerExecuteFrame = { + version: FIRECRACKER_GUEST_PROTOCOL_VERSION, + type: 'execute', + requestId, + argv: request.argv, + env: request.env, + cwd: request.cwd, + uid: request.uid, + gid: request.gid, + tty: request.tty ?? false, + ...(request.timeoutMs === undefined ? {} : { timeoutMs: request.timeoutMs }), + }; + + return new Promise((resolve, reject) => { + const pending: PendingExecution = { + requestId, + stdout: request.stdout, + stderr: request.stderr, + resolve, + reject, + hostTimedOut: false, + }; + this.pending = pending; + if (request.timeoutMs !== undefined) { + pending.timeout = setTimeout(() => { + pending.hostTimedOut = true; + void this.send({ + version: FIRECRACKER_GUEST_PROTOCOL_VERSION, + type: 'cancel', + requestId, + reason: `host timeout after ${request.timeoutMs}ms`, + }).catch((error) => this.fail(toError(error))); + pending.cancellation = setTimeout(() => { + if (this.pending !== pending) return; + this.completePending({ + version: FIRECRACKER_GUEST_PROTOCOL_VERSION, + type: 'result', + requestId, + exitCode: 124, + signal: null, + timedOut: true, + }); + this.socket?.destroy(); + }, this.cancellationGraceMs); + }, request.timeoutMs); + } + void this.send(frame).catch((error) => this.fail(toError(error))); + }); + } + + async writeStdin(data: Buffer, requestId = this.pending?.requestId): Promise { + if (!requestId) throw new Error('No active Firecracker guest request'); + for (let offset = 0; offset < data.length; offset += FIRECRACKER_MAX_STREAM_CHUNK_BYTES) { + await this.send({ + version: FIRECRACKER_GUEST_PROTOCOL_VERSION, + type: 'stdin', + requestId, + data: data.subarray(offset, offset + FIRECRACKER_MAX_STREAM_CHUNK_BYTES) + .toString('base64'), + }); + } + } + + endStdin(requestId = this.pending?.requestId): Promise { + if (!requestId) return Promise.reject(new Error('No active Firecracker guest request')); + return this.send({ + version: FIRECRACKER_GUEST_PROTOCOL_VERSION, + type: 'stdin', + requestId, + eof: true, + }); + } + + cancel(reason = 'host cancellation', requestId = this.pending?.requestId): Promise { + if (!requestId) return Promise.reject(new Error('No active Firecracker guest request')); + return this.send({ + version: FIRECRACKER_GUEST_PROTOCOL_VERSION, + type: 'cancel', + requestId, + reason, + }); + } + + resize(columns: number, rows: number, requestId = this.pending?.requestId): Promise { + if (!requestId) return Promise.reject(new Error('No active Firecracker guest request')); + if (!this.ready?.capabilities.resize) { + return Promise.reject(new Error('Firecracker guest supervisor does not support TTY resize')); + } + return this.send({ + version: FIRECRACKER_GUEST_PROTOCOL_VERSION, + type: 'resize', + requestId, + columns, + rows, + }); + } + + async shutdown(): Promise { + if (!this.socket) return; + if (this.pending) throw new Error('Cannot shut down Firecracker guest while a request is running'); + const requestId = 'shutdown'; + const acknowledgment = withTimeout( + new Promise((resolve, reject) => { + this.shutdownWaiter = { resolve, reject }; + }), + this.connectTimeoutMs, + `Firecracker guest shutdown acknowledgment timed out after ${this.connectTimeoutMs}ms`, + ); + await this.send({ + version: FIRECRACKER_GUEST_PROTOCOL_VERSION, + type: 'shutdown', + requestId, + }); + await acknowledgment; + this.socket.end(); + this.socket = undefined; + } + + destroy(error?: Error): void { + this.socket?.destroy(error); + this.socket = undefined; + } + + private onData(chunk: Buffer): void { + this.processing = this.processing.then(async () => { + let protocolData = chunk; + if (!this.handshakeComplete) { + this.handshakeBuffer = Buffer.concat([this.handshakeBuffer, chunk]); + if (this.handshakeBuffer.length > FIRECRACKER_VSOCK_HANDSHAKE_LIMIT) { + throw new Error('Firecracker vsock CONNECT response exceeded 128 bytes'); + } + const newline = this.handshakeBuffer.indexOf(0x0a); + if (newline === -1) return; + const response = this.handshakeBuffer.subarray(0, newline).toString('ascii'); + if (!/^OK(?: \d+)?$/.test(response)) { + throw new Error(`Firecracker vsock CONNECT failed: ${response}`); + } + this.handshakeComplete = true; + protocolData = this.handshakeBuffer.subarray(newline + 1); + this.handshakeBuffer = Buffer.alloc(0); + } + for (const frame of this.decoder.push(protocolData)) { + await this.handleFrame(frame); + } + clearTimeout(this.frameReadTimeout); + this.frameReadTimeout = undefined; + if (this.decoder.pendingBytes > 0) { + this.frameReadTimeout = setTimeout(() => { + this.fail(new Error( + `Firecracker guest frame read timed out after ${this.readTimeoutMs}ms`, + )); + }, this.readTimeoutMs); + } + }).catch((error) => this.fail(toError(error))); + } + + private async handleFrame(frame: FirecrackerGuestFrame): Promise { + if (frame.type === 'ready') { + if (this.ready) throw new FirecrackerProtocolError('invalid_frame', 'Duplicate ready frame'); + this.ready = frame; + this.readyWaiter?.resolve(frame); + this.readyWaiter = undefined; + return; + } + if (frame.type === 'error') { + const error = new FirecrackerGuestError(frame); + if (this.pending?.requestId === frame.requestId) { + this.rejectPending(error); + } else { + this.fail(error); + } + return; + } + if (frame.type === 'stdout' || frame.type === 'stderr') { + const pending = this.requirePending(frame.requestId); + const destination = frame.type === 'stdout' ? pending.stdout : pending.stderr; + if (destination) await writeWithBackpressure(destination, Buffer.from(frame.data, 'base64')); + return; + } + if (frame.type === 'result') { + this.requirePending(frame.requestId); + this.completePending(frame); + return; + } + if (frame.type === 'shutting_down') { + this.shutdownWaiter?.resolve(); + this.shutdownWaiter = undefined; + return; + } + throw new FirecrackerProtocolError( + 'invalid_frame', + `Unexpected ${frame.type} frame from Firecracker guest`, + ); + } + + private requirePending(requestId: string): PendingExecution { + if (!this.pending || this.pending.requestId !== requestId) { + throw new FirecrackerProtocolError( + 'request_not_found', + `Unexpected Firecracker guest request id: ${requestId}`, + ); + } + return this.pending; + } + + private completePending(frame: FirecrackerResultFrame): void { + const pending = this.requirePending(frame.requestId); + clearTimeout(pending.timeout); + clearTimeout(pending.cancellation); + this.pending = undefined; + if (pending.hostTimedOut || frame.timedOut) { + pending.resolve({ + requestId: frame.requestId, + exitCode: 124, + signal: frame.signal, + timedOut: true, + }); + return; + } + pending.resolve({ + requestId: frame.requestId, + exitCode: frame.exitCode ?? 128 + signalNumber(frame.signal), + signal: frame.signal, + timedOut: false, + }); + } + + private rejectPending(error: Error): void { + if (!this.pending) return; + clearTimeout(this.pending.timeout); + clearTimeout(this.pending.cancellation); + const pending = this.pending; + this.pending = undefined; + pending.reject(error); + } + + private send(frame: FirecrackerGuestFrame): Promise { + if (!this.handshakeComplete) { + return Promise.reject(new Error('Firecracker vsock CONNECT handshake is not complete')); + } + return this.writeRaw(encodeFirecrackerFrame(frame)); + } + + private writeRaw(data: Buffer): Promise { + const socket = this.socket; + if (!socket || socket.destroyed || !socket.writable) { + return Promise.reject(new Error('Firecracker guest connection is not writable')); + } + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error( + `Firecracker guest write timed out after ${this.writeTimeoutMs}ms`, + )); + socket.destroy(); + }, this.writeTimeoutMs); + socket.write(data, (error) => { + clearTimeout(timeout); + if (error) reject(error); + else resolve(); + }); + }); + } + + private fail(error: Error): void { + clearTimeout(this.frameReadTimeout); + this.frameReadTimeout = undefined; + this.readyWaiter?.reject(error); + this.readyWaiter = undefined; + this.shutdownWaiter?.reject(error); + this.shutdownWaiter = undefined; + this.rejectPending(error); + if (this.socket && !this.socket.destroyed) this.socket.destroy(); + } + + private onClose(): void { + if (this.pending) { + this.rejectPending(new Error( + `Firecracker guest disconnected while request ${this.pending.requestId} was running`, + )); + } + if (!this.ready) { + this.readyWaiter?.reject(new Error('Firecracker guest disconnected before readiness')); + this.readyWaiter = undefined; + } + } +} + +async function writeWithBackpressure(destination: Writable, data: Buffer): Promise { + if (destination.write(data)) return; + await new Promise((resolve, reject) => { + destination.once('drain', resolve); + destination.once('error', reject); + }); +} + +function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error(message)), timeoutMs); + promise.then( + (value) => { + clearTimeout(timeout); + resolve(value); + }, + (error) => { + clearTimeout(timeout); + reject(error); + }, + ); + }); +} + +function signalNumber(signal: string | null): number { + if (!signal) return 0; + return osConstants.signals[signal as keyof typeof osConstants.signals] ?? 0; +} + +function toError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} diff --git a/src/firecracker/vsock-protocol.test.ts b/src/firecracker/vsock-protocol.test.ts new file mode 100644 index 000000000..efe367299 --- /dev/null +++ b/src/firecracker/vsock-protocol.test.ts @@ -0,0 +1,110 @@ +import { + FIRECRACKER_GUEST_PROTOCOL_VERSION, + FIRECRACKER_MAX_FRAME_BYTES, + FIRECRACKER_MAX_STREAM_CHUNK_BYTES, + FirecrackerFrameDecoder, + FirecrackerProtocolError, + encodeFirecrackerFrame, + validateFirecrackerFrame, + type FirecrackerGuestFrame, +} from './vsock-protocol'; + +const ready: FirecrackerGuestFrame = { + version: FIRECRACKER_GUEST_PROTOCOL_VERSION, + type: 'ready', + requestId: 'control', + capabilities: { stdin: true, tty: false, resize: false }, +}; + +describe('Firecracker guest vsock protocol', () => { + it('frames and incrementally decodes typed messages', () => { + const encoded = encodeFirecrackerFrame(ready); + const decoder = new FirecrackerFrameDecoder(); + expect(decoder.push(encoded.subarray(0, 2))).toEqual([]); + expect(decoder.push(encoded.subarray(2, 7))).toEqual([]); + expect(decoder.push(encoded.subarray(7))).toEqual([ready]); + expect(() => decoder.finish()).not.toThrow(); + }); + + it('decodes multiple frames and rejects incomplete terminal data', () => { + const decoder = new FirecrackerFrameDecoder(); + expect(decoder.push(Buffer.concat([ + encodeFirecrackerFrame(ready), + encodeFirecrackerFrame({ ...ready, requestId: 'second' }), + ]))).toHaveLength(2); + decoder.push(Buffer.from([0, 0])); + expect(() => decoder.finish()).toThrow(/incomplete frame/); + }); + + it('rejects oversized, empty, malformed, and unknown frames', () => { + const decoder = new FirecrackerFrameDecoder(); + const oversized = Buffer.alloc(4); + oversized.writeUInt32BE(FIRECRACKER_MAX_FRAME_BYTES + 1); + expect(() => decoder.push(oversized)).toThrow(/Invalid.*length/); + + expect(() => validateFirecrackerFrame({ + ...ready, + version: 2, + })).toThrow(new FirecrackerProtocolError( + 'protocol_version_mismatch', + 'Unsupported Firecracker guest protocol version 2; expected 1', + )); + expect(() => validateFirecrackerFrame({ + ...ready, + unexpected: true, + })).toThrow(/Unexpected frame property/); + }); + + it('validates execute schemas, identifiers, and bounded environment data', () => { + expect(() => validateFirecrackerFrame({ + version: 1, + type: 'execute', + requestId: '../escape', + argv: ['sh'], + env: {}, + cwd: '/workspace', + uid: 1000, + gid: 1000, + tty: false, + })).toThrow(/requestId/); + expect(() => validateFirecrackerFrame({ + version: 1, + type: 'execute', + requestId: 'run', + argv: [], + env: {}, + cwd: '/workspace', + uid: 1000, + gid: 1000, + tty: false, + })).toThrow(/argv/); + expect(() => validateFirecrackerFrame({ + version: 1, + type: 'execute', + requestId: 'run', + argv: ['sh'], + env: { 'BAD-NAME': 'value' }, + cwd: '/workspace', + uid: 1000, + gid: 1000, + tty: false, + })).toThrow(/environment variable/); + }); + + it('enforces decoded stream chunk limits and exact result semantics', () => { + expect(() => validateFirecrackerFrame({ + version: 1, + type: 'stdout', + requestId: 'run', + data: Buffer.alloc(FIRECRACKER_MAX_STREAM_CHUNK_BYTES + 1).toString('base64'), + })).toThrow(/decoded stream data exceeds/); + expect(() => validateFirecrackerFrame({ + version: 1, + type: 'result', + requestId: 'run', + exitCode: 0, + signal: 'SIGTERM', + timedOut: false, + })).toThrow(/exactly one/); + }); +}); diff --git a/src/firecracker/vsock-protocol.ts b/src/firecracker/vsock-protocol.ts new file mode 100644 index 000000000..ec4f8d907 --- /dev/null +++ b/src/firecracker/vsock-protocol.ts @@ -0,0 +1,367 @@ +export const FIRECRACKER_GUEST_PROTOCOL_VERSION = 1 as const; +export const FIRECRACKER_MAX_FRAME_BYTES = 1024 * 1024; +export const FIRECRACKER_MAX_STREAM_CHUNK_BYTES = 64 * 1024; +export const FIRECRACKER_MAX_ENV_ENTRIES = 512; +export const FIRECRACKER_MAX_ARGV_ENTRIES = 4096; +export const FIRECRACKER_MAX_STRING_BYTES = 256 * 1024; + +export type FirecrackerGuestErrorCode = + | 'invalid_frame' + | 'protocol_version_mismatch' + | 'invalid_request' + | 'request_in_progress' + | 'request_not_found' + | 'tty_unsupported' + | 'internal_error'; + +interface ProtocolFrame { + readonly version: typeof FIRECRACKER_GUEST_PROTOCOL_VERSION; + readonly type: string; + readonly requestId: string; +} + +export interface FirecrackerReadyFrame extends ProtocolFrame { + readonly type: 'ready'; + readonly capabilities: { + readonly stdin: boolean; + readonly tty: boolean; + readonly resize: boolean; + }; +} + +export interface FirecrackerExecuteFrame extends ProtocolFrame { + readonly type: 'execute'; + readonly argv: readonly string[]; + readonly env: Readonly>; + readonly cwd: string; + readonly uid: number; + readonly gid: number; + readonly tty: boolean; + readonly timeoutMs?: number; +} + +export interface FirecrackerStreamFrame extends ProtocolFrame { + readonly type: 'stdout' | 'stderr'; + readonly data: string; +} + +export interface FirecrackerStdinFrame extends ProtocolFrame { + readonly type: 'stdin'; + readonly data?: string; + readonly eof?: boolean; +} + +export interface FirecrackerResizeFrame extends ProtocolFrame { + readonly type: 'resize'; + readonly columns: number; + readonly rows: number; +} + +export interface FirecrackerCancelFrame extends ProtocolFrame { + readonly type: 'cancel'; + readonly reason: string; +} + +export interface FirecrackerResultFrame extends ProtocolFrame { + readonly type: 'result'; + readonly exitCode: number | null; + readonly signal: string | null; + readonly timedOut: boolean; +} + +export interface FirecrackerErrorFrame extends ProtocolFrame { + readonly type: 'error'; + readonly code: FirecrackerGuestErrorCode; + readonly message: string; + readonly expectedVersion?: number; +} + +export interface FirecrackerShutdownFrame extends ProtocolFrame { + readonly type: 'shutdown' | 'shutting_down'; +} + +export type FirecrackerGuestFrame = + | FirecrackerReadyFrame + | FirecrackerExecuteFrame + | FirecrackerStreamFrame + | FirecrackerStdinFrame + | FirecrackerResizeFrame + | FirecrackerCancelFrame + | FirecrackerResultFrame + | FirecrackerErrorFrame + | FirecrackerShutdownFrame; + +export class FirecrackerProtocolError extends Error { + constructor( + readonly code: FirecrackerGuestErrorCode, + message: string, + ) { + super(message); + this.name = 'FirecrackerProtocolError'; + } +} + +export function encodeFirecrackerFrame(frame: FirecrackerGuestFrame): Buffer { + validateFirecrackerFrame(frame); + const payload = Buffer.from(JSON.stringify(frame), 'utf8'); + if (payload.length > FIRECRACKER_MAX_FRAME_BYTES) { + throw new FirecrackerProtocolError( + 'invalid_frame', + `Firecracker guest frame exceeds ${FIRECRACKER_MAX_FRAME_BYTES} bytes`, + ); + } + const header = Buffer.allocUnsafe(4); + header.writeUInt32BE(payload.length, 0); + return Buffer.concat([header, payload]); +} + +export class FirecrackerFrameDecoder { + private buffered: Buffer = Buffer.alloc(0); + + get pendingBytes(): number { + return this.buffered.length; + } + + push(chunk: Buffer): FirecrackerGuestFrame[] { + if (chunk.length === 0) return []; + this.buffered = this.buffered.length === 0 + ? chunk + : Buffer.concat([this.buffered, chunk]); + const frames: FirecrackerGuestFrame[] = []; + while (this.buffered.length >= 4) { + const payloadLength = this.buffered.readUInt32BE(0); + if (payloadLength === 0 || payloadLength > FIRECRACKER_MAX_FRAME_BYTES) { + throw new FirecrackerProtocolError( + 'invalid_frame', + `Invalid Firecracker guest frame length: ${payloadLength}`, + ); + } + if (this.buffered.length < payloadLength + 4) break; + const payload = this.buffered.subarray(4, payloadLength + 4); + this.buffered = this.buffered.subarray(payloadLength + 4); + let decoded: unknown; + try { + decoded = JSON.parse(payload.toString('utf8')); + } catch (error) { + throw new FirecrackerProtocolError( + 'invalid_frame', + `Firecracker guest frame contains invalid JSON: ${formatError(error)}`, + ); + } + validateFirecrackerFrame(decoded); + frames.push(decoded); + } + return frames; + } + + finish(): void { + if (this.buffered.length !== 0) { + throw new FirecrackerProtocolError( + 'invalid_frame', + `Firecracker guest connection ended with ${this.buffered.length} incomplete frame bytes`, + ); + } + } +} + +export function validateFirecrackerFrame(value: unknown): asserts value is FirecrackerGuestFrame { + const frame = asRecord(value, 'frame'); + const version = frame.version; + if (version !== FIRECRACKER_GUEST_PROTOCOL_VERSION) { + throw new FirecrackerProtocolError( + 'protocol_version_mismatch', + `Unsupported Firecracker guest protocol version ${String(version)}; ` + + `expected ${FIRECRACKER_GUEST_PROTOCOL_VERSION}`, + ); + } + const type = requiredString(frame.type, 'type', 64); + requiredRequestId(frame.requestId); + switch (type) { + case 'ready': { + assertKeys(frame, ['version', 'type', 'requestId', 'capabilities']); + const capabilities = asRecord(frame.capabilities, 'capabilities'); + assertKeys(capabilities, ['stdin', 'tty', 'resize']); + requiredBoolean(capabilities.stdin, 'capabilities.stdin'); + requiredBoolean(capabilities.tty, 'capabilities.tty'); + requiredBoolean(capabilities.resize, 'capabilities.resize'); + return; + } + case 'execute': { + assertKeys(frame, [ + 'version', 'type', 'requestId', 'argv', 'env', 'cwd', 'uid', 'gid', 'tty', 'timeoutMs', + ]); + if ( + !Array.isArray(frame.argv) || + frame.argv.length === 0 || + frame.argv.length > FIRECRACKER_MAX_ARGV_ENTRIES + ) { + invalid(`argv must contain 1-${FIRECRACKER_MAX_ARGV_ENTRIES} strings`); + } + for (const [index, arg] of frame.argv.entries()) { + requiredString(arg, `argv[${index}]`, FIRECRACKER_MAX_STRING_BYTES); + } + const env = asRecord(frame.env, 'env'); + const entries = Object.entries(env); + if (entries.length > FIRECRACKER_MAX_ENV_ENTRIES) { + invalid(`env exceeds ${FIRECRACKER_MAX_ENV_ENTRIES} entries`); + } + for (const [name, envValue] of entries) { + if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || name.length > 256) { + invalid(`Invalid environment variable name: ${name}`); + } + requiredString(envValue, `env.${name}`, FIRECRACKER_MAX_STRING_BYTES, true); + } + const cwd = requiredString(frame.cwd, 'cwd', 4096); + if (!cwd.startsWith('/') || cwd.includes('\0')) invalid('cwd must be an absolute path'); + positiveInteger(frame.uid, 'uid'); + positiveInteger(frame.gid, 'gid'); + requiredBoolean(frame.tty, 'tty'); + if (frame.timeoutMs !== undefined) positiveInteger(frame.timeoutMs, 'timeoutMs'); + return; + } + case 'stdout': + case 'stderr': { + assertKeys(frame, ['version', 'type', 'requestId', 'data']); + const data = requiredString( + frame.data, + 'data', + Math.ceil(FIRECRACKER_MAX_STREAM_CHUNK_BYTES * 4 / 3) + 4, + true, + ); + validateBase64Chunk(data); + return; + } + case 'stdin': { + assertKeys(frame, ['version', 'type', 'requestId', 'data', 'eof']); + if (frame.data === undefined && frame.eof !== true) { + invalid('stdin requires data or eof=true'); + } + if (frame.data !== undefined) { + validateBase64Chunk(requiredString( + frame.data, + 'data', + Math.ceil(FIRECRACKER_MAX_STREAM_CHUNK_BYTES * 4 / 3) + 4, + true, + )); + } + if (frame.eof !== undefined) requiredBoolean(frame.eof, 'eof'); + return; + } + case 'resize': + assertKeys(frame, ['version', 'type', 'requestId', 'columns', 'rows']); + boundedInteger(frame.columns, 'columns', 1, 65_535); + boundedInteger(frame.rows, 'rows', 1, 65_535); + return; + case 'cancel': + assertKeys(frame, ['version', 'type', 'requestId', 'reason']); + requiredString(frame.reason, 'reason', 4096); + return; + case 'result': + assertKeys(frame, [ + 'version', 'type', 'requestId', 'exitCode', 'signal', 'timedOut', + ]); + if (frame.exitCode !== null) boundedInteger(frame.exitCode, 'exitCode', 0, 255); + if (frame.signal !== null) requiredString(frame.signal, 'signal', 64); + if ((frame.exitCode === null) === (frame.signal === null)) { + invalid('result must contain exactly one of exitCode or signal'); + } + requiredBoolean(frame.timedOut, 'timedOut'); + return; + case 'error': + assertKeys(frame, [ + 'version', 'type', 'requestId', 'code', 'message', 'expectedVersion', + ]); + if (![ + 'invalid_frame', + 'protocol_version_mismatch', + 'invalid_request', + 'request_in_progress', + 'request_not_found', + 'tty_unsupported', + 'internal_error', + ].includes(String(frame.code))) { + invalid(`Unknown error code: ${String(frame.code)}`); + } + requiredString(frame.message, 'message', 16 * 1024); + if (frame.expectedVersion !== undefined) { + positiveInteger(frame.expectedVersion, 'expectedVersion'); + } + return; + case 'shutdown': + case 'shutting_down': + assertKeys(frame, ['version', 'type', 'requestId']); + return; + default: + invalid(`Unknown Firecracker guest frame type: ${type}`); + } +} + +function validateBase64Chunk(value: string): void { + if (value.length % 4 !== 0 || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) { + invalid('stream data must be canonical base64'); + } + if (Buffer.byteLength(value, 'base64') > FIRECRACKER_MAX_STREAM_CHUNK_BYTES) { + invalid(`decoded stream data exceeds ${FIRECRACKER_MAX_STREAM_CHUNK_BYTES} bytes`); + } +} + +function asRecord(value: unknown, label: string): Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + invalid(`${label} must be an object`); + } + return value as Record; +} + +function assertKeys(record: Record, allowed: readonly string[]): void { + const allowedKeys = new Set(allowed); + for (const key of Object.keys(record)) { + if (!allowedKeys.has(key)) invalid(`Unexpected frame property: ${key}`); + } +} + +function requiredRequestId(value: unknown): string { + const requestId = requiredString(value, 'requestId', 128); + if (!/^[A-Za-z0-9_.-]+$/.test(requestId)) invalid(`Invalid requestId: ${requestId}`); + return requestId; +} + +function requiredString( + value: unknown, + label: string, + maxBytes: number, + allowEmpty = false, +): string { + if ( + typeof value !== 'string' || + (!allowEmpty && value.length === 0) || + Buffer.byteLength(value, 'utf8') > maxBytes || + value.includes('\0') + ) { + invalid(`${label} must be a ${allowEmpty ? '' : 'non-empty '}string of at most ${maxBytes} bytes`); + } + return value; +} + +function requiredBoolean(value: unknown, label: string): boolean { + if (typeof value !== 'boolean') invalid(`${label} must be a boolean`); + return value; +} + +function positiveInteger(value: unknown, label: string): number { + return boundedInteger(value, label, 1, Number.MAX_SAFE_INTEGER); +} + +function boundedInteger(value: unknown, label: string, minimum: number, maximum: number): number { + if (!Number.isSafeInteger(value) || (value as number) < minimum || (value as number) > maximum) { + invalid(`${label} must be an integer in ${minimum}-${maximum}`); + } + return value as number; +} + +function invalid(message: string): never { + throw new FirecrackerProtocolError('invalid_request', message); +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/firecracker/workspace-image.test.ts b/src/firecracker/workspace-image.test.ts new file mode 100644 index 000000000..12852dda2 --- /dev/null +++ b/src/firecracker/workspace-image.test.ts @@ -0,0 +1,198 @@ +import { promises as fs } from 'fs'; +import { createHash } from 'crypto'; +import * as os from 'os'; +import * as path from 'path'; +import { + FIRECRACKER_DEFAULT_MAX_WORKSPACE_IMAGE_BYTES, + FIRECRACKER_MIN_WORKSPACE_IMAGE_BYTES, + FirecrackerWorkspaceImage, + assertNoWorkspaceConflicts, + buildFirecrackerWorkspaceManifest, + calculateFirecrackerWorkspaceImageBytes, + type FirecrackerWorkspaceImageDependencies, +} from './workspace-image'; + +describe('Firecracker workspace images', () => { + it('sizes images with headroom, block alignment, minimum, and cap', () => { + expect(calculateFirecrackerWorkspaceImageBytes(0)) + .toBe(FIRECRACKER_MIN_WORKSPACE_IMAGE_BYTES); + expect(calculateFirecrackerWorkspaceImageBytes(512 * 1024 * 1024) % 4096).toBe(0); + expect(() => calculateFirecrackerWorkspaceImageBytes( + FIRECRACKER_DEFAULT_MAX_WORKSPACE_IMAGE_BYTES, + )).toThrow(/exceeding cap/); + expect(() => calculateFirecrackerWorkspaceImageBytes(0, 1024)).toThrow(/cap/); + }); + + it('preserves hidden files, modes, and safe symlinks while excluding credentials', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'awf-workspace-')); + const workspace = path.join(root, 'source'); + const home = path.join(root, 'home'); + const baseRootfs = path.join(root, 'base.ext4'); + const supervisor = path.join(root, 'supervisor'); + await fs.mkdir(path.join(workspace, 'bin'), { recursive: true }); + await fs.writeFile(path.join(workspace, '.hidden'), 'hidden'); + await fs.writeFile(path.join(workspace, 'bin', 'run'), '#!/bin/sh\n'); + await fs.chmod(path.join(workspace, 'bin', 'run'), 0o755); + await fs.symlink('bin/run', path.join(workspace, 'run')); + await fs.mkdir(path.join(home, '.config', 'gh'), { recursive: true }); + await fs.writeFile(path.join(home, '.config', 'safe'), 'keep'); + await fs.writeFile(path.join(home, '.config', 'gh', 'hosts.yml'), 'secret'); + await fs.writeFile(baseRootfs, 'rootfs'); + await fs.writeFile(supervisor, 'binary'); + const commands: Array<{ command: string; args: readonly string[] }> = []; + const dependencies: FirecrackerWorkspaceImageDependencies = { + runTool: jest.fn(async (command, args) => { + commands.push({ command, args }); + }), + }; + const image = new FirecrackerWorkspaceImage({ + runId: 'run-1', + workDir: root, + workspacePath: workspace, + homePath: home, + baseRootfsPath: baseRootfs, + supervisorBinaryPath: supervisor, + supervisorSha256: createHash('sha256').update('binary').digest('hex'), + uid: process.getuid?.() ?? 1000, + gid: process.getgid?.() ?? 1000, + }, dependencies); + + const prepared = await image.prepare(); + expect(prepared.imageBytes).toBe(FIRECRACKER_MIN_WORKSPACE_IMAGE_BYTES); + expect(await fs.readFile( + path.join(image.stagingDirectory, 'workspace', '.hidden'), + 'utf8', + )).toBe('hidden'); + expect((await fs.stat( + path.join(image.stagingDirectory, 'workspace', 'bin', 'run'), + )).mode & 0o777).toBe(0o755); + expect(await fs.readlink( + path.join(image.stagingDirectory, 'workspace', 'run'), + )).toBe('bin/run'); + expect(await fs.readFile( + path.join(image.stagingDirectory, 'workspace', '.awf-home', '.config', 'safe'), + 'utf8', + )).toBe('keep'); + await expect(fs.access( + path.join(image.stagingDirectory, 'workspace', '.awf-home', '.config', 'gh'), + )).rejects.toThrow(); + expect(commands.map(({ command }) => command)).toEqual([ + 'mke2fs', 'debugfs', 'debugfs', 'e2fsck', + ]); + expect(commands[0].args).toEqual(expect.arrayContaining(['-b', '4096'])); + await fs.rm(root, { recursive: true, force: true }); + }); + + it('rejects escaping symlinks and special path hazards', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'awf-workspace-')); + const workspace = path.join(root, 'source'); + await fs.mkdir(workspace); + await fs.symlink('../outside', path.join(workspace, 'escape')); + await expect(buildFirecrackerWorkspaceManifest(workspace)) + .rejects.toThrow(/escapes/); + await fs.rm(root, { recursive: true, force: true }); + }); + + it('detects conflicting host and guest changes but permits identical convergence', () => { + const file = (digest: string) => ({ + type: 'file' as const, + mode: 0o644, + uid: 1000, + gid: 1000, + size: 1, + digest, + }); + const original = new Map([['file', file('before')]]); + const guest = new Map([['file', file('guest')]]); + expect(() => assertNoWorkspaceConflicts( + original, + guest, + new Map([['file', file('host')]]), + )).toThrow(/concurrently/); + expect(() => assertNoWorkspaceConflicts(original, guest, guest)).not.toThrow(); + }); + + it('preserves the changed image when copy-back fails and cleanup remains safe', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'awf-workspace-')); + const workspace = path.join(root, 'source'); + const home = path.join(root, 'home'); + await fs.mkdir(workspace); + await fs.mkdir(home); + await fs.writeFile(path.join(workspace, 'file'), 'before'); + await fs.writeFile(path.join(root, 'base.ext4'), 'rootfs'); + await fs.writeFile(path.join(root, 'supervisor'), 'binary'); + let e2fsckCalls = 0; + const dependencies: FirecrackerWorkspaceImageDependencies = { + runTool: jest.fn(async (command) => { + if (command === 'e2fsck' && ++e2fsckCalls > 1) { + throw new Error('corrupt image'); + } + }), + }; + const image = new FirecrackerWorkspaceImage({ + runId: 'run-2', + workDir: root, + workspacePath: workspace, + homePath: home, + baseRootfsPath: path.join(root, 'base.ext4'), + supervisorBinaryPath: path.join(root, 'supervisor'), + supervisorSha256: createHash('sha256').update('binary').digest('hex'), + uid: process.getuid?.() ?? 1000, + gid: process.getgid?.() ?? 1000, + }, dependencies); + await image.prepare(); + await expect(image.extractAfterStop()).rejects.toThrow(/preserved at/); + await expect(fs.access(image.recoveryImagePath)).resolves.toBeUndefined(); + await image.cleanup(); + await expect(fs.access(image.recoveryImagePath)).resolves.toBeUndefined(); + await fs.rm(root, { recursive: true, force: true }); + }); + + it('extracts only workspace content and delays cleanup until copy-back succeeds', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'awf-workspace-')); + const workspace = path.join(root, 'source'); + const home = path.join(root, 'home'); + await fs.mkdir(workspace); + await fs.mkdir(home); + await fs.writeFile(path.join(workspace, 'file'), 'before'); + await fs.writeFile(path.join(root, 'base.ext4'), 'rootfs'); + await fs.writeFile(path.join(root, 'supervisor'), 'binary'); + const rsyncCalls: string[][] = []; + const image = new FirecrackerWorkspaceImage({ + runId: 'run-3', + workDir: root, + workspacePath: workspace, + homePath: home, + baseRootfsPath: path.join(root, 'base.ext4'), + supervisorBinaryPath: path.join(root, 'supervisor'), + supervisorSha256: createHash('sha256').update('binary').digest('hex'), + uid: process.getuid?.() ?? 1000, + gid: process.getgid?.() ?? 1000, + }, { + runTool: jest.fn(async (command, args) => { + if (command === 'debugfs' && args[0] === '-R' && args[1].startsWith('rdump ')) { + const extracted = path.join(image.runDirectory, 'extracted'); + await fs.writeFile(path.join(extracted, 'file'), 'after'); + await fs.mkdir(path.join(extracted, '.awf-home'), { recursive: true }); + await fs.writeFile(path.join(extracted, '.awf-home', 'token'), 'guest-only'); + await fs.mkdir(path.join(extracted, 'lost+found'), { recursive: true }); + } + if (command === 'rsync') rsyncCalls.push([...args]); + }), + }); + + await image.prepare(); + await image.extractAfterStop(); + expect(rsyncCalls).toEqual([[ + '-a', + '--delete', + '--delay-updates', + '--safe-links', + `${path.join(image.runDirectory, 'extracted')}${path.sep}`, + `${workspace}${path.sep}`, + ]]); + await image.cleanup(); + await expect(fs.access(image.runDirectory)).rejects.toThrow(); + await fs.rm(root, { recursive: true, force: true }); + }); +}); diff --git a/src/firecracker/workspace-image.ts b/src/firecracker/workspace-image.ts new file mode 100644 index 000000000..aed770e26 --- /dev/null +++ b/src/firecracker/workspace-image.ts @@ -0,0 +1,554 @@ +import { createHash } from 'crypto'; +import { createReadStream, promises as fs, type Stats } from 'fs'; +import * as path from 'path'; +import execa from 'execa'; +import { + CREDENTIAL_ENTRIES, + HOME_TOOL_SUBDIRS, +} from '../config/mount-policy'; + +const MIB = 1024 * 1024; +export const FIRECRACKER_MIN_WORKSPACE_IMAGE_BYTES = 256 * MIB; +export const FIRECRACKER_DEFAULT_MAX_WORKSPACE_IMAGE_BYTES = 8 * 1024 * MIB; +const FIRECRACKER_WORKSPACE_IMAGE_HEADROOM_BYTES = 128 * MIB; +const FIRECRACKER_WORKSPACE_BLOCK_BYTES = 4096; + +export interface FirecrackerWorkspaceImageConfig { + readonly runId: string; + readonly workDir: string; + readonly workspacePath: string; + readonly homePath: string; + readonly baseRootfsPath: string; + readonly supervisorBinaryPath: string; + readonly supervisorSha256: string; + readonly maxImageBytes?: number; + readonly uid: number; + readonly gid: number; +} + +export interface FirecrackerWorkspaceManifestEntry { + readonly type: 'file' | 'directory' | 'symlink'; + readonly mode: number; + readonly uid: number; + readonly gid: number; + readonly size: number; + readonly digest?: string; + readonly target?: string; +} + +export type FirecrackerWorkspaceManifest = ReadonlyMap< + string, + FirecrackerWorkspaceManifestEntry +>; + +export interface FirecrackerWorkspaceImageDependencies { + runTool(command: 'mke2fs' | 'debugfs' | 'e2fsck' | 'rsync', args: readonly string[]): Promise; +} + +const defaultDependencies: FirecrackerWorkspaceImageDependencies = { + runTool: async (command, args) => { + const result = await execa(command, [...args], { + reject: false, + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 120_000, + }); + if (result.exitCode !== 0) { + throw new Error( + `${command} exited with code ${result.exitCode}: ${result.stderr.trim()}`, + ); + } + }, +}; + +export interface FirecrackerWorkspacePreparation { + readonly workspaceImagePath: string; + readonly rootfsImagePath: string; + readonly imageBytes: number; + readonly originalManifest: FirecrackerWorkspaceManifest; +} + +/** + * Owns the host-only population and post-stop extraction of one writable image. + */ +export class FirecrackerWorkspaceImage { + readonly runDirectory: string; + readonly stagingDirectory: string; + readonly workspaceImagePath: string; + readonly rootfsImagePath: string; + readonly recoveryImagePath: string; + private originalManifest: FirecrackerWorkspaceManifest | undefined; + private prepared = false; + private extractionSucceeded = false; + private recoveryPreserved = false; + + constructor( + private readonly config: FirecrackerWorkspaceImageConfig, + private readonly dependencies: FirecrackerWorkspaceImageDependencies = defaultDependencies, + ) { + assertSafeRunId(config.runId); + this.runDirectory = path.join(config.workDir, 'firecracker-images', config.runId); + this.stagingDirectory = path.join(this.runDirectory, 'staging'); + this.workspaceImagePath = path.join(this.runDirectory, 'workspace.ext4'); + this.rootfsImagePath = path.join(this.runDirectory, 'rootfs.ext4'); + this.recoveryImagePath = path.join( + config.workDir, + 'firecracker-recovery', + `${config.runId}-workspace.ext4`, + ); + } + + async prepare(): Promise { + if (this.prepared) throw new Error('Firecracker workspace image is already prepared'); + await fs.mkdir(path.join(this.stagingDirectory, 'workspace'), { + recursive: true, + mode: 0o700, + }); + await fs.mkdir(path.join(this.stagingDirectory, 'workspace', '.awf-home'), { + recursive: true, + mode: 0o700, + }); + await applySafeOwnership( + path.join(this.stagingDirectory, 'workspace'), + this.config.uid, + this.config.gid, + ); + await applySafeOwnership( + path.join(this.stagingDirectory, 'workspace', '.awf-home'), + this.config.uid, + this.config.gid, + ); + try { + await fs.lstat(path.join(this.config.workspacePath, '.awf-home')); + throw new Error( + 'Workspace contains reserved Firecracker guest home path: .awf-home', + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + await copySafeTree( + this.config.workspacePath, + path.join(this.stagingDirectory, 'workspace'), + this.config.workspacePath, + this.config.uid, + this.config.gid, + ); + await this.copyAllowedHomeState(); + this.originalManifest = await buildFirecrackerWorkspaceManifest(this.config.workspacePath); + + const imageRoot = path.join(this.stagingDirectory, 'workspace'); + const stagingUsage = await calculateTreeUsage(imageRoot); + const imageBytes = calculateFirecrackerWorkspaceImageBytes( + stagingUsage.bytes + stagingUsage.entries * FIRECRACKER_WORKSPACE_BLOCK_BYTES, + this.config.maxImageBytes, + ); + const inodeCount = Math.max(8192, Math.ceil(stagingUsage.entries * 1.25) + 1024); + await fs.writeFile(this.workspaceImagePath, ''); + await fs.truncate(this.workspaceImagePath, imageBytes); + await this.dependencies.runTool('mke2fs', [ + '-t', 'ext4', + '-F', + '-q', + '-b', String(FIRECRACKER_WORKSPACE_BLOCK_BYTES), + '-N', String(inodeCount), + '-d', imageRoot, + this.workspaceImagePath, + String(imageBytes / FIRECRACKER_WORKSPACE_BLOCK_BYTES), + ]); + + await this.prepareRootfs(); + this.prepared = true; + return { + workspaceImagePath: this.workspaceImagePath, + rootfsImagePath: this.rootfsImagePath, + imageBytes, + originalManifest: this.originalManifest, + }; + } + + /** + * Must only be called after the Firecracker process has terminated. + */ + async extractAfterStop(changedImagePath = this.workspaceImagePath): Promise { + if (!this.prepared || !this.originalManifest) { + throw new Error('Firecracker workspace image has not been prepared'); + } + if (this.extractionSucceeded) return; + const extractionDirectory = path.join(this.runDirectory, 'extracted'); + try { + await fs.rm(extractionDirectory, { recursive: true, force: true }); + await fs.mkdir(extractionDirectory, { recursive: true, mode: 0o700 }); + assertDebugfsOperand(extractionDirectory, 'extraction directory'); + await this.dependencies.runTool('e2fsck', ['-f', '-y', changedImagePath]); + await this.dependencies.runTool('debugfs', [ + '-R', `rdump / ${extractionDirectory}`, + changedImagePath, + ]); + await fs.rm(path.join(extractionDirectory, '.awf-home'), { + recursive: true, + force: true, + }); + await fs.rm(path.join(extractionDirectory, 'lost+found'), { + recursive: true, + force: true, + }); + const guestWorkspace = extractionDirectory; + const guestManifest = await buildFirecrackerWorkspaceManifest(guestWorkspace); + const currentManifest = await buildFirecrackerWorkspaceManifest(this.config.workspacePath); + assertNoWorkspaceConflicts(this.originalManifest, guestManifest, currentManifest); + await this.dependencies.runTool('rsync', [ + '-a', + '--delete', + '--delay-updates', + '--safe-links', + `${guestWorkspace}${path.sep}`, + `${this.config.workspacePath}${path.sep}`, + ]); + this.extractionSucceeded = true; + } catch (error) { + await this.preserveRecoveryImage(changedImagePath); + throw new Error( + `Firecracker workspace copy-back failed; changed image preserved at ` + + `${this.recoveryImagePath}: ${formatError(error)}`, + ); + } + } + + async cleanup(discardUnstarted = false): Promise { + if ( + this.prepared && + !discardUnstarted && + !this.extractionSucceeded && + !this.recoveryPreserved + ) { + return; + } + await fs.rm(this.runDirectory, { recursive: true, force: true }); + } + + private async copyAllowedHomeState(): Promise { + const excluded = CREDENTIAL_ENTRIES.map((entry) => normalizeRelative(entry.path)); + for (const subdir of HOME_TOOL_SUBDIRS) { + const source = path.join(this.config.homePath, subdir); + let stat; + try { + stat = await fs.lstat(source); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue; + throw error; + } + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new Error(`Allowed home state must be a real directory: ${source}`); + } + const destination = path.join( + this.stagingDirectory, + 'workspace', + '.awf-home', + subdir, + ); + await fs.mkdir(destination, { recursive: true, mode: 0o700 }); + await applySafeOwnership(destination, this.config.uid, this.config.gid); + await copySafeTree( + source, + destination, + this.config.homePath, + this.config.uid, + this.config.gid, + (relativeHomePath) => excluded.some((credentialPath) => ( + relativeHomePath === credentialPath || + relativeHomePath.startsWith(`${credentialPath}/`) + )), + ); + } + } + + private async prepareRootfs(): Promise { + await assertRegularFile(this.config.baseRootfsPath, 'Firecracker base rootfs'); + await assertRegularFile(this.config.supervisorBinaryPath, 'Firecracker guest supervisor'); + if (!/^[A-Fa-f0-9]{64}$/.test(this.config.supervisorSha256)) { + throw new Error('Firecracker guest supervisor SHA-256 must be 64 hexadecimal characters'); + } + const actual = await sha256File(this.config.supervisorBinaryPath); + if (actual !== this.config.supervisorSha256.toLowerCase()) { + throw new Error( + `Firecracker guest supervisor SHA-256 mismatch: expected ` + + `${this.config.supervisorSha256.toLowerCase()}, got ${actual}`, + ); + } + await fs.copyFile(this.config.baseRootfsPath, this.rootfsImagePath); + const localSupervisor = path.join(this.runDirectory, 'awf-supervisor'); + await fs.copyFile(this.config.supervisorBinaryPath, localSupervisor); + await fs.chmod(localSupervisor, 0o500); + assertDebugfsOperand(localSupervisor, 'supervisor staging path'); + await this.dependencies.runTool('debugfs', [ + '-w', + '-R', `write ${localSupervisor} /sbin/awf-supervisor`, + this.rootfsImagePath, + ]); + await this.dependencies.runTool('debugfs', [ + '-w', + '-R', 'sif /sbin/awf-supervisor mode 0100755', + this.rootfsImagePath, + ]); + await this.dependencies.runTool('e2fsck', ['-f', '-y', this.rootfsImagePath]); + } + + private async preserveRecoveryImage(changedImagePath: string): Promise { + if (this.recoveryPreserved) return; + await fs.mkdir(path.dirname(this.recoveryImagePath), { + recursive: true, + mode: 0o700, + }); + const temporary = `${this.recoveryImagePath}.tmp-${process.pid}`; + await fs.copyFile(changedImagePath, temporary); + await fs.chmod(temporary, 0o600); + await fs.rename(temporary, this.recoveryImagePath); + this.recoveryPreserved = true; + } +} + +export function calculateFirecrackerWorkspaceImageBytes( + contentBytes: number, + maximumBytes = FIRECRACKER_DEFAULT_MAX_WORKSPACE_IMAGE_BYTES, +): number { + if (!Number.isSafeInteger(contentBytes) || contentBytes < 0) { + throw new Error(`Invalid Firecracker workspace content size: ${contentBytes}`); + } + if ( + !Number.isSafeInteger(maximumBytes) || + maximumBytes < FIRECRACKER_MIN_WORKSPACE_IMAGE_BYTES + ) { + throw new Error( + `Firecracker workspace image cap must be at least ` + + `${FIRECRACKER_MIN_WORKSPACE_IMAGE_BYTES} bytes`, + ); + } + const withHeadroom = Math.ceil(contentBytes * 1.25) + + FIRECRACKER_WORKSPACE_IMAGE_HEADROOM_BYTES; + const requested = Math.max(FIRECRACKER_MIN_WORKSPACE_IMAGE_BYTES, withHeadroom); + const aligned = Math.ceil(requested / FIRECRACKER_WORKSPACE_BLOCK_BYTES) * + FIRECRACKER_WORKSPACE_BLOCK_BYTES; + if (aligned > maximumBytes) { + throw new Error( + `Firecracker workspace requires ${aligned} bytes, exceeding cap ${maximumBytes}`, + ); + } + return aligned; +} + +export async function buildFirecrackerWorkspaceManifest( + root: string, +): Promise { + const manifest = new Map(); + await walkSafeTree(root, root, async (absolutePath, relativePath, stat) => { + if (relativePath === '') return; + const mode = stat.mode & 0o7777; + if (stat.isDirectory()) { + manifest.set(relativePath, { + type: 'directory', + mode, + uid: stat.uid, + gid: stat.gid, + size: 0, + }); + } else if (stat.isFile()) { + manifest.set(relativePath, { + type: 'file', + mode, + uid: stat.uid, + gid: stat.gid, + size: stat.size, + digest: await sha256File(absolutePath), + }); + } else if (stat.isSymbolicLink()) { + manifest.set(relativePath, { + type: 'symlink', + mode, + uid: stat.uid, + gid: stat.gid, + size: stat.size, + target: await fs.readlink(absolutePath), + }); + } + }); + return manifest; +} + +export function assertNoWorkspaceConflicts( + original: FirecrackerWorkspaceManifest, + guest: FirecrackerWorkspaceManifest, + current: FirecrackerWorkspaceManifest, +): void { + const paths = new Set([...original.keys(), ...guest.keys(), ...current.keys()]); + const conflicts: string[] = []; + for (const relativePath of paths) { + const before = original.get(relativePath); + const after = guest.get(relativePath); + const live = current.get(relativePath); + if (entriesEqual(before, after)) continue; + if (!entriesEqual(before, live) && !entriesEqual(after, live)) conflicts.push(relativePath); + } + if (conflicts.length > 0) { + throw new Error( + `Workspace changed concurrently at ${conflicts.slice(0, 20).join(', ')}` + + (conflicts.length > 20 ? ` and ${conflicts.length - 20} more paths` : ''), + ); + } +} + +async function copySafeTree( + source: string, + destination: string, + safetyRoot: string, + uid: number, + gid: number, + exclude: (relativeToSafetyRoot: string) => boolean = () => false, +): Promise { + await walkSafeTree(source, safetyRoot, async (absolutePath, relativePath, stat) => { + if (relativePath === '') return; + if (exclude(relativePath)) return 'skip'; + const relativeToSource = path.relative(source, absolutePath); + const target = path.join(destination, relativeToSource); + assertContained(destination, target, 'workspace staging destination'); + if (stat.isDirectory()) { + await fs.mkdir(target, { recursive: true, mode: stat.mode & 0o7777 }); + await fs.chmod(target, stat.mode & 0o7777); + await applySafeOwnership(target, uid, gid); + } else if (stat.isFile()) { + await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 }); + await fs.copyFile(absolutePath, target); + await fs.chmod(target, stat.mode & 0o7777); + await applySafeOwnership(target, uid, gid); + await fs.utimes(target, stat.atime, stat.mtime); + } else if (stat.isSymbolicLink()) { + const linkTarget = await fs.readlink(absolutePath); + await fs.mkdir(path.dirname(target), { recursive: true, mode: 0o700 }); + await fs.symlink(linkTarget, target); + await applySafeOwnership(target, uid, gid, true); + } + }); +} + +type WalkResult = void | 'skip'; + +async function walkSafeTree( + root: string, + safetyRoot: string, + visitor: ( + absolutePath: string, + relativePath: string, + stat: Stats, + ) => Promise, +): Promise { + const resolvedRoot = path.resolve(root); + const resolvedSafetyRoot = path.resolve(safetyRoot); + assertContained(resolvedSafetyRoot, resolvedRoot, 'tree root'); + const walk = async (current: string): Promise => { + const stat = await fs.lstat(current); + const relativePath = normalizeRelative(path.relative(resolvedSafetyRoot, current)); + if (stat.isSymbolicLink()) { + const target = await fs.readlink(current); + if (path.isAbsolute(target)) { + throw new Error(`Absolute symlink is not safe for Firecracker workspace: ${current}`); + } + assertContained( + resolvedSafetyRoot, + path.resolve(path.dirname(current), target), + `symlink target for ${current}`, + ); + } else if (!stat.isFile() && !stat.isDirectory()) { + throw new Error(`Special filesystem entry is not safe for Firecracker workspace: ${current}`); + } + const result = await visitor(current, relativePath, stat); + if (!stat.isDirectory() || result === 'skip') return; + const entries = await fs.readdir(current); + entries.sort(); + for (const entry of entries) await walk(path.join(current, entry)); + }; + await walk(resolvedRoot); +} + +async function calculateTreeUsage(root: string): Promise<{ bytes: number; entries: number }> { + let bytes = 0; + let entries = 0; + await walkSafeTree(root, root, async (_absolutePath, relativePath, stat) => { + if (!relativePath) return; + entries += 1; + if (stat.isFile()) bytes += stat.size; + }); + return { bytes, entries }; +} + +function entriesEqual( + left: FirecrackerWorkspaceManifestEntry | undefined, + right: FirecrackerWorkspaceManifestEntry | undefined, +): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +function normalizeRelative(value: string): string { + return value.split(path.sep).join('/'); +} + +function assertContained(root: string, candidate: string, label: string): void { + const relative = path.relative(path.resolve(root), path.resolve(candidate)); + if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + throw new Error(`${label} escapes ${root}: ${candidate}`); + } +} + +function assertSafeRunId(runId: string): void { + if (!/^[A-Za-z0-9-]{1,64}$/.test(runId)) { + throw new Error(`Unsafe Firecracker workspace run id: ${runId}`); + } +} + +function assertDebugfsOperand(value: string, label: string): void { + if (/[\s"'\\;`\r\n]/.test(value)) { + throw new Error(`Firecracker ${label} is unsafe for debugfs commands: ${value}`); + } +} + +async function assertRegularFile(filePath: string, label: string): Promise { + const stat = await fs.lstat(filePath); + if (!stat.isFile() || stat.isSymbolicLink()) { + throw new Error(`${label} must be a regular file: ${filePath}`); + } +} + +async function applySafeOwnership( + target: string, + uid: number, + gid: number, + symbolicLink = false, +): Promise { + if ( + !Number.isInteger(uid) || + uid <= 0 || + !Number.isInteger(gid) || + gid <= 0 || + uid > 0xffff_ffff || + gid > 0xffff_ffff + ) { + throw new Error(`Invalid Firecracker workspace identity: ${uid}:${gid}`); + } + const currentUid = process.getuid?.(); + const currentGid = process.getgid?.(); + if (currentUid !== 0 && (currentUid !== uid || currentGid !== gid)) { + throw new Error( + `Cannot map Firecracker workspace ownership to ${uid}:${gid} as ` + + `${String(currentUid)}:${String(currentGid)}`, + ); + } + if (symbolicLink) await fs.lchown(target, uid, gid); + else await fs.chown(target, uid, gid); +} + +async function sha256File(filePath: string): Promise { + const hash = createHash('sha256'); + for await (const chunk of createReadStream(filePath)) hash.update(chunk as Buffer); + return hash.digest('hex'); +} + +function formatError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/src/types/runtime-options.ts b/src/types/runtime-options.ts index 99968ec07..dc25e3f40 100644 --- a/src/types/runtime-options.ts +++ b/src/types/runtime-options.ts @@ -21,8 +21,9 @@ export interface FirecrackerArtifactDigests { /** * Preview control-plane configuration for the Firecracker microVM runtime. * - * Host-side network enforcement is supplied directly to FirecrackerManager - * after infrastructure discovery; guest command execution is not available yet. + * Host-side network enforcement and guest execution inputs are supplied + * directly to FirecrackerManager after infrastructure discovery. Global + * runtime dispatch remains fail-closed until all integration probes are wired. */ export interface FirecrackerOptions { previewEnabled: boolean; From b53f3197871b402e2c241356d64fdbe742c62e9e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 9 Aug 2026 20:10:19 +0000 Subject: [PATCH 2/3] Fix Firecracker review feedback issues --- guest/firecracker-supervisor/runtime_linux.go | 45 ++++++- .../runtime_linux_test.go | 30 +++++ src/firecracker/manager.test.ts | 47 ++++++++ src/firecracker/manager.ts | 34 +++++- src/firecracker/vsock-client.test.ts | 33 ++++++ src/firecracker/vsock-client.ts | 2 + src/firecracker/workspace-image.test.ts | 20 +++- src/firecracker/workspace-image.ts | 111 +++++++++++++++--- 8 files changed, 292 insertions(+), 30 deletions(-) create mode 100644 guest/firecracker-supervisor/runtime_linux_test.go diff --git a/guest/firecracker-supervisor/runtime_linux.go b/guest/firecracker-supervisor/runtime_linux.go index e34a034dd..2153b016c 100644 --- a/guest/firecracker-supervisor/runtime_linux.go +++ b/guest/firecracker-supervisor/runtime_linux.go @@ -287,7 +287,12 @@ func (s *session) start(frame Frame) error { } else { ctx, cancel = context.WithCancel(ctx) } - command := exec.Command(frame.Argv[0], frame.Argv[1:]...) + resolvedCommand, err := resolveCommand(frame.Argv[0], frame.Env) + if err != nil { + cancel() + return err + } + command := exec.Command(resolvedCommand, frame.Argv[1:]...) command.Dir = cwd command.Env = environment(frame.Env) command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Credential: &syscall.Credential{Uid: uint32(frame.UID), Gid: uint32(frame.GID)}} @@ -398,9 +403,14 @@ func (s *session) terminate(execution *execution) { } func (s *session) wait(execution *execution, ctx context.Context) { - _ = execution.stdin.Close() execution.output.Wait() err := execution.command.Wait() + execution.stdinMu.Lock() + if !execution.stdinClosed { + execution.stdinClosed = true + _ = execution.stdin.Close() + } + execution.stdinMu.Unlock() terminateAndReapDescendants(execution.command.Process.Pid) // Prevent a just-cancelled context watcher from signalling a reaped PID. execution.once.Do(func() {}) @@ -422,9 +432,6 @@ func (s *session) wait(execution *execution, ctx context.Context) { } else { s.sendError(execution.requestID, errorInternal, "execution failed: "+err.Error()) } - if result.ExitCode != nil || result.Signal != nil { - _ = s.send(result) - } s.activeMu.Lock() if s.active == execution { s.active = nil @@ -432,6 +439,9 @@ func (s *session) wait(execution *execution, ctx context.Context) { s.activeMu.Unlock() close(execution.done) execution.cancel() + if result.ExitCode != nil || result.Signal != nil { + _ = s.send(result) + } } func terminateAndReapDescendants(processGroup int) { @@ -488,6 +498,31 @@ func environment(values map[string]string) []string { return environment } +func resolveCommand(command string, env map[string]string) (string, error) { + if strings.Contains(command, "/") { + if !filepath.IsAbs(command) { + return "", errors.New("argv[0] must be absolute when it includes a path separator") + } + return command, nil + } + searchPath := env["PATH"] + if searchPath == "" { + searchPath = "/usr/sbin:/usr/bin:/sbin:/bin" + } + for _, directory := range filepath.SplitList(searchPath) { + if directory == "" || !filepath.IsAbs(directory) { + continue + } + candidate := filepath.Join(directory, command) + info, err := os.Stat(candidate) + if err != nil || info.IsDir() || info.Mode()&0111 == 0 { + continue + } + return candidate, nil + } + return "", fmt.Errorf("command not found in PATH: %s", command) +} + func resolveCWD(workspace, cwd string) (string, error) { if !filepath.IsAbs(cwd) { return "", errors.New("cwd must be an absolute path under the workspace mount") diff --git a/guest/firecracker-supervisor/runtime_linux_test.go b/guest/firecracker-supervisor/runtime_linux_test.go new file mode 100644 index 000000000..0efb5211a --- /dev/null +++ b/guest/firecracker-supervisor/runtime_linux_test.go @@ -0,0 +1,30 @@ +//go:build linux + +package main + +import ( + "os" + "path/filepath" + "testing" +) + +func TestResolveCommandUsesRequestPath(t *testing.T) { + directory := t.TempDir() + commandPath := filepath.Join(directory, "demo") + if err := os.WriteFile(commandPath, []byte("#!/bin/sh\n"), 0700); err != nil { + t.Fatalf("write command: %v", err) + } + resolved, err := resolveCommand("demo", map[string]string{"PATH": directory}) + if err != nil { + t.Fatalf("resolve command: %v", err) + } + if resolved != commandPath { + t.Fatalf("resolved command mismatch: got %s want %s", resolved, commandPath) + } +} + +func TestResolveCommandRejectsRelativeExecutablePath(t *testing.T) { + if _, err := resolveCommand("./demo", map[string]string{"PATH": "/usr/bin"}); err == nil { + t.Fatal("expected relative executable path to fail") + } +} diff --git a/src/firecracker/manager.test.ts b/src/firecracker/manager.test.ts index 7f544ddec..a9d3993e8 100644 --- a/src/firecracker/manager.test.ts +++ b/src/firecracker/manager.test.ts @@ -448,6 +448,53 @@ describe('FirecrackerManager', () => { expect(lifecycle.cleanup).toHaveBeenCalledTimes(1); }); + it('waits briefly for natural VM exit after guest shutdown before sending SIGTERM', async () => { + const child = processMock(); + const workspace = { + prepare: jest.fn().mockResolvedValue({ + workspaceImagePath: '/tmp/prepared-workspace.ext4', + rootfsImagePath: '/tmp/prepared-rootfs.ext4', + imageBytes: 1024, + originalManifest: new Map(), + }), + extractAfterStop: jest.fn().mockResolvedValue(undefined), + cleanup: jest.fn().mockResolvedValue(undefined), + } as unknown as FirecrackerWorkspaceImage; + const guestClient = { + connect: jest.fn().mockResolvedValue(undefined), + shutdown: jest.fn().mockResolvedValue(undefined), + destroy: jest.fn(), + } as unknown as FirecrackerVsockClient; + let sleepCalls = 0; + const deps = dependencies({ + launch: jest.fn().mockReturnValue(child), + createWorkspaceImage: jest.fn().mockReturnValue(workspace), + createVsockClient: jest.fn().mockReturnValue(guestClient), + sleep: jest.fn(async () => { + sleepCalls += 1; + if (sleepCalls === 3) Object.assign(child, { exitCode: 0 }); + }), + }); + const manager = new FirecrackerManager( + config(), + '/tmp/awf', + deps, + 'natural-exit', + networkConfig(), + { + workspacePath: '/workspace', + homePath: '/home/runner', + supervisorBinaryPath: '/opt/awf-supervisor', + supervisorSha256: 'a'.repeat(64), + }, + ); + await manager.start(); + await manager.startInstance(); + await manager.stop(); + expect(child.kill).not.toHaveBeenCalled(); + expect(sleepCalls).toBeGreaterThan(0); + }); + it('rolls back the network when typed NIC configuration fails', async () => { const client = { putMachineConfig: jest.fn().mockResolvedValue(undefined), diff --git a/src/firecracker/manager.ts b/src/firecracker/manager.ts index b443a1512..e4faf6882 100644 --- a/src/firecracker/manager.ts +++ b/src/firecracker/manager.ts @@ -31,6 +31,7 @@ const ROOTFS_JAIL_PATH = '/rootfs'; const WORKSPACE_JAIL_PATH = '/workspace.ext4'; const VSOCK_JAIL_PATH = `/run/${VSOCK_SOCKET_NAME}`; export const FIRECRACKER_GUEST_VSOCK_PORT = 52; +const FIRECRACKER_GUEST_SHUTDOWN_GRACE_MS = 5_000; export interface FirecrackerRunPaths { runId: string; @@ -315,9 +316,11 @@ export class FirecrackerManager { async stop(): Promise { const errors: unknown[] = []; const instanceWasStarted = this.instanceStarted; + let guestShutdownAcknowledged = false; if (this.guestClient) { try { await this.guestClient.shutdown(); + guestShutdownAcknowledged = true; } catch (error) { errors.push(error); this.guestClient.destroy(); @@ -335,12 +338,22 @@ export class FirecrackerManager { ) { const child = this.process; try { + if (guestShutdownAcknowledged) { + terminationConfirmed = await this.waitForProcessExit( + child, + FIRECRACKER_GUEST_SHUTDOWN_GRACE_MS, + ); + } if (!child.killed) { - child.kill('SIGTERM', { forceKillAfterTimeout: 2_000 }); + if (child.exitCode === null && child.signalCode === null) { + child.kill('SIGTERM', { forceKillAfterTimeout: 2_000 }); + } } - await child; - if (child.exitCode === null && child.signalCode === null) { - throw new Error('Firecracker process termination was not confirmed'); + if (!terminationConfirmed) { + await child; + if (child.exitCode === null && child.signalCode === null) { + throw new Error('Firecracker process termination was not confirmed'); + } } terminationConfirmed = true; } catch (error) { @@ -406,6 +419,19 @@ export class FirecrackerManager { } } + private async waitForProcessExit( + child: ExecaChildProcess, + timeoutMs: number, + ): Promise { + const pollIntervalMs = 25; + const attempts = Math.max(1, Math.ceil(timeoutMs / pollIntervalMs)); + for (let attempt = 0; attempt < attempts; attempt += 1) { + if (child.exitCode !== null || child.signalCode !== null) return true; + await this.dependencies.sleep(pollIntervalMs); + } + return child.exitCode !== null || child.signalCode !== null; + } + private async waitForApiSocket(): Promise { const deadline = Date.now() + this.config.apiTimeoutMs; while (Date.now() < deadline) { diff --git a/src/firecracker/vsock-client.test.ts b/src/firecracker/vsock-client.test.ts index 59413701b..79995a275 100644 --- a/src/firecracker/vsock-client.test.ts +++ b/src/firecracker/vsock-client.test.ts @@ -179,6 +179,39 @@ describe('FirecrackerVsockClient', () => { await server.close(); }); + it('preserves numeric fallback signal exit status from the guest', async () => { + const server = await createServer((frame, socket) => { + if (frame.type === 'execute') { + socket.write(encodeFirecrackerFrame({ + version: 1, + type: 'result', + requestId: frame.requestId, + exitCode: null, + signal: 'SIG24', + timedOut: false, + })); + } + }); + const client = new FirecrackerVsockClient({ + socketPath: server.socketPath, + guestPort: 52, + }); + await client.connect(); + await expect(client.execute({ + argv: ['true'], + env: {}, + cwd: '/workspace', + uid: 1000, + gid: 1000, + })).resolves.toEqual(expect.objectContaining({ + exitCode: 152, + signal: 'SIG24', + timedOut: false, + })); + client.destroy(); + await server.close(); + }); + it('requires advertised TTY capability', async () => { const server = await createServer(() => undefined); const client = new FirecrackerVsockClient({ diff --git a/src/firecracker/vsock-client.ts b/src/firecracker/vsock-client.ts index 42cd95013..9a6655756 100644 --- a/src/firecracker/vsock-client.ts +++ b/src/firecracker/vsock-client.ts @@ -456,6 +456,8 @@ function withTimeout(promise: Promise, timeoutMs: number, message: string) function signalNumber(signal: string | null): number { if (!signal) return 0; + const numericFallback = /^SIG(\d+)$/.exec(signal); + if (numericFallback) return Number(numericFallback[1]); return osConstants.signals[signal as keyof typeof osConstants.signals] ?? 0; } diff --git a/src/firecracker/workspace-image.test.ts b/src/firecracker/workspace-image.test.ts index 12852dda2..8f4d732ab 100644 --- a/src/firecracker/workspace-image.test.ts +++ b/src/firecracker/workspace-image.test.ts @@ -109,6 +109,11 @@ describe('Firecracker workspace images', () => { guest, new Map([['file', file('host')]]), )).toThrow(/concurrently/); + expect(() => assertNoWorkspaceConflicts( + original, + original, + new Map([['file', file('host')]]), + )).toThrow(/concurrently/); expect(() => assertNoWorkspaceConflicts(original, guest, guest)).not.toThrow(); }); @@ -177,7 +182,16 @@ describe('Firecracker workspace images', () => { await fs.writeFile(path.join(extracted, '.awf-home', 'token'), 'guest-only'); await fs.mkdir(path.join(extracted, 'lost+found'), { recursive: true }); } - if (command === 'rsync') rsyncCalls.push([...args]); + if (command === 'rsync') { + rsyncCalls.push([...args]); + const sourceDirectory = args[args.length - 2]; + const destinationDirectory = args[args.length - 1]; + if (!sourceDirectory || !destinationDirectory) return; + const sourceFile = path.join(sourceDirectory, 'file'); + const destinationFile = path.join(destinationDirectory, 'file'); + await fs.mkdir(path.dirname(destinationFile), { recursive: true }); + await fs.copyFile(sourceFile, destinationFile); + } }), }); @@ -186,11 +200,11 @@ describe('Firecracker workspace images', () => { expect(rsyncCalls).toEqual([[ '-a', '--delete', - '--delay-updates', '--safe-links', `${path.join(image.runDirectory, 'extracted')}${path.sep}`, - `${workspace}${path.sep}`, + `${path.join(root, '.source.awf-merge-run-3')}${path.sep}`, ]]); + await expect(fs.readFile(path.join(workspace, 'file'), 'utf8')).resolves.toBe('after'); await image.cleanup(); await expect(fs.access(image.runDirectory)).rejects.toThrow(); await fs.rm(root, { recursive: true, force: true }); diff --git a/src/firecracker/workspace-image.ts b/src/firecracker/workspace-image.ts index aed770e26..119d8e2aa 100644 --- a/src/firecracker/workspace-image.ts +++ b/src/firecracker/workspace-image.ts @@ -12,6 +12,7 @@ export const FIRECRACKER_MIN_WORKSPACE_IMAGE_BYTES = 256 * MIB; export const FIRECRACKER_DEFAULT_MAX_WORKSPACE_IMAGE_BYTES = 8 * 1024 * MIB; const FIRECRACKER_WORKSPACE_IMAGE_HEADROOM_BYTES = 128 * MIB; const FIRECRACKER_WORKSPACE_BLOCK_BYTES = 4096; +const FIRECRACKER_E2FSCK_REPAIR_EXIT_CODE = 1; export interface FirecrackerWorkspaceImageConfig { readonly runId: string; @@ -52,11 +53,12 @@ const defaultDependencies: FirecrackerWorkspaceImageDependencies = { stdio: ['ignore', 'pipe', 'pipe'], timeout: 120_000, }); - if (result.exitCode !== 0) { - throw new Error( - `${command} exited with code ${result.exitCode}: ${result.stderr.trim()}`, - ); - } + if (result.exitCode === 0) return; + if (command === 'e2fsck' && result.exitCode === FIRECRACKER_E2FSCK_REPAIR_EXIT_CODE) return; + throw new Error( + `${command} exited with code ${result.exitCode}: ` + + `${result.stderr.trim() || result.stdout.trim()}`, + ); }, }; @@ -91,8 +93,8 @@ export class FirecrackerWorkspaceImage { this.workspaceImagePath = path.join(this.runDirectory, 'workspace.ext4'); this.rootfsImagePath = path.join(this.runDirectory, 'rootfs.ext4'); this.recoveryImagePath = path.join( - config.workDir, - 'firecracker-recovery', + config.workspacePath, + '.awf-firecracker-recovery', `${config.runId}-workspace.ext4`, ); } @@ -142,8 +144,12 @@ export class FirecrackerWorkspaceImage { this.config.maxImageBytes, ); const inodeCount = Math.max(8192, Math.ceil(stagingUsage.entries * 1.25) + 1024); - await fs.writeFile(this.workspaceImagePath, ''); - await fs.truncate(this.workspaceImagePath, imageBytes); + const workspaceImage = await fs.open(this.workspaceImagePath, 'wx', 0o600); + try { + await workspaceImage.truncate(imageBytes); + } finally { + await workspaceImage.close(); + } await this.dependencies.runTool('mke2fs', [ '-t', 'ext4', '-F', @@ -195,14 +201,7 @@ export class FirecrackerWorkspaceImage { const guestManifest = await buildFirecrackerWorkspaceManifest(guestWorkspace); const currentManifest = await buildFirecrackerWorkspaceManifest(this.config.workspacePath); assertNoWorkspaceConflicts(this.originalManifest, guestManifest, currentManifest); - await this.dependencies.runTool('rsync', [ - '-a', - '--delete', - '--delay-updates', - '--safe-links', - `${guestWorkspace}${path.sep}`, - `${this.config.workspacePath}${path.sep}`, - ]); + await this.applyWorkspaceUpdateAtomically(guestWorkspace, guestManifest); this.extractionSucceeded = true; } catch (error) { await this.preserveRecoveryImage(changedImagePath); @@ -304,6 +303,59 @@ export class FirecrackerWorkspaceImage { await fs.rename(temporary, this.recoveryImagePath); this.recoveryPreserved = true; } + + private async applyWorkspaceUpdateAtomically( + guestWorkspace: string, + guestManifest: FirecrackerWorkspaceManifest, + ): Promise { + const workspaceParent = path.dirname(this.config.workspacePath); + const workspaceName = path.basename(this.config.workspacePath); + const mergeDirectory = path.join( + workspaceParent, + `.${workspaceName}.awf-merge-${this.config.runId}`, + ); + const backupDirectory = path.join( + workspaceParent, + `.${workspaceName}.awf-backup-${this.config.runId}`, + ); + await fs.rm(mergeDirectory, { recursive: true, force: true }); + await fs.rm(backupDirectory, { recursive: true, force: true }); + await fs.mkdir(mergeDirectory, { recursive: true, mode: 0o700 }); + await this.dependencies.runTool('rsync', [ + '-a', + '--delete', + '--safe-links', + `${guestWorkspace}${path.sep}`, + `${mergeDirectory}${path.sep}`, + ]); + const stagedManifest = await buildFirecrackerWorkspaceManifest(mergeDirectory); + assertExactWorkspaceManifest(guestManifest, stagedManifest, 'staged workspace'); + const latestManifest = await buildFirecrackerWorkspaceManifest(this.config.workspacePath); + assertNoWorkspaceConflicts(this.originalManifest!, guestManifest, latestManifest); + + let backupPending = false; + try { + await fs.rename(this.config.workspacePath, backupDirectory); + backupPending = true; + await fs.rename(mergeDirectory, this.config.workspacePath); + backupPending = false; + } catch (error) { + if (backupPending) { + try { + await fs.rename(backupDirectory, this.config.workspacePath); + backupPending = false; + } catch { + // keep original failure message from the copy-back path + } + } + throw error; + } finally { + await fs.rm(mergeDirectory, { recursive: true, force: true }); + if (!backupPending) { + await fs.rm(backupDirectory, { recursive: true, force: true }); + } + } + } } export function calculateFirecrackerWorkspaceImageBytes( @@ -384,7 +436,10 @@ export function assertNoWorkspaceConflicts( const before = original.get(relativePath); const after = guest.get(relativePath); const live = current.get(relativePath); - if (entriesEqual(before, after)) continue; + if (entriesEqual(before, after)) { + if (!entriesEqual(before, live)) conflicts.push(relativePath); + continue; + } if (!entriesEqual(before, live) && !entriesEqual(after, live)) conflicts.push(relativePath); } if (conflicts.length > 0) { @@ -485,6 +540,26 @@ function entriesEqual( return JSON.stringify(left) === JSON.stringify(right); } +function assertExactWorkspaceManifest( + expected: FirecrackerWorkspaceManifest, + actual: FirecrackerWorkspaceManifest, + label: string, +): void { + const mismatches: string[] = []; + const paths = new Set([...expected.keys(), ...actual.keys()]); + for (const relativePath of paths) { + if (!entriesEqual(expected.get(relativePath), actual.get(relativePath))) { + mismatches.push(relativePath); + } + } + if (mismatches.length > 0) { + throw new Error( + `Firecracker ${label} diverged during staging at ${mismatches.slice(0, 20).join(', ')}` + + (mismatches.length > 20 ? ` and ${mismatches.length - 20} more paths` : ''), + ); + } +} + function normalizeRelative(value: string): string { return value.split(path.sep).join('/'); } From 52494a843c467fcd31af4671d7b39a35e1e42d64 Mon Sep 17 00:00:00 2001 From: Landon Cox Date: Sun, 9 Aug 2026 15:05:37 -0700 Subject: [PATCH 3/3] test: cover Firecracker guest transport edge cases Exercise bounded framing, validation, handshake failure, request lifecycle, chunking, cancellation fallback, error propagation, and stream backpressure so the layer maintains repository-wide coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/firecracker/vsock-client.test.ts | 338 ++++++++++++++++++++++++- src/firecracker/vsock-protocol.test.ts | 129 ++++++++++ 2 files changed, 464 insertions(+), 3 deletions(-) diff --git a/src/firecracker/vsock-client.test.ts b/src/firecracker/vsock-client.test.ts index 79995a275..0f447096f 100644 --- a/src/firecracker/vsock-client.test.ts +++ b/src/firecracker/vsock-client.test.ts @@ -2,17 +2,19 @@ import { promises as fs } from 'fs'; import * as net from 'net'; import * as os from 'os'; import * as path from 'path'; -import { PassThrough } from 'stream'; +import { PassThrough, Writable } from 'stream'; import { FIRECRACKER_GUEST_PROTOCOL_VERSION, + FIRECRACKER_MAX_STREAM_CHUNK_BYTES, FirecrackerFrameDecoder, encodeFirecrackerFrame, type FirecrackerGuestFrame, } from './vsock-protocol'; -import { FirecrackerVsockClient } from './vsock-client'; +import { FirecrackerGuestError, FirecrackerVsockClient } from './vsock-client'; async function createServer( handler: (frame: FirecrackerGuestFrame, socket: net.Socket) => void, + capabilities = { stdin: true, tty: false, resize: false }, ): Promise<{ socketPath: string; close(): Promise }> { const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'awf-vsock-')); const socketPath = path.join(directory, 'vsock.sock'); @@ -32,7 +34,7 @@ async function createServer( version: FIRECRACKER_GUEST_PROTOCOL_VERSION, type: 'ready', requestId: 'control', - capabilities: { stdin: true, tty: false, resize: false }, + capabilities, })); chunk = handshake.subarray(newline + 1); } @@ -52,6 +54,31 @@ async function createServer( }; } +async function createRawServer( + handler: (socket: net.Socket) => void, +): Promise<{ socketPath: string; close(): Promise }> { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'awf-vsock-raw-')); + const socketPath = path.join(directory, 'vsock.sock'); + const sockets = new Set(); + const server = net.createServer((socket) => { + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + handler(socket); + }); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(socketPath, resolve); + }); + return { + socketPath, + close: async () => { + for (const socket of sockets) socket.destroy(); + await new Promise((resolve) => server.close(() => resolve())); + await fs.rm(directory, { recursive: true, force: true }); + }, + }; +} + describe('FirecrackerVsockClient', () => { it('streams output, stdin, and exact terminal status', async () => { const received: FirecrackerGuestFrame[] = []; @@ -227,6 +254,7 @@ describe('FirecrackerVsockClient', () => { gid: 1000, tty: true, })).rejects.toThrow(/does not support TTY/); + await expect(client.resize(80, 24, 'run')).rejects.toThrow(/does not support TTY resize/); client.destroy(); await server.close(); }); @@ -293,4 +321,308 @@ describe('FirecrackerVsockClient', () => { })).rejects.toThrow(/frame read timed out/); await server.close(); }); + + it('guards disconnected control methods and invalid ports', async () => { + expect(() => new FirecrackerVsockClient({ + socketPath: '/tmp/unused', + guestPort: 0, + })).toThrow(/1-65535/); + const client = new FirecrackerVsockClient({ + socketPath: '/tmp/unused', + guestPort: 52, + }); + await expect(client.execute({ + argv: ['true'], + env: {}, + cwd: '/workspace', + uid: 1000, + gid: 1000, + })).rejects.toThrow(/not ready/); + await expect(client.writeStdin(Buffer.from('input'))).rejects.toThrow(/No active/); + await expect(client.endStdin()).rejects.toThrow(/No active/); + await expect(client.cancel()).rejects.toThrow(/No active/); + await expect(client.resize(80, 24)).rejects.toThrow(/No active/); + await expect(client.endStdin('explicit')).rejects.toThrow(/handshake is not complete/); + await expect(client.shutdown()).resolves.toBeUndefined(); + client.destroy(); + }); + + it('supports chunked stdin, cancellation, resize, and pending request guards', async () => { + const received: FirecrackerGuestFrame[] = []; + const server = await createServer((frame, socket) => { + received.push(frame); + if (frame.type === 'stdin' && frame.eof) { + socket.write(encodeFirecrackerFrame({ + version: 1, + type: 'result', + requestId: frame.requestId, + exitCode: 0, + signal: null, + timedOut: false, + })); + } + if (frame.type === 'shutdown') { + socket.write(encodeFirecrackerFrame({ + version: 1, + type: 'shutting_down', + requestId: frame.requestId, + })); + } + }, { stdin: true, tty: false, resize: true }); + const client = new FirecrackerVsockClient({ + socketPath: server.socketPath, + guestPort: 52, + }); + await client.connect(); + const execution = client.execute({ + argv: ['cat'], + env: {}, + cwd: '/workspace', + uid: 1000, + gid: 1000, + }); + await expect(client.execute({ + requestId: 'second', + argv: ['true'], + env: {}, + cwd: '/workspace', + uid: 1000, + gid: 1000, + })).rejects.toThrow(/still running/); + await expect(client.shutdown()).rejects.toThrow(/while a request is running/); + await client.resize(100, 40); + await client.cancel('manual cancellation'); + await client.writeStdin(Buffer.alloc(FIRECRACKER_MAX_STREAM_CHUNK_BYTES + 1, 1)); + await client.endStdin(); + await expect(execution).resolves.toEqual(expect.objectContaining({ exitCode: 0 })); + + const executeFrame = received.find((frame) => frame.type === 'execute'); + expect(executeFrame?.requestId).toMatch(/^exec-/); + expect(received.filter((frame) => frame.type === 'stdin' && frame.data)).toHaveLength(2); + expect(received).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: 'resize', columns: 100, rows: 40 }), + expect.objectContaining({ type: 'cancel', reason: 'manual cancellation' }), + ])); + await client.shutdown(); + await server.close(); + }); + + it('returns 124 when cancellation grace expires without a guest result', async () => { + const server = await createServer(() => undefined); + const client = new FirecrackerVsockClient({ + socketPath: server.socketPath, + guestPort: 52, + cancellationGraceMs: 5, + }); + await client.connect(); + await expect(client.execute({ + requestId: 'timeout', + argv: ['sleep', '10'], + env: {}, + cwd: '/workspace', + uid: 1000, + gid: 1000, + timeoutMs: 5, + })).resolves.toEqual({ + requestId: 'timeout', + exitCode: 124, + signal: null, + timedOut: true, + }); + await server.close(); + }); + + it('propagates typed guest errors for matching and unexpected requests', async () => { + let request = 0; + const server = await createServer((frame, socket) => { + if (frame.type !== 'execute') return; + request += 1; + socket.write(encodeFirecrackerFrame({ + version: 1, + type: 'error', + requestId: request === 1 ? frame.requestId : 'different', + code: 'invalid_request', + message: 'rejected', + })); + }); + const client = new FirecrackerVsockClient({ + socketPath: server.socketPath, + guestPort: 52, + }); + await client.connect(); + await expect(client.execute({ + requestId: 'matching', + argv: ['false'], + env: {}, + cwd: '/workspace', + uid: 1000, + gid: 1000, + })).rejects.toBeInstanceOf(FirecrackerGuestError); + await expect(client.execute({ + requestId: 'unexpected', + argv: ['false'], + env: {}, + cwd: '/workspace', + uid: 1000, + gid: 1000, + })).rejects.toThrow(/rejected/); + await server.close(); + }); + + it('bounds and validates the CONNECT handshake and readiness wait', async () => { + const invalid = await createRawServer((socket) => { + socket.once('data', () => setTimeout(() => socket.write('DENIED\n'), 5)); + }); + const invalidClient = new FirecrackerVsockClient({ + socketPath: invalid.socketPath, + guestPort: 52, + connectTimeoutMs: 100, + }); + await expect(invalidClient.connect()).rejects.toThrow(/CONNECT failed/); + invalidClient.destroy(); + await invalid.close(); + + const oversized = await createRawServer((socket) => { + socket.once('data', () => setTimeout(() => socket.write('x'.repeat(129)), 5)); + }); + const oversizedClient = new FirecrackerVsockClient({ + socketPath: oversized.socketPath, + guestPort: 52, + connectTimeoutMs: 100, + }); + await expect(oversizedClient.connect()).rejects.toThrow(/exceeded 128 bytes/); + oversizedClient.destroy(); + await oversized.close(); + + const silent = await createRawServer(() => undefined); + const silentClient = new FirecrackerVsockClient({ + socketPath: silent.socketPath, + guestPort: 52, + connectTimeoutMs: 5, + }); + await expect(silentClient.connect()).rejects.toThrow(/readiness timed out/); + silentClient.destroy(); + await silent.close(); + + const disconnected = await createRawServer((socket) => { + socket.once('data', () => setTimeout(() => socket.destroy(), 5)); + }); + const disconnectedClient = new FirecrackerVsockClient({ + socketPath: disconnected.socketPath, + guestPort: 52, + connectTimeoutMs: 100, + }); + await expect(disconnectedClient.connect()).rejects.toThrow(/before readiness/); + await disconnected.close(); + }); + + it('rejects unexpected guest frames and request identifiers', async () => { + const unexpected = await createServer((frame, socket) => { + if (frame.type === 'execute') { + socket.write(encodeFirecrackerFrame({ + version: 1, + type: 'shutdown', + requestId: frame.requestId, + })); + } + }); + const unexpectedClient = new FirecrackerVsockClient({ + socketPath: unexpected.socketPath, + guestPort: 52, + }); + await unexpectedClient.connect(); + await expect(unexpectedClient.execute({ + requestId: 'unexpected-frame', + argv: ['true'], + env: {}, + cwd: '/workspace', + uid: 1000, + gid: 1000, + })).rejects.toThrow(/Unexpected shutdown frame/); + await unexpected.close(); + + const mismatched = await createServer((frame, socket) => { + if (frame.type === 'execute') { + socket.write(encodeFirecrackerFrame({ + version: 1, + type: 'stdout', + requestId: 'different', + data: Buffer.from('output').toString('base64'), + })); + } + }); + const mismatchedClient = new FirecrackerVsockClient({ + socketPath: mismatched.socketPath, + guestPort: 52, + }); + await mismatchedClient.connect(); + await expect(mismatchedClient.execute({ + requestId: 'expected', + argv: ['true'], + env: {}, + cwd: '/workspace', + uid: 1000, + gid: 1000, + })).rejects.toThrow(/Unexpected Firecracker guest request id/); + await mismatched.close(); + }); + + it('honors output backpressure and unknown signal fallback status', async () => { + const server = await createServer((frame, socket) => { + if (frame.type !== 'execute') return; + socket.write(encodeFirecrackerFrame({ + version: 1, + type: 'stdout', + requestId: frame.requestId, + data: Buffer.alloc(1024, 1).toString('base64'), + })); + socket.write(encodeFirecrackerFrame({ + version: 1, + type: 'result', + requestId: frame.requestId, + exitCode: null, + signal: 'SIGUNKNOWN', + timedOut: false, + })); + }); + const output = new Writable({ + highWaterMark: 1, + write(_chunk, _encoding, callback) { + setImmediate(callback); + }, + }); + const client = new FirecrackerVsockClient({ + socketPath: server.socketPath, + guestPort: 52, + }); + await client.connect(); + await expect(client.execute({ + requestId: 'backpressure', + argv: ['true'], + env: {}, + cwd: '/workspace', + uid: 1000, + gid: 1000, + stdout: output, + })).resolves.toEqual({ + requestId: 'backpressure', + exitCode: 128, + signal: 'SIGUNKNOWN', + timedOut: false, + }); + client.destroy(); + await server.close(); + }); + + it('rejects writes after a connected transport is destroyed', async () => { + const server = await createServer(() => undefined); + const client = new FirecrackerVsockClient({ + socketPath: server.socketPath, + guestPort: 52, + }); + await client.connect(); + client.destroy(); + await expect(client.endStdin('run')).rejects.toThrow(/not writable/); + await server.close(); + }); }); diff --git a/src/firecracker/vsock-protocol.test.ts b/src/firecracker/vsock-protocol.test.ts index efe367299..9819bd5bd 100644 --- a/src/firecracker/vsock-protocol.test.ts +++ b/src/firecracker/vsock-protocol.test.ts @@ -107,4 +107,133 @@ describe('Firecracker guest vsock protocol', () => { timedOut: false, })).toThrow(/exactly one/); }); + + it('validates every host and guest frame schema boundary', () => { + const validFrames: FirecrackerGuestFrame[] = [ + { + version: 1, + type: 'execute', + requestId: 'run', + argv: ['sh'], + env: { EMPTY: '' }, + cwd: '/workspace', + uid: 1000, + gid: 1000, + tty: false, + timeoutMs: 1, + }, + { + version: 1, + type: 'stdin', + requestId: 'run', + data: Buffer.from('input').toString('base64'), + eof: true, + }, + { version: 1, type: 'resize', requestId: 'run', columns: 80, rows: 24 }, + { version: 1, type: 'cancel', requestId: 'run', reason: 'test' }, + { + version: 1, + type: 'result', + requestId: 'run', + exitCode: null, + signal: 'SIGTERM', + timedOut: false, + }, + { + version: 1, + type: 'error', + requestId: 'run', + code: 'protocol_version_mismatch', + message: 'wrong version', + expectedVersion: 1, + }, + { version: 1, type: 'shutdown', requestId: 'shutdown' }, + { version: 1, type: 'shutting_down', requestId: 'shutdown' }, + ]; + for (const frame of validFrames) { + expect(() => validateFirecrackerFrame(frame)).not.toThrow(); + } + + const invalidFrames: unknown[] = [ + null, + [], + { ...ready, capabilities: { stdin: true, tty: false, resize: 'no' } }, + { + version: 1, + type: 'execute', + requestId: 'run', + argv: ['sh'], + env: Object.fromEntries( + Array.from({ length: 513 }, (_, index) => [`V${index}`, 'value']), + ), + cwd: '/workspace', + uid: 1000, + gid: 1000, + tty: false, + }, + { + version: 1, + type: 'execute', + requestId: 'run', + argv: ['sh'], + env: {}, + cwd: 'relative', + uid: 0, + gid: -1, + tty: 'no', + timeoutMs: 0, + }, + { version: 1, type: 'stdin', requestId: 'run' }, + { version: 1, type: 'stdin', requestId: 'run', data: 'not-base64' }, + { version: 1, type: 'resize', requestId: 'run', columns: 0, rows: 65_536 }, + { version: 1, type: 'cancel', requestId: 'run', reason: '' }, + { + version: 1, + type: 'result', + requestId: 'run', + exitCode: 256, + signal: null, + timedOut: 'no', + }, + { + version: 1, + type: 'error', + requestId: 'run', + code: 'unknown', + message: '', + expectedVersion: 0, + }, + { version: 1, type: 'unknown', requestId: 'run' }, + ]; + for (const frame of invalidFrames) { + expect(() => validateFirecrackerFrame(frame)).toThrow(FirecrackerProtocolError); + } + }); + + it('rejects malformed JSON and encoded frames above the wire limit', () => { + const malformed = Buffer.from('{'); + const malformedWire = Buffer.alloc(4 + malformed.length); + malformedWire.writeUInt32BE(malformed.length, 0); + malformed.copy(malformedWire, 4); + expect(() => new FirecrackerFrameDecoder().push(malformedWire)) + .toThrow(/invalid JSON/); + + const oversizedFrame = { + version: 1, + type: 'execute', + requestId: 'large', + argv: ['sh'], + env: Object.fromEntries( + Array.from({ length: 5 }, (_, index) => [ + `VALUE_${index}`, + 'x'.repeat(256 * 1024), + ]), + ), + cwd: '/workspace', + uid: 1000, + gid: 1000, + tty: false, + } as FirecrackerGuestFrame; + expect(() => encodeFirecrackerFrame(oversizedFrame)).toThrow(/exceeds/); + }); });