Organize Agentbox runtime code
Co-authored-by: codegirl007 <codegirl-007@users.noreply.github.com>
This commit is contained in:
co-authored by
codegirl007
parent
f2fee5d26b
commit
4d2fb4a733
@@ -0,0 +1,175 @@
|
||||
package dockerx11
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"agentbox/internal/environment"
|
||||
)
|
||||
|
||||
const stagedApplicationPath = "/tmp/application"
|
||||
|
||||
func (environmentBackend *Environment) Launch(
|
||||
ctx context.Context,
|
||||
application environment.Command,
|
||||
) error {
|
||||
fmt.Fprintln(environmentBackend.config.Output, "Uploading build...")
|
||||
executable, err := os.Open(application.Path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open application: %w", err)
|
||||
}
|
||||
defer executable.Close()
|
||||
|
||||
// Stream instead of bind-mounting the developer's directory. The container
|
||||
// sees only the requested executable, and the copy disappears at teardown.
|
||||
stageCommand := exec.CommandContext(
|
||||
ctx,
|
||||
"docker",
|
||||
"exec",
|
||||
"-i",
|
||||
environmentBackend.containerName,
|
||||
"sh",
|
||||
"-c",
|
||||
"cat > "+stagedApplicationPath+" && chmod 0500 "+stagedApplicationPath,
|
||||
)
|
||||
stageCommand.Stdin = executable
|
||||
if output, err := stageCommand.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf(
|
||||
"stage application: %w: %s",
|
||||
err,
|
||||
strings.TrimSpace(string(output)),
|
||||
)
|
||||
}
|
||||
|
||||
fmt.Fprintln(environmentBackend.config.Output, "Launching application...")
|
||||
dockerArguments := []string{"exec", "-d"}
|
||||
for variableName, variableValue := range application.Env {
|
||||
dockerArguments = append(
|
||||
dockerArguments,
|
||||
"-e",
|
||||
variableName+"="+variableValue,
|
||||
)
|
||||
}
|
||||
dockerArguments = append(
|
||||
dockerArguments,
|
||||
environmentBackend.containerName,
|
||||
"sh",
|
||||
"-c",
|
||||
)
|
||||
shellCommand := []string{"exec", stagedApplicationPath}
|
||||
for _, applicationArgument := range application.Args {
|
||||
shellCommand = append(shellCommand, shellQuote(applicationArgument))
|
||||
}
|
||||
shellCommand = append(
|
||||
shellCommand,
|
||||
">/tmp/stdout.log",
|
||||
"2>/tmp/stderr.log",
|
||||
)
|
||||
dockerArguments = append(dockerArguments, strings.Join(shellCommand, " "))
|
||||
if _, err := environmentBackend.runDocker(ctx, dockerArguments...); err != nil {
|
||||
return fmt.Errorf("launch application: %w", err)
|
||||
}
|
||||
|
||||
return environmentBackend.focusApplicationWindow(ctx, application.WindowTitle)
|
||||
}
|
||||
|
||||
func (environmentBackend *Environment) focusApplicationWindow(
|
||||
ctx context.Context,
|
||||
windowTitle string,
|
||||
) error {
|
||||
if windowTitle == "" {
|
||||
// Applications without a title cannot be searched reliably. Give the
|
||||
// process a brief startup window before the first screenshot.
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
var windowID string
|
||||
if err := waitFor(ctx, 10*time.Second, func() bool {
|
||||
output, searchErr := environmentBackend.runDocker(
|
||||
ctx,
|
||||
"exec",
|
||||
environmentBackend.containerName,
|
||||
"xdotool",
|
||||
"search",
|
||||
"--name",
|
||||
windowTitle,
|
||||
)
|
||||
if searchErr != nil {
|
||||
return false
|
||||
}
|
||||
windowID = strings.TrimSpace(strings.Split(string(output), "\n")[0])
|
||||
return windowID != ""
|
||||
}); err != nil {
|
||||
return fmt.Errorf("wait for application window %q: %w", windowTitle, err)
|
||||
}
|
||||
if _, err := environmentBackend.runDocker(
|
||||
ctx,
|
||||
"exec",
|
||||
environmentBackend.containerName,
|
||||
"xdotool",
|
||||
"windowactivate",
|
||||
"--sync",
|
||||
windowID,
|
||||
); err != nil {
|
||||
return fmt.Errorf("focus application window: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (environmentBackend *Environment) Screenshot(ctx context.Context) ([]byte, error) {
|
||||
const screenshotPath = "/tmp/screenshot.png"
|
||||
if _, err := environmentBackend.runDocker(
|
||||
ctx,
|
||||
"exec",
|
||||
environmentBackend.containerName,
|
||||
"scrot",
|
||||
"-o",
|
||||
screenshotPath,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("capture screenshot: %w", err)
|
||||
}
|
||||
screenshot, err := environmentBackend.runDocker(
|
||||
ctx,
|
||||
"exec",
|
||||
environmentBackend.containerName,
|
||||
"cat",
|
||||
screenshotPath,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("extract screenshot: %w", err)
|
||||
}
|
||||
return screenshot, nil
|
||||
}
|
||||
|
||||
func (environmentBackend *Environment) Logs(
|
||||
ctx context.Context,
|
||||
) ([]environment.LogEntry, error) {
|
||||
var logEntries []environment.LogEntry
|
||||
for _, streamName := range []string{"stdout", "stderr"} {
|
||||
logContents, err := environmentBackend.runDocker(
|
||||
ctx,
|
||||
"exec",
|
||||
environmentBackend.containerName,
|
||||
"cat",
|
||||
"/tmp/"+streamName+".log",
|
||||
)
|
||||
if err != nil || len(logContents) == 0 {
|
||||
continue
|
||||
}
|
||||
logEntries = append(logEntries, environment.LogEntry{
|
||||
Stream: streamName,
|
||||
Message: string(logContents),
|
||||
Time: time.Now().UTC(),
|
||||
})
|
||||
}
|
||||
return logEntries, nil
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package dockerx11
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (environmentBackend *Environment) runDocker(
|
||||
ctx context.Context,
|
||||
arguments ...string,
|
||||
) ([]byte, error) {
|
||||
command := exec.CommandContext(ctx, "docker", arguments...)
|
||||
output, err := command.CombinedOutput()
|
||||
if err != nil {
|
||||
return output, fmt.Errorf(
|
||||
"docker %s: %w: %s",
|
||||
arguments[0],
|
||||
err,
|
||||
strings.TrimSpace(string(output)),
|
||||
)
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func (environmentBackend *Environment) streamDockerOutput(
|
||||
ctx context.Context,
|
||||
arguments ...string,
|
||||
) error {
|
||||
command := exec.CommandContext(ctx, "docker", arguments...)
|
||||
command.Stdout = environmentBackend.config.Output
|
||||
command.Stderr = environmentBackend.config.Output
|
||||
return command.Run()
|
||||
}
|
||||
|
||||
func waitFor(ctx context.Context, timeout time.Duration, ready func() bool) error {
|
||||
timeoutTimer := time.NewTimer(timeout)
|
||||
defer timeoutTimer.Stop()
|
||||
retryTicker := time.NewTicker(100 * time.Millisecond)
|
||||
defer retryTicker.Stop()
|
||||
|
||||
for {
|
||||
if ready() {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timeoutTimer.C:
|
||||
return errors.New("timed out")
|
||||
case <-retryTicker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func shellQuote(value string) string {
|
||||
return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'"
|
||||
}
|
||||
@@ -1,35 +1,30 @@
|
||||
package dockerx11
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"agentbox/internal/environment"
|
||||
)
|
||||
|
||||
const imageName = "agentbox-runtime:local"
|
||||
const runtimeImageName = "agentbox-runtime:local"
|
||||
|
||||
// Config contains host-side values needed to create one isolated desktop.
|
||||
type Config struct {
|
||||
ProjectRoot string
|
||||
RunID string
|
||||
Output io.Writer
|
||||
}
|
||||
|
||||
// Environment implements the backend-neutral environment contract with one
|
||||
// Docker container, one X11 display, and one application process.
|
||||
type Environment struct {
|
||||
config Config
|
||||
containerName string
|
||||
created bool
|
||||
stopped bool
|
||||
mu sync.Mutex
|
||||
|
||||
stopMutex sync.Mutex
|
||||
containerCreated bool
|
||||
containerStopped bool
|
||||
}
|
||||
|
||||
func New(config Config) *Environment {
|
||||
@@ -39,257 +34,4 @@ func New(config Config) *Environment {
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Environment) Start(ctx context.Context) error {
|
||||
if e.config.Output == nil {
|
||||
e.config.Output = io.Discard
|
||||
}
|
||||
if _, err := e.docker(ctx, "version", "--format", "{{.Server.Version}}"); err != nil {
|
||||
return fmt.Errorf("Docker is required and the daemon must be accessible: %w", err)
|
||||
}
|
||||
fmt.Fprintln(e.config.Output, "Creating environment...")
|
||||
if err := e.dockerStream(ctx, "build", "-q", "-t", imageName, "-f",
|
||||
filepath.Join(e.config.ProjectRoot, "environment", "Dockerfile"), e.config.ProjectRoot); err != nil {
|
||||
return fmt.Errorf("build environment image: %w", err)
|
||||
}
|
||||
_, err := e.docker(ctx,
|
||||
"create",
|
||||
"--name", e.containerName,
|
||||
"--init",
|
||||
"--network=none",
|
||||
"--read-only",
|
||||
"--tmpfs=/tmp:rw,exec,nosuid,nodev,size=128m",
|
||||
"--cap-drop=ALL",
|
||||
"--security-opt=no-new-privileges",
|
||||
"--pids-limit=128",
|
||||
"--memory=512m",
|
||||
"--cpus=1",
|
||||
imageName,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create environment: %w", err)
|
||||
}
|
||||
e.created = true
|
||||
if _, err := e.docker(ctx, "start", e.containerName); err != nil {
|
||||
return fmt.Errorf("start environment: %w", err)
|
||||
}
|
||||
if err := e.waitFor(ctx, 10*time.Second, func() bool {
|
||||
_, readyErr := e.docker(ctx, "exec", e.containerName, "test", "-f", "/tmp/agentbox-ready")
|
||||
return readyErr == nil
|
||||
}); err != nil {
|
||||
return fmt.Errorf("wait for graphical environment: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Environment) Launch(ctx context.Context, command environment.Command) error {
|
||||
fmt.Fprintln(e.config.Output, "Uploading build...")
|
||||
file, err := os.Open(command.Path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open application: %w", err)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
stage := exec.CommandContext(ctx, "docker", "exec", "-i", e.containerName, "sh", "-c",
|
||||
"cat > /tmp/application && chmod 0500 /tmp/application")
|
||||
stage.Stdin = file
|
||||
if output, err := stage.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("stage application: %w: %s", err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
|
||||
fmt.Fprintln(e.config.Output, "Launching application...")
|
||||
args := []string{"exec", "-d"}
|
||||
for key, value := range command.Env {
|
||||
args = append(args, "-e", key+"="+value)
|
||||
}
|
||||
args = append(args, e.containerName, "sh", "-c")
|
||||
parts := []string{"exec", "/tmp/application"}
|
||||
for _, arg := range command.Args {
|
||||
parts = append(parts, shellQuote(arg))
|
||||
}
|
||||
parts = append(parts, ">/tmp/stdout.log", "2>/tmp/stderr.log")
|
||||
args = append(args, strings.Join(parts, " "))
|
||||
if _, err := e.docker(ctx, args...); err != nil {
|
||||
return fmt.Errorf("launch application: %w", err)
|
||||
}
|
||||
|
||||
if command.WindowTitle != "" {
|
||||
var windowID string
|
||||
if err := e.waitFor(ctx, 10*time.Second, func() bool {
|
||||
output, searchErr := e.docker(ctx, "exec", e.containerName, "xdotool",
|
||||
"search", "--name", command.WindowTitle)
|
||||
if searchErr != nil {
|
||||
return false
|
||||
}
|
||||
windowID = strings.TrimSpace(strings.Split(string(output), "\n")[0])
|
||||
return windowID != ""
|
||||
}); err != nil {
|
||||
return fmt.Errorf("wait for application window %q: %w", command.WindowTitle, err)
|
||||
}
|
||||
if _, err := e.docker(ctx, "exec", e.containerName, "xdotool",
|
||||
"windowactivate", "--sync", windowID); err != nil {
|
||||
return fmt.Errorf("focus application window: %w", err)
|
||||
}
|
||||
} else {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Environment) Screenshot(ctx context.Context) ([]byte, error) {
|
||||
if _, err := e.docker(ctx, "exec", e.containerName, "scrot", "-o", "/tmp/screenshot.png"); err != nil {
|
||||
return nil, fmt.Errorf("capture screenshot: %w", err)
|
||||
}
|
||||
data, err := e.docker(ctx, "exec", e.containerName, "cat", "/tmp/screenshot.png")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("extract screenshot: %w", err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func (e *Environment) SendInput(ctx context.Context, action environment.InputAction) error {
|
||||
var args []string
|
||||
switch action.Type {
|
||||
case environment.KeyDown:
|
||||
if action.Key == "" {
|
||||
return errors.New("key_down requires key")
|
||||
}
|
||||
args = []string{"keydown", x11Key(action.Key)}
|
||||
case environment.KeyUp:
|
||||
if action.Key == "" {
|
||||
return errors.New("key_up requires key")
|
||||
}
|
||||
args = []string{"keyup", x11Key(action.Key)}
|
||||
case environment.MouseMove:
|
||||
args = []string{"mousemove", strconv.Itoa(action.X), strconv.Itoa(action.Y)}
|
||||
case environment.MouseDown:
|
||||
args = []string{"mousedown", strconv.Itoa(action.Button)}
|
||||
case environment.MouseUp:
|
||||
args = []string{"mouseup", strconv.Itoa(action.Button)}
|
||||
case environment.Wait:
|
||||
if action.DurationMS < 0 {
|
||||
return errors.New("wait duration cannot be negative")
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(time.Duration(action.DurationMS) * time.Millisecond):
|
||||
return nil
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported input action %q", action.Type)
|
||||
}
|
||||
_, err := e.docker(ctx, append([]string{"exec", e.containerName, "xdotool"}, args...)...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("send %s: %w", action.Type, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Environment) Logs(ctx context.Context) ([]environment.LogEntry, error) {
|
||||
var entries []environment.LogEntry
|
||||
for _, stream := range []string{"stdout", "stderr"} {
|
||||
data, err := e.docker(ctx, "exec", e.containerName, "cat", "/tmp/"+stream+".log")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if len(data) > 0 {
|
||||
entries = append(entries, environment.LogEntry{
|
||||
Stream: stream, Message: string(data), Time: time.Now().UTC(),
|
||||
})
|
||||
}
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func (e *Environment) Stop(ctx context.Context) error {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
if !e.created || e.stopped {
|
||||
return nil
|
||||
}
|
||||
e.stopped = true
|
||||
fmt.Fprintln(e.config.Output, "Shutting environment down...")
|
||||
_, stopErr := e.docker(ctx, "stop", "--time=3", e.containerName)
|
||||
_, removeErr := e.docker(ctx, "rm", "-f", e.containerName)
|
||||
if stopErr != nil {
|
||||
return fmt.Errorf("stop environment: %w", stopErr)
|
||||
}
|
||||
if removeErr != nil {
|
||||
return fmt.Errorf("remove environment: %w", removeErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Environment) waitFor(ctx context.Context, timeout time.Duration, check func() bool) error {
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
ticker := time.NewTicker(100 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
if check() {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
return errors.New("timed out")
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Environment) docker(ctx context.Context, args ...string) ([]byte, error) {
|
||||
command := exec.CommandContext(ctx, "docker", args...)
|
||||
output, err := command.CombinedOutput()
|
||||
if err != nil {
|
||||
return output, fmt.Errorf("docker %s: %w: %s", args[0], err, strings.TrimSpace(string(output)))
|
||||
}
|
||||
return output, nil
|
||||
}
|
||||
|
||||
func (e *Environment) dockerStream(ctx context.Context, args ...string) error {
|
||||
command := exec.CommandContext(ctx, "docker", args...)
|
||||
command.Stdout = e.config.Output
|
||||
command.Stderr = e.config.Output
|
||||
return command.Run()
|
||||
}
|
||||
|
||||
func shellQuote(value string) string {
|
||||
return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'"
|
||||
}
|
||||
|
||||
func x11Key(key string) string {
|
||||
switch strings.ToUpper(key) {
|
||||
case "LEFT":
|
||||
return "Left"
|
||||
case "RIGHT":
|
||||
return "Right"
|
||||
case "UP":
|
||||
return "Up"
|
||||
case "DOWN":
|
||||
return "Down"
|
||||
case "ENTER", "RETURN":
|
||||
return "Return"
|
||||
case "ESC", "ESCAPE":
|
||||
return "Escape"
|
||||
case "SPACE":
|
||||
return "space"
|
||||
case "TAB":
|
||||
return "Tab"
|
||||
case "BACKSPACE":
|
||||
return "BackSpace"
|
||||
case "DELETE":
|
||||
return "Delete"
|
||||
}
|
||||
if len(key) == 1 {
|
||||
return strings.ToLower(key)
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
var _ environment.Environment = (*Environment)(nil)
|
||||
|
||||
@@ -11,8 +11,8 @@ func TestX11KeyTranslatesLogicalNames(t *testing.T) {
|
||||
"A": "a",
|
||||
}
|
||||
for input, want := range tests {
|
||||
if got := x11Key(input); got != want {
|
||||
t.Errorf("x11Key(%q) = %q, want %q", input, got, want)
|
||||
if got := toX11KeyName(input); got != want {
|
||||
t.Errorf("toX11KeyName(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
package dockerx11
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"agentbox/internal/environment"
|
||||
)
|
||||
|
||||
func (environmentBackend *Environment) SendInput(
|
||||
ctx context.Context,
|
||||
action environment.InputAction,
|
||||
) error {
|
||||
var xdotoolArguments []string
|
||||
switch action.Type {
|
||||
case environment.KeyDown:
|
||||
if action.Key == "" {
|
||||
return errors.New("key_down requires key")
|
||||
}
|
||||
xdotoolArguments = []string{"keydown", toX11KeyName(action.Key)}
|
||||
case environment.KeyUp:
|
||||
if action.Key == "" {
|
||||
return errors.New("key_up requires key")
|
||||
}
|
||||
xdotoolArguments = []string{"keyup", toX11KeyName(action.Key)}
|
||||
case environment.MouseMove:
|
||||
xdotoolArguments = []string{
|
||||
"mousemove",
|
||||
strconv.Itoa(action.X),
|
||||
strconv.Itoa(action.Y),
|
||||
}
|
||||
case environment.MouseDown:
|
||||
xdotoolArguments = []string{"mousedown", strconv.Itoa(action.Button)}
|
||||
case environment.MouseUp:
|
||||
xdotoolArguments = []string{"mouseup", strconv.Itoa(action.Button)}
|
||||
case environment.Wait:
|
||||
if action.DurationMS < 0 {
|
||||
return errors.New("wait duration cannot be negative")
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-time.After(time.Duration(action.DurationMS) * time.Millisecond):
|
||||
return nil
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unsupported input action %q", action.Type)
|
||||
}
|
||||
|
||||
dockerArguments := []string{
|
||||
"exec",
|
||||
environmentBackend.containerName,
|
||||
"xdotool",
|
||||
}
|
||||
dockerArguments = append(dockerArguments, xdotoolArguments...)
|
||||
if _, err := environmentBackend.runDocker(ctx, dockerArguments...); err != nil {
|
||||
return fmt.Errorf("send %s: %w", action.Type, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// toX11KeyName keeps X11 spellings out of the public action API. Agents can use
|
||||
// logical names such as RIGHT even though xdotool expects Right.
|
||||
func toX11KeyName(logicalName string) string {
|
||||
switch strings.ToUpper(logicalName) {
|
||||
case "LEFT":
|
||||
return "Left"
|
||||
case "RIGHT":
|
||||
return "Right"
|
||||
case "UP":
|
||||
return "Up"
|
||||
case "DOWN":
|
||||
return "Down"
|
||||
case "ENTER", "RETURN":
|
||||
return "Return"
|
||||
case "ESC", "ESCAPE":
|
||||
return "Escape"
|
||||
case "SPACE":
|
||||
return "space"
|
||||
case "TAB":
|
||||
return "Tab"
|
||||
case "BACKSPACE":
|
||||
return "BackSpace"
|
||||
case "DELETE":
|
||||
return "Delete"
|
||||
}
|
||||
if len(logicalName) == 1 {
|
||||
return strings.ToLower(logicalName)
|
||||
}
|
||||
return logicalName
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package dockerx11
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (environmentBackend *Environment) Start(ctx context.Context) error {
|
||||
if environmentBackend.config.Output == nil {
|
||||
environmentBackend.config.Output = io.Discard
|
||||
}
|
||||
if _, err := environmentBackend.runDocker(
|
||||
ctx,
|
||||
"version",
|
||||
"--format",
|
||||
"{{.Server.Version}}",
|
||||
); err != nil {
|
||||
return fmt.Errorf("Docker is required and the daemon must be accessible: %w", err)
|
||||
}
|
||||
|
||||
fmt.Fprintln(environmentBackend.config.Output, "Creating environment...")
|
||||
dockerfilePath := filepath.Join(
|
||||
environmentBackend.config.ProjectRoot,
|
||||
"environment",
|
||||
"Dockerfile",
|
||||
)
|
||||
if err := environmentBackend.streamDockerOutput(
|
||||
ctx,
|
||||
"build",
|
||||
"-q",
|
||||
"-t",
|
||||
runtimeImageName,
|
||||
"-f",
|
||||
dockerfilePath,
|
||||
environmentBackend.config.ProjectRoot,
|
||||
); err != nil {
|
||||
return fmt.Errorf("build environment image: %w", err)
|
||||
}
|
||||
|
||||
// These restrictions reduce accidental damage. They are defense in depth,
|
||||
// not a safe boundary for hostile customer code; see docs/architecture.md.
|
||||
_, err := environmentBackend.runDocker(
|
||||
ctx,
|
||||
"create",
|
||||
"--name", environmentBackend.containerName,
|
||||
"--init",
|
||||
"--network=none",
|
||||
"--read-only",
|
||||
"--tmpfs=/tmp:rw,exec,nosuid,nodev,size=128m",
|
||||
"--cap-drop=ALL",
|
||||
"--security-opt=no-new-privileges",
|
||||
"--pids-limit=128",
|
||||
"--memory=512m",
|
||||
"--cpus=1",
|
||||
runtimeImageName,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create environment: %w", err)
|
||||
}
|
||||
environmentBackend.containerCreated = true
|
||||
|
||||
if _, err := environmentBackend.runDocker(
|
||||
ctx,
|
||||
"start",
|
||||
environmentBackend.containerName,
|
||||
); err != nil {
|
||||
return fmt.Errorf("start environment: %w", err)
|
||||
}
|
||||
if err := waitFor(ctx, 10*time.Second, func() bool {
|
||||
_, readyErr := environmentBackend.runDocker(
|
||||
ctx,
|
||||
"exec",
|
||||
environmentBackend.containerName,
|
||||
"test",
|
||||
"-f",
|
||||
"/tmp/agentbox-ready",
|
||||
)
|
||||
return readyErr == nil
|
||||
}); err != nil {
|
||||
return fmt.Errorf("wait for graphical environment: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop is idempotent because both normal completion and deferred cleanup may
|
||||
// try to tear down the same environment.
|
||||
func (environmentBackend *Environment) Stop(ctx context.Context) error {
|
||||
environmentBackend.stopMutex.Lock()
|
||||
defer environmentBackend.stopMutex.Unlock()
|
||||
|
||||
if !environmentBackend.containerCreated || environmentBackend.containerStopped {
|
||||
return nil
|
||||
}
|
||||
environmentBackend.containerStopped = true
|
||||
fmt.Fprintln(environmentBackend.config.Output, "Shutting environment down...")
|
||||
|
||||
_, stopErr := environmentBackend.runDocker(
|
||||
ctx,
|
||||
"stop",
|
||||
"--time=3",
|
||||
environmentBackend.containerName,
|
||||
)
|
||||
_, removeErr := environmentBackend.runDocker(
|
||||
ctx,
|
||||
"rm",
|
||||
"-f",
|
||||
environmentBackend.containerName,
|
||||
)
|
||||
if stopErr != nil {
|
||||
return fmt.Errorf("stop environment: %w", stopErr)
|
||||
}
|
||||
if removeErr != nil {
|
||||
return fmt.Errorf("remove environment: %w", removeErr)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user