lemans 1.5.1 → 1.6.0

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.
@@ -7,7 +7,7 @@ The proxy supports two request types:
7
7
  - `CONNECT host:port` tunnels (https).
8
8
  - Absolute-form requests (`GET http://host/path`) for plain http.
9
9
 
10
- The proxy refuses a host that is not on the list with `403 Forbidden`. Host names must match exactly. IP ranges are not supported.
10
+ The proxy refuses a host that is not on the list with `403 Forbidden`. Host names must match exactly. IP ranges are not supported. The host `*` lets every host through.
11
11
 
12
12
  ## How the Docker backend uses it
13
13
 
@@ -5,6 +5,7 @@ require "uri"
5
5
 
6
6
  # An HTTP proxy that lets clients reach the allowed hosts only:
7
7
  # CONNECT tunnels for https, absolute-form requests for plain http.
8
+ # A "*" host lets every host through.
8
9
  class AllowlistProxy
9
10
  HOP_HEADERS = /\A(?:connection|keep-alive|proxy-[\w-]+):/i
10
11
 
@@ -55,7 +56,7 @@ class AllowlistProxy
55
56
  pump(client, upstream)
56
57
  end
57
58
 
58
- def allowed?(host) = @hosts.include?(host.to_s.downcase)
59
+ def allowed?(host) = @hosts.include?("*") || @hosts.include?(host.to_s.downcase)
59
60
 
60
61
  def deny(client) = client.write("HTTP/1.1 403 Forbidden\r\n\r\n")
61
62
 
@@ -16,6 +16,10 @@ module Lemans
16
16
 
17
17
  attr_reader :container
18
18
 
19
+ def self.proxy_image
20
+ @proxy_image ||= Config::ImageSpec.dockerfile(Pathname(__dir__).join("docker/proxy/Dockerfile"), slug: "proxy")
21
+ end
22
+
19
23
  def initialize(image:, resources:, network:, env: {}, labels: {}, logger: nil, build_timeout: nil, ttl: nil)
20
24
  super(image:, resources:, network:, env:, labels:, ttl:,
21
25
  build_timeout: build_timeout || DEFAULT_BUILD_TIMEOUT)
@@ -38,7 +42,7 @@ module Lemans
38
42
  def exec(command, timeout: nil, env: {})
39
43
  timeout ||= DEFAULT_TIMEOUT
40
44
  started = now
41
- argv = [ "exec" ]
45
+ argv = [ "exec", "--user", "0" ]
42
46
  env = proxy_env.merge(env) if network.allowlist?
43
47
  env.each { |key, value| argv += [ "--env", "#{key}=#{value}" ] }
44
48
  # The in-container timeout is what actually kills the process
@@ -49,7 +53,7 @@ module Lemans
49
53
  end
50
54
 
51
55
  def upload(local_path, remote_path)
52
- docker!("exec", container, "mkdir", "-p", File.dirname(remote_path.to_s))
56
+ docker!("exec", "--user", "0", container, "mkdir", "-p", File.dirname(remote_path.to_s))
53
57
  docker!("cp", local_path.to_s, "#{container}:#{remote_path}")
54
58
  end
55
59
 
@@ -108,7 +112,7 @@ module Lemans
108
112
  # The task container sits on an internal network with no route out; the
109
113
  # proxy is on it and on bridge, so the hosts it allows are the only way out.
110
114
  def start_proxy!(hosts)
111
- image = Config::ImageSpec.dockerfile(Pathname(__dir__).join("docker/proxy/Dockerfile"), slug: "proxy")
115
+ image = self.class.proxy_image
112
116
  build_image!(image)
113
117
  create_internal_network!
