lemans 0.0.0.pre → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +9 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +228 -0
  5. data/exe/lemans +17 -0
  6. data/exe/lemans-remote +984 -0
  7. data/lib/lemans/agents/base.rb +30 -0
  8. data/lib/lemans/agents/miniswen.rb +119 -0
  9. data/lib/lemans/agents/miniswen_installed.rb +67 -0
  10. data/lib/lemans/agents/nop.rb +15 -0
  11. data/lib/lemans/agents/oracle.rb +53 -0
  12. data/lib/lemans/agents.rb +21 -0
  13. data/lib/lemans/bench.rb +280 -0
  14. data/lib/lemans/cli/board_reporter.rb +135 -0
  15. data/lib/lemans/cli/progress_reporter.rb +67 -0
  16. data/lib/lemans/cli.rb +181 -0
  17. data/lib/lemans/clobber.rb +79 -0
  18. data/lib/lemans/environments/base.rb +55 -0
  19. data/lib/lemans/environments/daytona/retries.rb +49 -0
  20. data/lib/lemans/environments/daytona/sdk_tweaks.rb +50 -0
  21. data/lib/lemans/environments/daytona/shell.rb +142 -0
  22. data/lib/lemans/environments/daytona/snapshot_store.rb +163 -0
  23. data/lib/lemans/environments/daytona.rb +175 -0
  24. data/lib/lemans/environments.rb +16 -0
  25. data/lib/lemans/network_policy.rb +66 -0
  26. data/lib/lemans/patch.rb +70 -0
  27. data/lib/lemans/restore_paths.rb +21 -0
  28. data/lib/lemans/results/aggregate.rb +114 -0
  29. data/lib/lemans/results/cost_source.rb +13 -0
  30. data/lib/lemans/results/outcome.rb +36 -0
  31. data/lib/lemans/results/report.rb +149 -0
  32. data/lib/lemans/results/sorting.rb +24 -0
  33. data/lib/lemans/results/tally.rb +19 -0
  34. data/lib/lemans/results/usage.rb +24 -0
  35. data/lib/lemans/run.rb +152 -0
  36. data/lib/lemans/setup.rb +59 -0
  37. data/lib/lemans/setup_files.rb +36 -0
  38. data/lib/lemans/snapshot.rb +55 -0
  39. data/lib/lemans/task.rb +207 -0
  40. data/lib/lemans/tree_digest.rb +24 -0
  41. data/lib/lemans/trial.rb +187 -0
  42. data/lib/lemans/units.rb +44 -0
  43. data/lib/lemans/verifier/assets/eport-lemans.rb +36 -0
  44. data/lib/lemans/verifier/assets/lemans_minitest_reporter.rb +61 -0
  45. data/lib/lemans/verifier.rb +199 -0
  46. data/lib/lemans/version.rb +5 -0
  47. data/lib/lemans.rb +29 -0
  48. data/lib/miniswen/agent.rb +678 -0
  49. data/lib/miniswen/cli.rb +224 -0
  50. data/lib/miniswen/environment.rb +14 -0
  51. data/lib/miniswen/local.rb +42 -0
  52. data/lib/miniswen/ruby_llm.rb +42 -0
  53. data/lib/miniswen/testing.rb +134 -0
  54. data/lib/miniswen/trajectory.rb +110 -0
  55. data/lib/miniswen/version.rb +5 -0
  56. data/lib/miniswen.rb +48 -0
  57. metadata +161 -7
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "daytona"
4
+ require "securerandom"
5
+ require "shellwords"
6
+
7
+ module Lemans
8
+ module Environments
9
+ class Daytona
10
+ # Runs one sandbox's commands. Long commands run detached and are polled — a direct call
11
+ # would die at the HTTP deadline — and no async exit code exists, so the command writes its own.
12
+ class Shell
13
+ include Retries
14
+
15
+ SHORT_COMMAND_SEC = 120
16
+ POLL_INTERVAL_SEC = 2
17
+ MAX_OUTPUT_BYTES = 200_000
18
+ HOUSEKEEPING_TIMEOUT = 60
19
+
20
+ def initialize(sandbox)
21
+ @sandbox = sandbox
22
+ @session_id = fresh_session_id
23
+ sandbox.process.create_session(@session_id)
24
+ end
25
+
26
+ def exec(command, timeout: nil, env: {})
27
+ validate_env!(env)
28
+ started = now
29
+ response =
30
+ if timeout && timeout > SHORT_COMMAND_SEC
31
+ exec_in_session(command, timeout: timeout, env: env)
32
+ else
33
+ exec_directly(command, timeout: timeout, env: env)
34
+ end
35
+
36
+ Base::ExecResult.new(command: command, duration_sec: (now - started).round(3), **response)
37
+ end
38
+
39
+ private
40
+
41
+ attr_reader :sandbox
42
+
43
+ # Both transports must accept the same env: the session path serializes
44
+ # keys into `export` lines, where a key that is not a shell identifier
45
+ # would fail silently and run the command without its variable.
46
+ def validate_env!(env)
47
+ env.each_key do |key|
48
+ next if key.to_s.match?(/\A[A-Za-z_]\w*\z/)
49
+
50
+ raise ConfigError, "environment variable #{key.inspect} is not a shell identifier"
51
+ end
52
+ end
53
+
54
+ def exec_directly(command, timeout:, env:)
55
+ response = sandbox.process.exec(
56
+ command: command,
57
+ env: env.empty? ? nil : env,
58
+ timeout: timeout&.to_i
59
+ )
60
+ { exit_code: response.exit_code, output: response.result.to_s }
61
+ end
62
+
63
+ def exec_in_session(command, timeout:, env:)
64
+ run_id = SecureRandom.hex(6)
65
+ status_file = "/tmp/lemans-#{run_id}.status"
66
+ log_file = "/tmp/lemans-#{run_id}.log"
67
+ exports = env.map { |key, value| "export #{key}=#{Shellwords.escape(value.to_s)}" }.join("\n")
68
+
69
+ sandbox.process.execute_session_command(
70
+ session_id: @session_id,
71
+ req: ::Daytona::SessionExecuteRequest.new(
72
+ # A subshell, not a brace group: `set -e`/`exit` would take the
73
+ # session's own shell down and leave the status line unwritten.
74
+ command: "(\n#{exports}\n#{command}\n) > #{Shellwords.escape(log_file)} 2>&1\n" \
75
+ "echo $? > #{Shellwords.escape(status_file)}",
76
+ run_async: true
77
+ )
78
+ )
79
+
80
+ exit_code = nil
81
+ begin
82
+ exit_code = await_status(status_file, timeout)
83
+ { exit_code: exit_code || 124, output: tail(log_file) }
84
+ ensure
85
+ # On every exit path, including a poll that died: a command that
86
+ # outran its budget must not keep running, and the scratch files
87
+ # must not stay for the model to find. Session torn down first,
88
+ # for the best odds the command is dead before the rm runs.
89
+ terminate_session! if exit_code.nil?
90
+ clear_scratch(log_file, status_file)
91
+ end
92
+ end
93
+
94
+ def clear_scratch(*paths)
95
+ command = "rm -f #{paths.map { Shellwords.escape(_1) }.join(" ")}"
96
+ exec_directly(command, timeout: HOUSEKEEPING_TIMEOUT, env: {})
97
+ rescue *SDK_ERRORS => e
98
+ warn "lemans: could not clear #{paths.join(", ")}: #{e.message}"
99
+ end
100
+
101
+ def terminate_session!
102
+ sandbox.process.delete_session(@session_id)
103
+ rescue StandardError => e
104
+ warn "lemans: could not delete session #{@session_id}: #{e.message}"
105
+ ensure
106
+ @session_id = fresh_session_id
107
+ begin
108
+ sandbox.process.create_session(@session_id)
109
+ rescue StandardError => e
110
+ warn "lemans: could not open a replacement session: #{e.message}"
111
+ end
112
+ end
113
+
114
+ def await_status(status_file, timeout)
115
+ deadline = now + timeout
116
+ poll = "cat #{Shellwords.escape(status_file)} 2>/dev/null"
117
+ loop do
118
+ status = with_read_retries { exec_directly(poll, timeout: HOUSEKEEPING_TIMEOUT, env: {}) }
119
+ value = status[:output].to_s.strip
120
+ return Integer(value) if value.match?(/\A\d+\z/)
121
+ return nil if now > deadline
122
+
123
+ sleep POLL_INTERVAL_SEC
124
+ end
125
+ end
126
+
127
+ def tail(log_file, bytes: MAX_OUTPUT_BYTES)
128
+ with_read_retries do
129
+ exec_directly("tail -c #{bytes} #{Shellwords.escape(log_file)} 2>/dev/null",
130
+ timeout: HOUSEKEEPING_TIMEOUT, env: {})
131
+ # Scrubbed where bytes become a string: tail -c cuts on a byte
132
+ # boundary and can split a multibyte character.
133
+ end[:output].to_s.scrub
134
+ end
135
+
136
+ def fresh_session_id = "lemans-#{SecureRandom.hex(6)}"
137
+
138
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
139
+ end
140
+ end
141
+ end
142
+ end
@@ -0,0 +1,163 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+ require "daytona"
5
+ require "digest"
6
+ require "timeout"
7
+
8
+ module Lemans
9
+ module Environments
10
+ class Daytona
11
+ # Content-named snapshots: one build per image digest × resource shape,
12
+ # reused forever after.
13
+ class SnapshotStore
14
+ include Retries
15
+
16
+ POLL_INTERVAL_SEC = 2
17
+
18
+ SNAPSHOT_FAILED = [
19
+ ::DaytonaApiClient::SnapshotState::ERROR,
20
+ ::DaytonaApiClient::SnapshotState::BUILD_FAILED
21
+ ].freeze
22
+
23
+ # Two trials that want the same missing snapshot must not both build
24
+ # it; across processes the name collision settles it and the loser waits.
25
+ LOCKS = Concurrent::Map.new
26
+
27
+ def self.lock(name)
28
+ LOCKS.compute_if_absent(name) { Mutex.new }
29
+ end
30
+
31
+ def initialize(client:, image:, resources:, build_timeout_sec:, logger: nil)
32
+ @client = client
33
+ @image = image
34
+ @resources = resources
35
+ @build_timeout_sec = build_timeout_sec
36
+ @logger = logger
37
+ end
38
+
39
+ def call
40
+ self.class.lock(name).synchronize do
41
+ existing = find(name)
42
+ existing = discard(existing) if existing && SNAPSHOT_FAILED.include?(existing.state)
43
+ existing ? await_ready(existing) : build(name)
44
+ end
45
+ name
46
+ end
47
+
48
+ # The lookup name and the whole reuse policy: image digest plus resource
49
+ # shape, because a sandbox inherits resources from its snapshot.
50
+ def name
51
+ @name ||= begin
52
+ shape = [image.digest, resources.cpus, resources.memory_mb, resources.storage_mb].join(":")
53
+ "lemans-#{Digest::SHA256.hexdigest(shape)[0, 32]}"
54
+ end
55
+ end
56
+
57
+ private
58
+
59
+ attr_reader :client, :image, :resources, :build_timeout_sec, :logger
60
+
61
+ # A failed build keeps the name, so it would wedge every future trial
62
+ # sharing that image. The state is terminal; rebuilding is all that is left.
63
+ def discard(snapshot)
64
+ client.snapshot.delete(snapshot)
65
+
66
+ deadline = now + build_timeout_sec
67
+ while find(name)
68
+ raise InfrastructureError, "daytona: failed snapshot #{name} would not go away" if now > deadline
69
+
70
+ sleep POLL_INTERVAL_SEC
71
+ end
72
+ nil
73
+ rescue *SDK_ERRORS => e
74
+ # Another process discarding the same failed snapshot got there
75
+ # first: gone is the goal state, same as find's 404 policy.
76
+ return nil if status_code(e) == 404
77
+
78
+ raise InfrastructureError, "daytona: could not remove failed snapshot #{name}: #{e.message}"
79
+ end
80
+
81
+ def find(name)
82
+ with_read_retries { client.snapshot.get(name) }
83
+ rescue *SDK_ERRORS => e
84
+ return nil if status_code(e) == 404
85
+
86
+ raise InfrastructureError, "daytona: could not look up snapshot #{name}: #{e.message}"
87
+ end
88
+
89
+ # The SDK's blocking create has no budget of its own, so
90
+ # environment.build_timeout is enforced here or nowhere.
91
+ def build(name)
92
+ params = ::Daytona::CreateSnapshotParams.new(
93
+ name: name,
94
+ # A published reference goes in as a one-line `FROM`, not a bare name:
95
+ # Daytona mangles a bare name's `@sha256:` pin into an invalid reference.
96
+ image: image.built? ? ::Daytona::Image.from_dockerfile(image.dockerfile_path.to_s) : base_image,
97
+ resources: daytona_resources
98
+ )
99
+ Timeout.timeout(build_timeout_sec, InfrastructureError,
100
+ "daytona: snapshot #{name} did not build within #{build_timeout_sec}s") do
101
+ client.snapshot.create(params, on_logs: logger)
102
+ end
103
+ rescue *SDK_ERRORS => e
104
+ unless status_code(e) == 409
105
+ raise InfrastructureError,
106
+ "daytona: could not build snapshot #{name}: #{e.message}"
107
+ end
108
+
109
+ # Another process reached the same missing snapshot first; this trial
110
+ # waits for its build rather than failing.
111
+ taken = find(name)
112
+ raise InfrastructureError, "daytona: snapshot #{name} was taken and then vanished" if taken.nil?
113
+
114
+ await_ready(taken)
115
+ end
116
+
117
+ # A snapshot that exists may still be building for whoever won the race,
118
+ # or deactivated from disuse — a weekly run will hit that.
119
+ def await_ready(snapshot)
120
+ deadline = now + build_timeout_sec
121
+ activated = false
122
+
123
+ loop do
124
+ state = snapshot.state
125
+ return if state == ::DaytonaApiClient::SnapshotState::ACTIVE
126
+
127
+ raise InfrastructureError, "daytona: snapshot #{name} is #{state}: #{snapshot.error_reason}" if SNAPSHOT_FAILED.include?(state)
128
+
129
+ if state == ::DaytonaApiClient::SnapshotState::INACTIVE && !activated
130
+ client.snapshot.activate(snapshot)
131
+ activated = true
132
+ end
133
+
134
+ raise InfrastructureError, "daytona: snapshot #{name} was still #{state} after #{build_timeout_sec}s" if now > deadline
135
+
136
+ sleep POLL_INTERVAL_SEC
137
+ snapshot = find(name)
138
+ if snapshot.nil?
139
+ raise InfrastructureError,
140
+ "daytona: snapshot #{name} vanished while it was being waited on"
141
+ end
142
+ end
143
+ rescue *SDK_ERRORS => e
144
+ raise InfrastructureError, "daytona: could not wait for snapshot #{name}: #{e.message}"
145
+ end
146
+
147
+ def base_image = ::Daytona::Image.base(image.reference)
148
+
149
+ def daytona_resources
150
+ ::Daytona::Resources.new(
151
+ cpu: resources.cpus,
152
+ memory: to_gib(resources.memory_mb),
153
+ disk: to_gib(resources.storage_mb)
154
+ )
155
+ end
156
+
157
+ def to_gib(megabytes) = [(megabytes / 1024.0).ceil, 1].max
158
+
159
+ def now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
160
+ end
161
+ end
162
+ end
163
+ end
@@ -0,0 +1,175 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "concurrent"
4
+
5
+ require "daytona"
6
+
7
+ module Lemans
8
+ module Environments
9
+ # Daytona sandboxes. Daytona builds images server-side into reusable content-named
10
+ # snapshots and enforces the network policy itself.
11
+ class Daytona < Base
12
+ TTL_MINUTES = 120
13
+
14
+ DEFAULT_BUILD_TIMEOUT_SEC = 600
15
+
16
+ # Workspace tarballs ride uploads/downloads, so transfers get their own
17
+ # budget through the SDK's streaming API instead of the global HTTP cap.
18
+ TRANSFER_TIMEOUT = 900
19
+
20
+ # File transfers ride the SDK's typhoeus/libcurl stack, which segfaults
21
+ # the VM under enough concurrent easy_perform calls (a GC race on string
22
+ # options libcurl is still copying). Transfers are seconds each, so
23
+ # capping them costs little; execs and lifecycle stay fully parallel.
24
+ TRANSFER_SLOTS = Concurrent::Semaphore.new(6)
25
+
26
+ SdkTweaks.apply!
27
+
28
+ attr_reader :sandbox
29
+
30
+ # Trials run concurrently, and an unsynchronised memo would build several
31
+ # SDK clients and leak all but one.
32
+ CLIENT = Concurrent::Delay.new { ::Daytona::Daytona.new(credentials) }
33
+
34
+ def self.client = CLIENT.value!
35
+
36
+ # The CLI stores its key as DAYTONA_TOKEN, the SDK only reads
37
+ # DAYTONA_API_KEY; Config also resolves .env files, so go through it.
38
+ def self.credentials
39
+ config = ::Daytona::Config.new
40
+ config.api_key ||= config.read_env("DAYTONA_TOKEN")
41
+ raise ConfigError, "no Daytona credentials: set DAYTONA_API_KEY or DAYTONA_TOKEN" unless config.api_key || config.jwt_token
42
+
43
+ config
44
+ end
45
+
46
+ def initialize(image:, resources:, network:, env: {}, labels: {}, logger: nil, build_timeout_sec: nil)
47
+ super(image: image, resources: resources, network: network, env: env, labels: labels,
48
+ build_timeout_sec: build_timeout_sec || DEFAULT_BUILD_TIMEOUT_SEC)
49
+ @logger = logger
50
+ end
51
+
52
+ def start
53
+ @sandbox = client.create(create_params, on_snapshot_create_logs: @logger)
54
+ @shell = Shell.new(sandbox)
55
+ self
56
+ rescue *Retries::SDK_ERRORS => e
57
+ # A sandbox created but never handed over would bill until its TTL:
58
+ # the caller's ensure can only stop an environment it received.
59
+ stop
60
+ raise InfrastructureError, "daytona: could not start sandbox: #{e.message}"
61
+ end
62
+
63
+ def exec(command, timeout: nil, env: {})
64
+ @shell.exec(command, timeout: timeout || DEFAULT_TIMEOUT, env: env)
65
+ rescue *Retries::SDK_ERRORS => e
66
+ raise InfrastructureError, "daytona: exec failed: #{e.message}"
67
+ end
68
+
69
+ def upload(local_path, remote_path)
70
+ transfer do
71
+ # An open handle, not a path string: the SDK uploads a non-existent
72
+ # path AS ITS OWN BYTES, so a missing file must die here as ENOENT.
73
+ Pathname(local_path).open("rb") do |file|
74
+ sandbox.fs.upload_file_stream(file, remote_path.to_s, timeout: TRANSFER_TIMEOUT)
75
+ end
76
+ end
77
+ rescue *Retries::SDK_ERRORS => e
78
+ raise InfrastructureError, "daytona: could not upload #{local_path}: #{e.message}"
79
+ end
80
+
81
+ def download(remote_path, local_path)
82
+ local_path = Pathname(local_path)
83
+ local_path.dirname.mkpath
84
+ transfer do
85
+ local_path.open("wb") do |file|
86
+ sandbox.fs.download_file_stream(remote_path.to_s, timeout: TRANSFER_TIMEOUT) { file.write(_1) }
87
+ end
88
+ end
89
+ rescue *Retries::SDK_ERRORS, SystemCallError => e
90
+ raise InfrastructureError, "daytona: could not download #{remote_path}: #{e.message}"
91
+ end
92
+
93
+ def network_policy=(policy)
94
+ sandbox.update_network_settings(**network_kwargs(policy, for_update: true))
95
+ @network = policy
96
+ rescue *Retries::SDK_ERRORS => e
97
+ raise InfrastructureError, "daytona: could not apply #{policy.mode} policy: #{e.message}"
98
+ end
99
+
100
+ # Deleting is the only cleanup that stops the meter. Never raises:
101
+ # cleanup must not replace an otherwise valid result with an exception.
102
+ def stop
103
+ return if @sandbox.nil?
104
+
105
+ id = @sandbox.id
106
+ wait = true
107
+ begin
108
+ @sandbox.delete(wait:)
109
+ @sandbox = nil
110
+ rescue StandardError => e
111
+ # VM shutdown: confirming destruction needs threads Ruby no longer
112
+ # grants, but the bare DELETE needs none — the meter still stops.
113
+ if e.is_a?(ThreadError) && wait
114
+ wait = false
115
+ retry
116
+ end
117
+ warn "lemans: sandbox #{id} may still be running — delete failed: #{e.class}: #{e.message}"
118
+ end
119
+ end
120
+
121
+ private
122
+
123
+ def transfer
124
+ TRANSFER_SLOTS.acquire
125
+ yield
126
+ ensure
127
+ TRANSFER_SLOTS.release
128
+ end
129
+
130
+ def client = self.class.client
131
+
132
+ # A sandbox inherits the snapshot's resources, so the profile's are
133
+ # stamped into the snapshot at build time.
134
+ def create_params
135
+ ::Daytona::CreateSandboxFromSnapshotParams.new(
136
+ snapshot: snapshot_store.call,
137
+ env_vars: env,
138
+ labels: labels,
139
+ auto_stop_interval: 0, # a 30-minute agent must not be stopped under it
140
+ auto_delete_interval: 60,
141
+ # A real ceiling: without it a harness that dies mid-run leaves a
142
+ # running sandbox billing forever.
143
+ ttl_minutes: TTL_MINUTES,
144
+ **network_kwargs(network)
145
+ )
146
+ end
147
+
148
+ def snapshot_store
149
+ SnapshotStore.new(client: client, image: image, resources: resources,
150
+ build_timeout_sec: build_timeout_sec, logger: @logger)
151
+ end
152
+
153
+ def network_kwargs(policy, for_update: false)
154
+ case policy.mode
155
+ when :none
156
+ { network_block_all: true }
157
+ when :public
158
+ for_update ? { network_block_all: false } : {}
159
+ when :allowlist
160
+ raise ConfigError, "daytona: an allowlist cannot mix domains and IP targets (#{policy.hosts.join(", ")})" if policy.domains.any? && policy.ip_targets.any?
161
+
162
+ {
163
+ network_block_all: false,
164
+ domain_allow_list: policy.domains.join(","),
165
+ network_allow_list: policy.ip_targets.join(",")
166
+ }.reject { |_, value| value == "" }
167
+ else
168
+ # Silently returning nil would launch under Daytona's default
169
+ # network — the opposite of what this method exists to prevent.
170
+ raise ConfigError, "daytona: unsupported network mode #{policy.mode.inspect}"
171
+ end
172
+ end
173
+ end
174
+ end
175
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lemans
4
+ # The backends `lemans run --backend` can name, and the one place a name
5
+ # becomes a class. An unknown backend fails as a config mistake.
6
+ module Environments
7
+ BACKENDS = { "daytona" => "Daytona" }.freeze
8
+
9
+ def self.build(backend, **)
10
+ constant = BACKENDS[backend.to_s] or
11
+ raise ConfigError, "unknown backend #{backend.inspect} (known: #{BACKENDS.keys.join(", ")})"
12
+
13
+ const_get(constant).new(**)
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ipaddr"
4
+
5
+ module Lemans
6
+ # What a phase is allowed to reach. Every phase names its policy explicitly:
7
+ # network: { mode: allowlist, hosts: [openrouter.ai, "*.example.com", 10.0.0.0/8] }
8
+ class NetworkPolicy
9
+ MODES = %i[none allowlist public].freeze
10
+
11
+ attr_reader :mode, :hosts, :domains, :ip_targets
12
+
13
+ def self.from_config(config, field:)
14
+ raise ConfigError, "#{field}: network policy is required" if config.nil?
15
+
16
+ mode = config["mode"] or raise ConfigError, "#{field}.mode is required (#{MODES.join(", ")})"
17
+ new(mode: mode.to_s.to_sym, hosts: config["hosts"] || [], field: field)
18
+ end
19
+
20
+ def self.none = new(mode: :none)
21
+
22
+ def initialize(mode:, hosts: [], field: "network")
23
+ unless MODES.include?(mode)
24
+ raise ConfigError,
25
+ "#{field}.mode: #{mode.inspect} is not one of #{MODES.join(", ")}"
26
+ end
27
+
28
+ raise ConfigError, "#{field}.hosts must be a list" unless hosts.is_a?(Array)
29
+ if mode != :allowlist && !hosts.empty?
30
+ raise ConfigError,
31
+ "#{field}.hosts is only meaningful with mode: allowlist"
32
+ end
33
+
34
+ raise ConfigError, "#{field}.hosts cannot be empty with mode: allowlist" if mode == :allowlist && hosts.empty?
35
+
36
+ hosts = validated_hosts(hosts, field)
37
+
38
+ @mode = mode
39
+ @hosts = hosts.freeze
40
+ # Split once, at construction: backends allowlist domains and IP ranges
41
+ # through separate APIs, and a bad entry must fail here, loudly — a
42
+ # malformed allowlist must never launch a sandbox open.
43
+ @ip_targets, @domains = hosts.partition { ip_target?(_1) }.map(&:freeze)
44
+ freeze
45
+ end
46
+
47
+ def to_h = { mode: mode, hosts: hosts }
48
+
49
+ private
50
+
51
+ def validated_hosts(hosts, field)
52
+ hosts.map do |entry|
53
+ raise ConfigError, "#{field}.hosts entry #{entry.inspect} is not a host name, pattern, or IP range" unless entry.is_a?(String) && !entry.strip.empty?
54
+
55
+ entry.strip
56
+ end
57
+ end
58
+
59
+ def ip_target?(entry)
60
+ IPAddr.new(entry)
61
+ true
62
+ rescue IPAddr::Error
63
+ false
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "shellwords"
5
+
6
+ module Lemans
7
+ # The agent's work as one git patch, diffed against a baseline sealed before
8
+ # its first turn
9
+ class Patch
10
+ LOCAL_PATH = "agent.patch"
11
+ REMOTE_PATCH = "/tmp/lemans-agent.patch"
12
+ REMOTE_INDEX = "/tmp/lemans-patch.idx"
13
+
14
+ TIMEOUT = 300
15
+
16
+ def initialize(environment, bench:, dir:)
17
+ @environment = environment
18
+ @workdir = bench.environment.workdir
19
+ @path = Pathname(dir).join(LOCAL_PATH)
20
+ @baseline = nil
21
+ end
22
+
23
+ def seal!
24
+ @baseline = write_tree
25
+ end
26
+
27
+ # Must run before the verifier restores the graded surfaces: a patch taken
28
+ # after would not show what the agent did to them
29
+ def collect!
30
+ return unless baseline
31
+
32
+ after = write_tree
33
+ return unless after
34
+
35
+ result = environment.exec("#{git} diff --binary #{baseline} #{after} > #{REMOTE_PATCH}", timeout: TIMEOUT)
36
+ return unless result.success?
37
+
38
+ path.dirname.mkpath
39
+ environment.download(REMOTE_PATCH, path)
40
+ environment.exec("rm -f #{REMOTE_PATCH} #{REMOTE_INDEX}", timeout: TIMEOUT)
41
+ path
42
+ rescue InfrastructureError => e
43
+ warn "lemans: could not collect the agent patch for #{path.dirname.basename}: #{e.message}"
44
+ nil
45
+ end
46
+
47
+ private
48
+
49
+ attr_reader :environment, :workdir, :path, :baseline
50
+
51
+ # `safe.directory` because the sandbox may run the tree as a different user
52
+ # than built it, and git refuses to read a repo it thinks is someone else's.
53
+ def git = "git -c safe.directory='*' -C #{Shellwords.escape(workdir)}"
54
+
55
+ # Untracked files only reach a diff through an index, so both sides are
56
+ # staged into a scratch one and hashed. Rebuilt from empty each time, so a
57
+ # stale entry cannot survive into the second tree.
58
+ def write_tree
59
+ result = environment.exec(
60
+ "rm -f #{REMOTE_INDEX} && GIT_INDEX_FILE=#{REMOTE_INDEX} #{git} add -A && " \
61
+ "GIT_INDEX_FILE=#{REMOTE_INDEX} #{git} write-tree",
62
+ timeout: TIMEOUT
63
+ )
64
+ return nil unless result.success?
65
+
66
+ tree = result.output.to_s.lines.map(&:strip).reject(&:empty?).last
67
+ tree if /\A[0-9a-f]{40,64}\z/.match?(tree.to_s)
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+
5
+ module Lemans
6
+ module RestorePaths # :nodoc:
7
+ def self.call(declared, label:)
8
+ Array(declared).each_with_index.map do |path, index|
9
+ entry = "#{label}[#{index}]"
10
+ raise ConfigError, "#{entry} must be a path string, got #{path.inspect}" unless path.is_a?(String)
11
+ raise ConfigError, "#{entry} must be workdir-relative, got #{path.inspect}" if path.start_with?("/")
12
+ raise ConfigError, "#{entry} must not escape the workdir: #{path.inspect}" if
13
+ path.split("/").include?("..")
14
+ raise ConfigError, "#{entry} must name something inside the workdir, got #{path.inspect}" if
15
+ Pathname(path).cleanpath.to_s == "."
16
+
17
+ path
18
+ end.freeze
19
+ end
20
+ end
21
+ end