lemans 1.3.5 → 1.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5788e530e81eac31830bb91ab40a07e62edd6d6f8c371378d64384f716ee71a4
4
- data.tar.gz: f20ff180fbf2c6bc3b2c6f15793f6a5e53d86ac052b462629cd2877e0e1e445e
3
+ metadata.gz: 7ec0760a162ddb4b0fe13d4ddeb4027f934c1ffae9c7acbfda330da9ff68d91f
4
+ data.tar.gz: 85cd135708fade8a06610c4cd26e4f4556e8d90ec566d3a5e12e02d13e46d351
5
5
  SHA512:
6
- metadata.gz: 9791f737cafb403fe0a01999ea04c36254f5b9db32a59f7d2d38d22fa9fac79bf751d4f3c20efc2ff78296d27c95cf2d8243e3ad6a024a63ac0f2ea510db07f2
7
- data.tar.gz: 4e0fd6ca35ed5803a79fd722f30e53ec19e21b1482f97c6e77b0d9d187122dc85ab234b8a87808cd6591be431b961766f3f73bc2811e006263da6c41957ec608
6
+ metadata.gz: eb13268e0b98f9e8ca4f82f3e50b0a454366f00f0030fcaed4d03bee6d67eb214370e1317f9ffbba7701beefdb4d4e8e6c5f399cdaad90425208bdbc2b950fdc
7
+ data.tar.gz: f0a8acc7bc79be0fd546fdc0c59db8e0cfa354aba6806bb09f416bf2d61b898c895a0b94a71b27c122a6a7f65854f51d07fd32f2fd0ce66d91e9e4568163bdeb
data/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
1
1
  ## [Unreleased]
2
2
 
3
+ ## [1.4.0] - 2026-09-24
4
+
5
+ - miniswen: add built-in proxy and the `--allow-hosts` switch.
6
+ - miniswen: `--workdir`.
7
+ - Fix(miniswen): a background process holding a command's output no longer hangs the agent.
8
+ - Fix(miniswen): a turn that ends past the time limit runs no command.
9
+ - Fix(miniswen): allow free OpenRouter models.
10
+ - Add `verifier.environment.network` to allow grading access particular hosts, e.g. to repeat gem installations.
11
+ - Files get one mtime after `environment.patch` is applied, so mtimes do not reveal what it touched.
12
+
13
+
3
14
  ## [1.3.5] - 2026-09-15
4
15
 
5
16
  - Docker: `--docker` no longer fails at exit.
@@ -59,6 +59,11 @@ module Lemans
59
59
  raise ConfigError, "miniswen-installed: #{e.message}"
60
60
  end
61
61
 
62
+ def allowed_hosts
63
+ policy = profile.environment.network
64
+ policy.mode == "allowlist" ? policy.domains : []
65
+ end
66
+
62
67
  def command_for(task)
63
68
  argv = [ "miniswen", "-q", "--no-refresh-registry", "--jail",
64
69
  "-m", model.to_s, "-p", task.instruction,
@@ -67,6 +72,8 @@ module Lemans
67
72
  "--exec-timeout", profile.exec_timeout.to_i,
68
73
  "--max-output-tokens", profile.max_output_tokens ]
69
74
  argv += [ "--max-cost", profile.cost_limit.to_i ] if profile.cost_limit
75
+ argv += [ "--workdir", task.environment.workdir ]
76
+ argv += [ "--allow-hosts", allowed_hosts.join(",") ] if allowed_hosts.any?
70
77
  argv.map { Shellwords.escape(it.to_s) }.join(" ")
71
78
  end
72
79
  end
@@ -74,6 +74,7 @@ agent:
74
74
 
75
75
  # The sandbox network while the agent works: just enough to reach the model
76
76
  # Use mode: none when using a local agent (miniswen)
77
+ # With mode allowlist, agent commands reach these hosts through the jail's proxy
77
78
  environment:
78
79
  network:
79
80
  mode: allowlist
@@ -87,6 +88,12 @@ verifier:
87
88
  # bare command list). Runs after the agent finished, before the tests:
88
89
  # setup: [gem install debug]
89
90
 