114
118
  docker!("run", "--detach", "--name", proxy_name, "--network", internal_network,
@@ -6,11 +6,21 @@ module Lemans
6
6
  module Environments
7
7
  BACKENDS = { "daytona" => "Daytona", "docker" => "Docker" }.freeze
8
8
 
9
- def self.build(backend, **)
10
- constant = BACKENDS[backend.to_s] or
11
- raise ConfigError, "unknown backend #{backend.inspect} (known: #{BACKENDS.keys.join(", ")})"
9
+ @registered = {}
12
10
 
13
- const_get(constant).new(**)
11
+ class << self
12
+ def register(name, environment_class) = @registered[name] = environment_class
13
+
14
+ def unregister(name) = @registered.delete(name)
15
+
16
+ def names = BACKENDS.keys | @registered.keys
17
+
18
+ def build(backend, **) = lookup(backend.to_s).new(**)
19
+
20
+ def lookup(name)
21
+ @registered[name] || (BACKENDS[name] && const_get(BACKENDS[name])) or
22
+ raise ConfigError, "unknown backend #{name.inspect} (known: #{names.join(", ")})"
23
+ end
14
24
  end
15
25
  end
16
26
  end
@@ -4,8 +4,8 @@ module Lemans
4
4
  # Redacts known secret values (API credentials) from everything the store persists.
5
5
  class SecretsFilter
6
6
  # Credentials follow the <PROVIDER>_<KIND> environment convention
7
- # (DAYTONA_API_KEY, OPENROUTER_API_KEY, DAYTONA_TOKEN, ...).
8
- def self.default = new(ENV.filter_map { |name, value| value if name.match?(/_(API_KEY|TOKEN|SECRET|JWT)\z/) })
7
+ # (DAYTONA_API_KEY, OPENROUTER_API_KEY, AWS_SECRET_ACCESS_KEY, DAYTONA_TOKEN, ...).
8
+ def self.default = new(ENV.filter_map { |name, value| value if name.match?(/_(KEY|TOKEN|SECRET|JWT)\z/) })
9
9
 
10
10
  def initialize(secrets, replacement: "<filtered>", min_length: 8)
11
11
  @replacement = replacement
@@ -11,11 +11,12 @@ module Lemans
11
11
  class FS < Store
12
12
  FILENAME = "result.json"
13
13
 
14
- private attr_reader :root, :filterer
14
+ private attr_reader :root, :read_roots, :filterer
15
15
 
16
- def initialize(root, filterer: nil)
16
+ def initialize(root, *read_roots, filterer: nil)
17
17
  super()
18
18
  @root = Pathname(root)
19
+ @read_roots = [ @root ] + read_roots.map { Pathname(it) }
19
20
  @filterer = filterer
20
21
  end
21
22
 
@@ -26,7 +27,7 @@ module Lemans
26
27
  end
27
28
 
28
29
  def fetch
29
- root.glob("**/#{FILENAME}").filter_map { to_record(it) }
30
+ read_roots.flat_map { it.glob("**/#{FILENAME}") }.filter_map { to_record(it) }
30
31
  end
31
32
 
32
33
  # The file system keeps no index, so filtering happens in memory.
@@ -43,7 +44,7 @@ module Lemans
43
44
  RESULT_ID = /\A(?<task>.+)__[A-Za-z0-9]{7}\z/
44
45
 
45
46
  def unreadable
46
- root.glob("**/#{FILENAME}").filter_map do |path|
47
+ read_roots.flat_map { it.glob("**/#{FILENAME}") }.filter_map do |path|
47
48
  next if to_record(path)
48
49
 
49
50
  id = path.dirname.basename.to_s
@@ -9,8 +9,11 @@ module Lemans
9
9
  # The agent's work as one git patch, diffed against a baseline sealed before
10
10
  # its first turn
11
11
  class Patch
12
- REMOTE_PATCH = "/tmp/lemans-agent.patch"
13
- REMOTE_INDEX = "/tmp/lemans-patch.idx"
12
+ GIT_DIR = "/var/lib/lemans/git"
13
+ GIT = "GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null git"
14
+ INIT = "#{GIT} init -q --bare #{GIT_DIR}".freeze
15
+ REMOTE_PATCH = "#{GIT_DIR}/agent.patch".freeze
16
+ REMOTE_INDEX = "#{GIT_DIR}/patch.idx".freeze
14
17
 
15
18
  private attr_reader :task, :environment, :path, :workdir, :baseline, :savepoint, :timeout
16
19
 
@@ -77,7 +80,7 @@ module Lemans
77
80
  return unless savepoint
78
81
 
79
82
  environment.exec!(
80
- "#{git} read-tree #{savepoint} && #{git} checkout-index -f -a && #{git} clean -fd && #{git} reset -q",
83
+ "#{git} read-tree #{savepoint} && #{git} checkout-index -f -a && #{git} clean -fd",
81
84
  timeout:
82
85
  )
83
86
  end
@@ -112,16 +115,14 @@ module Lemans
112
115
  nil
113
116
  end
114
117
 
115
- # `safe.directory` because the sandbox may run the tree as a different user
116
- # than built it, and git refuses to read a repo it thinks is someone else's.
117
- def git = "git -c safe.directory='*' -C #{Shellwords.escape(workdir)}"
118
+ def git = "#{GIT} -C #{Shellwords.escape(workdir)} --git-dir=#{GIT_DIR} --work-tree=."
118
119
 
119
120
  # Untracked files only reach a diff through an index, so both sides are
120
121
  # staged into a scratch one and hashed. Rebuilt from empty each time, so a
121
122
  # stale entry cannot survive into the second tree.
122
123
  def write_tree
123
124
  result = environment.exec(
124
- "rm -f #{REMOTE_INDEX} && GIT_INDEX_FILE=#{REMOTE_INDEX} #{git} add -A && " \
125
+ "#{INIT} && rm -f #{REMOTE_INDEX} && GIT_INDEX_FILE=#{REMOTE_INDEX} #{git} add -A && " \
125
126
  "GIT_INDEX_FILE=#{REMOTE_INDEX} #{git} write-tree",
126
127
  timeout:
127
128
  )
@@ -52,7 +52,7 @@ module Lemans
52
52
  workdir = Shellwords.escape(task.environment.workdir)
53
53
  environment.exec!(
54
54
  "cd #{workdir} && git apply --binary --whitespace=nowarn #{Shellwords.escape(seed)} && " \
55
- "find . -path ./.git -prune -o -exec touch -h {} + && " \
55
+ "#{clear_bootsnap_caches} && #{unify_mtimes} && " \
56
56
  "rm -rf .git && git init -q && git add -A && " \
57
57
  "git -c user.name=lemans -c user.email=lemans@localhost commit -qm 'Initial commit'",
58
58
  timeout:
@@ -62,6 +62,10 @@ module Lemans
62
62
  def run_commands!(environment)
63
63
  commands.each { environment.exec!(it, timeout:) }
64
64
  end
65
+
66
+ def clear_bootsnap_caches = 'rm -rf "${BOOTSNAP_CACHE_DIR:-tmp/cache}"/bootsnap*'
67
+
68
+ def unify_mtimes = "find . -path ./.git -prune -o -exec touch -h {} +"
65
69
  end
66
70
  end
67
71
  end
@@ -7,7 +7,7 @@ module Lemans
7
7
  # The sealed baseline of a task's graded surfaces: a git tree written before
8
8
  # the agent's first turn, checked out over the live tree at verification.
9
9
  class Snapshot
10
- REMOTE_INDEX = "/tmp/lemans-baseline.idx"
10
+ REMOTE_INDEX = "#{Patch::GIT_DIR}/baseline.idx".freeze
11
11
 
12
12
  private attr_reader :task, :environment, :workdir, :paths, :timeout, :baseline
13
13
 
@@ -26,7 +26,7 @@ module Lemans
26
26
  return if paths.empty?
27
27
 
28
28
  result = environment.exec(
29
- "rm -f #{REMOTE_INDEX} && GIT_INDEX_FILE=#{REMOTE_INDEX} #{git} add -A && " \
29
+ "#{Patch::INIT} && rm -f #{REMOTE_INDEX} && GIT_INDEX_FILE=#{REMOTE_INDEX} #{git} add -A && " \
30
30
  "GIT_INDEX_FILE=#{REMOTE_INDEX} #{git} write-tree && rm -f #{REMOTE_INDEX}",
31
31
  timeout:
32
32
  )
@@ -51,7 +51,7 @@ module Lemans
51
51
 
52
52
  private
53
53
 
54
- def git = "git -c safe.directory='*' -C #{Shellwords.escape(workdir)}"
54
+ def git = "#{Patch::GIT} -C #{Shellwords.escape(workdir)} --git-dir=#{Patch::GIT_DIR} --work-tree=."
55
55
  end
56
56
  end
57
57
  end
@@ -36,7 +36,7 @@ module LemansReport
36
36
  end
37
37
 
38
38
  def report
39
- abort "lemans: a false assertion no longer raises, so no result can be trusted" if tampered?
39
+ abort "lemans: Minitest was tampered with, so no result can be trusted" if tampered?
40
40
 
41
41
  graded = @results.select { graded?(it) }
42
42
  prior = existing.fetch("checks", {})
@@ -66,6 +66,9 @@ module LemansReport
66
66
  private
67
67
 
68
68
  def tampered?
69
+ gem = Gem.loaded_specs["minitest"].full_gem_path
70
+ return true unless Minitest::Test.instance_method(:run).source_location.first.start_with?("#{gem}/")
71
+
69
72
  Minitest::Test.new("probe").assert(false)
70
73
  true
71
74
  rescue Minitest::Assertion
@@ -61,7 +61,7 @@ module Lemans
61
61
  def visit_class_node(node) = scoped(node) { super }
62
62
 
63
63
  def visit_def_node(node)
64
- return super unless node.name.start_with?("test_")
64
+ return super unless node.name.start_with?("test_") && node.parameters.nil?
65
65
 
66
66
  within("#{@scope.join("::")}##{node.name}", node) { super }
67
67
  end
@@ -100,8 +100,8 @@ module Lemans
100
100
 
101
101
  result = environment.exec(command, timeout:, env:)
102
102
 
103
- reward = read_reward(result)
104
103
  checks = read_checks
104
+ reward = read_reward(result, checks)
105
105
  features = (self.class.features_from(checks.fetch("checks", {}), TestScanner.for(task)&.features) if checks && task.final_step?)
106
106
  Verification.new(reward:, credit: credit_from(checks, reward), features:, logs: result.output.to_s)
107
107
  end
@@ -110,10 +110,10 @@ module Lemans
110
110
  [ task.verifier.preverify, task.verifier.command ].compact.map { "( #{it} )" }.join(" && ")
111
111
  end
112
112
 
113
- def read_reward(command_result)
113
+ def read_reward(command_result, checks)
114
114
  reward_path = task.verifier.reward_path
115
115
  present = environment.exec("test -e #{Shellwords.escape(reward_path)}")
116
- return reward_from_exit(command_result) unless present.success?
116
+ return reward_from_exit(command_result, checks) unless present.success?
117
117
 
118
118
  result = environment.exec("cat #{Shellwords.escape(reward_path)}")
119
119
  raise VerifierError, "could not read #{reward_path}: #{result.output.to_s[0, 500]}" unless result.success?
@@ -154,9 +154,9 @@ module Lemans
154
154
  (base_credit + (1 - base_credit) * (passed.to_f / total)).round(2)
155
155
  end
156
156
 
157
- def reward_from_exit(command_result)
157
+ def reward_from_exit(command_result, checks)
158
158
  case command_result.exit_code
159
- when 0 then 1.0
159
+ when 0 then clean_exit_reward(checks)
160
160
  when 1 then 0.0
161
161
  else
162
162
  raise VerifierError,
@@ -164,6 +164,13 @@ module Lemans
164
164
  end
165
165
  end
166
166
 
167
+ def clean_exit_reward(checks)
168
+ return 0.0 if checks&.dig("failures")&.any?
169
+ return 0.0 if TestScanner.for(task)&.tests&.any? { !checks&.dig("checks")&.key?(it) }
170
+
171
+ 1.0
172
+ end
173
+
167
174
  def download_evidence(collector)
168
175
  return unless collector
169
176
 
data/lib/lemans/trial.rb CHANGED
@@ -102,6 +102,7 @@ module Lemans
102
102
  # Whatever the agent brought back is evidence, a failed run's included
103
103
  save_trajectory!(response.trajectory)
104
104
  store&.save_artifact(result, response.raw_result, path: with_step_index("agent.result.json")) if response.raw_result
105
+ response.artifacts.each { |path, contents| store&.save_artifact(result, contents, path: with_step_index(path)) }
105
106
 
106
107
  if response.error?
107
108
  result.failed!(:agent_error, response.error)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lemans
4
- VERSION = "1.5.1"
4
+ VERSION = "1.6.0"
5
5
  end