lemans 0.2.2 → 1.0.0.pre.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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +30 -10
  3. data/exe/lemans-remote +161 -46
  4. data/lib/lemans/agent.rb +39 -0
  5. data/lib/lemans/agents/miniswen.rb +28 -25
  6. data/lib/lemans/agents/miniswen_installed.rb +13 -9
  7. data/lib/lemans/agents/nop.rb +3 -3
  8. data/lib/lemans/agents/oracle.rb +8 -8
  9. data/lib/lemans/cli/board_reporter.rb +16 -16
  10. data/lib/lemans/cli/progress_reporter.rb +19 -19
  11. data/lib/lemans/cli/report/aggregate.rb +117 -0
  12. data/lib/lemans/cli/report.rb +164 -0
  13. data/lib/lemans/cli.rb +58 -63
  14. data/lib/lemans/clobber.rb +17 -55
  15. data/lib/lemans/config/agent.rb +51 -0
  16. data/lib/lemans/config/conversion.rb +62 -0
  17. data/lib/lemans/config/environment.rb +39 -0
  18. data/lib/lemans/config/image_spec.rb +42 -0
  19. data/lib/lemans/config/network_policy.rb +55 -0
  20. data/lib/lemans/config/revision.rb +42 -0
  21. data/lib/lemans/config/setup.rb +57 -0
  22. data/lib/lemans/config/tree_digest.rb +26 -0
  23. data/lib/lemans/config/verifier.rb +72 -0
  24. data/lib/lemans/config.rb +107 -0
  25. data/lib/lemans/environment.rb +54 -0
  26. data/lib/lemans/environments/daytona/faraday_transfer.rb +172 -0
  27. data/lib/lemans/environments/daytona/sdk_tweaks.rb +1 -1
  28. data/lib/lemans/environments/daytona/shell.rb +1 -1
  29. data/lib/lemans/environments/daytona/snapshot_store.rb +11 -11
  30. data/lib/lemans/environments/daytona.rb +21 -35
  31. data/lib/lemans/result.rb +270 -0
  32. data/lib/lemans/runner/executor.rb +64 -0
  33. data/lib/lemans/runner/task.rb +62 -0
  34. data/lib/lemans/runner.rb +82 -0
  35. data/lib/lemans/store.rb +44 -0
  36. data/lib/lemans/stores/fs.rb +122 -0
  37. data/lib/lemans/task_definition.rb +194 -0
  38. data/lib/lemans/trial/patch.rb +76 -0
  39. data/lib/lemans/trial/setup.rb +66 -0
  40. data/lib/lemans/trial/snapshot.rb +57 -0
  41. data/lib/lemans/trial/verifier.rb +190 -0
  42. data/lib/lemans/trial.rb +113 -147
  43. data/lib/lemans/version.rb +1 -1
  44. data/lib/lemans.rb +3 -2
  45. data/lib/miniswen/trajectory.rb +2 -0
  46. metadata +58 -25
  47. data/lib/lemans/agents/base.rb +0 -30
  48. data/lib/lemans/bench.rb +0 -280
  49. data/lib/lemans/environments/base.rb +0 -55
  50. data/lib/lemans/network_policy.rb +0 -66
  51. data/lib/lemans/patch.rb +0 -70
  52. data/lib/lemans/restore_paths.rb +0 -21
  53. data/lib/lemans/results/aggregate.rb +0 -114
  54. data/lib/lemans/results/cost_source.rb +0 -13
  55. data/lib/lemans/results/outcome.rb +0 -36
  56. data/lib/lemans/results/report.rb +0 -149
  57. data/lib/lemans/results/sorting.rb +0 -24
  58. data/lib/lemans/results/tally.rb +0 -19
  59. data/lib/lemans/results/usage.rb +0 -24
  60. data/lib/lemans/run.rb +0 -152
  61. data/lib/lemans/setup.rb +0 -59
  62. data/lib/lemans/setup_files.rb +0 -36
  63. data/lib/lemans/snapshot.rb +0 -55
  64. data/lib/lemans/task.rb +0 -207
  65. data/lib/lemans/tree_digest.rb +0 -24
  66. data/lib/lemans/units.rb +0 -44
  67. data/lib/lemans/verifier.rb +0 -199
  68. /data/lib/lemans/{verifier → trial/verifier}/assets/eport-lemans.rb +0 -0
  69. /data/lib/lemans/{verifier → trial/verifier}/assets/lemans_minitest_reporter.rb +0 -0