91
+ # Grading runs sealed unless the task names hosts, say to install the gems a solution adds:
92
+ # environment:
93
+ # network:
94
+ # mode: allowlist
95
+ # hosts: [index.rubygems.org, rubygems.org]
96
+
90
97
  # A command that must pass before the graded checks run, e.g. the app's own
91
98
  # test suite:
92
99
  # preverify: bin/rails test
@@ -26,6 +26,10 @@ module Lemans
26
26
  conf.logs_dir = absolute_path!(data["logs_dir"]) if data["logs_dir"]
27
27
  conf.verification = root.join(data["verification"]) if data["verification"]
28
28
 
29
+ if (network_data = data.dig("environment", "network"))
30
+ conf.environment = Environment.new(network: NetworkPolicy.from_config(network_data))
31
+ end
32
+
29
33
  conf
30
34
  end
31
35
 
@@ -42,7 +46,9 @@ module Lemans
42
46
  end
43
47
  end
44
48
 
45
- attr_accessor :timeout, :setup, :command, :preverify, :restore_paths, :logs_dir, :verification
49
+ attr_accessor :timeout, :setup, :command, :preverify, :restore_paths, :logs_dir, :verification, :environment
50
+
51
+ Environment = Struct.new(:network, keyword_init: true)
46
52
 
47
53
  def initialize
48
54
  @timeout = 10 * 60
@@ -52,6 +58,7 @@ module Lemans
52
58
  @restore_paths = []
53
59
  @logs_dir = "/logs/verifier"
54
60
  @verification = nil
61
+ @environment = Environment.new(network: NetworkPolicy.new("none"))
55
62
  end
56
63
 
57
64
  def reward_path = "#{logs_dir.chomp("/")}/reward.txt"
@@ -64,7 +71,8 @@ module Lemans
64
71
  "preverify" => preverify,
65
72
  "restore" => restore_paths,
66
73
  "logs_dir" => logs_dir,
67
- "verification" => verification&.to_s
74
+ "verification" => verification&.to_s,
75
+ "environment" => { "network" => environment.network.to_h }
68
76
  }.compact
69
77
  end
70
78
 
@@ -52,6 +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
56
  "rm -rf .git && git init -q && git add -A && " \
56
57
  "git -c user.name=lemans -c user.email=lemans@localhost commit -qm 'Initial commit'",
57
58
  timeout:
data/lib/lemans/trial.rb CHANGED
@@ -114,8 +114,8 @@ module Lemans
114
114
 
115
115
  if result.scored? && step_task.verifiable?
116
116
  phase(:verifier) do
117
- # The sandbox is sealed before the tests arrive
118
- environment.switch_network_policy!(Config::NetworkPolicy.new("none"))
117
+ # The sandbox is sealed before the tests arrive, unless the task names hosts
118
+ environment.switch_network_policy!(step_task.verifier.environment.network)
119
119
 
120
120
  verification = Verifier.new(step_task, environment, snapshot).verify! do |evidence, path|
121
121
  store&.save_artifact(result, evidence, path: with_step_index(path))
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Lemans
4
- VERSION = "1.3.5"
4
+ VERSION = "1.4.0"
5
5
  end
@@ -294,6 +294,8 @@ module Miniswen
294
294
  end
295
295
 
296
296
  actions.each do |action|
297
+ return finish(:time_limit) if out_of_time?
298
+
297
299
  reporter&.on_tool_call(action)
298
300
  result = execute(action.fetch(:arguments).fetch("command"))
299
301
  # The submit command's output is observed too, so the final tool
@@ -334,12 +336,14 @@ module Miniswen
334
336
  # Checked before the model is asked, so the tripping step is never paid for.
335
337
  def limit_reached
336
338
  return :step_limit if max_steps.positive? && @steps >= max_steps
337
- return :time_limit if max_time.positive? && (@clock.call - @started_at) >= max_time
339
+ return :time_limit if out_of_time?
338
340
  return :cost_limit if max_cost && @cost_known && @cost >= max_cost
339
341
 
340
342
  nil
341
343
  end
342
344
 
345
+ def out_of_time? = max_time.positive? && (@clock.call - @started_at) >= max_time
346
+
343
347
  # One model turn. Returns the actions to run, or nil after appending a
