Class: AgentSandbox::Backends::Docker

Inherits:
Object
  • Object
show all
Defined in:
lib/agent_sandbox/backends/docker.rb

Constant Summary collapse

HARDENED_DEFAULTS =
{
  user: "nobody",
  memory: "512m",
  pids_limit: 256,
  cpus: "1.0",
  # Agents often need internet (package installs, API calls). Pass
  # `network: :none` to block all egress.
  network: "bridge",
  read_only: true,
  drop_caps: true,
  no_new_privileges: true
}.freeze
SUPPORTED =
%i[exec spawn write_file read_file port_url].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(image: "ruby:3.3-slim", ports: [], workdir: "/workspace", name: nil, hardened: true, user: nil, memory: nil, pids_limit: nil, cpus: nil, network: nil, read_only: nil, drop_caps: nil, no_new_privileges: nil, tmpfs_size: "256m", port_bind: "127.0.0.1") ⇒ Docker

Returns a new instance of Docker.



26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
# File 'lib/agent_sandbox/backends/docker.rb', line 26

def initialize(
  image: "ruby:3.3-slim", ports: [], workdir: "/workspace", name: nil,
  hardened: true,
  user: nil, memory: nil, pids_limit: nil, cpus: nil,
  network: nil, read_only: nil, drop_caps: nil, no_new_privileges: nil,
  tmpfs_size: "256m",
  # Publish sandbox ports only on loopback by default. Pass "0.0.0.0"
  # to expose on all host interfaces (LAN-reachable) — opt-in only.
  port_bind: "127.0.0.1"
)
  @image = image
  @ports = Array(ports)
  @workdir = workdir
  @name = name || "agent-sandbox-#{SecureRandom.hex(4)}"
  @port_map = {}
  @port_bind = port_bind
  @tmpfs_size = tmpfs_size

  defaults = hardened ? HARDENED_DEFAULTS : {}
  @user = user || defaults[:user]
  @memory = memory || defaults[:memory]
  @pids_limit = pids_limit || defaults[:pids_limit]
  @cpus = cpus || defaults[:cpus]
  @network = (network || defaults[:network])&.to_s
  @read_only = pick(read_only, defaults[:read_only])
  @drop_caps = pick(drop_caps, defaults[:drop_caps])
  @no_new_privileges = pick(no_new_privileges, defaults[:no_new_privileges])
end

Instance Attribute Details

#imageObject (readonly)

Returns the value of attribute image.



21
22
23
# File 'lib/agent_sandbox/backends/docker.rb', line 21

def image
  @image
end

#nameObject (readonly)

Returns the value of attribute name.



21
22
23
# File 'lib/agent_sandbox/backends/docker.rb', line 21

def name
  @name
end

#port_mapObject (readonly)

Returns the value of attribute port_map.



21
22
23
# File 'lib/agent_sandbox/backends/docker.rb', line 21

def port_map
  @port_map
end

#portsObject (readonly)

Returns the value of attribute ports.



21
22
23
# File 'lib/agent_sandbox/backends/docker.rb', line 21

def ports
  @ports
end

Instance Method Details

#exec(command) ⇒ Object



88
89
90
91
# File 'lib/agent_sandbox/backends/docker.rb', line 88

def exec(command)
  stdout, stderr, status = Open3.capture3("docker", "exec", @name, "sh", "-lc", command)
  ExecResult.new(stdout: stdout, stderr: stderr, status: status.exitstatus)
end

#port_url(port) ⇒ Object

Raises:



111
112
113
114
115
116
117
118
119
# File 'lib/agent_sandbox/backends/docker.rb', line 111

def port_url(port)
  raise Error, "sandbox not started — call start (or use `sandbox.open { ... }`) before port_url" unless @started
  unless @ports.include?(port)
    raise Error, "port #{port} was not declared at init (pass ports: [#{port}])"
  end
  binding = @port_map[port] or raise Error, "port #{port} not yet mapped by docker"
  host = binding[:family] == :ipv6 ? "[#{binding[:host]}]" : binding[:host]
  "http://#{host}:#{binding[:port]}"
end

#read_file(path) ⇒ Object

Raises:



105
106
107
108
109
# File 'lib/agent_sandbox/backends/docker.rb', line 105

def read_file(path)
  stdout, stderr, status = Open3.capture3("docker", "exec", @name, "cat", path)
  raise Error, "read_file failed: #{stderr.strip}" unless status.success?
  stdout
end

#spawn(command) ⇒ Object



93
94
95
# File 'lib/agent_sandbox/backends/docker.rb', line 93

def spawn(command)
  system("docker", "exec", "-d", @name, "sh", "-lc", command, out: File::NULL, err: File::NULL) or raise Error, "spawn failed"
end

#startObject



55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
# File 'lib/agent_sandbox/backends/docker.rb', line 55

def start
  cmd = ["docker", "run", "-d", "--name", @name, "-w", @workdir]
  cmd += ["--user", @user] if @user
  cmd += ["--memory", @memory] if @memory
  cmd += ["--pids-limit", @pids_limit.to_s] if @pids_limit
  cmd += ["--cpus", @cpus.to_s] if @cpus
  cmd += ["--cap-drop", "ALL"] if @drop_caps
  cmd += ["--security-opt", "no-new-privileges"] if @no_new_privileges
  cmd += ["--network", @network] if @network
  if @read_only
    cmd += ["--read-only"]
    cmd += ["--tmpfs", "#{@workdir}:rw,mode=1777,size=#{@tmpfs_size}"]
    cmd += ["--tmpfs", "/tmp:rw,mode=1777,size=64m"]
  end
  cmd += @ports.flat_map { |p| ["-p", "#{@port_bind}:0:#{p}"] }
  cmd += [@image, "sh", "-c", "sleep infinity"]
  run!(cmd)
  @started = true
  begin
    resolve_port_map if @ports.any?
  rescue => e
    # Roll back the container so a partial failure doesn't leak.
    # If the rollback itself fails, surface both — a leaked container
    # is something the caller needs to know about.
    begin
      stop
    rescue => cleanup
      raise Error, "#{e.class}: #{e.message} (and cleanup failed: #{cleanup.message})"
    end
    raise
  end
end

#stopObject

Raises:



121
122
123
124
125
126
127
128
129
130
131
132
# File 'lib/agent_sandbox/backends/docker.rb', line 121

def stop
  # Always clear lifecycle state so stale mappings can't outlive the
  # container (port_url must raise after stop, even if rm fails).
  @started = false
  @port_map = {}
  out, err, status = Open3.capture3("docker", "rm", "-f", @name)
  return if status.success?
  # "No such container" = already gone (double-stop, or a retry after a
  # previous rm that actually succeeded). Treat as idempotent success.
  return if err.include?("No such container")
  raise Error, "docker rm -f #{@name} failed: #{(err + out).strip}"
end

#supports?(capability) ⇒ Boolean

Returns:

  • (Boolean)


24
# File 'lib/agent_sandbox/backends/docker.rb', line 24

def supports?(capability) = SUPPORTED.include?(capability)

#write_file(path, content) ⇒ Object



97
98
99
100
101
102
103
# File 'lib/agent_sandbox/backends/docker.rb', line 97

def write_file(path, content)
  Open3.popen3("docker", "exec", "-i", @name, "sh", "-c", "mkdir -p \"$(dirname #{Shellwords.escape(path)})\" && cat > #{Shellwords.escape(path)}") do |stdin, _out, _err, wait|
    stdin.write(content)
    stdin.close
    raise Error, "write_file failed" unless wait.value.success?
  end
end