@@ -0,0 +1,194 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "yaml"
5
+
6
+ module Lemans
7
+ # A single task definition: an instruction, an environment, tests, a solution —
8
+ # plus the task's projection of the bench config (setup and verifier sections
9
+ # with per-task overrides applied). Anything lemans does not understand
10
+ # belongs under `metadata`, copied untouched.
11
+ class TaskDefinition
12
+ INSTRUCTION = "instruction.md"
13
+ ENVIRONMENT_DIR = "environment"
14
+ TESTS_DIR = "tests"
15
+ SOLUTION_DIR = "solution"
16
+
17
+ FLAT_TEST = "verification_test.rb"
18
+ FLAT_SOLUTION = "solution.patch"
19
+ FLAT_SEED = "environment.patch"
20
+
21
+ FRONTMATTER = /\A---\n(.*?)\n---\n/m
22
+
23
+ class << self
24
+ def load_from_directory(config, dir)
25
+ dir = Pathname(dir)
26
+ data = frontmatter(dir)
27
+
28
+ task = new(config, data["name"] || dir.basename.to_s, dir:)
29
+ task.description = data["description"].to_s if data["description"]
30
+ task.difficulty = data["difficulty"].to_sym if data["difficulty"]
31
+ task.tags = Array(data["tags"]).map(&:to_s) if data["tags"]
32
+ task.metadata = data["metadata"] if data["metadata"]
33
+
34
+ declared_setup = Config::Setup.from_config(data["setup"], root: dir)
35
+ refuse_config_collisions!(task, declared_setup, config.setup)
36
+ task.setup = declared_setup
37
+
38
+ task.verifier.restore_paths = Config::Verifier.restore_paths!(data["restore"]) if data.key?("restore")
39
+ if (declared = Config::Setup.from_config(data.dig("verifier", "setup"), root: dir))
40
+ refuse_config_collisions!(task, declared, config.verifier.setup)
41
+ task.verifier.setup = config.verifier.setup.merge(declared)
42
+ end
43
+
44
+ validate!(task, data)
45
+ task
46
+ end
47
+
48
+ private
49
+
50
+ def frontmatter(dir)
51
+ path = dir.join(INSTRUCTION)
52
+ raise ConfigError, "#{dir}: #{INSTRUCTION} is required" unless path.file?
53
+
54
+ contents = path.read
55
+ match = contents.match(FRONTMATTER)
56
+
57
+ unless match
58
+ raise ConfigError, "#{path}: frontmatter opens with --- but never closes" if contents.start_with?("---")
59
+
60
+ return {}
61
+ end
62
+
63
+ data = YAML.safe_load(match[1], aliases: true) || {}
64
+ raise ConfigError, "#{path}: frontmatter must be a mapping" unless data.is_a?(Hash)
65
+
66
+ data
67
+ rescue Psych::Exception => e
68
+ raise ConfigError, "#{path}: #{e.message}"
69
+ end
70
+
71
+ def validate!(task, data)
72
+ if data.key?("overrides")
73
+ named = data["overrides"].is_a?(Hash) && data["overrides"].any? ? " (#{data["overrides"].keys.join(", ")})" : ""
74
+ raise ConfigError, "#{task.dir}: a task cannot override the frozen profile#{named} — " \
75
+ "what has to vary belongs in bench.yml, where it varies for every trial"
76
+ end
77
+
78
+ if (extras = data["verifier"].is_a?(Hash) ? data["verifier"].keys - ["setup"] : nil) && extras.any?
79
+ raise ConfigError, "#{task.dir}: a task may only override verifier.setup, not verifier.#{extras.first}"
80
+ end
81
+
82
+ raise ConfigError, "#{task.dir}: #{ENVIRONMENT_DIR}/Dockerfile is required when bench.yml names no shared image" unless
83
+ task.config.environment.image || task.environment_dockerfile.file?
84
+
85
+ return unless task.test_files.empty?
86
+
87
+ raise ConfigError, "#{task.dir}: #{TESTS_DIR}/ or a flat #{FLAT_TEST} is required — " \
88
+ "the verifier uploads it at verification time"
89
+ end
90
+
91
+ # Collisions would be resolved by upload order, so a task never gets to
92
+ # shadow the bench-wide copy.
93
+ def refuse_config_collisions!(task, declared, base)
94
+ return unless declared
95
+
96
+ shadowed = declared.files.map(&:last) & base.files.map(&:last)
97
+ return if shadowed.empty?
98
+
99
+ raise ConfigError, "#{task.dir}: setup.files names #{shadowed.first}, which the bench " \
100
+ "already ships to every task"
101
+ end
102
+ end
103
+
104
+ attr_reader :config, :name, :dir
105
+
106
+ attr_accessor :difficulty, :tags, :description, :metadata
107
+
108
+ def initialize(config, name, dir: nil)
109
+ @config = config
110
+ @name = name
111
+
112
+ @difficulty = :easy
113
+ @tags = []
114
+ @description = ""
115
+ @metadata = {}
116
+
117
+ @dir = dir || config.tasks_dir.join(name)
118
+ end
119
+
120
+ # The story alone: frontmatter is for the harness, never for the agent.
121
+ def instruction
122
+ @instruction ||= dir.join(INSTRUCTION).read.sub(FRONTMATTER, "")
123
+ end
124
+
125
+ def digest
126
+ @digest ||= Config::TreeDigest.call(dir)[0, 16]
127
+ end
128
+
129
+ # The task's projection of the config sections: bench-wide values with the
130
+ # task's own overrides applied. Phase machinery reads these, never the
131
+ # global config.
132
+ def setup
133
+ @setup ||= config.setup.merge(own_setup_with_seed)
134
+ end
135
+
136
+ def setup=(declared)
137
+ @declared_setup = declared
138
+ end
139
+
140
+ def verifier
141
+ @verifier ||= config.verifier.dup
142
+ end
143
+
144
+ def environment = config.environment
145
+
146
+ def seed? = dir.join(FLAT_SEED).file?
147
+
148
+ # [absolute, remote-relative] pairs. Tests stay on the harness side while
149
+ # the agent works; uploaded into the sandbox only at verification.
150
+ def test_files
151
+ tests_dir.directory? ? expand(tests_dir) : flat(FLAT_TEST)
152
+ end
153
+
154
+ def solution_files
155
+ solution_dir.directory? ? expand(solution_dir) : flat(FLAT_SOLUTION)
156
+ end
157
+
158
+ def solution? = solution_files.any?
159
+
160
+ def tests_dir = dir.join(TESTS_DIR)
161
+
162
+ def solution_dir = dir.join(SOLUTION_DIR)
163
+
164
+ def environment_dockerfile = dir.join(ENVIRONMENT_DIR, "Dockerfile")
165
+
166
+ def environment_image
167
+ if environment.image
168
+ Config::ImageSpec.registry(environment.image)
169
+ else
170
+ Config::ImageSpec.dockerfile(environment_dockerfile, slug: name)
171
+ end
172
+ end
173
+
174
+ private
175
+
176
+ def own_setup_with_seed
177
+ declared = @declared_setup || Config::Setup.new
178
+ return declared unless seed? && declared.files.none? { |_, remote| remote == FLAT_SEED }
179
+
180
+ declared.merge(Config::Setup.new.tap { it.files = [[dir.join(FLAT_SEED), FLAT_SEED]] })
181
+ end
182
+
183
+ # Dotfiles included, matching TreeDigest: the files a digest records are
184
+ # exactly the files that ship.
185
+ def expand(root)
186
+ root.glob("**/*", File::FNM_DOTMATCH).select(&:file?).map { [it, it.relative_path_from(root).to_s] }
187
+ end
188
+
189
+ def flat(filename)
190
+ path = dir.join(filename)
191
+ path.file? ? [[path, filename]] : []
192
+ end
193
+ end
194
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "shellwords"
5
+ require "tempfile"
6
+
7
+ module Lemans
8
+ class Trial
9
+ # The agent's work as one git patch, diffed against a baseline sealed before
10
+ # its first turn
11
+ class Patch
12
+ REMOTE_PATCH = "/tmp/lemans-agent.patch"
13
+ REMOTE_INDEX = "/tmp/lemans-patch.idx"
14
+
15
+ private attr_reader :task, :environment, :path, :workdir, :baseline, :timeout
16
+
17
+ def initialize(task, environment, timeout: 300, path: "agent.patch")
18
+ @task = task
19
+ @environment = environment
20
+ @path = path
21
+ @timeout = timeout
22
+
23
+ @workdir = task.environment.workdir
24
+ @baseline = nil
25
+ end
26
+
27
+ def seal!
28
+ @baseline = write_tree
29
+ end
30
+
31
+ # Must run before the verifier restores the graded surfaces: a patch taken
32
+ # after would not show what the agent did to them
33
+ def collect!(result, store)
34
+ return unless baseline
35
+
36
+ after = write_tree
37
+ return unless after
38
+
39
+ diffed = environment.exec("#{git} diff --binary #{baseline} #{after} > #{REMOTE_PATCH}", timeout:)
40
+ return unless diffed.success?
41
+
42
+ Tempfile.create(%w[agent .patch]) do |file|
43
+ environment.download(REMOTE_PATCH, file.path)
44
+ store.save_artifact(result, Pathname(file.path), path:)
45
+ end
46
+
47
+ environment.exec("rm -f #{REMOTE_PATCH} #{REMOTE_INDEX}", timeout:)
48
+ path
49
+ rescue InfrastructureError => e
50
+ warn "lemans: could not collect the agent patch for #{result.id}: #{e.message}"
51
+ nil
52
+ end
53
+
54
+ private
55
+
56
+ # `safe.directory` because the sandbox may run the tree as a different user
57
+ # than built it, and git refuses to read a repo it thinks is someone else's.
58
+ def git = "git -c safe.directory='*' -C #{Shellwords.escape(workdir)}"
59
+
60
+ # Untracked files only reach a diff through an index, so both sides are
61
+ # staged into a scratch one and hashed. Rebuilt from empty each time, so a
62
+ # stale entry cannot survive into the second tree.
63
+ def write_tree
64
+ result = environment.exec(
65
+ "rm -f #{REMOTE_INDEX} && GIT_INDEX_FILE=#{REMOTE_INDEX} #{git} add -A && " \
66
+ "GIT_INDEX_FILE=#{REMOTE_INDEX} #{git} write-tree",
67
+ timeout:
68
+ )
69
+ return nil unless result.success?
70
+
71
+ tree = result.output.to_s.lines.map(&:strip).reject(&:empty?).last
72
+ tree if /\A[0-9a-f]{40,64}\z/.match?(tree.to_s)
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+
5
+ module Lemans
6
+ class Trial
7
+ # Makes one shared image into this task's sandbox at start-up: uploads the
8
+ # declared files and runs the setup commands
9
+ class Setup
10
+ # Uploads land under one harness-owned directory, wiped once the steps have
11
+ # run: an agent whose first `ls /` finds the seed patch is reading the harness.
12
+ ROOT = "/lemans"
13
+ DIR = "#{ROOT}/setup".freeze
14
+
15
+ private attr_reader :task, :files, :commands, :needs_seed, :timeout
16
+
17
+ def initialize(task, files: [], commands: [], seed: false, exec_timeout: nil)
18
+ @task = task
19
+ @files = files
20
+ @commands = commands
21
+ @needs_seed = seed
22
+ @timeout = exec_timeout || task.environment.build_timeout
23
+ end
24
+
25
+ def execute!(environment)
26
+ return if commands.empty? && files.empty?
27
+
28
+ upload_files!(environment)
29
+ apply_seed!(environment)
30
+ run_commands!(environment)
31
+
32
+ # cleanup: no trace of the setup files is left for the agent to read
33
+ environment.exec!("rm -rf #{Shellwords.escape(ROOT)}")
34
+ end
35
+
36
+ private
37
+
38
+ def upload_files!(environment)
39
+ files.each do |local, remote|
40
+ environment.upload(local, File.join(DIR, remote))
41
+ end
42
+ end
43
+
44
+ # Folds a task's flat environment.patch into the workdir, then reseals the
45
+ # tree as a one-commit repo so `git log` does not point at the defect.
46
+ def apply_seed!(environment)
47
+ return unless needs_seed
48
+
49
+ # NOTE: the seed rides the files list, appended there by TaskDefinition
50
+ seed = File.join(DIR, TaskDefinition::FLAT_SEED)
51
+
52
+ workdir = Shellwords.escape(task.environment.workdir)
53
+ environment.exec!(
54
+ "cd #{workdir} && git apply --binary --whitespace=nowarn #{Shellwords.escape(seed)} && " \
55
+ "rm -rf .git && git init -q && git add -A && " \
56
+ "git -c user.name=lemans -c user.email=lemans@localhost commit -qm 'Initial commit'",
57
+ timeout:
58
+ )
59
+ end
60
+
61
+ def run_commands!(environment)
62
+ commands.each { environment.exec!(it, timeout:) }
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "shellwords"
4
+
5
+ module Lemans
6
+ class Trial
7
+ # The sealed baseline of a task's graded surfaces: a git tree written before
8
+ # the agent's first turn, checked out over the live tree at verification.
9
+ class Snapshot
10
+ REMOTE_INDEX = "/tmp/lemans-baseline.idx"
11
+
12
+ private attr_reader :task, :environment, :workdir, :paths, :timeout, :baseline
13
+
14
+ def initialize(task, environment, timeout: 300)
15
+ @task = task
16
+ @environment = environment
17
+ @timeout = timeout
18
+
19
+ @workdir = task.environment.workdir
20
+ @paths = task.verifier.restore_paths
21
+ @baseline = nil
22
+ end
23
+
24
+ # Sealing failure is an environment error: the agent has not run yet.
25
+ def capture!
26
+ return if paths.empty?
27
+
28
+ result = environment.exec(
29
+ "rm -f #{REMOTE_INDEX} && GIT_INDEX_FILE=#{REMOTE_INDEX} #{git} add -A && " \
30
+ "GIT_INDEX_FILE=#{REMOTE_INDEX} #{git} write-tree && rm -f #{REMOTE_INDEX}",
31
+ timeout:
32
+ )
33
+ tree = result.output.to_s.lines.map(&:strip).reject(&:empty?).last
34
+ raise InfrastructureError, "could not seal the graded surfaces: #{result.output.to_s[0, 500]}" unless result.success? && /\A[0-9a-f]{40,64}\z/.match?(tree.to_s)
35
+
36
+ @baseline = tree
37
+ end
38
+
39
+ def restore! # rubocop:disable Naming/PredicateMethod
40
+ return true if paths.empty?
41
+ raise VerifierError, "restore is declared but no baseline was sealed" unless baseline
42
+
43
+ escaped_paths = Shellwords.join(paths)
44
+
45
+ environment.exec(
46
+ "cd #{Shellwords.escape(workdir)} && #{git} cat-file -e #{baseline} && " \
47
+ "rm -rf -- #{escaped_paths} && #{git} checkout #{baseline} -- #{escaped_paths}",
48
+ timeout:
49
+ ).success?
50
+ end
51
+
52
+ private
53
+
54
+ def git = "git -c safe.directory='*' -C #{Shellwords.escape(workdir)}"
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pathname"
4
+ require "shellwords"
5
+ require "tmpdir"
6
+
7
+ module Lemans
8
+ class Trial
9
+ # Verifies a trial in the sandbox the agent worked in, after Trial has closed
10
+ # its network. The tests are uploaded fresh at verification time, never before.
11
+ class Verifier
12
+ Verification = Data.define(:reward, :logs)
13
+
14
+ REWARD_RANGE = (0.0..1.0)
15
+
16
+ # Where the task's tests land at verification time
17
+ TESTS_DIR = "/tests"
18
+
19
+ # Harness-owned files used in verification tests
20
+ ASSETS = Pathname(File.expand_path("verifier/assets", __dir__))
21
+
22
+ VERIFY_BIN = "verify"
23
+
24
+ # The message a person finds where the suite output would have been.
25
+ TAMPERED = "The graded surfaces could not be restored from the sealed baseline: the sandbox no " \
26
+ "longer holds the tree sealed before the agent's first turn. Removing or rewriting " \
27
+ "it is a failed check, so this run scores 0.\n"
28
+
29
+ private attr_reader :task, :environment, :snapshot, :timeout
30
+
31
+ def initialize(task, environment, snapshot)
32
+ @task = task
33
+ @environment = environment
34
+ @snapshot = snapshot
35
+ @timeout = task.verifier.timeout
36
+ end
37
+
38
+ def verify!(&evidence_collector)
39
+ upload_tests!
40
+ prepare_env!
41
+
42
+ # A baseline the agent made unrestorable is a verdict, not an error.
43
+ return Verification.new(reward: 0.0, logs: TAMPERED) unless snapshot.restore!
44
+
45
+ verification = run_tests!
46
+
47
+ download_evidence(evidence_collector)
48
+
49
+ verification
50
+ rescue InfrastructureError => e
51
+ salvage_evidence(evidence_collector)
52
+ # Everything under verification is the verifier's failure, never the
53
+ # model's
54
+ raise if e.is_a?(VerifierError)
55
+
56
+ raise VerifierError, e.message
57
+ rescue StandardError
58
+ salvage_evidence(evidence_collector)
59
+ raise
60
+ end
61
+
62
+ private
63
+
64
+ def upload_tests!
65
+ environment.exec!("rm -rf #{TESTS_DIR} && mkdir -p #{TESTS_DIR}")
66
+
67
+ uploads = task.verifier.files.to_h { |local, remote| [remote, local] }
68
+ .merge(task.test_files.to_h { |local, remote| [remote, local] })
69
+
70
+ uploads.each { |remote, local| environment.upload(local, "#{TESTS_DIR}/#{remote}") }
71
+ ASSETS.glob("*.rb").each { |asset| environment.upload(asset, "#{TESTS_DIR}/#{asset.basename}") }
72
+ # An upload promises no mode bit, so the executable gets its own.
73
+ environment.exec!("chmod +x #{TESTS_DIR}/#{VERIFY_BIN}") if uploads.key?(VERIFY_BIN)
74
+ end
75
+
76
+ def prepare_env!
77
+ Setup.new(
78
+ task,
79
+ files: task.verifier.setup.files,
80
+ commands: task.verifier.setup.commands,
81
+ exec_timeout: timeout
82
+ ).execute!(environment)
83
+ end
84
+
85
+ def run_tests!
86
+ # Ensure $LOGS exists
87
+ environment.exec!("mkdir -p #{Shellwords.escape(task.verifier.logs_dir)}")
88
+ # Ensure the agent hasn't pre-written reward.txt or checks.json
89
+ environment.exec!("rm -f #{Shellwords.escape(task.verifier.reward_path)} " \
90
+ "#{Shellwords.escape(File.join(task.verifier.logs_dir, "checks.json"))}")
91
+
92
+ env = { "WORKDIR" => task.environment.workdir,
93
+ "TESTS" => TESTS_DIR,
94
+ "LOGS" => task.verifier.logs_dir }
95
+ command = "cd #{Shellwords.escape(task.environment.workdir)} && " \
96
+ "export RUBYOPT=\"${RUBYOPT:+$RUBYOPT }-I#{TESTS_DIR}\" && " \
97
+ "#{verifier_script}"
98
+
99
+ result = environment.exec(command, timeout:, env:)
100
+
101
+ Verification.new(reward: read_reward(result), logs: result.output.to_s)
102
+ end
103
+
104
+ def verifier_script
105
+ [task.verifier.preverify, task.verifier.command].compact.map { "( #{it} )" }.join(" && ")
106
+ end
107
+
108
+ def read_reward(command_result)
109
+ reward_path = task.verifier.reward_path
110
+ present = environment.exec("test -e #{Shellwords.escape(reward_path)}")
111
+ return reward_from_exit(command_result) unless present.success?
112
+
113
+ result = environment.exec("cat #{Shellwords.escape(reward_path)}")
114
+ raise VerifierError, "could not read #{reward_path}: #{result.output.to_s[0, 500]}" unless result.success?
115
+
116
+ value = begin
117
+ Float(result.output.to_s.strip)
118
+ rescue ArgumentError
119
+ raise VerifierError, "verifier wrote #{result.output.to_s.strip.inspect}, which is not a reward"
120
+ end
121
+ raise VerifierError, "verifier wrote a non-finite reward" unless value.finite?
122
+ raise VerifierError, "reward #{value} is outside #{REWARD_RANGE}" unless REWARD_RANGE.cover?(value)
123
+
124
+ value
125
+ end
126
+
127
+ def reward_from_exit(command_result)
128
+ case command_result.exit_code
129
+ when 0 then 1.0
130
+ when 1 then 0.0
131
+ else
132
+ raise VerifierError,
133
+ "verifier exited #{command_result.exit_code} and wrote no reward to #{task.verifier.reward_path}"
134
+ end
135
+ end
136
+
137
+ def download_evidence(collector)
138
+ return unless collector
139
+
140
+ @evidence_attempted = true
141
+ root = task.verifier.logs_dir
142
+ paths = list_files(root)
143
+ return if paths.empty?
144
+
145
+ relative_to = Pathname(root).cleanpath
146
+
147
+ # Use a temp dir to download evidence to check for collisions
148
+ Dir.mktmpdir("lemans-evidence") do |staging|
149
+ paths.each do |remote|
150
+ relative = checked_remote_path(remote, root).relative_path_from(relative_to)
151
+ staged = Pathname(staging).join(relative)
152
+ staged.dirname.mkpath
153
+ environment.download(remote, staged)
154
+
155
+ collector.call(staged, relative.to_s)
156
+ end
157
+ end
158
+ end
159
+
160
+ def list_files(declared)
161
+ return [] unless environment.exec("test -d #{Shellwords.escape(declared)}").success?
162
+
163
+ listing = environment.exec("find #{Shellwords.escape(declared)} -type f -print0")
164
+ raise VerifierError, "could not list #{declared}: #{listing.output.to_s[0, 500]}" unless listing.success?
165
+
166
+ listing.output.to_s.split("\0").reject(&:empty?)
167
+ end
168
+
169
+ def checked_remote_path(remote, declared)
170
+ root = Pathname(declared).cleanpath
171
+ candidate = Pathname(remote).cleanpath
172
+
173
+ raise VerifierError, "evidence file #{remote.inspect} escapes #{declared}" unless candidate.absolute? && candidate.to_s.start_with?("#{root}/")
174
+
175
+ raise VerifierError, "evidence file #{remote.inspect} contains control characters" if remote.match?(/[[:cntrl:]]/)
176
+
177
+ candidate
178
+ end
179
+
180
+ def salvage_evidence(collector)
181
+ return unless collector
182
+ return if @evidence_attempted
183
+
184
+ download_evidence(collector)
185
+ rescue StandardError => e
186
+ warn "lemans: could not save the verifier's evidence: #{e.class}: #{e.message}"
187
+ end
188
+ end
189
+ end
190
+ end