miniswen 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 +4 -4
- data/lib/miniswen/agent.rb +13 -2
- data/lib/miniswen/cli.rb +12 -1
- data/lib/miniswen/jail/proxy.rb +62 -0
- data/lib/miniswen/jail.rb +35 -8
- data/lib/miniswen/local.rb +5 -6
- 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: 49e7b2a4a062867c6b85476f94ebda50ce7d356add5500d40b2c5c513155f7ca
|
|
4
|
+
data.tar.gz: 4b5dfc61e161b9e2776b2547e8a10862e785fedcbd5fff1a1621054657ed21fb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b6e67519d94b5e32469173068f8121f8682dd5f16398c38c118f4516931d9e8b2e068e89f612bdc3d543df0a10cc9d8279bc425de404b1e63774085f77f8fab1
|
|
7
|
+
data.tar.gz: addc1fbee5c18257a2e0f115316a33a4f0f5e9233bb6a7f47ed43d244f1e49ec0a4fd912519e9442edb149a01c25d21cebe3f84cf8d4d6142d170bc30266c80b
|
data/lib/miniswen/agent.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
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 ||=
|
|
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,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
|
-
|
|
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
|
|
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.4.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
|