miniswen 1.3.5 → 1.5.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/README.md +17 -2
- data/lib/miniswen/agent.rb +65 -18
- data/lib/miniswen/cli.rb +20 -4
- data/lib/miniswen/environment/docker.rb +1 -1
- data/lib/miniswen/jail/proxy.rb +62 -0
- data/lib/miniswen/jail.rb +36 -9
- data/lib/miniswen/local.rb +6 -7
- data/lib/miniswen/testing.rb +7 -1
- data/lib/miniswen/version.rb +1 -1
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2df0b59321d93fcea1ddb87d555323b3dd247eed780532bd645d1410e5a6a391
|
|
4
|
+
data.tar.gz: 07c36283c59e9a311af76aa59b46118b92ff705a5478dca847f5bd321c03c7de
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d481729c89f35e3aae9cbb02dc2c5db59327b292c49a4a30920e81a178480411d075e72bc59dc845bc0906e9194af7cd37d2833f2a092ae135a359b7460eb066
|
|
7
|
+
data.tar.gz: 1eb136297aac2c4702efc6b2c6bc4829a53bdca70a85c582bd39150cc17e0cd7851f62adcb0acee53cb05f82461339efb9d7a1d5057657cc47fc45300aee051a
|
data/README.md
CHANGED
|
@@ -15,6 +15,7 @@ lemans is a harness for benchmarking coding agents, the Ruby way:
|
|
|
15
15
|
- Ruby 3.4+ is required to run `lemans`
|
|
16
16
|
- Daytona account (API token) or Docker (for local sandboxes)
|
|
17
17
|
- Some LLM provider/proxy credentials (e.g., OpenRouter)
|
|
18
|
+
- Bash in the sandbox image for command execution
|
|
18
19
|
- iproute2 in the sandbox image, so a jailed agent keeps loopback while cut off the internet
|
|
19
20
|
|
|
20
21
|
## Getting started
|
|
@@ -102,6 +103,8 @@ verifier:
|
|
|
102
103
|
# restore: [test, bin] # [optional] paths restored from the pre-agent snapshot, so grading can't be tampered with
|
|
103
104
|
```
|
|
104
105
|
|
|
106
|
+
On the Docker backend, `allowlist` sends traffic through a proxy container. Only tools that use `http_proxy`/`https_proxy` can reach the listed hosts. Host names must match exactly, and IP ranges are not supported. See [the proxy README](lib/lemans/environments/docker/proxy/README.md).
|
|
107
|
+
|
|
105
108
|
A minimal task example—checking whether an agent can write "Hello, world" into a file:
|
|
106
109
|
|
|
107
110
|
- `instruction.md`: the task itself (note the preamble about the training corpora and the frontmatter)
|
|
@@ -231,6 +234,17 @@ Each trial writes a flat `runs/<model>/<task>__<id>/` directory:
|
|
|
231
234
|
|
|
232
235
|
Every `result.json` is stamped with the `lemans_version` that wrote it, and newer lemans keeps reading runs produced by older releases. `lemans run --resume` skips trials that already have a scored result for the same agent and model.
|
|
233
236
|
|
|
237
|
+
`lemans restart <run>` continues a multistep run that failed for reasons outside the model, such as a provider outage. Give it the run directories or the trial ids: `lemans restart RUN [RUN...]` restarts every run under the same options, `-c` at once, and runs none if any is refused. The new run replays the agent patches of the settled steps in a fresh sandbox and starts at the next step. It copies the settled steps' usage, phases, and artifacts, and records the source in `restarted_from`. The failed run stays as it is. It restarts even if the task or the bench changed since; the new run records the current digests.
|
|
238
|
+
|
|
239
|
+
Two modes restart inside a step:
|
|
240
|
+
|
|
241
|
+
- `--recover` continues the failed step's agent session. The new run also replays the step's partial patch, and the agent goes on from the saved `agent.result.json`. The session keeps the steps, tokens, cost, and time it already spent. Only `miniswen` and `miniswen-installed` can recover.
|
|
242
|
+
- `--reverify` runs the last verification again with the current tests. Use it after you change the grading or the harness. The new run replays the graded step's patch and does not run its agent. If the verification was an intermediate gate and it now passes, the trial goes on to the next step. A reverification accepts scored runs.
|
|
243
|
+
|
|
244
|
+
A scored run restarts in the other modes only with `--allow-scored`.
|
|
245
|
+
|
|
246
|
+
Only the files that git tracks come back: ignored files (databases, logs, installed dependencies) are not in the patches.
|
|
247
|
+
|
|
234
248
|
You can also run `lemans report` with various flags to see aggregated results, e.g.:
|
|
235
249
|
|
|
236
250
|
```sh
|
|
@@ -256,8 +270,9 @@ gpt-5.6-luna ar-archive-book-access 2/2 2m 23s $0.0132 12.5 156905
|
|
|
256
270
|
| `lemans init` | Scaffold a new bench directory: an annotated `bench.yml` and two example tasks |
|
|
257
271
|
| `lemans tasks` | List the tasks in a bench (`--tag` to filter) |
|
|
258
272
|
| `lemans run` | Run tasks and grade them (`--task`, `--tag`, `--agent`, `--model`, `--max-output-tokens`, `-k`, `-c`, `--resume`) |
|
|
259
|
-
| `lemans
|
|
260
|
-
| `lemans
|
|
273
|
+
| `lemans restart <run>...` | Continue failed multistep runs from their last settled step in new runs (`-c`, `--recover` to continue the failed step's session, `--reverify` to grade again, `--allow-scored`, `--backend`, `--max-output-tokens`) |
|
|
274
|
+
| `lemans report [RUNS_DIR]` | Summarize `runs/` (or `RUNS_DIR`) as a table or CSV (`--task`, `--tag`, `--metadata key:value` to filter, `-A [task-agent-model]` to aggregate, `-S <column>` to sort); repeated attempts add pass@k per model × task, fractional grading a `credit` column |
|
|
275
|
+
| `lemans clobber [RUNS_DIR]` | Delete run results under `runs/` (or `RUNS_DIR`) (`--task`, `--ttl 10m\|2h\|1d`, `--invalid`, `-f` to skip the confirmation) |
|
|
261
276
|
|
|
262
277
|
## miniswen
|
|
263
278
|
|
data/lib/miniswen/agent.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require "time"
|
|
4
5
|
require "miniswen/version"
|
|
5
6
|
require "miniswen/ruby_llm"
|
|
6
7
|
|
|
@@ -261,25 +262,17 @@ module Miniswen
|
|
|
261
262
|
@reporter = reporter
|
|
262
263
|
end
|
|
263
264
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
]
|
|
273
|
-
|
|
274
|
-
@steps = 0
|
|
275
|
-
@cost = 0.0
|
|
276
|
-
|
|
277
|
-
@totals = { input_tokens: 0, output_tokens: 0, cached_tokens: 0, thinking_tokens: 0 }
|
|
265
|
+
# With a `history` (the Result of an interrupted run), the session goes on
|
|
266
|
+
# where it stopped, under the budget the earlier part already spent.
|
|
267
|
+
def run(instruction = nil, history: nil)
|
|
268
|
+
if history
|
|
269
|
+
resume(history)
|
|
270
|
+
else
|
|
271
|
+
start(instruction)
|
|
272
|
+
end
|
|
278
273
|
|
|
279
|
-
@cost_known = true
|
|
280
274
|
@consecutive_format_errors = 0
|
|
281
275
|
@refused_turns = 0
|
|
282
|
-
@started_at = @clock.call
|
|
283
276
|
|
|
284
277
|
loop do
|
|
285
278
|
(status = limit_reached) and return finish(status)
|
|
@@ -294,6 +287,8 @@ module Miniswen
|
|
|
294
287
|
end
|
|
295
288
|
|
|
296
289
|
actions.each do |action|
|
|
290
|
+
return finish(:time_limit) if out_of_time?
|
|
291
|
+
|
|
297
292
|
reporter&.on_tool_call(action)
|
|
298
293
|
result = execute(action.fetch(:arguments).fetch("command"))
|
|
299
294
|
# The submit command's output is observed too, so the final tool
|
|
@@ -327,6 +322,49 @@ module Miniswen
|
|
|
327
322
|
|
|
328
323
|
private
|
|
329
324
|
|
|
325
|
+
def start(instruction)
|
|
326
|
+
uname = execute("uname -srvm").output.to_s.strip
|
|
327
|
+
@messages = [
|
|
328
|
+
{ role: "system", content: SYSTEM_TEMPLATE },
|
|
329
|
+
{ role: "user", content: format(INSTANCE_TEMPLATE,
|
|
330
|
+
instruction: instruction,
|
|
331
|
+
system_information: uname,
|
|
332
|
+
macos_sed_note: uname.start_with?("Darwin") ? "\n#{MACOS_SED_NOTE}" : "") }
|
|
333
|
+
]
|
|
334
|
+
|
|
335
|
+
@steps = 0
|
|
336
|
+
@cost = 0.0
|
|
337
|
+
@cost_known = true
|
|
338
|
+
@totals = { input_tokens: 0, output_tokens: 0, cached_tokens: 0, thinking_tokens: 0 }
|
|
339
|
+
@started_at = @clock.call
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
def resume(history)
|
|
343
|
+
@messages = answered_messages(history.messages)
|
|
344
|
+
|
|
345
|
+
@steps = history.steps.to_i
|
|
346
|
+
@cost = history.cost_usd.to_f
|
|
347
|
+
@cost_known = !history.cost_usd.nil?
|
|
348
|
+
@totals = { input_tokens: history.input_tokens.to_i, output_tokens: history.output_tokens.to_i,
|
|
349
|
+
cached_tokens: history.cached_tokens.to_i, thinking_tokens: history.thinking_tokens.to_i }
|
|
350
|
+
@started_at = @clock.call - elapsed(history.messages)
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# A turn whose calls did not all get a result cannot go back to a provider:
|
|
354
|
+
# it is dropped, and the model decides again from the tree it left.
|
|
355
|
+
def answered_messages(messages)
|
|
356
|
+
turn = messages.rindex { it[:role] == "assistant" && it[:tool_calls] }
|
|
357
|
+
return messages.dup unless turn
|
|
358
|
+
|
|
359
|
+
answered = messages[(turn + 1)..].filter_map { it[:tool_call_id] if it[:role] == "tool" }
|
|
360
|
+
messages[turn][:tool_calls].all? { answered.include?(it[:id]) } ? messages.dup : messages[0...turn]
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def elapsed(messages)
|
|
364
|
+
times = messages.filter_map { Time.parse(it[:timestamp]) if it[:role] == "assistant" && it[:timestamp] }
|
|
365
|
+
times.empty? ? 0.0 : times.max - times.min
|
|
366
|
+
end
|
|
367
|
+
|
|
330
368
|
def execute(command)
|
|
331
369
|
environment.exec(command, timeout: exec_timeout, env: EXEC_ENV)
|
|
332
370
|
end
|
|
@@ -334,12 +372,14 @@ module Miniswen
|
|
|
334
372
|
# Checked before the model is asked, so the tripping step is never paid for.
|
|
335
373
|
def limit_reached
|
|
336
374
|
return :step_limit if max_steps.positive? && @steps >= max_steps
|
|
337
|
-
return :time_limit if
|
|
375
|
+
return :time_limit if out_of_time?
|
|
338
376
|
return :cost_limit if max_cost && @cost_known && @cost >= max_cost
|
|
339
377
|
|
|
340
378
|
nil
|
|
341
379
|
end
|
|
342
380
|
|
|
381
|
+
def out_of_time? = max_time.positive? && (@clock.call - @started_at) >= max_time
|
|
382
|
+
|
|
343
383
|
# One model turn. Returns the actions to run, or nil after appending a
|
|
344
384
|
# format-error message the model gets to react to on its next turn.
|
|
345
385
|
def next_actions
|
|
@@ -568,6 +608,11 @@ module Miniswen
|
|
|
568
608
|
registry: nil)
|
|
569
609
|
end
|
|
570
610
|
return nil unless info
|
|
611
|
+
if free?
|
|
612
|
+
return CostSource.new(name: :free_model, model: @model,
|
|
613
|
+
priced_as: "#{info.provider}/#{info.id} ($0.00, free)",
|
|
614
|
+
registry: Miniswen.registry_revision)
|
|
615
|
+
end
|
|
571
616
|
|
|
572
617
|
CostSource.new(name: :model_registry, model: @model,
|
|
573
618
|
priced_as: "#{info.provider}/#{info.id}",
|
|
@@ -668,6 +713,8 @@ module Miniswen
|
|
|
668
713
|
|
|
669
714
|
def local? = LOCAL_PROVIDERS.include?((@provider || info&.provider)&.to_sym)
|
|
670
715
|
|
|
716
|
+
def free? = info&.provider == "openrouter" && info.input_price_per_million.nil? && info.output_price_per_million.nil?
|
|
717
|
+
|
|
671
718
|
def info
|
|
672
719
|
return @info if defined?(@info)
|
|
673
720
|
|
|
@@ -681,7 +728,7 @@ module Miniswen
|
|
|
681
728
|
end
|
|
682
729
|
|
|
683
730
|
def price(response)
|
|
684
|
-
return 0.0 if local?
|
|
731
|
+
return 0.0 if local? || free?
|
|
685
732
|
|
|
686
733
|
input = info&.input_price_per_million
|
|
687
734
|
output = info&.output_price_per_million
|
data/lib/miniswen/cli.rb
CHANGED
|
@@ -23,10 +23,14 @@ module Miniswen
|
|
|
23
23
|
@refresh_registry = false
|
|
24
24
|
@skip_registry_refresh = false
|
|
25
25
|
@jail = false
|
|
26
|
+
@allowed_hosts = nil
|
|
27
|
+
@workdir = nil
|
|
28
|
+
@history = nil
|
|
26
29
|
end
|
|
27
30
|
|
|
28
31
|
def run
|
|
29
32
|
parse_args!
|
|
33
|
+
Dir.chdir(@workdir) if @workdir
|
|
30
34
|
|
|
31
35
|
# Require the core library after parsing options,
|
|
32
36
|
# so env flags kick in
|
|
@@ -45,7 +49,7 @@ module Miniswen
|
|
|
45
49
|
Environment::Docker.new(@docker_id)
|
|
46
50
|
elsif @jail
|
|
47
51
|
require "miniswen/jail"
|
|
48
|
-
Jail.new.start
|
|
52
|
+
Jail.new(allowed_hosts: @allowed_hosts).start
|
|
49
53
|
else
|
|
50
54
|
Local.new
|
|
51
55
|
end
|
|
@@ -53,7 +57,7 @@ module Miniswen
|
|
|
53
57
|
agent = Agent.new(model:, reporter:, environment:, **options)
|
|
54
58
|
|
|
55
59
|
begin
|
|
56
|
-
result = agent.run(instruction)
|
|
60
|
+
result = agent.run(instruction, history: @history && Agent::Result.from_h(JSON.parse(@history)))
|
|
57
61
|
rescue StandardError => e
|
|
58
62
|
write_results(agent.partial_result(error_message(e)))
|
|
59
63
|
raise
|
|
@@ -102,7 +106,7 @@ module Miniswen
|
|
|
102
106
|
|
|
103
107
|
def parse_args!
|
|
104
108
|
parser = OptionParser.new do |opts|
|
|
105
|
-
opts.banner = "Usage: miniswen -m MODEL -p INSTRUCTION [...options]"
|
|
109
|
+
opts.banner = "Usage: miniswen -m MODEL (-p INSTRUCTION | --continue-from PATH) [...options]"
|
|
106
110
|
|
|
107
111
|
opts.on("-m MODEL", "--model=MODEL", String,
|
|
108
112
|
"LLM to use (litellm format, e.g.: openrouter/openai/gpt-5.6-luna") do |v|
|
|
@@ -113,6 +117,10 @@ module Miniswen
|
|
|
113
117
|
@instruction = File.file?(v) ? File.read(v) : v
|
|
114
118
|
end
|
|
115
119
|
|
|
120
|
+
opts.on("--continue-from=PATH", String, "Continue the session saved by --results-path at PATH (no -p needed)") do |v|
|
|
121
|
+
@history = File.read(v)
|
|
122
|
+
end
|
|
123
|
+
|
|
116
124
|
opts.on("--max-steps=STEPS", Integer, "Max steps count") do |v|
|
|
117
125
|
options[:max_steps] = v
|
|
118
126
|
end
|
|
@@ -161,6 +169,14 @@ module Miniswen
|
|
|
161
169
|
@jail = true
|
|
162
170
|
end
|
|
163
171
|
|
|
172
|
+
opts.on("--allow-hosts=HOSTS", String, "Let jailed commands reach these hosts through a proxy (comma-separated)") do |v|
|
|
173
|
+
@allowed_hosts = v.split(",").map(&:strip).reject(&:empty?)
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
opts.on("--workdir=DIR", String, "Run commands in DIR instead of the current directory") do |v|
|
|
177
|
+
@workdir = v
|
|
178
|
+
end
|
|
179
|
+
|
|
164
180
|
opts.on("--refresh-registry", "Refresh the model registry, persist it, and exit") do
|
|
165
181
|
@refresh_registry = true
|
|
166
182
|
end
|
|
@@ -186,7 +202,7 @@ module Miniswen
|
|
|
186
202
|
return if @refresh_registry
|
|
187
203
|
|
|
188
204
|
raise "Use -m to specify the model" unless @model
|
|
189
|
-
raise "Please, provide instructions via -p option" unless @instruction
|
|
205
|
+
raise "Please, provide instructions via -p option" unless @instruction || @history
|
|
190
206
|
end
|
|
191
207
|
end
|
|
192
208
|
end
|
|
@@ -20,7 +20,7 @@ module Miniswen
|
|
|
20
20
|
env&.each { |key, value| argv += [ "--env", "#{key}=#{value}" ] }
|
|
21
21
|
argv << id
|
|
22
22
|
argv += [ "timeout", timeout.ceil.to_s ] if timeout&.positive?
|
|
23
|
-
argv += [ "
|
|
23
|
+
argv += [ "bash", "-c", command ]
|
|
24
24
|
|
|
25
25
|
Open3.popen2e(*argv) do |stdin, pipe, wait|
|
|
26
26
|
stdin.close
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
28
|
+
raise InfrastructureError, "jail did not start: #{@stderr.read}" unless @stdout.gets == "ready\n"
|
|
22
29
|
|
|
23
|
-
|
|
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
|
|
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
|
-
[
|
|
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
|
-
"--", "setpriv", "--bounding-set=-all", "--inh-caps=-all", "--no-new-privs", "--", "
|
|
64
|
+
"--", "setpriv", "--bounding-set=-all", "--inh-caps=-all", "--no-new-privs", "--", "bash", "-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 ||=
|
|
82
|
+
@container_variables ||= container_env.keys | Agent::EXEC_ENV.keys
|
|
56
83
|
end
|
|
57
84
|
end
|
|
58
85
|
end
|
data/lib/miniswen/local.rb
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "
|
|
3
|
+
require "tempfile"
|
|
4
4
|
|
|
5
5
|
require "miniswen/environment"
|
|
6
6
|
|
|
@@ -12,24 +12,23 @@ 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
|
-
|
|
16
|
-
|
|
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(
|
|
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(
|
|
25
|
+
ExecResult.new(exit_code: exit_code(wait_thr.value), output: scrub(File.read(log)))
|
|
27
26
|
end
|
|
28
27
|
end
|
|
29
28
|
|
|
30
29
|
private
|
|
31
30
|
|
|
32
|
-
def spawn_arguments(command, env) = [ env || {}, "
|
|
31
|
+
def spawn_arguments(command, env) = [ env || {}, "bash", "-c", command ]
|
|
33
32
|
|
|
34
33
|
def spawn_options = { pgroup: true }
|
|
35
34
|
|
data/lib/miniswen/testing.rb
CHANGED
|
@@ -36,7 +36,13 @@ module Miniswen
|
|
|
36
36
|
modalities: { input: %w[text], output: %w[text] }
|
|
37
37
|
)
|
|
38
38
|
|
|
39
|
-
|
|
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
|
data/lib/miniswen/version.rb
CHANGED
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: miniswen
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Svyatoslav Kryukov
|
|
@@ -59,6 +59,7 @@ files:
|
|
|
59
59
|
- lib/miniswen/environment.rb
|
|
60
60
|
- lib/miniswen/environment/docker.rb
|
|
61
61
|
- lib/miniswen/jail.rb
|
|
62
|
+
- lib/miniswen/jail/proxy.rb
|
|
62
63
|
- lib/miniswen/local.rb
|
|
63
64
|
- lib/miniswen/ruby_llm.rb
|
|
64
65
|
- lib/miniswen/testing.rb
|