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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +28 -0
- data/README.md +87 -1
- data/exe/lemans-remote +69 -11
- data/lib/lemans/agent.rb +5 -4
- data/lib/lemans/agents/miniswen.rb +5 -4
- data/lib/lemans/agents/miniswen_installed.rb +2 -1
- data/lib/lemans/agents.rb +13 -5
- data/lib/lemans/cli/report/aggregate.rb +8 -5
- data/lib/lemans/cli/report.rb +16 -2
- data/lib/lemans/cli.rb +28 -7
- data/lib/lemans/environments/daytona/retries.rb +2 -2
- data/lib/lemans/environments/daytona/shell.rb +3 -1
- data/lib/lemans/environments/daytona.rb +7 -1
- data/lib/lemans/environments/docker/proxy/README.md +1 -1
- data/lib/lemans/environments/docker/proxy/server.rb +2 -1
- data/lib/lemans/environments/docker.rb +7 -3
- data/lib/lemans/environments.rb +14 -4
- data/lib/lemans/secrets_filter.rb +2 -2
- data/lib/lemans/stores/fs.rb +5 -4
- data/lib/lemans/trial/patch.rb +8 -7
- data/lib/lemans/trial/setup.rb +5 -1
- data/lib/lemans/trial/snapshot.rb +3 -3
- data/lib/lemans/trial/verifier/assets/lemans_minitest_reporter.rb +4 -1
- data/lib/lemans/trial/verifier/test_scanner.rb +1 -1
- data/lib/lemans/trial/verifier.rb +12 -5
- data/lib/lemans/trial.rb +1 -0
- data/lib/lemans/version.rb +1 -1
- data/lib/miniswen/agent.rb +67 -140
- data/lib/miniswen/cli.rb +1 -1
- data/lib/miniswen/jail/proxy.rb +7 -1
- data/lib/miniswen/jail.rb +2 -1
- data/lib/miniswen/ruby_llm.rb +32 -17
- data/lib/miniswen/testing.rb +13 -17
- data/lib/miniswen/trajectory.rb +2 -1
- data/lib/miniswen/version.rb +1 -1
- data/lib/miniswen.rb +2 -6
- metadata +11 -19
|
@@ -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 =
|
|
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,
|
data/lib/lemans/environments.rb
CHANGED
|
@@ -6,11 +6,21 @@ module Lemans
|
|
|
6
6
|
module Environments
|
|
7
7
|
BACKENDS = { "daytona" => "Daytona", "docker" => "Docker" }.freeze
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
constant = BACKENDS[backend.to_s] or
|
|
11
|
-
raise ConfigError, "unknown backend #{backend.inspect} (known: #{BACKENDS.keys.join(", ")})"
|
|
9
|
+
@registered = {}
|
|
12
10
|
|
|
13
|
-
|
|
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?(/_(
|
|
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
|
data/lib/lemans/stores/fs.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
data/lib/lemans/trial/patch.rb
CHANGED
|
@@ -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
|
-
|
|
13
|
-
|
|
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
|
|
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
|
-
|
|
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
|
)
|
data/lib/lemans/trial/setup.rb
CHANGED
|
@@ -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
|
-
"
|
|
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 = "/
|
|
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 = "
|
|
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:
|
|
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
|
|
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)
|
data/lib/lemans/version.rb
CHANGED