Add guest sessions and agent VM defaults

Add daemon-backed workspace and guest-session primitives so host
orchestrators can prepare /root/repo, launch long-lived guest commands,
and attach to pipe-mode sessions over the local stdio mux bridge.

Persist richer session metadata and launch diagnostics, preflight guest
cwd/command requirements, make pipe-mode attach rehydratable from guest
state after daemon restart, and allow submodules when workspace prepare
runs in full_copy mode.

At the same time, stop vm run from auto-attaching opencode, make it
print next-step commands instead, and make glibc guest images more
agent-ready by installing node, opencode, claude, and pi while syncing
opencode/claude/pi auth files into work disks on VM start.

Validation:
- GOCACHE=/tmp/banger-gocache go test ./...
- make build
- banger vm workspace prepare --help
- banger vm session --help
- banger vm session start --help
- banger vm session attach --help
This commit is contained in:
Thales Maciel 2026-04-12 23:48:42 -03:00
parent 497e6dca3d
commit 37c4c091ec
No known key found for this signature in database
GPG key ID: 33112E6833C34679
18 changed files with 3212 additions and 405 deletions

View file

@ -15,6 +15,7 @@ import (
"path/filepath"
"sort"
"strings"
"sync"
"time"
"golang.org/x/crypto/ssh"
@ -24,6 +25,16 @@ type Client struct {
client *ssh.Client
}
type StreamSession struct {
client *Client
session *ssh.Session
stdin io.WriteCloser
stdout io.Reader
stderr io.Reader
waitCh chan error
closeOnce sync.Once
}
func WaitForSSH(ctx context.Context, address, privateKeyPath string, interval time.Duration) error {
if interval <= 0 {
interval = time.Second
@ -109,6 +120,116 @@ func (c *Client) StreamTarEntries(ctx context.Context, sourceDir string, entries
return errors.Join(runErr, tarErr)
}
func (c *Client) StartCommand(ctx context.Context, command string) (*StreamSession, error) {
if c == nil || c.client == nil {
return nil, fmt.Errorf("ssh client is not connected")
}
session, err := c.client.NewSession()
if err != nil {
return nil, err
}
stdin, err := session.StdinPipe()
if err != nil {
_ = session.Close()
return nil, err
}
stdout, err := session.StdoutPipe()
if err != nil {
_ = session.Close()
return nil, err
}
stderr, err := session.StderrPipe()
if err != nil {
_ = session.Close()
return nil, err
}
done := make(chan struct{})
go func() {
select {
case <-ctx.Done():
_ = session.Close()
_ = c.client.Close()
case <-done:
}
}()
if err := session.Start(command); err != nil {
close(done)
_ = session.Close()
return nil, err
}
stream := &StreamSession{
client: c,
session: session,
stdin: stdin,
stdout: stdout,
stderr: stderr,
waitCh: make(chan error, 1),
}
go func() {
err := session.Wait()
close(done)
stream.waitCh <- err
close(stream.waitCh)
}()
return stream, nil
}
func (s *StreamSession) Stdin() io.WriteCloser {
if s == nil {
return nil
}
return s.stdin
}
func (s *StreamSession) Stdout() io.Reader {
if s == nil {
return nil
}
return s.stdout
}
func (s *StreamSession) Stderr() io.Reader {
if s == nil {
return nil
}
return s.stderr
}
func (s *StreamSession) Wait() error {
if s == nil || s.waitCh == nil {
return nil
}
err, ok := <-s.waitCh
if !ok {
return nil
}
return err
}
func (s *StreamSession) Close() error {
if s == nil {
return nil
}
var err error
s.closeOnce.Do(func() {
err = errors.Join(
func() error {
if s.session != nil {
return s.session.Close()
}
return nil
}(),
func() error {
if s.client != nil {
return s.client.Close()
}
return nil
}(),
)
})
return err
}
func (c *Client) runSession(ctx context.Context, command string, stdin io.Reader, logWriter io.Writer) error {
if c == nil || c.client == nil {
return fmt.Errorf("ssh client is not connected")