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

@ -89,6 +89,9 @@ var (
vmWorkspacePrepareFunc = func(ctx context.Context, socketPath string, params api.VMWorkspacePrepareParams) (api.VMWorkspacePrepareResult, error) {
return rpc.Call[api.VMWorkspacePrepareResult](ctx, socketPath, "vm.workspace.prepare", params)
}
vmWorkspaceExportFunc = func(ctx context.Context, socketPath string, params api.WorkspaceExportParams) (api.WorkspaceExportResult, error) {
return rpc.Call[api.WorkspaceExportResult](ctx, socketPath, "vm.workspace.export", params)
}
guestSessionStartFunc = func(ctx context.Context, socketPath string, params api.GuestSessionStartParams) (api.GuestSessionShowResult, error) {
return rpc.Call[api.GuestSessionShowResult](ctx, socketPath, "guest.session.start", params)
}
@ -110,6 +113,9 @@ var (
guestSessionAttachBeginFunc = func(ctx context.Context, socketPath string, params api.GuestSessionAttachBeginParams) (api.GuestSessionAttachBeginResult, error) {
return rpc.Call[api.GuestSessionAttachBeginResult](ctx, socketPath, "guest.session.attach.begin", params)
}
guestSessionSendFunc = func(ctx context.Context, socketPath string, params api.GuestSessionSendParams) (api.GuestSessionSendResult, error) {
return rpc.Call[api.GuestSessionSendResult](ctx, socketPath, "guest.session.send", params)
}
guestWaitForSSHFunc = func(ctx context.Context, address, privateKeyPath string, interval time.Duration) error {
return guest.WaitForSSH(ctx, address, privateKeyPath, interval)
}
@ -869,7 +875,10 @@ func newVMWorkspaceCommand() *cobra.Command {
Short: "Manage repository workspaces inside a running VM",
RunE: helpNoArgs,
}
cmd.AddCommand(newVMWorkspacePrepareCommand())
cmd.AddCommand(
newVMWorkspacePrepareCommand(),
newVMWorkspaceExportCommand(),
)
return cmd
}
@ -929,6 +938,52 @@ func newVMWorkspacePrepareCommand() *cobra.Command {
return cmd
}
func newVMWorkspaceExportCommand() *cobra.Command {
var guestPath string
var outputPath string
cmd := &cobra.Command{
Use: "export <id-or-name>",
Short: "Pull changes from a guest workspace back to the host as a patch",
Long: "Stage all changes inside the guest workspace (git add -A) and emit a binary-safe unified diff against HEAD. With no --output flag the patch is written to stdout so it can be piped directly to git apply.",
Args: exactArgsUsage(1, "usage: banger vm workspace export <id-or-name>"),
Example: strings.TrimSpace(`
banger vm workspace export devbox | git apply
banger vm workspace export devbox --output worker.diff
banger vm workspace export devbox --guest-path /root/project --output changes.diff
`),
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := ensureDaemon(cmd.Context())
if err != nil {
return err
}
result, err := vmWorkspaceExportFunc(cmd.Context(), layout.SocketPath, api.WorkspaceExportParams{
IDOrName: args[0],
GuestPath: guestPath,
})
if err != nil {
return err
}
if !result.HasChanges {
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "no changes")
return nil
}
if outputPath != "" {
if err := os.WriteFile(outputPath, result.Patch, 0o644); err != nil {
return fmt.Errorf("write patch: %w", err)
}
_, err = fmt.Fprintf(cmd.ErrOrStderr(), "patch written to %s (%d bytes, %d files)\n",
outputPath, len(result.Patch), len(result.ChangedFiles))
return err
}
_, err = cmd.OutOrStdout().Write(result.Patch)
return err
},
}
cmd.Flags().StringVar(&guestPath, "guest-path", "/root/repo", "guest workspace path")
cmd.Flags().StringVar(&outputPath, "output", "", "write patch to this file instead of stdout")
return cmd
}
func newVMSessionCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "session",
@ -944,6 +999,7 @@ func newVMSessionCommand() *cobra.Command {
newVMSessionStopCommand(),
newVMSessionKillCommand(),
newVMSessionAttachCommand(),
newVMSessionSendCommand(),
)
return cmd
}
@ -1134,6 +1190,51 @@ func newVMSessionAttachCommand() *cobra.Command {
}
}
func newVMSessionSendCommand() *cobra.Command {
var message string
cmd := &cobra.Command{
Use: "send <id-or-name> <session>",
Short: "Write bytes to a running guest session's stdin pipe",
Long: "Write a payload to the stdin pipe of a running pipe-mode guest session without holding the exclusive attach. Use --message for an inline JSONL string, or pipe bytes via stdin when --message is omitted. A trailing newline is appended to --message values that lack one.",
Args: exactArgsUsage(2, "usage: banger vm session send <id-or-name> <session> [--message '<json>']"),
Example: strings.TrimSpace(`
banger vm session send devbox planner --message '{"type":"abort"}'
banger vm session send devbox planner --message '{"type":"steer","message":"Focus on src/"}'
echo '{"type":"prompt","prompt":"Summarize."}' | banger vm session send devbox planner
`),
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := ensureDaemon(cmd.Context())
if err != nil {
return err
}
var payload []byte
if message != "" {
payload = []byte(message)
if len(payload) > 0 && payload[len(payload)-1] != '\n' {
payload = append(payload, '\n')
}
} else {
payload, err = io.ReadAll(cmd.InOrStdin())
if err != nil {
return fmt.Errorf("read stdin: %w", err)
}
}
result, err := guestSessionSendFunc(cmd.Context(), layout.SocketPath, api.GuestSessionSendParams{
VMIDOrName: args[0],
SessionIDOrName: args[1],
Payload: payload,
})
if err != nil {
return err
}
_, err = fmt.Fprintf(cmd.OutOrStdout(), "sent %d bytes to session %s\n", result.BytesWritten, result.Session.Name)
return err
},
}
cmd.Flags().StringVar(&message, "message", "", "JSONL message to send; a trailing newline is appended if absent")
return cmd
}
func parseKeyValuePairs(values []string) (map[string]string, error) {
if len(values) == 0 {
return nil, nil