feat: add desktop recording command

This commit is contained in:
Peter Steinberger 2026-05-05 04:44:39 +01:00 committed by GitHub
parent 957b836525
commit 628ea8cb9a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 679 additions and 11 deletions

View File

@ -1,5 +1,11 @@
# Changelog
## 0.6.0 - Unreleased
### Added
- Added `crabbox record` to capture MP4 desktop recordings from UI-capable leases.
## 0.5.1 - 2026-05-05
### Added

View File

@ -37,6 +37,8 @@ crabbox warmup [--provider hetzner|aws|ssh|blacksmith-testbox] [--target linux|m
crabbox run [--id <lease-id-or-slug>] [--provider hetzner|aws|ssh|blacksmith-testbox] [--target linux|macos|windows] [--windows-mode normal|wsl2] [--desktop] [--browser] [--tailscale] [--network auto|tailscale|public] [--shell] [--checksum] [--debug] [--force-sync-large] [--timing-json] [--blacksmith-workflow <workflow>] -- <command...>
crabbox desktop launch --id <lease-id-or-slug> [--browser] [--url <url>] [-- <command...>]
crabbox screenshot --id <lease-id-or-slug> [--output <path>]
crabbox record --id <lease-id-or-slug> [--duration 10s] [--output desktop.mp4]
crabbox record --id <lease-id-or-slug> --while -- <local-command...>
crabbox sync-plan [--limit <n>]
crabbox history [--lease <lease-id>] [--owner <email>] [--org <name>] [--limit <n>] [--json]
crabbox logs <run-id> [--json]
@ -88,6 +90,7 @@ crabbox vnc --id blue-lobster --open
crabbox webvnc --id blue-lobster --open
crabbox desktop launch --id blue-lobster --browser --url https://example.com
crabbox screenshot --id blue-lobster --output desktop.png
crabbox record --id blue-lobster --duration 10s --output desktop.mp4
crabbox run --id blue-lobster --shell 'pnpm install --frozen-lockfile && pnpm test'
crabbox stop blue-lobster
```

View File

@ -29,6 +29,7 @@ Command docs live here, one file per top-level command. Keep `docs/cli.md` as th
- [vnc](vnc.md)
- [webvnc](webvnc.md)
- [screenshot](screenshot.md)
- [record](record.md)
- [inspect](inspect.md)
- [stop](stop.md)
- [cleanup](cleanup.md)

55
docs/commands/record.md Normal file
View File

@ -0,0 +1,55 @@
# record
`crabbox record` captures an MP4 from a desktop lease without opening a VNC
client.
```sh
crabbox warmup --desktop
crabbox record --id blue-lobster
crabbox record --id blue-lobster --duration 10s --output desktop.mp4
crabbox record --id blue-lobster --duration 2m --output task.mp4 --while -- ./drive-ui.sh
```
The command resolves and touches the lease like `crabbox screenshot`, verifies
that the lease has `desktop=true`, waits for the loopback desktop/VNC service,
then streams an MP4 over SSH. Linux records `DISPLAY=:99` with `ffmpeg`
`x11grab`. Windows, including `--windows-mode wsl2` leases, records the visible
Windows console by creating a one-shot scheduled task inside the logged-in
`crabbox` session and using `ffmpeg` `gdigrab`.
New managed Linux desktop leases install `ffmpeg` as part of the desktop
profile. Existing desktop leases can record after `ffmpeg` is installed on the
guest. Windows recording requires `ffmpeg.exe` on the desktop lease.
Use `--while -- <local-command...>` when another tool should drive the remote
desktop while Crabbox records it. Crabbox waits until the remote recorder is
armed, runs the local command, then stops the recorder and writes the MP4. The
local driver gets `CRABBOX_RECORD_LEASE_ID` and `CRABBOX_RECORD_PROVIDER` in its
environment so it can call commands such as `crabbox desktop launch`,
`crabbox screenshot`, or a scenario runner against the same lease. `--duration`
is a hard cap for the local driver command; if the driver is still running at
that limit, Crabbox stops the driver, stops the recorder, and returns an error.
The recording itself can include a small setup/teardown margin around the driver
so it does not miss the tail of the interaction. `--while` currently supports
POSIX desktop targets.
Flags:
```text
--id <lease-id-or-slug>
--provider hetzner|aws|ssh
--target linux|macos|windows
--windows-mode normal|wsl2
--network auto|tailscale|public
--duration <duration>
--fps <frames-per-second>
--size <width>x<height>|auto
--while -- <local-command...>
--output <path>
--reclaim
```
Related docs:
- [Interactive desktop and VNC](../features/interactive-desktop-vnc.md)
- [screenshot command](screenshot.md)

View File

@ -20,6 +20,7 @@ crabbox warmup --desktop --browser
crabbox vnc --id blue-lobster --open
crabbox webvnc --id blue-lobster --open
crabbox screenshot --id blue-lobster --output desktop.png
crabbox record --id blue-lobster --duration 10s --output desktop.mp4
```
AWS Windows and EC2 Mac use the same VNC command once the desktop lease exists:
@ -99,6 +100,19 @@ Use `crabbox screenshot` when you need a PNG without taking over the session:
crabbox screenshot --id blue-lobster --output desktop.png
```
Use `crabbox record` when a temporal UI bug needs video evidence:
```sh
crabbox record --id blue-lobster --duration 10s --output desktop.mp4
crabbox record --id blue-lobster --duration 2m --output task.mp4 --while -- ./drive-ui.sh
```
The `--while` form records while a local driver command controls the desktop.
Drivers can be deterministic scripts, Playwright/CDP flows, VNC/xdotool
automation, or an agent wrapper. Crabbox injects `CRABBOX_RECORD_LEASE_ID` and
`CRABBOX_RECORD_PROVIDER` into the driver environment. `--duration` is the hard
cap for the local driver; the recording can include a small margin around it.
Use `crabbox desktop launch` to start a browser or app inside the visible
session without keeping the SSH command attached:
@ -158,4 +172,4 @@ often machine- and user-encrypted.
- [AWS](aws.md): AWS target matrix, capacity, AMIs, and EC2 Mac host requirements.
- [Hetzner](hetzner.md): Linux-only managed Hetzner behavior.
- [Blacksmith Testbox](blacksmith-testbox.md): delegated Testbox behavior and why VNC is not a Crabbox feature there yet.
- [vnc command](../commands/vnc.md), [webvnc command](../commands/webvnc.md), [screenshot command](../commands/screenshot.md), [desktop command](../commands/desktop.md).
- [vnc command](../commands/vnc.md), [webvnc command](../commands/webvnc.md), [screenshot command](../commands/screenshot.md), [record command](../commands/record.md), [desktop command](../commands/desktop.md).

View File

@ -48,7 +48,7 @@ This page maps user-facing behavior back to implementation files. Keep docs desc
- Desktop app launch into visible sessions: `internal/cli/desktop.go`
- VNC tunnel command: `internal/cli/vnc.go`
- WebVNC portal bridge: `internal/cli/webvnc.go`, `worker/src/portal.ts`, `worker/src/fleet.ts`
- Desktop screenshot command: `internal/cli/screenshot.go`
- Desktop screenshot and recording commands: `internal/cli/screenshot.go`, `internal/cli/record.go`
- Interactive desktop/VNC contract: `docs/features/interactive-desktop-vnc.md`, `docs/features/vnc-linux.md`, `docs/features/vnc-windows.md`, `docs/features/vnc-macos.md`
Bootstrap is intentionally tiny unless optional lease capabilities are requested:

View File

@ -89,6 +89,8 @@ func (a App) directCommandHelp(ctx context.Context, args []string) (error, bool)
return a.webvnc(ctx, helpArgs), true
case "screenshot":
return a.screenshot(ctx, helpArgs), true
case "record":
return a.recordDesktop(ctx, helpArgs), true
case "inspect":
return a.inspect(ctx, helpArgs), true
case "stop", "release":
@ -151,6 +153,7 @@ Commands:
vnc Print or open VNC connection details for a desktop lease
webvnc Bridge a desktop lease into the authenticated web portal
screenshot Capture a PNG from a desktop lease
record Capture an MP4 from a desktop lease
inspect Print lease/provider details; add --json for scripts
stop Release a lease or delete a direct-provider machine
cleanup Sweep expired direct-provider machines
@ -166,6 +169,7 @@ Common Flows:
crabbox desktop launch --id blue-lobster --browser --url https://example.com
crabbox webvnc --id blue-lobster --open
crabbox screenshot --id blue-lobster --output desktop.png
crabbox record --id blue-lobster --duration 10s --output desktop.mp4
crabbox inspect --id blue-lobster --json
crabbox history --lease cbx_abcdef123456
crabbox logs run_123

View File

@ -520,7 +520,7 @@ func cloudInitOptionalBootstrap(cfg Config) string {
parts = append(parts, cloudInitTailscaleBootstrap(cfg))
}
if cfg.Desktop {
parts = append(parts, ` retry apt-get install -y --no-install-recommends xvfb xfce4 xfce4-terminal x11vnc xauth dbus-x11 x11-xserver-utils xterm scrot fonts-dejavu-core fonts-liberation iproute2 openssl
parts = append(parts, ` retry apt-get install -y --no-install-recommends xvfb xfce4 xfce4-terminal x11vnc xauth dbus-x11 x11-xserver-utils xterm scrot ffmpeg fonts-dejavu-core fonts-liberation iproute2 openssl
install -d -m 0750 -o crabbox -g crabbox /var/lib/crabbox
if [ ! -s /var/lib/crabbox/vnc.password ]; then
(umask 077 && openssl rand -base64 18 > /var/lib/crabbox/vnc.password)

View File

@ -39,7 +39,7 @@ func TestCloudInitDesktopProfile(t *testing.T) {
got := cloudInit(cfg, "ssh-ed25519 test")
for _, want := range []string{
"xvfb xfce4 xfce4-terminal x11vnc xauth dbus-x11",
"x11-xserver-utils xterm scrot",
"x11-xserver-utils xterm scrot ffmpeg",
"/etc/systemd/system/crabbox-xvfb.service",
"/etc/systemd/system/crabbox-desktop.service",
"/usr/local/bin/crabbox-desktop-session",

View File

@ -37,6 +37,7 @@ type crabboxKongCLI struct {
Vnc vncKongCmd `cmd:"" name:"vnc" passthrough:"" help:"Print or open VNC connection details for a desktop lease."`
Webvnc webvncKongCmd `cmd:"" name:"webvnc" passthrough:"" help:"Bridge a desktop lease into the authenticated web portal."`
Screenshot screenshotKongCmd `cmd:"" passthrough:"" help:"Capture a PNG from a desktop lease."`
Record recordKongCmd `cmd:"" passthrough:"" help:"Capture an MP4 from a desktop lease."`
Inspect inspectKongCmd `cmd:"" passthrough:"" help:"Print lease/provider details; add --json for scripts."`
Stop stopKongCmd `cmd:"" passthrough:"" help:"Release a lease or delete a direct-provider machine."`
Release releaseKongCmd `cmd:"" passthrough:"" help:"Alias for stop."`
@ -173,6 +174,9 @@ type webvncKongCmd struct {
type screenshotKongCmd struct {
Args []string `arg:"" optional:""`
}
type recordKongCmd struct {
Args []string `arg:"" optional:""`
}
type inspectKongCmd struct {
Args []string `arg:"" optional:""`
}
@ -304,6 +308,9 @@ func (c *webvncKongCmd) Run(ctx context.Context, app App) error { return app.w
func (c *screenshotKongCmd) Run(ctx context.Context, app App) error {
return app.screenshot(ctx, c.Args)
}
func (c *recordKongCmd) Run(ctx context.Context, app App) error {
return app.recordDesktop(ctx, c.Args)
}
func (c *inspectKongCmd) Run(ctx context.Context, app App) error { return app.inspect(ctx, c.Args) }
func (c *stopKongCmd) Run(ctx context.Context, app App) error { return app.stop(ctx, c.Args) }
func (c *releaseKongCmd) Run(ctx context.Context, app App) error { return app.stop(ctx, c.Args) }

438
internal/cli/record.go Normal file
View File

@ -0,0 +1,438 @@
package cli
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
)
const (
recordWhileRecorderReadyTimeout = 10 * time.Second
recordWhileRemoteStopHeadroom = 15 * time.Second
)
func (a App) recordDesktop(ctx context.Context, args []string) error {
defaults := defaultConfig()
fs := newFlagSet("record", a.Stderr)
provider := fs.String("provider", defaults.Provider, "provider: hetzner, aws, or ssh")
id := fs.String("id", "", "lease id or slug")
output := fs.String("output", "", "local MP4 output path")
duration := fs.Duration("duration", 10*time.Second, "recording duration")
fps := fs.Int("fps", 15, "recording frame rate")
size := fs.String("size", "1024x768", "recording size, or auto")
while := fs.Bool("while", false, "record while a local command runs after --")
reclaim := fs.Bool("reclaim", false, "claim this lease for the current repo")
targetFlags := registerTargetFlags(fs, defaults)
networkFlags := registerNetworkModeFlag(fs, defaults)
if err := parseFlags(fs, args); err != nil {
return err
}
idFromPositional := false
if *id == "" && fs.NArg() > 0 {
*id = fs.Arg(0)
idFromPositional = true
}
whileCommand := recordWhileCommandArgs(fs.Args(), *id, idFromPositional)
if *while && len(whileCommand) == 0 {
return exit(2, "usage: crabbox record --id <lease-id-or-slug> --while -- <local-command...>")
}
if *duration <= 0 {
return exit(2, "--duration must be greater than zero")
}
if *duration > 10*time.Minute {
return exit(2, "--duration must be 10m or less")
}
if *fps <= 0 || *fps > 60 {
return exit(2, "--fps must be between 1 and 60")
}
cfg, err := loadConfig()
if err != nil {
return err
}
cfg.Provider = *provider
cfg.Desktop = true
if err := applyTargetFlagOverrides(&cfg, fs, targetFlags); err != nil {
return err
}
if err := applyNetworkModeFlagOverride(&cfg, fs, networkFlags); err != nil {
return err
}
if isBlacksmithProvider(cfg.Provider) {
return exit(2, "desktop recording is not supported for provider=%s; Blacksmith owns machine connectivity", cfg.Provider)
}
if *id == "" && !isStaticProvider(cfg.Provider) {
return exit(2, "usage: crabbox record --id <lease-id-or-slug> [--duration 10s] [--output <path>]")
}
server, target, leaseID, err := a.resolveLeaseTarget(ctx, cfg, *id)
if err != nil {
return err
}
if resolved, err := resolveNetworkTarget(ctx, cfg, server, target); err != nil {
return err
} else {
target = resolved.Target
}
if isStaticProvider(cfg.Provider) && target.TargetOS != targetLinux {
return exit(2, "desktop recordings are not captured from static %s hosts because those are existing host machines, not Crabbox-created desktops", target.TargetOS)
}
if target.TargetOS == targetMacOS {
return exit(2, "desktop recording is not supported for macOS targets yet")
}
if err := enforceManagedLeaseCapabilities(cfg, server, leaseID); err != nil {
return err
}
repo, err := findRepo()
if err != nil {
return err
}
if err := claimLeaseForRepoConfig(leaseID, serverSlug(server), cfg, repo.Root, cfg.IdleTimeout, *reclaim); err != nil {
return err
}
a.touchActiveLeaseBestEffort(ctx, cfg, server, leaseID)
recordTarget := recordDesktopControlTarget(target)
if err := waitForLoopbackVNC(ctx, &recordTarget); err != nil {
return err
}
outPath := strings.TrimSpace(*output)
if outPath == "" {
outPath = defaultRecordingPath(leaseID, serverSlug(server))
}
recordOpts := recordDesktopOptions{
Duration: *duration,
FPS: *fps,
Size: strings.TrimSpace(*size),
}
if *while {
if recordTarget.TargetOS == targetWindows {
return exit(2, "record --while is not supported on Windows targets yet")
}
if err := captureDesktopRecordingWhile(ctx, recordTarget, outPath, recordOpts, recordWhileCommandOptions{
Command: whileCommand,
LeaseID: leaseID,
Provider: cfg.Provider,
Timeout: recordOpts.Duration,
}); err != nil {
return err
}
} else if err := captureDesktopRecording(ctx, recordTarget, outPath, recordOpts); err != nil {
return err
}
fmt.Fprintf(a.Stdout, "recording: %s\n", outPath)
return nil
}
func recordWhileCommandArgs(args []string, id string, idFromPositional bool) []string {
if idFromPositional && id != "" && len(args) > 0 && args[0] == id {
return args[1:]
}
return args
}
func recordDesktopControlTarget(target SSHTarget) SSHTarget {
if target.TargetOS == targetWindows {
target.WindowsMode = windowsModeNormal
}
return target
}
type recordDesktopOptions struct {
Duration time.Duration
FPS int
Size string
}
type recordWhileCommandOptions struct {
Command []string
LeaseID string
Provider string
Timeout time.Duration
}
func defaultRecordingPath(leaseID, slug string) string {
name := slug
if strings.TrimSpace(name) == "" {
name = leaseID
}
if strings.TrimSpace(name) == "" {
name = "crabbox"
}
return "crabbox-" + normalizeLeaseSlug(name) + "-recording.mp4"
}
func captureDesktopRecordingWhile(ctx context.Context, target SSHTarget, outputPath string, opts recordDesktopOptions, command recordWhileCommandOptions) error {
if target.TargetOS == targetWindows {
return exit(2, "record --while is not supported on Windows targets yet")
}
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
return exit(2, "create recording directory: %v", err)
}
file, err := os.Create(outputPath)
if err != nil {
return exit(2, "create recording %s: %v", outputPath, err)
}
ok := false
defer func() {
_ = file.Close()
if !ok {
_ = os.Remove(outputPath)
}
}()
token := fmt.Sprintf("%d", time.Now().UnixNano())
remoteStopPath := "/tmp/crabbox-record-" + token + ".stop"
remoteReadyPath := "/tmp/crabbox-record-" + token + ".ready"
remoteOutputPath := "/tmp/crabbox-record-" + token + ".mp4"
recorderCtx, cancelRecorder := context.WithCancel(ctx)
defer cancelRecorder()
recordDone := make(chan error, 1)
go func() {
recordDone <- runSSHToWriter(recorderCtx, target, recordRemoteUntilStopCommand(target, opts, remoteOutputPath, remoteStopPath, remoteReadyPath), file)
}()
if err := waitForRecordWhileRecorderReady(ctx, target, remoteReadyPath, recordDone); err != nil {
_ = runSSHQuiet(ctx, target, "touch "+shellQuote(remoteStopPath))
cancelRecorder()
recordErr := <-recordDone
if recordErr != nil {
return exit(5, "start recording: %v; recorder: %v", err, recordErr)
}
return exit(5, "start recording: %v", err)
}
driverErr := runRecordWhileLocalCommand(ctx, command)
stopErr := runSSHQuiet(ctx, target, "touch "+shellQuote(remoteStopPath))
recordErr := <-recordDone
if recordErr == nil {
ok = true
}
if driverErr != nil {
return exit(5, "record driver command: %v", driverErr)
}
if stopErr != nil {
return exit(5, "stop recording: %v", stopErr)
}
if recordErr != nil {
return exit(5, "capture recording: %v", recordErr)
}
return nil
}
func waitForRecordWhileRecorderReady(ctx context.Context, target SSHTarget, remoteReadyPath string, recordDone chan error) error {
waitCtx, cancel := context.WithTimeout(ctx, recordWhileRecorderReadyTimeout)
defer cancel()
ticker := time.NewTicker(200 * time.Millisecond)
defer ticker.Stop()
for {
select {
case err := <-recordDone:
recordDone <- err
if err != nil {
return fmt.Errorf("recorder exited before ready: %w", err)
}
return fmt.Errorf("recorder exited before ready")
default:
}
probeCtx, probeCancel := context.WithTimeout(waitCtx, 2*time.Second)
err := runSSHQuietWithOptions(probeCtx, target, "test -f "+shellQuote(remoteReadyPath), "2", "1")
probeCancel()
if err == nil {
return nil
}
select {
case <-waitCtx.Done():
return fmt.Errorf("recorder did not become ready within %s", recordWhileRecorderReadyTimeout)
case <-ticker.C:
}
}
}
func runRecordWhileLocalCommand(ctx context.Context, opts recordWhileCommandOptions) error {
driverCtx := ctx
cancel := func() {}
if opts.Timeout > 0 {
driverCtx, cancel = context.WithTimeout(ctx, opts.Timeout)
}
defer cancel()
cmd := exec.CommandContext(driverCtx, opts.Command[0], opts.Command[1:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = append(os.Environ(),
"CRABBOX_RECORD_LEASE_ID="+opts.LeaseID,
"CRABBOX_RECORD_PROVIDER="+opts.Provider,
)
err := cmd.Run()
if err != nil && errors.Is(driverCtx.Err(), context.DeadlineExceeded) {
return fmt.Errorf("timed out after %s", opts.Timeout)
}
return err
}
func captureDesktopRecording(ctx context.Context, target SSHTarget, outputPath string, opts recordDesktopOptions) error {
target = recordDesktopControlTarget(target)
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
return exit(2, "create recording directory: %v", err)
}
file, err := os.Create(outputPath)
if err != nil {
return exit(2, "create recording %s: %v", outputPath, err)
}
ok := false
defer func() {
_ = file.Close()
if !ok {
_ = os.Remove(outputPath)
}
}()
if err := runSSHToWriter(ctx, target, recordRemoteCommand(target, opts), file); err != nil {
return exit(5, "capture recording: %v", err)
}
ok = true
return nil
}
func recordRemoteUntilStopCommand(target SSHTarget, opts recordDesktopOptions, remoteOutputPath, remoteStopPath, remoteReadyPath string) string {
durationSeconds := int(opts.Duration.Round(time.Second).Seconds())
if durationSeconds < 1 {
durationSeconds = 1
}
remoteMaxSeconds := int((opts.Duration + recordWhileRemoteStopHeadroom).Round(time.Second).Seconds())
if remoteMaxSeconds < durationSeconds {
remoteMaxSeconds = durationSeconds
}
size := strings.TrimSpace(opts.Size)
if size == "" {
size = "1024x768"
}
return posixRecordUntilStopRemoteCommand(remoteMaxSeconds, opts.FPS, size, remoteOutputPath, remoteStopPath, remoteReadyPath)
}
func recordRemoteCommand(target SSHTarget, opts recordDesktopOptions) string {
durationSeconds := int(opts.Duration.Round(time.Second).Seconds())
if durationSeconds < 1 {
durationSeconds = 1
}
size := strings.TrimSpace(opts.Size)
if size == "" {
size = "1024x768"
}
if target.TargetOS == targetWindows {
return windowsRecordRemoteCommand(durationSeconds, opts.FPS, size)
}
return posixRecordRemoteCommand(durationSeconds, opts.FPS, size)
}
func posixRecordUntilStopRemoteCommand(durationSeconds, fps int, size, remoteOutputPath, remoteStopPath, remoteReadyPath string) string {
sizeArg := ""
if size != "auto" {
sizeArg = " -video_size " + shellQuote(size)
}
return fmt.Sprintf(`set -eu
export DISPLAY="${DISPLAY:-:99}"
out=%s
stop=%s
ready=%s
rm -f "$out" "$stop" "$ready"
if ! command -v ffmpeg >/dev/null 2>&1; then
sudo apt-get update -y >/tmp/crabbox-record-apt.log 2>&1 || true
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y ffmpeg >>/tmp/crabbox-record-apt.log 2>&1 || true
fi
if ! command -v ffmpeg >/dev/null 2>&1; then
echo "no video recording tool found; warm a new --desktop lease or install ffmpeg; apt log: /tmp/crabbox-record-apt.log" >&2
exit 127
fi
input="$DISPLAY"
case "$input" in
*.*) ;;
*) input="${input}.0" ;;
esac
ffmpeg -hide_banner -loglevel error -y -f x11grab%s -framerate %d -i "$input" -t %d -pix_fmt yuv420p "$out" >/tmp/crabbox-record-ffmpeg.log 2>&1 &
pid=$!
printf ready > "$ready"
while kill -0 "$pid" >/dev/null 2>&1; do
if [ -f "$stop" ]; then
kill -INT "$pid" >/dev/null 2>&1 || true
break
fi
sleep 0.2
done
wait "$pid" || true
test -s "$out"
cat "$out"
rm -f "$out" "$stop" "$ready"
`, shellQuote(remoteOutputPath), shellQuote(remoteStopPath), shellQuote(remoteReadyPath), sizeArg, fps, durationSeconds)
}
func posixRecordRemoteCommand(durationSeconds, fps int, size string) string {
sizeArg := ""
if size != "auto" {
sizeArg = " -video_size " + shellQuote(size)
}
return fmt.Sprintf(`set -eu
export DISPLAY="${DISPLAY:-:99}"
if ! command -v ffmpeg >/dev/null 2>&1; then
sudo apt-get update -y >/tmp/crabbox-record-apt.log 2>&1 || true
sudo DEBIAN_FRONTEND=noninteractive apt-get install -y ffmpeg >>/tmp/crabbox-record-apt.log 2>&1 || true
fi
if ! command -v ffmpeg >/dev/null 2>&1; then
echo "no video recording tool found; warm a new --desktop lease or install ffmpeg; apt log: /tmp/crabbox-record-apt.log" >&2
exit 127
fi
input="$DISPLAY"
case "$input" in
*.*) ;;
*) input="${input}.0" ;;
esac
ffmpeg -hide_banner -loglevel error -y -f x11grab%s -framerate %d -i "$input" -t %d -pix_fmt yuv420p -movflags frag_keyframe+empty_moov -f mp4 pipe:1
`, sizeArg, fps, durationSeconds)
}
func windowsRecordRemoteCommand(durationSeconds, fps int, size string) string {
sizeArgs := ""
if size != "auto" {
sizeArgs = `, "-video_size", ` + psQuote(size)
}
inner := `$ErrorActionPreference = "Stop"
$ffmpegCommand = Get-Command ffmpeg.exe -ErrorAction SilentlyContinue
if (-not $ffmpegCommand) { throw "no video recording tool found; install ffmpeg.exe on the Windows desktop lease" }
$args = @("-hide_banner", "-loglevel", "error", "-y", "-f", "gdigrab"` + sizeArgs + `, "-framerate", "` + fmt.Sprint(fps) + `", "-i", "desktop", "-t", "` + fmt.Sprint(durationSeconds) + `", "-pix_fmt", "yuv420p", "__CRABBOX_RECORDING_OUT__")
$proc = Start-Process -FilePath $ffmpegCommand.Source -ArgumentList $args -WindowStyle Hidden -Wait -PassThru
if ($proc.ExitCode -ne 0) { throw "ffmpeg.exe failed with exit code $($proc.ExitCode)" }
if (-not (Test-Path -LiteralPath "__CRABBOX_RECORDING_OUT__") -or ((Get-Item -LiteralPath "__CRABBOX_RECORDING_OUT__").Length -le 0)) { throw "ffmpeg.exe produced no recording" }
Set-Content -Encoding ASCII -LiteralPath "__CRABBOX_RECORDING_DONE__" -Value "done"
`
return `$ErrorActionPreference = "Stop"
$base = "C:\ProgramData\crabbox"
$passwordPath = Join-Path $base "windows.password"
$password = if (Test-Path -LiteralPath $passwordPath) { (Get-Content -Raw -LiteralPath $passwordPath).Trim() } else { "" }
$taskName = "CrabboxRecord-" + [Guid]::NewGuid().ToString("N")
$out = Join-Path $base ($taskName + ".mp4")
$done = Join-Path $base ($taskName + ".done")
$script = Join-Path $base ($taskName + ".ps1")
try {
Set-Content -Encoding UTF8 -LiteralPath $script -Value (` + psQuote(inner) + `.Replace("__CRABBOX_RECORDING_OUT__", $out).Replace("__CRABBOX_RECORDING_DONE__", $done))
cmd.exe /c "schtasks.exe /Delete /TN $taskName /F 2>NUL" | Out-Null
$startTime = (Get-Date).AddMinutes(1).ToString("HH:mm")
$createArgs = @("/Create", "/TN", $taskName, "/SC", "ONCE", "/ST", $startTime, "/TR", "powershell.exe -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File $script", "/RU", $env:USERNAME, "/IT", "/F")
& schtasks.exe @createArgs | Out-Null
if ($LASTEXITCODE -ne 0 -and $password -ne "") {
& schtasks.exe @($createArgs + @("/RP", $password)) | Out-Null
}
if ($LASTEXITCODE -ne 0) { throw "failed to create interactive recording task" }
schtasks.exe /Run /TN $taskName | Out-Null
$deadline = (Get-Date).AddSeconds(` + fmt.Sprint(durationSeconds+45) + `)
while ((Get-Date) -lt $deadline) {
if (Test-Path -LiteralPath $done) { break }
Start-Sleep -Milliseconds 500
}
if (-not (Test-Path -LiteralPath $done)) { throw "scheduled interactive recording did not finish" }
$bytes = [IO.File]::ReadAllBytes($out)
[Console]::OpenStandardOutput().Write($bytes, 0, $bytes.Length)
} finally {
cmd.exe /c "schtasks.exe /Delete /TN $taskName /F 2>NUL" | Out-Null
Remove-Item -Force -LiteralPath $out, $done, $script -ErrorAction SilentlyContinue
}
`
}

140
internal/cli/record_test.go Normal file
View File

@ -0,0 +1,140 @@
package cli
import (
"os/exec"
"strings"
"testing"
"time"
)
func TestDefaultRecordingPath(t *testing.T) {
if got := defaultRecordingPath("cbx_123", "Blue Lobster"); got != "crabbox-blue-lobster-recording.mp4" {
t.Fatalf("path=%q", got)
}
if got := defaultRecordingPath("cbx_123", ""); got != "crabbox-cbx-123-recording.mp4" {
t.Fatalf("fallback path=%q", got)
}
}
func TestRecordWhileCommandArgsOnlyStripsPositionalID(t *testing.T) {
if got := recordWhileCommandArgs([]string{"test", "-f", "foo"}, "test", false); strings.Join(got, " ") != "test -f foo" {
t.Fatalf("flag id should not strip driver command, got %q", got)
}
if got := recordWhileCommandArgs([]string{"test", "driver"}, "test", true); strings.Join(got, " ") != "driver" {
t.Fatalf("positional id should be stripped, got %q", got)
}
}
func TestRecordDesktopControlTargetUsesWindowsConsoleForWSL2(t *testing.T) {
target := recordDesktopControlTarget(SSHTarget{TargetOS: targetWindows, WindowsMode: windowsModeWSL2})
if target.WindowsMode != windowsModeNormal {
t.Fatalf("record desktop target should use Windows console mode, got %q", target.WindowsMode)
}
}
func TestRecordRemoteCommandUsesFFmpegX11Grab(t *testing.T) {
got := recordRemoteCommand(SSHTarget{TargetOS: targetLinux}, recordDesktopOptions{
Duration: 3 * time.Second,
FPS: 12,
Size: "1280x720",
})
for _, want := range []string{
`DISPLAY="${DISPLAY:-:99}"`,
"command -v ffmpeg",
"-f x11grab",
"-video_size '1280x720'",
"-framerate 12",
"-t 3",
"-movflags frag_keyframe+empty_moov",
"-f mp4 pipe:1",
} {
if !strings.Contains(got, want) {
t.Fatalf("record command missing %q:\n%s", want, got)
}
}
}
func TestRecordRemoteUntilStopCommandUsesStopFile(t *testing.T) {
got := recordRemoteUntilStopCommand(
SSHTarget{TargetOS: targetLinux},
recordDesktopOptions{Duration: 30 * time.Second, FPS: 8, Size: "auto"},
"/tmp/out.mp4",
"/tmp/stop",
"/tmp/ready",
)
for _, want := range []string{
"out='/tmp/out.mp4'",
"stop='/tmp/stop'",
"ready='/tmp/ready'",
"-f x11grab",
"-framerate 8",
"-t 45",
`printf ready > "$ready"`,
`if [ -f "$stop" ]`,
`kill -INT "$pid"`,
`cat "$out"`,
} {
if !strings.Contains(got, want) {
t.Fatalf("record until-stop command missing %q:\n%s", want, got)
}
}
if strings.Contains(got, "-video_size") {
t.Fatalf("auto size should omit -video_size:\n%s", got)
}
}
func TestRecordRemoteCommandSupportsWindowsInteractiveTask(t *testing.T) {
for _, target := range []SSHTarget{
{TargetOS: targetWindows, WindowsMode: windowsModeNormal},
{TargetOS: targetWindows, WindowsMode: windowsModeWSL2},
} {
got := recordRemoteCommand(
target,
recordDesktopOptions{Duration: 4 * time.Second, FPS: 10, Size: "1024x768"},
)
assertWindowsRecordCommand(t, got)
}
}
func assertWindowsRecordCommand(t *testing.T, got string) {
t.Helper()
for _, want := range []string{
"CrabboxRecord-",
"ffmpeg.exe",
"gdigrab",
"desktop",
`"/IT"`,
"windows.password",
"ReadAllBytes",
"-PassThru",
"ExitCode",
"ffmpeg.exe produced no recording",
"} finally {",
"schtasks.exe /Delete /TN $taskName /F",
"Remove-Item -Force -LiteralPath $out, $done, $script",
} {
if !strings.Contains(got, want) {
t.Fatalf("windows record command missing %q:\n%s", want, got)
}
}
}
func TestRunRecordWhileLocalCommandTimesOut(t *testing.T) {
if _, err := exec.LookPath("sleep"); err != nil {
t.Skip("sleep command unavailable")
}
start := time.Now()
err := runRecordWhileLocalCommand(t.Context(), recordWhileCommandOptions{
Command: []string{"sleep", "5"},
Timeout: 100 * time.Millisecond,
})
if err == nil {
t.Fatal("expected timeout error")
}
if !strings.Contains(err.Error(), "timed out after 100ms") {
t.Fatalf("timeout error=%v", err)
}
if elapsed := time.Since(start); elapsed > time.Second {
t.Fatalf("driver timeout took too long: %s", elapsed)
}
}

View File

@ -1,3 +1,3 @@
package cli
var version = "0.5.1"
var version = "0.6.0"

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/crabbox-plugin",
"version": "0.5.1",
"version": "0.6.0",
"description": "OpenClaw plugin for running Crabbox remote testbox workflows",
"license": "MIT",
"type": "module",

View File

@ -1,12 +1,12 @@
{
"name": "@openclaw/crabbox-worker",
"version": "0.5.1",
"version": "0.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/crabbox-worker",
"version": "0.5.1",
"version": "0.6.0",
"dependencies": {
"@novnc/novnc": "1.6.0",
"aws4fetch": "^1.0.20",

View File

@ -1,6 +1,6 @@
{
"name": "@openclaw/crabbox-worker",
"version": "0.5.1",
"version": "0.6.0",
"private": true,
"type": "module",
"scripts": {

View File

@ -371,7 +371,7 @@ function optionalBootstrap(config: LeaseConfig): string {
parts.push(tailscaleBootstrap(config));
}
if (config.desktop) {
parts.push(` retry apt-get install -y --no-install-recommends xvfb xfce4 xfce4-terminal x11vnc xauth dbus-x11 x11-xserver-utils xterm scrot fonts-dejavu-core fonts-liberation iproute2 openssl
parts.push(` retry apt-get install -y --no-install-recommends xvfb xfce4 xfce4-terminal x11vnc xauth dbus-x11 x11-xserver-utils xterm scrot ffmpeg fonts-dejavu-core fonts-liberation iproute2 openssl
install -d -m 0750 -o crabbox -g crabbox /var/lib/crabbox
if [ ! -s /var/lib/crabbox/vnc.password ]; then
(umask 077 && openssl rand -base64 18 > /var/lib/crabbox/vnc.password)

View File

@ -79,7 +79,7 @@ describe("cloud-init bootstrap", () => {
expect(got).toContain("ExecStart=/usr/bin/startxfce4");
expect(got).toContain("systemctl is-active --quiet crabbox-desktop.service");
expect(got).toContain("systemctl is-active --quiet crabbox-desktop-session.service");
expect(got).toContain("x11-xserver-utils xterm scrot");
expect(got).toContain("x11-xserver-utils xterm scrot ffmpeg");
expect(got).toContain("xsetroot -solid '#20242b'");
expect(got).toContain("xterm -title 'Crabbox Desktop'");
expect(got).toContain("(umask 077 && openssl rand -base64 18 > /var/lib/crabbox/vnc.password)");