Class: AgentSandbox::Backends::Docker
- Inherits:
-
Object
- Object
- AgentSandbox::Backends::Docker
- 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
-
#image ⇒ Object
readonly
Returns the value of attribute image.
-
#name ⇒ Object
readonly
Returns the value of attribute name.
-
#port_map ⇒ Object
readonly
Returns the value of attribute port_map.
-
#ports ⇒ Object
readonly
Returns the value of attribute ports.
Instance Method Summary collapse
- #exec(command) ⇒ Object
-
#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
constructor
A new instance of Docker.
- #port_url(port) ⇒ Object
- #read_file(path) ⇒ Object
- #spawn(command) ⇒ Object
- #start ⇒ Object
- #stop ⇒ Object
- #supports?(capability) ⇒ Boolean
- #write_file(path, content) ⇒ Object
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
#image ⇒ Object (readonly)
Returns the value of attribute image.
21 22 23 |
# File 'lib/agent_sandbox/backends/docker.rb', line 21 def image @image end |
#name ⇒ Object (readonly)
Returns the value of attribute name.
21 22 23 |
# File 'lib/agent_sandbox/backends/docker.rb', line 21 def name @name end |
#port_map ⇒ Object (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 |
#ports ⇒ Object (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
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
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 |
#start ⇒ Object
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.} (and cleanup failed: #{cleanup.})" end raise end end |
#stop ⇒ Object
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
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 |