From 686f2e880b06116a8562e7533f9bd64dcbdb7f27 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 4 May 2026 23:32:53 -0700 Subject: [PATCH] Revert "feat: add desktop recording command" This reverts commit 628ea8cb9a0b64019c89ae550e1d8a4b44d57c29. --- CHANGELOG.md | 6 - docs/cli.md | 3 - docs/commands/README.md | 1 - docs/commands/record.md | 55 --- docs/features/interactive-desktop-vnc.md | 16 +- docs/source-map.md | 2 +- internal/cli/app.go | 4 - internal/cli/bootstrap.go | 2 +- internal/cli/bootstrap_test.go | 2 +- internal/cli/cli_kong.go | 7 - internal/cli/record.go | 438 ----------------------- internal/cli/record_test.go | 140 -------- internal/cli/version.go | 2 +- package.json | 2 +- worker/package-lock.json | 4 +- worker/package.json | 2 +- worker/src/bootstrap.ts | 2 +- worker/test/bootstrap.test.ts | 2 +- 18 files changed, 11 insertions(+), 679 deletions(-) delete mode 100644 docs/commands/record.md delete mode 100644 internal/cli/record.go delete mode 100644 internal/cli/record_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 79a5c22..501ea7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,5 @@ # 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 diff --git a/docs/cli.md b/docs/cli.md index 3481c39..d37e69c 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -37,8 +37,6 @@ crabbox warmup [--provider hetzner|aws|ssh|blacksmith-testbox] [--target linux|m crabbox run [--id ] [--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 ] -- crabbox desktop launch --id [--browser] [--url ] [-- ] crabbox screenshot --id [--output ] -crabbox record --id [--duration 10s] [--output desktop.mp4] -crabbox record --id --while -- crabbox sync-plan [--limit ] crabbox history [--lease ] [--owner ] [--org ] [--limit ] [--json] crabbox logs [--json] @@ -90,7 +88,6 @@ 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 ``` diff --git a/docs/commands/README.md b/docs/commands/README.md index ca6e091..df78a18 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -29,7 +29,6 @@ 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) diff --git a/docs/commands/record.md b/docs/commands/record.md deleted file mode 100644 index 01df82a..0000000 --- a/docs/commands/record.md +++ /dev/null @@ -1,55 +0,0 @@ -# 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 -- ` 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 ---provider hetzner|aws|ssh ---target linux|macos|windows ---windows-mode normal|wsl2 ---network auto|tailscale|public ---duration ---fps ---size x|auto ---while -- ---output ---reclaim -``` - -Related docs: - -- [Interactive desktop and VNC](../features/interactive-desktop-vnc.md) -- [screenshot command](screenshot.md) diff --git a/docs/features/interactive-desktop-vnc.md b/docs/features/interactive-desktop-vnc.md index efff838..4189454 100644 --- a/docs/features/interactive-desktop-vnc.md +++ b/docs/features/interactive-desktop-vnc.md @@ -20,7 +20,6 @@ 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: @@ -100,19 +99,6 @@ 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: @@ -172,4 +158,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), [record command](../commands/record.md), [desktop command](../commands/desktop.md). +- [vnc command](../commands/vnc.md), [webvnc command](../commands/webvnc.md), [screenshot command](../commands/screenshot.md), [desktop command](../commands/desktop.md). diff --git a/docs/source-map.md b/docs/source-map.md index 2859767..7ff16e6 100644 --- a/docs/source-map.md +++ b/docs/source-map.md @@ -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 and recording commands: `internal/cli/screenshot.go`, `internal/cli/record.go` +- Desktop screenshot command: `internal/cli/screenshot.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: diff --git a/internal/cli/app.go b/internal/cli/app.go index dfc3225..b64ea0f 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -89,8 +89,6 @@ 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": @@ -153,7 +151,6 @@ 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 @@ -169,7 +166,6 @@ 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 diff --git a/internal/cli/bootstrap.go b/internal/cli/bootstrap.go index 6e27547..87a8a75 100644 --- a/internal/cli/bootstrap.go +++ b/internal/cli/bootstrap.go @@ -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 ffmpeg 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 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) diff --git a/internal/cli/bootstrap_test.go b/internal/cli/bootstrap_test.go index 19df827..939f9e3 100644 --- a/internal/cli/bootstrap_test.go +++ b/internal/cli/bootstrap_test.go @@ -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 ffmpeg", + "x11-xserver-utils xterm scrot", "/etc/systemd/system/crabbox-xvfb.service", "/etc/systemd/system/crabbox-desktop.service", "/usr/local/bin/crabbox-desktop-session", diff --git a/internal/cli/cli_kong.go b/internal/cli/cli_kong.go index af71305..de76317 100644 --- a/internal/cli/cli_kong.go +++ b/internal/cli/cli_kong.go @@ -37,7 +37,6 @@ 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."` @@ -174,9 +173,6 @@ type webvncKongCmd struct { type screenshotKongCmd struct { Args []string `arg:"" optional:""` } -type recordKongCmd struct { - Args []string `arg:"" optional:""` -} type inspectKongCmd struct { Args []string `arg:"" optional:""` } @@ -308,9 +304,6 @@ 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) } diff --git a/internal/cli/record.go b/internal/cli/record.go deleted file mode 100644 index c860c1d..0000000 --- a/internal/cli/record.go +++ /dev/null @@ -1,438 +0,0 @@ -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 --while -- ") - } - 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 [--duration 10s] [--output ]") - } - 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 -} -` -} diff --git a/internal/cli/record_test.go b/internal/cli/record_test.go deleted file mode 100644 index 7430438..0000000 --- a/internal/cli/record_test.go +++ /dev/null @@ -1,140 +0,0 @@ -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) - } -} diff --git a/internal/cli/version.go b/internal/cli/version.go index 5fbdd9b..f63d261 100644 --- a/internal/cli/version.go +++ b/internal/cli/version.go @@ -1,3 +1,3 @@ package cli -var version = "0.6.0" +var version = "0.5.1" diff --git a/package.json b/package.json index 83b2093..28e0006 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/crabbox-plugin", - "version": "0.6.0", + "version": "0.5.1", "description": "OpenClaw plugin for running Crabbox remote testbox workflows", "license": "MIT", "type": "module", diff --git a/worker/package-lock.json b/worker/package-lock.json index 7b4e9c3..13e7039 100644 --- a/worker/package-lock.json +++ b/worker/package-lock.json @@ -1,12 +1,12 @@ { "name": "@openclaw/crabbox-worker", - "version": "0.6.0", + "version": "0.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@openclaw/crabbox-worker", - "version": "0.6.0", + "version": "0.5.1", "dependencies": { "@novnc/novnc": "1.6.0", "aws4fetch": "^1.0.20", diff --git a/worker/package.json b/worker/package.json index 15fff16..8e8f71a 100644 --- a/worker/package.json +++ b/worker/package.json @@ -1,6 +1,6 @@ { "name": "@openclaw/crabbox-worker", - "version": "0.6.0", + "version": "0.5.1", "private": true, "type": "module", "scripts": { diff --git a/worker/src/bootstrap.ts b/worker/src/bootstrap.ts index cb70e40..d66a0db 100644 --- a/worker/src/bootstrap.ts +++ b/worker/src/bootstrap.ts @@ -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 ffmpeg 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 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) diff --git a/worker/test/bootstrap.test.ts b/worker/test/bootstrap.test.ts index 6a06cf5..b99beb0 100644 --- a/worker/test/bootstrap.test.ts +++ b/worker/test/bootstrap.test.ts @@ -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 ffmpeg"); + expect(got).toContain("x11-xserver-utils xterm scrot"); 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)");