From 2e0e0217aac54437dad2093b428c6eb504fc58cf Mon Sep 17 00:00:00 2001 From: Jan Ullrich Date: Sat, 7 Feb 2026 17:16:07 +0100 Subject: [PATCH] Add ruby sandbox command --- Rakefile | 22 + ruby/bin/sandbox | 6 + ruby/lib/sandbox.rb | 33 ++ ruby/lib/sandbox/backend.rb | 21 + ruby/lib/sandbox/backend_selector.rb | 26 + ruby/lib/sandbox/backends/container.rb | 61 +++ ruby/lib/sandbox/backends/hcloud.rb | 80 +++ ruby/lib/sandbox/backends/kvm.rb | 65 +++ ruby/lib/sandbox/backends/proxy.rb | 62 +++ ruby/lib/sandbox/bash_runner.rb | 104 ++++ ruby/lib/sandbox/cli.rb | 463 ++++++++++++++++++ ruby/lib/sandbox/command_runner.rb | 68 +++ ruby/lib/sandbox/connection_info.rb | 9 + ruby/lib/sandbox/name.rb | 13 + .../lib/sandbox/spec/backend_selector_spec.rb | 52 ++ ruby/lib/sandbox/spec/name_spec.rb | 17 + ruby/lib/sandbox/spec/proxy_spec.rb | 43 ++ ruby/lib/sandbox/spec/spec_helper.rb | 6 + ruby/lib/sandbox/spec/spinner_spec.rb | 43 ++ ruby/lib/sandbox/spinner.rb | 71 +++ ruby/lib/sandbox/ssh_config.rb | 24 + 21 files changed, 1289 insertions(+) create mode 100644 ruby/bin/sandbox create mode 100644 ruby/lib/sandbox.rb create mode 100644 ruby/lib/sandbox/backend.rb create mode 100644 ruby/lib/sandbox/backend_selector.rb create mode 100644 ruby/lib/sandbox/backends/container.rb create mode 100644 ruby/lib/sandbox/backends/hcloud.rb create mode 100644 ruby/lib/sandbox/backends/kvm.rb create mode 100644 ruby/lib/sandbox/backends/proxy.rb create mode 100644 ruby/lib/sandbox/bash_runner.rb create mode 100644 ruby/lib/sandbox/cli.rb create mode 100644 ruby/lib/sandbox/command_runner.rb create mode 100644 ruby/lib/sandbox/connection_info.rb create mode 100644 ruby/lib/sandbox/name.rb create mode 100644 ruby/lib/sandbox/spec/backend_selector_spec.rb create mode 100644 ruby/lib/sandbox/spec/name_spec.rb create mode 100644 ruby/lib/sandbox/spec/proxy_spec.rb create mode 100644 ruby/lib/sandbox/spec/spec_helper.rb create mode 100644 ruby/lib/sandbox/spec/spinner_spec.rb create mode 100644 ruby/lib/sandbox/spinner.rb create mode 100644 ruby/lib/sandbox/ssh_config.rb diff --git a/Rakefile b/Rakefile index d947677..70ab99d 100644 --- a/Rakefile +++ b/Rakefile @@ -308,6 +308,10 @@ task :cfg => :bash do sh 'install -m 755 ruby/bin/cfg "$HOME/bin/cfg"' end +task :sandbox => :cfg do + sh 'install -m 755 ruby/bin/sandbox "$HOME/bin/sandbox"' +end + task :test_cfg do begin require 'rspec/core/rake_task' @@ -325,3 +329,21 @@ task :test_cfg do end Rake::Task[:_run_cfg_specs].invoke end + +task :test_sandbox do + begin + require 'rspec/core/rake_task' + rescue LoadError + puts "Warning: rspec not installed, skipping sandbox tests" + next + end + test_files = Dir.glob("ruby/lib/sandbox/spec/*_spec.rb") + if test_files.empty? + puts "No sandbox spec files found" + next + end + RSpec::Core::RakeTask.new(:_run_sandbox_specs) do |t| + t.pattern = test_files + end + Rake::Task[:_run_sandbox_specs].invoke +end diff --git a/ruby/bin/sandbox b/ruby/bin/sandbox new file mode 100644 index 0000000..909d6ad --- /dev/null +++ b/ruby/bin/sandbox @@ -0,0 +1,6 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +$LOAD_PATH.unshift("#{Dir.home}/.local/lib/dotfiles") +require 'sandbox' +Sandbox::CLI.run(ARGV) diff --git a/ruby/lib/sandbox.rb b/ruby/lib/sandbox.rb new file mode 100644 index 0000000..5b24d04 --- /dev/null +++ b/ruby/lib/sandbox.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Sandbox + class Error < StandardError; end + class BackendError < Error; end + class BackendConflictError < Error; end + class BackendNotRunningError < Error; end + class MissingBackendError < Error; end + class CommandError < Error + attr_reader :status, :stdout, :stderr + + def initialize(message, status:, stdout: nil, stderr: nil) + super(message) + @status = status + @stdout = stdout + @stderr = stderr + end + end +end + +require_relative 'sandbox/name' +require_relative 'sandbox/connection_info' +require_relative 'sandbox/spinner' +require_relative 'sandbox/command_runner' +require_relative 'sandbox/bash_runner' +require_relative 'sandbox/backend' +require_relative 'sandbox/ssh_config' +require_relative 'sandbox/backend_selector' +require_relative 'sandbox/backends/container' +require_relative 'sandbox/backends/kvm' +require_relative 'sandbox/backends/hcloud' +require_relative 'sandbox/backends/proxy' +require_relative 'sandbox/cli' diff --git a/ruby/lib/sandbox/backend.rb b/ruby/lib/sandbox/backend.rb new file mode 100644 index 0000000..ed6e886 --- /dev/null +++ b/ruby/lib/sandbox/backend.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Sandbox + module Backend + REQUIRED_METHODS = %i[ + backend_start + backend_stop + backend_enter + backend_is_running + backend_get_ssh_port + backend_get_ip + ].freeze + + def self.ensure!(backend) + missing = REQUIRED_METHODS.reject { |method| backend.respond_to?(method) } + return if missing.empty? + + raise Sandbox::BackendError, "Backend #{backend.class} missing methods: #{missing.join(', ')}" + end + end +end diff --git a/ruby/lib/sandbox/backend_selector.rb b/ruby/lib/sandbox/backend_selector.rb new file mode 100644 index 0000000..272d3d0 --- /dev/null +++ b/ruby/lib/sandbox/backend_selector.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Sandbox + class BackendSelector + def initialize(backends:, sandbox_name:) + @backends = backends + @sandbox_name = sandbox_name + end + + def detect_running_backend + running = @backends.select { |_name, backend| backend.backend_is_running(@sandbox_name) } + return nil if running.empty? + if running.size > 1 + raise Sandbox::BackendConflictError, 'ERROR: Multiple backends running' + end + + running.keys.first + end + + def select_backend(explicit_backend) + return explicit_backend if explicit_backend + + detect_running_backend || 'container' + end + end +end diff --git a/ruby/lib/sandbox/backends/container.rb b/ruby/lib/sandbox/backends/container.rb new file mode 100644 index 0000000..ea6f4ba --- /dev/null +++ b/ruby/lib/sandbox/backends/container.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module Sandbox + module Backends + class Container + attr_writer :proxy_enabled + + def initialize(bash_runner:, command_runner:, proxy: false) + @bash_runner = bash_runner + @command_runner = command_runner + @proxy = proxy + end + + def backend_start(name, use_pty: false) + @bash_runner.call('start_container_sandbox', name, env: proxy_env, use_pty: use_pty) + end + + def backend_stop(name) + @bash_runner.call('stop_container_sandbox', name, env: proxy_env) + end + + def backend_enter(name) + engine = @bash_runner.call('detect_container_engine', capture: true).strip + raise Sandbox::BackendError, 'Error: Neither podman nor docker found' if engine.empty? + + cmd = [engine, 'exec', '-it', '-e', "TERM=#{ENV.fetch('TERM', 'xterm-256color')}", '-u', 'dev', + '-w', '/home/dev/workspace', name, 'zsh'] + @command_runner.exec(cmd) + end + + def backend_is_running(name) + @bash_runner.call('is_container_running', name) + true + rescue Sandbox::CommandError + false + end + + def backend_get_ssh_port(name) + @bash_runner.call('get_container_ssh_port', name, capture: true).strip + end + + def backend_get_ip(_name) + 'localhost' + end + + def list + @bash_runner.call('list_container_sandboxes') + end + + def backend_stop_all + @bash_runner.call('stop_all_container_sandboxes') + end + + private + + def proxy_env + (@proxy || @proxy_enabled) ? { 'PROXY' => 'true' } : {} + end + end + end +end diff --git a/ruby/lib/sandbox/backends/hcloud.rb b/ruby/lib/sandbox/backends/hcloud.rb new file mode 100644 index 0000000..f8d80c5 --- /dev/null +++ b/ruby/lib/sandbox/backends/hcloud.rb @@ -0,0 +1,80 @@ +# frozen_string_literal: true + +module Sandbox + module Backends + class Hcloud + def initialize(bash_runner:, command_runner:, sync: false) + @bash_runner = bash_runner + @command_runner = command_runner + @sync = sync + end + + def backend_start(name, use_pty: false) + env = {} + env['SYNC'] = 'true' if @sync + @bash_runner.call('start_hcloud_sandbox', name, env: env, use_pty: use_pty) + end + + def backend_stop(name) + @bash_runner.call('stop_hcloud_sandbox', name) + end + + def backend_enter(name) + if ssh_alias_setup?(name) + @command_runner.exec(['ssh', name]) + else + ip = server_ip(name) + raise Sandbox::BackendError, 'Error: Could not determine server IP' if ip.nil? || ip.empty? + + cmd = ['ssh', '-o', 'UserKnownHostsFile=/dev/null', '-o', 'StrictHostKeyChecking=no', "dev@#{ip}"] + @command_runner.exec(cmd) + end + end + + def backend_is_running(name) + @bash_runner.call('is_hcloud_running', name) + true + rescue Sandbox::CommandError + false + end + + def backend_get_ssh_port(name) + @bash_runner.call('get_hcloud_ssh_port', name, capture: true).strip + end + + def backend_get_ip(name) + @bash_runner.call('get_hcloud_ip', name, capture: true).strip + end + + def list + @bash_runner.call('list_hcloud_sandboxes') + end + + def backend_stop_all + @bash_runner.call('stop_all_hcloud_sandboxes') + end + + def state_dir(name) + @bash_runner.call('hcloud_state_dir', name, capture: true).strip + end + + private + + def ssh_alias_setup?(name) + @bash_runner.call('is_ssh_alias_setup', name) + true + rescue Sandbox::CommandError + false + end + + def server_ip(name) + ip = backend_get_ip(name) + return ip unless ip.empty? + + state_path = state_dir(name) + ip_file = File.join(state_path, 'server.ip') + File.exist?(ip_file) ? File.read(ip_file).strip : '' + end + end + end +end diff --git a/ruby/lib/sandbox/backends/kvm.rb b/ruby/lib/sandbox/backends/kvm.rb new file mode 100644 index 0000000..0c310a6 --- /dev/null +++ b/ruby/lib/sandbox/backends/kvm.rb @@ -0,0 +1,65 @@ +# frozen_string_literal: true + +module Sandbox + module Backends + class Kvm + attr_writer :proxy_enabled + + def initialize(bash_runner:, command_runner:, proxy: false) + @bash_runner = bash_runner + @command_runner = command_runner + @proxy = proxy + end + + def backend_start(name, use_pty: false) + @bash_runner.call('start_kvm_sandbox', name, env: proxy_env, use_pty: use_pty) + end + + def backend_stop(name) + @bash_runner.call('stop_kvm_sandbox', name, env: proxy_env) + end + + def backend_enter(name) + port = backend_get_ssh_port(name) + raise Sandbox::BackendError, 'Error: Could not determine SSH port' if port.empty? + + cmd = ['ssh', '-o', 'UserKnownHostsFile=/dev/null', '-o', 'StrictHostKeyChecking=no', + '-p', port, 'dev@localhost'] + @command_runner.exec(cmd) + end + + def backend_is_running(name) + @bash_runner.call('is_kvm_running', name) + true + rescue Sandbox::CommandError + false + end + + def backend_get_ssh_port(name) + @bash_runner.call('get_kvm_ssh_port', name, capture: true).strip + end + + def backend_get_ip(name) + @bash_runner.call('get_kvm_ip', name, capture: true).strip + end + + def list + @bash_runner.call('list_kvm_sandboxes') + end + + def backend_stop_all + @bash_runner.call('stop_all_kvm_sandboxes') + end + + def console_socket(name) + @bash_runner.call('kvm_console_socket', name, capture: true).strip + end + + private + + def proxy_env + (@proxy || @proxy_enabled) ? { 'PROXY' => 'true' } : {} + end + end + end +end diff --git a/ruby/lib/sandbox/backends/proxy.rb b/ruby/lib/sandbox/backends/proxy.rb new file mode 100644 index 0000000..347d58a --- /dev/null +++ b/ruby/lib/sandbox/backends/proxy.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +module Sandbox + module Backends + class Proxy + attr_reader :backend + + def initialize(backend) + @backend = backend + end + + def backend_start(name, use_pty: false) + with_proxy { @backend.backend_start(name, use_pty: use_pty) } + end + + def backend_stop(name) + with_proxy { @backend.backend_stop(name) } + end + + def backend_enter(name) + @backend.backend_enter(name) + end + + def backend_is_running(name) + @backend.backend_is_running(name) + end + + def backend_get_ssh_port(name) + @backend.backend_get_ssh_port(name) + end + + def backend_get_ip(name) + @backend.backend_get_ip(name) + end + + def list + @backend.list + end + + def console_socket(name) + return @backend.console_socket(name) if @backend.respond_to?(:console_socket) + + nil + end + + private + + def with_proxy + if @backend.respond_to?(:proxy_enabled=) + @backend.proxy_enabled = true + yield + else + yield + end + ensure + if @backend.respond_to?(:proxy_enabled=) + @backend.proxy_enabled = false + end + end + end + end +end diff --git a/ruby/lib/sandbox/bash_runner.rb b/ruby/lib/sandbox/bash_runner.rb new file mode 100644 index 0000000..294f6c8 --- /dev/null +++ b/ruby/lib/sandbox/bash_runner.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require 'shellwords' + +module Sandbox + class BashRunner + def initialize(command_runner:, lib_root: nil) + @command_runner = command_runner + @lib_root = lib_root || detect_lib_root + end + + def call(function_name, *args, env: {}, use_pty: false, capture: false) + script = build_script(function_name) + command = ['bash', '-lc', script, '--', *args] + if capture + @command_runner.capture(command, env: env) + else + @command_runner.run(command, env: env, use_pty: use_pty) + end + end + + def lib_root + @lib_root + end + + private + + def detect_lib_root + env_path = ENV['SANDBOX_BASH_LIB_DIR'] + if env_path && File.exist?(File.join(env_path, 'common')) + return env_path + end + + home_path = File.join(Dir.home, '.config/lib/bash/sandbox') + return home_path if File.exist?(File.join(home_path, 'common')) + + repo_path = File.expand_path('../../../bash/lib/sandbox', __dir__) + return repo_path if File.exist?(File.join(repo_path, 'common')) + + raise Sandbox::Error, 'Unable to locate bash sandbox libraries' + end + + def build_script(function_name) + sources = sources_for(function_name).map { |file| source_line(file) }.join(\"\\n\") + + <<~BASH + set -euo pipefail + #{sandbox_helpers} + #{sources} + #{function_name} "$@" + BASH + end + + def sources_for(function_name) + base = %w[ + common + container-backend + kvm-backend + hcloud-backend + proxy-backend + proxy-cli + ] + + if %w[ensure_know_agent bootstrap_ai].include?(function_name) + base + ['ai-bootstrap'] + else + base + end + end + + def source_line(file) + path = File.join(@lib_root, file) + "source #{Shellwords.shellescape(path)}" + end + + def sandbox_helpers + <<~BASH + sandbox_name() { + local name + name="sandbox-$(basename "${PWD}")" + echo "${name//[^a-zA-Z0-9_-]/_}" + } + + backend_get_ssh_port() { + local name="$1" + case "${BACKEND:-}" in + container) get_container_ssh_port "$name" ;; + kvm) get_kvm_ssh_port "$name" ;; + hcloud) get_hcloud_ssh_port "$name" ;; + esac + } + + backend_get_ip() { + local name="$1" + case "${BACKEND:-}" in + container) get_container_ip "$name" ;; + kvm) get_kvm_ip "$name" ;; + hcloud) get_hcloud_ip "$name" ;; + esac + } + BASH + end + end +end diff --git a/ruby/lib/sandbox/cli.rb b/ruby/lib/sandbox/cli.rb new file mode 100644 index 0000000..1090b7f --- /dev/null +++ b/ruby/lib/sandbox/cli.rb @@ -0,0 +1,463 @@ +# frozen_string_literal: true + +module Sandbox + class CLI + def self.run(argv, env: ENV, cwd: Dir.pwd, out: $stdout, err: $stderr) + new(argv, env: env, cwd: cwd, out: out, err: err).run + rescue Sandbox::Error => e + err.puts(e.message) + exit 1 + end + + def initialize(argv, env:, cwd:, out:, err:) + @argv = argv.dup + @env = env + @cwd = cwd + @out = out + @err = err + @spinner = Spinner.new(io: @out) + @runner = CommandRunner.new(out: @out, err: @err) + @bash_runner = BashRunner.new(command_runner: @runner) + @ssh_config = SshConfig.new(@bash_runner) + @backend_key = nil + @proxy = false + @sync = false + @agents = nil + @non_option_args = [] + end + + def run + parse_options + backend = build_backend + Sandbox::Backend.ensure!(backend) + + command = @non_option_args.shift.to_s + + case command + when '', 'start', 'enter' + cmd_start_or_enter(backend) + when 'idea' + cmd_idea(backend) + when 'code' + cmd_code(backend) + when 'tmux' + cmd_tmux(backend) + when 'proxy' + cmd_proxy + when 'sync' + cmd_sync(backend) + when 'ls', 'list' + cmd_list + when 'stop' + cmd_stop(backend) + when 'info' + cmd_info(backend) + when 'help', '--help', '-h' + cmd_help(backend) + else + @err.puts("Error: Unknown command: #{command}") + @err.puts + cmd_help(backend) + raise Sandbox::Error, "Unknown command: #{command}" + end + end + + private + + def parse_options + until @argv.empty? + arg = @argv.shift + case arg + when '--kvm' + @backend_key = 'kvm' + when '--container' + @backend_key = 'container' + when '--hcloud' + @backend_key = 'hcloud' + when '--proxy' + @proxy = true + when '--sync' + @sync = true + when '--agents' + agents = @argv.shift + raise Sandbox::Error, '--agents parameter is missing the value' if agents.nil? || agents.strip.empty? + + validate_agents(agents) + @agents = agents + else + @non_option_args << arg + end + end + end + + def validate_agents(list) + list.split(',').each do |agent| + @bash_runner.call('ensure_know_agent', agent) + end + end + + def build_backend + backend_key = selected_backend_key + base_backend = backend_for(backend_key) + base_backend = Backends::Proxy.new(base_backend) if @proxy + base_backend + end + + def backend_for(key) + case key + when 'container' + backend = Backends::Container.new(bash_runner: @bash_runner, command_runner: @runner) + when 'kvm' + validate_kvm_prereqs! + backend = Backends::Kvm.new(bash_runner: @bash_runner, command_runner: @runner) + when 'hcloud' + backend = Backends::Hcloud.new(bash_runner: @bash_runner, command_runner: @runner, sync: @sync) + else + raise Sandbox::MissingBackendError, "Unknown backend: #{key}" + end + + backend + end + + def selected_backend_key + selector = BackendSelector.new(backends: all_backends, sandbox_name: sandbox_name) + selector.select_backend(@backend_key) + end + + def all_backends + { + 'container' => Backends::Container.new(bash_runner: @bash_runner, command_runner: @runner), + 'kvm' => Backends::Kvm.new(bash_runner: @bash_runner, command_runner: @runner), + 'hcloud' => Backends::Hcloud.new(bash_runner: @bash_runner, command_runner: @runner, sync: @sync) + } + end + + def sandbox_name + Name.sandbox_name(@cwd) + end + + def validate_no_backend_conflict + selector = BackendSelector.new(backends: all_backends, sandbox_name: sandbox_name) + detected = selector.detect_running_backend + return if detected.nil? || detected == @backend_key + + raise Sandbox::BackendConflictError, "Error: Already a running #{detected} sandbox" + end + + def ensure_sandbox_running(backend) + return if backend.backend_is_running(sandbox_name) + + raise Sandbox::BackendNotRunningError, 'No sandbox running for current directory' + end + + def ensure_not_inside_sandbox + return if @env['SANDBOX_CONTAINER'].nil? || @env['SANDBOX_CONTAINER'].empty? + + raise Sandbox::Error, 'Error: Already inside sandbox container' + end + + def cmd_start_or_enter(backend) + ensure_not_inside_sandbox + name = sandbox_name + + if backend.backend_is_running(name) + @out.puts("Entering existing sandbox: #{name} (#{backend_name(backend)} backend)") + else + validate_no_backend_conflict + @out.puts("Starting sandbox: #{name} (#{backend_name(backend)} backend)") + @spinner.with_task('Starting sandbox') do + backend.backend_start(name, use_pty: @spinner.tty?) + end + end + + if @agents + @agents.split(',').each do |agent| + bootstrap_agent(name, agent, backend) + end + end + + backend.backend_enter(name) + end + + def bootstrap_agent(name, agent, backend) + @out.puts("Bootstrapping AI agent: #{agent}") + env = { 'BACKEND' => backend_name(backend) } + env['PROXY'] = 'true' if @proxy + @bash_runner.call('bootstrap_ai', name, agent, env: env, use_pty: true) + end + + def cmd_list + @out.puts('Running sandboxes (container backend):') + Backends::Container.new(bash_runner: @bash_runner, command_runner: @runner).list + @out.puts + @out.puts('Running sandboxes (kvm backend):') + Backends::Kvm.new(bash_runner: @bash_runner, command_runner: @runner).list + @out.puts + @out.puts('Running sandboxes (hcloud backend):') + Backends::Hcloud.new(bash_runner: @bash_runner, command_runner: @runner, sync: @sync).list + end + + def cmd_stop(backend) + stop_all = @non_option_args.first + if %w[-a --all].include?(stop_all) + @out.puts('Stopping all sandboxes (all backends)...') + Backends::Container.new(bash_runner: @bash_runner, command_runner: @runner).backend_stop_all + Backends::Kvm.new(bash_runner: @bash_runner, command_runner: @runner).backend_stop_all + Backends::Hcloud.new(bash_runner: @bash_runner, command_runner: @runner, sync: @sync).backend_stop_all + @ssh_config.remove_all_aliases + @out.puts('Done') + else + name = sandbox_name + @out.puts("Stopping sandbox: #{name}") + if backend.backend_is_running(name) + backend.backend_stop(name) + @ssh_config.remove_alias(name) + end + @out.puts('Done') + end + end + + def cmd_info(backend) + name = sandbox_name + ensure_sandbox_running(backend) + port = backend.backend_get_ssh_port(name) + raise Sandbox::BackendError, 'Error: Could not determine SSH port' if port.nil? || port.empty? + + ip = backend.backend_get_ip(name) + raise Sandbox::BackendError, 'Error: Could not determine IP address' if ip.nil? || ip.empty? + + @out.puts("Sandbox: #{name}") + @out.puts("Backend: #{backend_name(backend)}") + @out.puts("Workspace: #{@cwd}") + + if @ssh_config.alias_setup?(name) + @out.puts + @out.puts('You can connect using the SSH alias:') + @out.puts(" ssh #{name}") + else + @out.puts + @out.puts('SSH connection:') + @out.puts(" ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -p #{port} dev@#{ip}") + @out.puts + @out.puts('JetBrains Gateway:') + @out.puts(" Host: #{ip}") + @out.puts(" Port: #{port}") + @out.puts(' User: dev') + end + + if backend.respond_to?(:console_socket) + socket = backend.console_socket(name) + if socket && !socket.empty? + @out.puts + @out.puts('Connect to vm console (to exit: ctrl+] or ctrl+altgr + 9 on qwertz keyboard):') + @out.puts(" socat STDIO,raw,echo=0,escape=0x1d UNIX-CONNECT:#{socket}") + end + end + end + + def cmd_idea(backend) + name = sandbox_name + ensure_sandbox_running(backend) + port = backend.backend_get_ssh_port(name) + raise Sandbox::BackendError, 'Error: Could not determine SSH port' if port.empty? + + url = if @ssh_config.alias_setup?(name) + "jetbrains://gateway/ssh/environment?h=#{name}&launchIde=true&ideHint=IU&projectHint=/home/dev/workspace" + else + "jetbrains://gateway/ssh/environment?h=localhost&u=dev&p=#{port}&launchIde=true&ideHint=IU&projectHint=/home/dev/workspace" + end + + @out.puts("Opening IntelliJ IDEA on #{name}...") + open_url(url) + end + + def cmd_code(backend) + name = sandbox_name + ensure_sandbox_running(backend) + port = backend.backend_get_ssh_port(name) + raise Sandbox::BackendError, 'Error: Could not determine SSH port' if port.empty? + + remote = if @ssh_config.alias_setup?(name) + "ssh-remote+#{name}/home/dev/workspace" + else + "ssh-remote+dev@localhost:#{port}/home/dev/workspace" + end + url = "vscode://vscode-remote/#{remote}" + + @out.puts("Opening Visual Studio Code on #{name}...") + if command_available?('code') + @runner.run(['code', '--folder-uri', "vscode-remote://#{remote}"]) + else + open_url(url) + end + end + + def cmd_tmux(backend) + name = sandbox_name + ensure_sandbox_running(backend) + unless command_available?('alacritty') + @err.puts('Error: Alacritty terminal emulator not found') + @err.puts("Please install Alacritty to use 'sandbox tmux'") + raise Sandbox::Error, 'Alacritty not found' + end + + @out.puts("Opening Alacritty terminal with tmux session on #{name}...") + title = "dev@#{name} (ssh)" + + if @ssh_config.alias_setup?(name) + Process.detach( + Process.spawn('alacritty', '--title', title, '-e', 'ssh', '-t', name, + 'cd /home/dev/workspace && exec tmux new-session -A') + ) + else + port = backend.backend_get_ssh_port(name) + ip = backend.backend_get_ip(name) + Process.detach( + Process.spawn('alacritty', '--title', title, '-e', 'ssh', '-t', + '-o', 'UserKnownHostsFile=/dev/null', + '-o', 'StrictHostKeyChecking=no', + '-p', port, "dev@#{ip}", + 'cd /home/dev/workspace && exec tmux new-session -A') + ) + end + end + + def cmd_proxy + @bash_runner.call('cmd_proxy', *@non_option_args, use_pty: true) + end + + def cmd_sync(backend) + direction = @non_option_args.shift + unless %w[up down].include?(direction) + @err.puts("Error: sync requires 'up' or 'down' argument") + @err.puts('Usage: sandbox sync [up|down]') + raise Sandbox::Error, 'Invalid sync direction' + end + + if %w[container kvm].include?(backend_name(backend)) + @err.puts('Error: sync command is only available for cloud backends (hcloud)') + @err.puts('Container and KVM backends use bind mounts, so files are already synchronized') + raise Sandbox::Error, 'Sync not available for this backend' + end + + name = sandbox_name + ensure_sandbox_running(backend) + + ssh_target = if @ssh_config.alias_setup?(name) + name + else + "dev@#{backend.backend_get_ip(name)}" + end + + if direction == 'up' + @out.puts('Uploading current directory to sandbox workspace...') + @runner.run(['rsync', '-hzav', '--no-o', '--no-g', '--delete', + '-e', 'ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no', + './', "#{ssh_target}:/home/dev/workspace/"]) + else + @out.puts('Downloading sandbox workspace to current directory...') + @runner.run(['rsync', '-hzav', '--no-o', '--no-g', '--delete', + '-e', 'ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no', + "#{ssh_target}:/home/dev/workspace/", './']) + end + @out.puts('Sync complete') + end + + def cmd_help(backend) + @out.puts <<~HELP + sandbox - Containerized development sandbox + + Usage: + sandbox [--kvm|--container|--hcloud] [flags] + + Global Flags: + --kvm Use KVM backend + Stronger Isolation, sudo and root access inside sandbox + BUT: without the proxy option the vm has access to all port open on the host... + --container Use container backend + Container Isolation, no root access + --hcloud Use Hetzner Cloud backend + Cloud-based VMs with ephemeral lifecycle (destroyed on stop) + Requires hcloud CLI and authentication + --sync Sync workspace to VM (hcloud backend only) + Runs rsync from current directory to /home/dev/workspace + --proxy Force all communication to go throug a restrictive proxy + --agents Bootstrap AI agents in the sandbox with credentials from the host + Accepts comma-separated list (e.g., claude,gemini) + Supported Agents: claude, gemini, opencode + + Backend Selection: + The backend is automatically detected based on running sandboxes. + If no sandbox is running, defaults to container backend. + Use --kvm, --container, or --hcloud to explicitly select a backend. + + Commands: + (none) Start/enter sandbox for current directory + idea Open IntelliJ IDEA connected to sandbox + code Open Visual Studio Code connected to sandbox + tmux Open Alacritty terminal with tmux session + proxy Manage proxy and domain allowlist (see 'proxy help') + sync up Upload current directory to sandbox (cloud backends only) + sync down Download from sandbox to current directory (cloud backends only) + list List running sandboxes + stop Stop sandbox for current directory + stop -a Stop all sandboxes (all backends) + info Show SSH connection details + help Show this help + + Examples: + sandbox Start/enter (auto-detects backend) + sandbox --kvm Start KVM sandbox + sandbox --hcloud Start Hetzner Cloud sandbox + sandbox --hcloud --sync Start Hetzner Cloud sandbox with workspace sync + sandbox code Open VS Code (auto-detects backend) + sandbox info Show info (auto-detects backend) + sandbox stop -a Stop all sandboxes + + Current backend: #{backend_name(backend)} + HELP + end + + def backend_name(backend) + case backend + when Backends::Proxy + backend_name(backend.backend) + when Backends::Container + 'container' + when Backends::Kvm + 'kvm' + when Backends::Hcloud + 'hcloud' + else + 'unknown' + end + end + + def open_url(url) + if command_available?('open') + @runner.run(['open', url]) + else + @err.puts('Error: open command found') + @err.puts('Please open this URL manually:') + @err.puts(url) + raise Sandbox::Error, 'open command missing' + end + end + + def command_available?(name) + system('command', '-v', name, out: File::NULL, err: File::NULL) + end + + def validate_kvm_prereqs! + unless File.exist?('/dev/kvm') + raise Sandbox::BackendError, 'Error: KVM not available (no /dev/kvm)' + end + unless File.writable?('/dev/kvm') + raise Sandbox::BackendError, 'Error: KVM not writeable for this user' + end + unless command_available?('qemu-system-x86_64') + raise Sandbox::BackendError, 'Error: qemu-system-x86_64 not found' + end + end + end +end diff --git a/ruby/lib/sandbox/command_runner.rb b/ruby/lib/sandbox/command_runner.rb new file mode 100644 index 0000000..2efb90f --- /dev/null +++ b/ruby/lib/sandbox/command_runner.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +require 'open3' +require 'pty' + +module Sandbox + class CommandRunner + def initialize(out: $stdout, err: $stderr) + @out = out + @err = err + end + + def run(command, env: {}, chdir: nil, use_pty: false) + if use_pty + run_with_pty(command, env: env, chdir: chdir) + else + status = system(env, *command, chdir: chdir) + raise CommandError.new("Command failed: #{command.join(' ')}", status: $?.exitstatus) unless status + true + end + end + + def capture(command, env: {}, chdir: nil) + stdout, stderr, status = Open3.capture3(env, *command, chdir: chdir) + unless status.success? + raise CommandError.new("Command failed: #{command.join(' ')}", status: status.exitstatus, stdout: stdout, stderr: stderr) + end + + stdout + end + + def exec(command, env: {}, chdir: nil) + Kernel.exec(env, *command, chdir: chdir) + end + + private + + def run_with_pty(command, env:, chdir: nil) + pid = nil + previous_int = trap('INT') { forward_signal(pid, 'INT') } + previous_term = trap('TERM') { forward_signal(pid, 'TERM') } + PTY.spawn(env, *command, chdir: chdir) do |reader, _writer, child_pid| + pid = child_pid + begin + reader.each do |data| + @out.print(data) + end + rescue Errno::EIO + # PTY closed + end + end + _, status = Process.wait2(pid) + raise CommandError.new("Command failed: #{command.join(' ')}", status: status.exitstatus) unless status.success? + true + ensure + trap('INT', previous_int) if previous_int + trap('TERM', previous_term) if previous_term + end + + def forward_signal(pid, signal) + return unless pid + + Process.kill(signal, pid) + rescue Errno::ESRCH + nil + end + end +end diff --git a/ruby/lib/sandbox/connection_info.rb b/ruby/lib/sandbox/connection_info.rb new file mode 100644 index 0000000..316432f --- /dev/null +++ b/ruby/lib/sandbox/connection_info.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Sandbox + ConnectionInfo = Struct.new(:host, :port, :user, keyword_init: true) do + def ssh_target + user ? "#{user}@#{host}" : host + end + end +end diff --git a/ruby/lib/sandbox/name.rb b/ruby/lib/sandbox/name.rb new file mode 100644 index 0000000..92e492a --- /dev/null +++ b/ruby/lib/sandbox/name.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Sandbox + module Name + module_function + + def sandbox_name(cwd) + base = File.basename(cwd) + sanitized = base.gsub(/[^a-zA-Z0-9_-]/, '_') + "sandbox-#{sanitized}" + end + end +end diff --git a/ruby/lib/sandbox/spec/backend_selector_spec.rb b/ruby/lib/sandbox/spec/backend_selector_spec.rb new file mode 100644 index 0000000..429aa05 --- /dev/null +++ b/ruby/lib/sandbox/spec/backend_selector_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Sandbox::BackendSelector do + let(:sandbox_name) { 'sandbox-demo' } + + def fake_backend(running) + Class.new do + define_method(:initialize) { |running| @running = running } + define_method(:backend_is_running) { |_name| @running } + end.new(running) + end + + it 'returns explicit backend when provided' do + selector = described_class.new( + backends: { 'container' => fake_backend(false) }, + sandbox_name: sandbox_name + ) + expect(selector.select_backend('kvm')).to eq('kvm') + end + + it 'detects a running backend when no explicit backend is set' do + selector = described_class.new( + backends: { + 'container' => fake_backend(false), + 'kvm' => fake_backend(true) + }, + sandbox_name: sandbox_name + ) + expect(selector.select_backend(nil)).to eq('kvm') + end + + it 'defaults to container when none are running' do + selector = described_class.new( + backends: { 'container' => fake_backend(false) }, + sandbox_name: sandbox_name + ) + expect(selector.select_backend(nil)).to eq('container') + end + + it 'raises when multiple backends are running' do + selector = described_class.new( + backends: { + 'container' => fake_backend(true), + 'kvm' => fake_backend(true) + }, + sandbox_name: sandbox_name + ) + expect { selector.detect_running_backend }.to raise_error(Sandbox::BackendConflictError) + end +end diff --git a/ruby/lib/sandbox/spec/name_spec.rb b/ruby/lib/sandbox/spec/name_spec.rb new file mode 100644 index 0000000..33ede57 --- /dev/null +++ b/ruby/lib/sandbox/spec/name_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Sandbox::Name do + describe '.sandbox_name' do + it 'prefixes and sanitizes the directory name' do + name = described_class.sandbox_name('/tmp/my project!') + expect(name).to eq('sandbox-my_project_') + end + + it 'keeps allowed characters' do + name = described_class.sandbox_name('/tmp/project-1_ok') + expect(name).to eq('sandbox-project-1_ok') + end + end +end diff --git a/ruby/lib/sandbox/spec/proxy_spec.rb b/ruby/lib/sandbox/spec/proxy_spec.rb new file mode 100644 index 0000000..585d734 --- /dev/null +++ b/ruby/lib/sandbox/spec/proxy_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Sandbox::Backends::Proxy do + class FakeBackend + attr_reader :proxy_enabled + + def proxy_enabled=(value) + @proxy_enabled = value + end + + def backend_start(_name, use_pty: false) + use_pty + @started = true + end + + def backend_stop(_name); end + + def backend_enter(_name); end + + def backend_is_running(_name) + false + end + + def backend_get_ssh_port(_name) + '' + end + + def backend_get_ip(_name) + '' + end + end + + it 'enables and resets proxy flag around start' do + backend = FakeBackend.new + proxy = described_class.new(backend) + + proxy.backend_start('sandbox-demo') + + expect(backend.proxy_enabled).to be(false).or be_nil + end +end diff --git a/ruby/lib/sandbox/spec/spec_helper.rb b/ruby/lib/sandbox/spec/spec_helper.rb new file mode 100644 index 0000000..8a27621 --- /dev/null +++ b/ruby/lib/sandbox/spec/spec_helper.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +require 'rspec' + +$LOAD_PATH.unshift(File.expand_path('../../', __dir__)) +require 'sandbox' diff --git a/ruby/lib/sandbox/spec/spinner_spec.rb b/ruby/lib/sandbox/spec/spinner_spec.rb new file mode 100644 index 0000000..611ab5a --- /dev/null +++ b/ruby/lib/sandbox/spec/spinner_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'stringio' + +class FakeIO < StringIO + def initialize(tty: false) + super() + @tty = tty + end + + def tty? + @tty + end +end + +RSpec.describe Sandbox::Spinner do + it 'prints a status line when not a tty' do + io = FakeIO.new(tty: false) + spinner = described_class.new(io: io) + + spinner.with_task('Updating image') do + io.print('done') + end + + io.rewind + output = io.read + expect(output).to include("Updating image...\ndone") + end + + it 'renders ANSI updates when tty' do + io = FakeIO.new(tty: true) + spinner = described_class.new(io: io) + + spinner.with_task('Starting') do + sleep 0.15 + end + + io.rewind + output = io.read + expect(output).to include("\e[1A") + end +end diff --git a/ruby/lib/sandbox/spinner.rb b/ruby/lib/sandbox/spinner.rb new file mode 100644 index 0000000..68f082a --- /dev/null +++ b/ruby/lib/sandbox/spinner.rb @@ -0,0 +1,71 @@ +# frozen_string_literal: true + +module Sandbox + class Spinner + FRAMES = %w[⣾ ⣽ ⣻ ⢿ ⡿ ⣟ ⣯ ⣷].freeze + INTERVAL = 0.1 + + def initialize(io: $stdout) + @io = io + @label = nil + @active = false + @thread = nil + @index = 0 + end + + def tty? + @io.respond_to?(:tty?) && @io.tty? + end + + def with_task(label) + if tty? + start(label) + yield + else + @io.puts("#{label}...") + yield + end + ensure + stop if tty? + end + + def update(label) + @label = label + end + + private + + def start(label) + @label = label + @active = true + render_line + @io.print("\n") + @io.flush + @thread = Thread.new { spin } + end + + def stop + @active = false + @thread&.join + clear_line + end + + def spin + while @active + sleep INTERVAL + @index = (@index + 1) % FRAMES.length + render_line + end + end + + def render_line + @io.print("\e7\e[1A\e[2K#{FRAMES[@index]} #{@label}\e8") + @io.flush + end + + def clear_line + @io.print("\e7\e[1A\e[2K#{@label}\e8") + @io.flush + end + end +end diff --git a/ruby/lib/sandbox/ssh_config.rb b/ruby/lib/sandbox/ssh_config.rb new file mode 100644 index 0000000..1388b4a --- /dev/null +++ b/ruby/lib/sandbox/ssh_config.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Sandbox + class SshConfig + def initialize(bash_runner) + @bash_runner = bash_runner + end + + def alias_setup?(name) + @bash_runner.call('is_ssh_alias_setup', name) + true + rescue Sandbox::CommandError + false + end + + def remove_alias(name) + @bash_runner.call('remove_ssh_alias', name) + end + + def remove_all_aliases + @bash_runner.call('remove_all_ssh_aliases') + end + end +end