Add guest.session.send and vm.workspace.export RPCs

guest.session.send — write to a pipe-mode session's stdin without
holding the exclusive attach. The daemon dials a fresh SSH connection,
uploads the payload to a temp file, and cats it into the session's
named FIFO. Linux atomicity for writes ≤ PIPE_BUF covers all pi RPC
JSONL lines. Attach exclusivity is unchanged.

vm.workspace.export — pull changes from guest back to host. Runs
`git add -A && git diff --cached HEAD --binary` inside the guest via a
new RunScriptOutput helper on guest.Client (stdout-only capture,
distinct from RunScript which merges stderr). Returns a binary-safe
patch and a list of changed files. CLI writes the patch to stdout for
`| git apply` or to a file via --output.

RunScriptOutput is implemented as a direct SSH session (same pattern as
runSession) rather than going through StartCommand/StreamSession to
avoid closing the underlying Client, which is required since
ExportVMWorkspace calls it twice on the same connection.

New files: internal/daemon/workspace_test.go
This commit is contained in:
Thales Maciel 2026-04-14 15:21:50 -03:00
parent 797a9de1ce
commit 94c353f317
No known key found for this signature in database
GPG key ID: 33112E6833C34679
9 changed files with 1074 additions and 1 deletions

View file

@ -1936,3 +1936,285 @@ func (c *testVMRunGuestClient) StreamTarEntries(ctx context.Context, sourceDir s
c.streamCommand = remoteCommand
return nil
}
func TestVMSessionSendCommandExists(t *testing.T) {
root := NewBangerCommand()
vm, _, err := root.Find([]string{"vm"})
if err != nil {
t.Fatalf("find vm: %v", err)
}
session, _, err := vm.Find([]string{"session"})
if err != nil {
t.Fatalf("find session: %v", err)
}
if _, _, err := session.Find([]string{"send"}); err != nil {
t.Fatalf("find session send: %v", err)
}
}
func TestVMSessionSendRejectsWrongArgCount(t *testing.T) {
cmd := NewBangerCommand()
cmd.SetArgs([]string{"vm", "session", "send", "only-one-arg"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "usage: banger vm session send") {
t.Fatalf("Execute() error = %v, want send usage error", err)
}
}
func stubEnsureDaemonForSend(t *testing.T) {
t.Helper()
t.Setenv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "config"))
t.Setenv("XDG_STATE_HOME", filepath.Join(t.TempDir(), "state"))
t.Setenv("XDG_RUNTIME_DIR", filepath.Join(t.TempDir(), "run"))
origPing := daemonPingFunc
t.Cleanup(func() { daemonPingFunc = origPing })
daemonPingFunc = func(context.Context, string) (api.PingResult, error) {
return api.PingResult{Status: "ok", PID: os.Getpid()}, nil
}
}
func TestVMSessionSendWithMessageFlag(t *testing.T) {
stubEnsureDaemonForSend(t)
original := guestSessionSendFunc
t.Cleanup(func() { guestSessionSendFunc = original })
var capturedParams api.GuestSessionSendParams
guestSessionSendFunc = func(_ context.Context, _ string, params api.GuestSessionSendParams) (api.GuestSessionSendResult, error) {
capturedParams = params
return api.GuestSessionSendResult{
Session: model.GuestSession{ID: "sess-id", Name: "planner"},
BytesWritten: len(params.Payload),
}, nil
}
cmd := NewBangerCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetArgs([]string{"vm", "session", "send", "devbox", "planner", "--message", `{"type":"abort"}`})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
wantPayload := []byte(`{"type":"abort"}` + "\n")
if string(capturedParams.Payload) != string(wantPayload) {
t.Fatalf("payload = %q, want %q", capturedParams.Payload, wantPayload)
}
if capturedParams.VMIDOrName != "devbox" {
t.Fatalf("VMIDOrName = %q, want %q", capturedParams.VMIDOrName, "devbox")
}
if capturedParams.SessionIDOrName != "planner" {
t.Fatalf("SessionIDOrName = %q, want %q", capturedParams.SessionIDOrName, "planner")
}
if !strings.Contains(out.String(), "17") {
t.Fatalf("output = %q, want bytes_written in output", out.String())
}
}
func TestVMSessionSendMessageAlreadyHasNewline(t *testing.T) {
stubEnsureDaemonForSend(t)
original := guestSessionSendFunc
t.Cleanup(func() { guestSessionSendFunc = original })
var capturedPayload []byte
guestSessionSendFunc = func(_ context.Context, _ string, params api.GuestSessionSendParams) (api.GuestSessionSendResult, error) {
capturedPayload = params.Payload
return api.GuestSessionSendResult{
Session: model.GuestSession{Name: "s"},
BytesWritten: len(params.Payload),
}, nil
}
cmd := NewBangerCommand()
cmd.SetOut(io.Discard)
cmd.SetArgs([]string{"vm", "session", "send", "devbox", "s", "--message", "{\"type\":\"abort\"}\n"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
// Must not double-append newline.
if capturedPayload[len(capturedPayload)-1] != '\n' {
t.Fatalf("payload missing trailing newline: %q", capturedPayload)
}
if len(capturedPayload) > 0 && capturedPayload[len(capturedPayload)-2] == '\n' {
t.Fatalf("payload has double trailing newline: %q", capturedPayload)
}
}
func TestVMSessionSendFromStdin(t *testing.T) {
stubEnsureDaemonForSend(t)
original := guestSessionSendFunc
t.Cleanup(func() { guestSessionSendFunc = original })
var capturedPayload []byte
guestSessionSendFunc = func(_ context.Context, _ string, params api.GuestSessionSendParams) (api.GuestSessionSendResult, error) {
capturedPayload = params.Payload
return api.GuestSessionSendResult{
Session: model.GuestSession{Name: "planner"},
BytesWritten: len(params.Payload),
}, nil
}
stdinPayload := `{"type":"steer","message":"Focus on src/"}` + "\n"
cmd := NewBangerCommand()
cmd.SetOut(io.Discard)
cmd.SetIn(strings.NewReader(stdinPayload))
cmd.SetArgs([]string{"vm", "session", "send", "devbox", "planner"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
if string(capturedPayload) != stdinPayload {
t.Fatalf("payload = %q, want %q", capturedPayload, stdinPayload)
}
}
func TestVMWorkspaceExportCommandExists(t *testing.T) {
root := NewBangerCommand()
vm, _, err := root.Find([]string{"vm"})
if err != nil {
t.Fatalf("find vm: %v", err)
}
workspace, _, err := vm.Find([]string{"workspace"})
if err != nil {
t.Fatalf("find workspace: %v", err)
}
if _, _, err := workspace.Find([]string{"export"}); err != nil {
t.Fatalf("find workspace export: %v", err)
}
}
func TestVMWorkspaceExportRejectsMissingArg(t *testing.T) {
cmd := NewBangerCommand()
cmd.SetArgs([]string{"vm", "workspace", "export"})
err := cmd.Execute()
if err == nil || !strings.Contains(err.Error(), "usage: banger vm workspace export") {
t.Fatalf("Execute() error = %v, want usage error", err)
}
}
func TestVMWorkspaceExportWritesToStdout(t *testing.T) {
stubEnsureDaemonForSend(t)
origExport := vmWorkspaceExportFunc
t.Cleanup(func() { vmWorkspaceExportFunc = origExport })
patch := []byte("diff --git a/main.go b/main.go\nindex 0000000..1111111 100644\n")
vmWorkspaceExportFunc = func(_ context.Context, _ string, params api.WorkspaceExportParams) (api.WorkspaceExportResult, error) {
return api.WorkspaceExportResult{
GuestPath: params.GuestPath,
Patch: patch,
ChangedFiles: []string{"main.go"},
HasChanges: true,
}, nil
}
cmd := NewBangerCommand()
var out bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{"vm", "workspace", "export", "devbox"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
if !bytes.Equal(out.Bytes(), patch) {
t.Fatalf("stdout = %q, want %q", out.Bytes(), patch)
}
}
func TestVMWorkspaceExportWritesToFile(t *testing.T) {
stubEnsureDaemonForSend(t)
origExport := vmWorkspaceExportFunc
t.Cleanup(func() { vmWorkspaceExportFunc = origExport })
patch := []byte("diff --git a/main.go b/main.go\n")
vmWorkspaceExportFunc = func(_ context.Context, _ string, _ api.WorkspaceExportParams) (api.WorkspaceExportResult, error) {
return api.WorkspaceExportResult{
GuestPath: "/root/repo",
Patch: patch,
ChangedFiles: []string{"main.go"},
HasChanges: true,
}, nil
}
outFile := filepath.Join(t.TempDir(), "worker.diff")
cmd := NewBangerCommand()
cmd.SetOut(io.Discard)
var stderr bytes.Buffer
cmd.SetErr(&stderr)
cmd.SetArgs([]string{"vm", "workspace", "export", "devbox", "--output", outFile})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
got, err := os.ReadFile(outFile)
if err != nil {
t.Fatalf("ReadFile: %v", err)
}
if !bytes.Equal(got, patch) {
t.Fatalf("file content = %q, want %q", got, patch)
}
if !strings.Contains(stderr.String(), "worker.diff") {
t.Fatalf("stderr = %q, want output path mentioned", stderr.String())
}
}
func TestVMWorkspaceExportNoChanges(t *testing.T) {
stubEnsureDaemonForSend(t)
origExport := vmWorkspaceExportFunc
t.Cleanup(func() { vmWorkspaceExportFunc = origExport })
vmWorkspaceExportFunc = func(_ context.Context, _ string, _ api.WorkspaceExportParams) (api.WorkspaceExportResult, error) {
return api.WorkspaceExportResult{
GuestPath: "/root/repo",
HasChanges: false,
}, nil
}
cmd := NewBangerCommand()
var out bytes.Buffer
var stderr bytes.Buffer
cmd.SetOut(&out)
cmd.SetErr(&stderr)
cmd.SetArgs([]string{"vm", "workspace", "export", "devbox"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
if out.Len() != 0 {
t.Fatalf("stdout = %q, want empty when no changes", out.String())
}
if !strings.Contains(stderr.String(), "no changes") {
t.Fatalf("stderr = %q, want 'no changes'", stderr.String())
}
}
func TestVMWorkspaceExportGuestPathFlag(t *testing.T) {
stubEnsureDaemonForSend(t)
origExport := vmWorkspaceExportFunc
t.Cleanup(func() { vmWorkspaceExportFunc = origExport })
var capturedParams api.WorkspaceExportParams
vmWorkspaceExportFunc = func(_ context.Context, _ string, params api.WorkspaceExportParams) (api.WorkspaceExportResult, error) {
capturedParams = params
return api.WorkspaceExportResult{HasChanges: false}, nil
}
cmd := NewBangerCommand()
cmd.SetOut(io.Discard)
cmd.SetErr(io.Discard)
cmd.SetArgs([]string{"vm", "workspace", "export", "devbox", "--guest-path", "/root/project"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
if capturedParams.GuestPath != "/root/project" {
t.Fatalf("GuestPath = %q, want /root/project", capturedParams.GuestPath)
}
if capturedParams.IDOrName != "devbox" {
t.Fatalf("IDOrName = %q, want devbox", capturedParams.IDOrName)
}
}