···179179builds and activates it before the user steps run. Afterwards, each step is sent
180180as an exec request (`$shell -lc <command>` as an unprivileged workflow user in
181181`/workspace/repo`, with workflow/step environment and unlocked secrets), and
182182-stdout/stderr stream back as messages until an exit message arrives. Timeouts
183183-are cooperative: we derive a deadline from the workflow timeout and ship it to
182182+stdout streams back raw over a dedicated per-exec vsock connection (the
183183+agent dials it; stdin rides the same socket), while stderr and the exit
184184+status stay on the control channel. Timeouts are cooperative: we derive a deadline from the workflow timeout and ship it to
184185the guest, with a little grace on the host side so the guest gets to report the
185186timeout itself. While a step runs we also watch for the VM crashing, if it does
186187we tail the serial (and qemu) logs into the step's stderr so you get something
+73-11
spindle/engines/microvm/agent.go
···107107type AgentExec struct {
108108 *agentv1.ExecStart
109109 ID string
110110+ Stdin io.Reader
110111 Stdout io.Writer
111112 Stderr io.Writer
112113}
113114114115type AgentSession struct {
115116 conn net.Conn
117117+ cid uint32
116118 enc *agentproto.Encoder
117119 dec *agentproto.Decoder
118120 l *slog.Logger
119121 mu sync.Mutex
120122}
121123122122-func NewAgentSession(conn net.Conn, l *slog.Logger) *AgentSession {
124124+func NewAgentSession(conn net.Conn, cid uint32, l *slog.Logger) *AgentSession {
123125 return &AgentSession{
124126 conn: conn,
127127+ cid: cid,
125128 enc: agentproto.NewEncoder(conn),
126129 dec: agentproto.NewDecoder(conn),
127130 l: l,
···140143 if helloPayload == nil {
141144 return fmt.Errorf("expected agent hello, got nil")
142145 }
146146+ if helloPayload.ProtocolVersion != agentproto.ProtocolVersion {
147147+ return fmt.Errorf("agent protocol version %d, want %d (stale guest image?)", helloPayload.ProtocolVersion, agentproto.ProtocolVersion)
148148+ }
143149 s.l.Info("agent connected", "protocol", helloPayload.ProtocolVersion, "version", helloPayload.AgentVersion, "boot", helloPayload.BootId, "nix", helloPayload.NixVersion)
144150145151 if err := s.enc.Encode(&agentproto.Message{
···163169 exec.ExecStart.TimeoutSeconds = timeoutSeconds(ctx, guestTimeoutGrace)
164170 }
165171172172+ ln, port, err := listenRandomVsockPort(ctx)
173173+ if err != nil {
174174+ return 0, fmt.Errorf("listen for exec stdio: %w", err)
175175+ }
176176+ defer ln.Close()
177177+ exec.ExecStart.StdioVsockPort = port
178178+166179 if err := s.enc.Encode(&agentproto.Message{
167180 Id: exec.ID,
168181 ExecStart: exec.ExecStart,
169182 }); err != nil {
170183 return 0, fmt.Errorf("send exec_start: %w", err)
171184 }
185185+186186+ filtered := &cidFilteredVsockListener{Listener: ln, cid: s.cid, logger: s.l}
187187+ stdioDone := make(chan error, 1)
188188+ go func() {
189189+ stdioDone <- pumpStdio(ctx, filtered, exec.Stdin, exec.Stdout)
190190+ }()
172191173192 for {
174193 msg, err := s.decode(ctx)
175194 if err != nil {
195195+ ln.Close()
196196+ <-stdioDone
176197 return 0, err
177198 }
178199 if msg.BuiltPaths == nil && msg.Id != exec.ID {
179200 continue
180201 }
181202182182- if p := msg.ExecStdout; p != nil {
183183- _, _ = io.WriteString(exec.Stdout, p.Data)
203203+ if p := msg.BuiltPaths; p != nil {
204204+ // s.l.Debug("guest built paths", "reason", p.Reason, "count", len(p.Paths))
184205 } else if p := msg.ExecStderr; p != nil {
185185- _, _ = io.WriteString(exec.Stderr, p.Data)
186186- } else if p := msg.BuiltPaths; p != nil {
187187- // s.l.Debug("guest built paths", "reason", p.Reason, "count", len(p.Paths))
206206+ _, _ = exec.Stderr.Write(p.Data)
188207 } else if p := msg.ExecExit; p != nil {
189208 if p.Error != "" {
190209 s.l.Warn("guest exec error", "id", msg.Id, "error", p.Error)
191210 }
211211+ ln.Close() // wake Accept if the guest never dialed
212212+ if err := <-stdioDone; err != nil {
213213+ return 0, err
214214+ }
192215 if p.TimedOut {
193216 return int(p.ExitCode), errGuestTimedOut
194217 }
···197220 }
198221}
199222223223+func pumpStdio(ctx context.Context, ln net.Listener, stdin io.Reader, stdout io.Writer) error {
224224+ conn, err := ln.Accept()
225225+ if err != nil {
226226+ if errors.Is(err, net.ErrClosed) {
227227+ return nil // exec ended before dialing
228228+ }
229229+ return fmt.Errorf("accept guest stdio connection: %w", err)
230230+ }
231231+ vsockConn, ok := conn.(*vsock.Conn)
232232+ if !ok {
233233+ conn.Close()
234234+ return fmt.Errorf("guest connection is not a vsock connection")
235235+ }
236236+ defer vsockConn.Close()
237237+ stop := context.AfterFunc(ctx, func() { vsockConn.Close() })
238238+ defer stop()
239239+240240+ stdinDone := make(chan error, 1)
241241+ go func() {
242242+ stdinDone <- writeStdin(vsockConn, stdin)
243243+ }()
244244+245245+ if _, err := io.Copy(stdout, conn); err != nil {
246246+ vsockConn.Close()
247247+ <-stdinDone
248248+ return fmt.Errorf("read guest stdout: %w", err)
249249+ }
250250+ if err := <-stdinDone; err != nil {
251251+ return fmt.Errorf("write guest stdin: %w", err)
252252+ }
253253+ return nil
254254+}
255255+256256+// send stdin EOF without closing stdout
257257+func writeStdin(conn *vsock.Conn, r io.Reader) error {
258258+ if r != nil {
259259+ if _, err := io.Copy(conn, r); err != nil {
260260+ return err
261261+ }
262262+ }
263263+ return conn.CloseWrite()
264264+}
265265+200266func (s *AgentSession) ActivateConfig(ctx context.Context, id string, req *agentv1.ActivateConfig, out io.Writer) (*agentv1.ActivateConfigResult, error) {
201267 s.mu.Lock()
202268 defer s.mu.Unlock()
···227293 // s.l.Debug("guest built paths", "reason", p.Reason, "count", len(p.Paths))
228294 } else if p := msg.ExecStderr; p != nil {
229295 if out != nil {
230230- _, _ = io.WriteString(out, p.Data)
231231- }
232232- } else if p := msg.ExecStdout; p != nil {
233233- if out != nil {
234234- _, _ = io.WriteString(out, p.Data)
296296+ _, _ = out.Write(p.Data)
235297 }
236298 } else if p := msg.ActivateConfigResult; p != nil {
237299 if p.Error != "" {