344
348
  # format-error message the model gets to react to on its next turn.
345
349
  def next_actions
@@ -568,6 +572,11 @@ module Miniswen
568
572
  registry: nil)
569
573
  end
570
574
  return nil unless info
575
+ if free?
576
+ return CostSource.new(name: :free_model, model: @model,
577
+ priced_as: "#{info.provider}/#{info.id} ($0.00, free)",
578
+ registry: Miniswen.registry_revision)
579
+ end
571
580
 
572
581
  CostSource.new(name: :model_registry, model: @model,
573
582
  priced_as: "#{info.provider}/#{info.id}",
@@ -668,6 +677,8 @@ module Miniswen
668
677
 
669
678
  def local? = LOCAL_PROVIDERS.include?((@provider || info&.provider)&.to_sym)
670
679
 
680
+ def free? = info&.provider == "openrouter" && info.input_price_per_million.nil? && info.output_price_per_million.nil?
681
+
671
682
  def info
672
683
  return @info if defined?(@info)
673
684
 
@@ -681,7 +692,7 @@ module Miniswen
681
692
  end
682
693
 
683
694
  def price(response)
684
- return 0.0 if local?
695
+ return 0.0 if local? || free?
685
696
 
686
697
  input = info&.input_price_per_million
687
698
  output = info&.output_price_per_million
data/lib/miniswen/cli.rb CHANGED
@@ -23,10 +23,13 @@ module Miniswen
23
23
  @refresh_registry = false
24
24
  @skip_registry_refresh = false
25
25
  @jail = false
26
+ @allowed_hosts = nil
27
+ @workdir = nil
26
28
  end
27
29
 
28
30
  def run
29
31
  parse_args!
32
+ Dir.chdir(@workdir) if @workdir
30
33
 
31
34
  # Require the core library after parsing options,
32
35
  # so env flags kick in
@@ -45,7 +48,7 @@ module Miniswen
45
48
  Environment::Docker.new(@docker_id)
46
49
  elsif @jail
47
50
  require "miniswen/jail"
48
- Jail.new.start
51
+ Jail.new(allowed_hosts: @allowed_hosts).start
49
52
  else
50
53
  Local.new
51
54
  end
@@ -161,6 +164,14 @@ module Miniswen
161
164
  @jail = true
162
165
  end
163
166
 
167
+ opts.on("--allow-hosts=HOSTS", String, "Let jailed commands reach these hosts through a proxy (comma-separated)") do |v|
168
+ @allowed_hosts = v.split(",").map(&:strip).reject(&:empty?)
169
+ end
170
+
171
+ opts.on("--workdir=DIR", String, "Run commands in DIR instead of the current directory") do |v|
172
+ @workdir = v
173
+ end
174
+
164
175
  opts.on("--refresh-registry", "Refresh the model registry, persist it, and exit") do
165
176
  @refresh_registry = true
166
177
  end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "socket"
5
+
6
+ module Miniswen
7
+ class Jail < Local
8
+ # An HTTP CONNECT proxy that lets jailed commands reach the allowed hosts only.
9
+ class Proxy
10
+ def self.forward(port, socket)
11
+ server = TCPServer.new("127.0.0.1", port)
12
+ exit!(0) if fork
13
+ serve(server) { pump(it, UNIXSocket.new(socket)) }
14
+ end
15
+
16
+ def self.serve(server)
17
+ Thread.report_on_exception = false
18
+ loop { Thread.new(server.accept) { yield it } }
19
+ end
20
+
21
+ def self.pump(client, upstream)
22
+ Thread.new do
23
+ IO.copy_stream(client, upstream)
24
+ upstream.close_write
25
+ end
26
+ IO.copy_stream(upstream, client)
27
+ ensure
28
+ client.close
29
+ upstream.close
30
+ end
31
+
32
+ def initialize(hosts:, socket:)
33
+ @hosts = hosts
34
+ @socket = socket
35
+ end
36
+
37
+ def start
38
+ FileUtils.rm_f(@socket)
39
+ server = UNIXServer.new(@socket)
40
+ @pid = fork { self.class.serve(server) { handle(it) } }
41
+ self
42
+ end
43
+
44
+ def stop
45
+ Process.kill(:KILL, @pid)
46
+ Process.wait(@pid)
47
+ end
48
+
49
+ def handle(client)
50
+ _, target = client.gets("\r\n\r\n").split(" ", 3)
51
+ host, port = target.split(":", 2)
52
+ return client.write("HTTP/1.1 403 Forbidden\r\n\r\n") unless @hosts.include?(host)
53
+
54
+ upstream = Socket.tcp(host, port.to_i, connect_timeout: 10)
55
+ client.write("HTTP/1.1 200 Connection Established\r\n\r\n")
56
+ self.class.pump(client, upstream)
57
+ ensure
58
+ client.close
59
+ end
60
+ end
61
+ end
62
+ end
data/lib/miniswen/jail.rb CHANGED
@@ -1,30 +1,39 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "open3"
3
4
  require "shellwords"
4
5
 
5
6
  require "miniswen/local"
7
+ require "miniswen/jail/proxy"
6
8
 
7
9
  module Miniswen
8
10
  # Runs every command in its own namespaces: none of the harness's environment,
9
- # no network, read-only system, none of its files.
11
+ # no network, read-only system, none of its files. A task that may reach some
12
+ # hosts gets an HTTP proxy on loopback that allows only those.
10
13
  class Jail < Local
11
- def initialize(workdir: Dir.pwd)
14
+ PROXY_PORT = 3128
15
+ PROXY_SOCKET = "/var/lib/miniswen/run/miniswen-proxy.sock"
16
+
17
+ def initialize(workdir: Dir.pwd, allowed_hosts: nil)
12
18
  @workdir = workdir
19
+ @allowed_hosts = allowed_hosts
13
20
  end
14
21
 
15
22
  def start
16
23
  _, @stdout, @stderr, @holder = Open3.popen3(
17
- ENV.to_h.slice(*container_variables),
24
+ container_env,
18
25
  "unshare", "--net", "--mount", "--pid", "--fork", "--kill-child", "--mount-proc", "sh", "-c", setup,
19
26
  pgroup: true, unsetenv_others: true
20
27
  )
21
- return self if @stdout.gets == "ready\n"
28
+ raise InfrastructureError, "jail did not start: #{@stderr.read}" unless @stdout.gets == "ready\n"
22
29
 
23
- raise InfrastructureError, "jail did not start: #{@stderr.read}"
30
+ @proxy = Proxy.new(hosts: @allowed_hosts, socket: PROXY_SOCKET).start if @allowed_hosts
31
+ self
24
32
  end
25
33
 
26
34
  def stop
27
- Process.kill(:KILL, -@holder.pid) if @holder.alive?
35
+ Process.kill(:KILL, -@holder.pid) if @holder&.alive?
36
+ @proxy&.stop
28
37
  end
29
38
 
30
39
  private
@@ -37,22 +46,40 @@ module Miniswen
37
46
  mount --bind #{Shellwords.escape(@workdir)} #{Shellwords.escape(@workdir)}
38
47
  # Make private temp dirs their own mounts
39
48
  for dir in /tmp /run /root; do mkdir -p "/var/lib/miniswen$dir" && mount --bind "/var/lib/miniswen$dir" "$dir"; done
49
+ #{forwarder_command if @allowed_hosts}
40
50
  # Make everything without its own mount read-only
41
51
  mount -o remount,bind,ro /
42
52
  echo ready
43
53
  exec sleep infinity
44
54
  SH
45
55
 
56
+ def forwarder_command
57
+ forward = "Miniswen::Jail::Proxy.forward(#{PROXY_PORT}, #{"/run/#{File.basename(PROXY_SOCKET)}".inspect})"
58
+ [ RbConfig.ruby, "-I", File.expand_path("..", __dir__), "-rminiswen/jail", "-e", forward ].map { Shellwords.escape(it) }.join(" ")
59
+ end
60
+
46
61
  def spawn_arguments(command, env)
47
- [ ENV.to_h.merge(env.to_h).slice(*container_variables),
62
+ [ command_env(env),
48
63
  "nsenter", "--target", @holder.pid.to_s, "--net", "--mount", "--pid=/proc/#{@holder.pid}/ns/pid_for_children", "--wd=#{@workdir}",
49
64
  "--", "setpriv", "--bounding-set=-all", "--inh-caps=-all", "--no-new-privs", "--", "sh", "-c", command ]
50
65
  end
51
66
 
52
67
  def spawn_options = super.merge(unsetenv_others: true)
53
68
 
69
+ def command_env(env)
70
+ variables = container_env.merge(env.to_h).slice(*container_variables)
71
+ return variables unless @allowed_hosts
72
+
73
+ proxy = "http://127.0.0.1:#{PROXY_PORT}"
74
+ variables.merge("http_proxy" => proxy, "https_proxy" => proxy, "no_proxy" => "localhost,127.0.0.1")
75
+ end
76
+
77
+ def container_env
78
+ @container_env ||= File.read("/proc/1/environ").split("\0").to_h { it.split("=", 2) }
79
+ end
80
+
54
81
  def container_variables
55
- @container_variables ||= File.read("/proc/1/environ").split("\0").map { it.split("=").first } | Agent::EXEC_ENV.keys
82
+ @container_variables ||= container_env.keys | Agent::EXEC_ENV.keys
56
83
  end
57
84
  end
58
85
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "open3"
3
+ require "tempfile"
4
4
 
5
5
  require "miniswen/environment"
6
6
 
@@ -12,18 +12,17 @@ module Miniswen
12
12
  # Always through a shell: Ruby execs a metacharacter-free string directly,
13
13
  # and a missing binary would then raise ENOENT here instead of exiting 127.
14
14
  def exec(command, timeout: nil, env: nil)
15
- Open3.popen2e(*spawn_arguments(command, env), **spawn_options) do |stdin, io, wait_thr|
16
- stdin.close
17
- reader = Thread.new { io.read }
15
+ Tempfile.create("miniswen") do |log|
16
+ wait_thr = Process.detach(Process.spawn(*spawn_arguments(command, env), in: File::NULL, %i[out err] => log, **spawn_options))
18
17
 
19
18
  if timeout&.positive? && wait_thr.join(timeout).nil?
20
19
  kill_group(wait_thr.pid)
21
20
  wait_thr.join
22
- output = "#{scrub(reader.value)}\n<command timed out after #{timeout} seconds>"
21
+ output = "#{scrub(File.read(log))}\n<command timed out after #{timeout} seconds>"
23
22
  return ExecResult.new(exit_code: TIMEOUT_EXIT_CODE, output:)
24
23
  end
25
24
 
26
- ExecResult.new(exit_code: exit_code(wait_thr.value), output: scrub(reader.value))
25
+ ExecResult.new(exit_code: exit_code(wait_thr.value), output: scrub(File.read(log)))
27
26
  end
28
27
  end
29
28
 
@@ -36,7 +36,13 @@ module Miniswen
36
36
  modalities: { input: %w[text], output: %w[text] }
37
37
  )
38
38
 
39
- MODELS = { PRICED_MODEL.id => PRICED_MODEL, UNPRICED_MODEL.id => UNPRICED_MODEL }.freeze
39
+ FREE_MODEL = RubyLLM::Model::Info.new(
40
+ id: "test-free", name: "Test free", provider: "openrouter",
41
+ capabilities: %w[function_calling],
42
+ modalities: { input: %w[text], output: %w[text] }
43
+ )
44
+
45
+ MODELS = { PRICED_MODEL.id => PRICED_MODEL, UNPRICED_MODEL.id => UNPRICED_MODEL, FREE_MODEL.id => FREE_MODEL }.freeze
40
46
 
41
47
  # Serves the test models from ruby_llm's registry lookups.
42
48
  module FindTestModels
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Miniswen
4
- VERSION = "1.3.5"
4
+ VERSION = "1.4.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lemans
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.5
4
+ version: 1.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Svyatoslav Kryukov
@@ -237,6 +237,7 @@ files:
237
237
  - lib/miniswen/environment.rb
238
238
  - lib/miniswen/environment/docker.rb
239
239
  - lib/miniswen/jail.rb
240
+ - lib/miniswen/jail/proxy.rb
240
241
  - lib/miniswen/local.rb
241
242
  - lib/miniswen/ruby_llm.rb
242
243
  - lib/miniswen/testing.rb