lemans 1.4.0 → 1.5.1

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,18 +7,20 @@ module Lemans
7
7
  # Renders stored results as a table or CSV. The store is the source of
8
8
  # truth; rows are plain hashes derived from Result records.
9
9
  class Report
10
- COLUMNS = %i[task agent model reward credit outcome scored cost_usd steps tokens duration started_at trial
11
- tags detail].freeze
12
- TABLE_COLUMNS = %i[task agent model reward credit outcome cost_usd steps tokens duration trial].freeze
13
- NUMERIC_COLUMNS = %i[reward credit cost_usd steps tokens duration].freeze
10
+ COLUMNS = %i[task agent model reward credit completed_steps total_steps features_passed features_total outcome
11
+ scored cost_usd steps tokens duration started_at trial tags detail].freeze
12
+ TABLE_COLUMNS = %i[task agent model reward credit progress features outcome cost_usd steps tokens duration
13
+ trial].freeze
14
+ NUMERIC_COLUMNS = %i[reward credit progress features cost_usd steps tokens duration].freeze
14
15
 
15
- attr_reader :rows, :unreadable
16
+ attr_reader :rows, :unreadable, :show_features, :hide_columns
16
17
 
17
18
  class << self
18
- def load(store, tags: nil, names: nil, metadata: nil)
19
+ def load(store, tags: nil, names: nil, metadata: nil, skip_invalid: false, **)
19
20
  rows = store.query(task: names, tags:, metadata:).map { row_from(it) }
21
+ rows = rows.select { it[:scored] } if skip_invalid
20
22
  new(rows.sort_by { [ it[:task].to_s, it[:started_at].to_s, it[:trial].to_s ] },
21
- unreadable: store.unreadable.size)
23
+ unreadable: store.unreadable.size, **)
22
24
  end
23
25
 
24
26
  def row_from(result)
@@ -29,6 +31,9 @@ module Lemans
29
31
  model: result.model,
30
32
  reward: result.reward,
31
33
  credit: result.credit,
34
+ features: result.features,
35
+ completed_steps: result.steps&.size,
36
+ total_steps: result.total_steps,
32
37
  outcome: result.status,
33
38
  scored: result.scored?,
34
39
  detail: result.detail,
@@ -44,6 +49,38 @@ module Lemans
44
49
  }
45
50
  end
46
51
 
52
+ # Steps completed out of the task's steps; unknown for older runs halted midway
53
+ def progress_ratio(row) = row[:total_steps] && Rational(row[:completed_steps], row[:total_steps])
54
+
55
+ # Feature columns carry a prefix no regular column has
56
+ def feature_column(name) = :"feat:#{name}"
57
+
58
+ def feature_of(row, column) = row[:features]&.[](column.to_s.delete_prefix("feat:"))
59
+
60
+ # Features passed and graded in a run, nil when it graded none
61
+ def features_tally(row)
62
+ features = row[:features]
63
+ [ features.count { |_, passed| passed }, features.size ] if features && !features.empty?
64
+ end
65
+
66
+ def duration_display(sec)
67
+ return "-" if sec.nil?
68
+
69
+ minutes, seconds = sec.round.divmod(60)
70
+ minutes.positive? ? "#{minutes}m #{seconds}s" : "#{seconds}s"
71
+ end
72
+
73
+ def cost_display(value) = value.nil? ? "-" : "$#{format("%g", value.round(4))}"
74
+
75
+ def tokens_display(value)
76
+ return "-" if value.nil?
77
+
78
+ [ [ 1e9, "B" ], [ 1e6, "M" ], [ 1e3, "K" ] ].each do |unit, suffix|
79
+ return format("%.1f#{suffix}", value / unit) if value >= unit
80
+ end
81
+ value.round.to_s
82
+ end
83
+
47
84
  # A bench may name no model at all (nop, oracle); the summary needs a
48
85
  # label, not a nil for ljust to crash on.
49
86
  def short_model(model) = model.to_s.split("/").last || "(default)"
@@ -72,27 +109,63 @@ module Lemans
72
109
  end
73
110
  end
74
111
 
75
- # One sorting rule for every view: validate the column name and keep
76
- # rows that never measured the value at the bottom.
77
- def sort_column(name, allowed:)
78
- column = name.to_s.to_sym
79
- return column if allowed.include?(column)
112
+ # One sorting rule for every view. `score-credit` sorts by score, then
113
+ # credit; `^` reverses a column's natural order. Column names may hold
114
+ # dashes (features), so the longest known name wins.
115
+ def sort_columns(spec, allowed:)
116
+ columns, unknown = split_columns(spec, allowed:)
117
+ return columns if unknown.empty? && columns.any?
118
+
119
+ raise ConfigError, "--sort: unknown column in #{spec.inspect} (try #{allowed.join(", ")})"
120
+ end
121
+
122
+ # `--hide-columns steps-tokens`: names nobody knows are let go
123
+ def hidden_columns(spec, allowed:) = split_columns(spec, allowed:).first.map(&:first)
124
+
125
+ # [[column, reversed], ...] and the parts that name no column
126
+ def split_columns(spec, allowed:)
127
+ names = allowed.map(&:to_s).sort_by { -it.length }
128
+ rest = spec.to_s
129
+ columns = []
130
+ unknown = []
131
+ until rest.empty?
132
+ reversed = rest.start_with?("^")
133
+ rest = rest.delete_prefix("^")
134
+ if (name = names.find { rest == it || rest.start_with?("#{it}-") })
135
+ columns << [ name.to_sym, reversed ]
136
+ else
137
+ unknown << (name = rest[/\A[^-]*/])
138
+ end
139
+ rest = rest.delete_prefix(name).delete_prefix("-")
140
+ end
141
+ [ columns, unknown ]
142
+ end
80
143
 
81
- raise ConfigError, "--sort: unknown column #{name.inspect} (try #{allowed.join(", ")})"
144
+ # Keys are [value, descending] pairs, tried in order; rows that never
145
+ # measured a value sink below the rest, and ties keep their order.
146
+ def sort_rows(rows, keys)
147
+ keyed = rows.each_with_index.map { |row, index| [ keys.map { |value, _| value.call(row) }, index, row ] }
148
+ keyed.sort { |(a, i, _), (b, j, _)| compare(a, b, keys.map(&:last)).nonzero? || i <=> j }.map(&:last)
82
149
  end
83
150
 
84
- def sort_rows(rows, descending: false)
85
- keyed = rows.map { [ yield(it), it ] }
86
- present, missing = keyed.partition { |value, _| value }
87
- sorted = present.sort_by { |value, _| value }
88
- sorted.reverse! if descending
89
- (sorted + missing).map(&:last)
151
+ private def compare(values, others, descending)
152
+ values.zip(others, descending).each do |value, other, desc|
153
+ next if value == other
154
+ return 1 if value.nil?
155
+ return -1 if other.nil?
156
+
157
+ order = value <=> other
158
+ return desc ? -order : order unless order.zero?
159
+ end
160
+ 0
90
161
  end
91
162
  end
92
163
 
93
- def initialize(rows, unreadable: 0)
94
- @rows = rows
164
+ def initialize(rows, unreadable: 0, show_features: false, hide_columns: nil)
165
+ @rows = with_total_steps(rows)
95
166
  @unreadable = unreadable
167
+ @show_features = show_features
168
+ @hide_columns = hide_columns
96
169
  end
97
170
 
98
171
  # A store holding only unreadable results is not empty: the report's
@@ -101,9 +174,12 @@ module Lemans
101
174
 
102
175
  # Numbers rank best-first the way a leaderboard reads; names sort A-Z.
103
176
  # Trials that never measured the column sink to the bottom either way.
104
- def order_by!(column)
105
- column = self.class.sort_column(column, allowed: TABLE_COLUMNS)
106
- @rows = self.class.sort_rows(rows, descending: NUMERIC_COLUMNS.include?(column)) { it[column] }
177
+ def order_by!(spec)
178
+ keys = self.class.sort_columns(spec, allowed: TABLE_COLUMNS + feature_columns).map do |column, reversed|
179
+ numeric = NUMERIC_COLUMNS.include?(column) || feature_columns.include?(column)
180
+ [ ->(row) { sort_value(row, column) }, numeric != reversed ]
181
+ end
182
+ @rows = self.class.sort_rows(rows, keys)
107
183
  self
108
184
  end
109
185
 
@@ -113,13 +189,42 @@ module Lemans
113
189
 
114
190
  def fractional? = rows.any? { it[:credit] && it[:credit] != it[:reward] }
115
191
 
116
- def table_columns = fractional? ? TABLE_COLUMNS : TABLE_COLUMNS - [ :credit ]
192
+ def multistep? = rows.any? { it[:completed_steps] }
193
+
194
+ def features? = rows.any? { self.class.features_tally(it) }
195
+
196
+ # The features every task in view tracks; tasks with no graded run yet have no say.
197
+ # Shown one column each only on request.
198
+ def feature_columns
199
+ @feature_columns ||= rows.select { it[:features] }
200
+ .group_by { it[:task] }
201
+ .map { |_, group| group.flat_map { it[:features].keys }.uniq }
202
+ .reduce(:&).to_a.sort.map { self.class.feature_column(it) }
203
+ end
204
+
205
+ def shown_feature_columns = show_features ? feature_columns : []
206
+
207
+ def table_columns
208
+ columns = TABLE_COLUMNS - [ (:credit unless fractional?), (:progress unless multistep?),
209
+ (:features unless features?) ].compact
210
+ columns.insert(columns.index(:trial), *shown_feature_columns)
211
+ columns - self.class.hidden_columns(hide_columns, allowed: columns)
212
+ end
117
213
 
118
214
  def to_rows
119
215
  [ table_columns.map(&:to_s) ] +
120
216
  rows.map do |row|
121
217
  table_columns.map do |column|
122
- display(column == :model ? short_model(row[:model]) : row[column])
218
+ case column
219
+ when :model then display(short_model(row[:model]))
220
+ when :progress then progress(row)
221
+ when :duration then self.class.duration_display(row[:duration])
222
+ when :cost_usd then self.class.cost_display(row[:cost_usd])
223
+ when :tokens then self.class.tokens_display(row[:tokens])
224
+ when :features then self.class.features_tally(row)&.join("/") || "-"
225
+ when *feature_columns then { true => "✓", false => "✗" }.fetch(self.class.feature_of(row, column), "-")
226
+ else display(row[column])
227
+ end
123
228
  end
124
229
  end
125
230
  end
@@ -140,15 +245,48 @@ module Lemans
140
245
 
141
246
  def to_csv
142
247
  CSV.generate do |csv|
143
- csv << COLUMNS
248
+ csv << COLUMNS + shown_feature_columns
144
249
  rows.each do |row|
145
- csv << COLUMNS.map { |column| column == :tags ? Array(row[:tags]).join(" ") : row[column] }
250
+ csv << COLUMNS.map { csv_value(row, it) } +
251
+ shown_feature_columns.map { self.class.feature_of(row, it) }
146
252
  end
147
253
  end
148
254
  end
149
255
 
150
256
  private
151
257
 
258
+ def csv_value(row, column)
259
+ case column
260
+ when :tags then Array(row[:tags]).join(" ")
261
+ when :features_passed then self.class.features_tally(row)&.first
262
+ when :features_total then self.class.features_tally(row)&.last
263
+ else row[column]
264
+ end
265
+ end
266
+
267
+ def sort_value(row, column)
268
+ if column == :model then short_model(row[:model])
269
+ elsif column == :progress then self.class.progress_ratio(row)
270
+ elsif column == :features then self.class.features_tally(row)&.then { Rational(*it) }
271
+ elsif feature_columns.include?(column) then { true => 1, false => 0 }[self.class.feature_of(row, column)]
272
+ else row[column]
273
+ end
274
+ end
275
+
276
+ # Older multistep results lack the task's step count: a run of the same
277
+ # task that solved it went through every step.
278
+ def with_total_steps(rows)
279
+ known = rows.filter_map do |row|
280
+ total = row[:total_steps] || (row[:completed_steps] if row[:reward].to_f >= 1.0)
281
+ [ row[:task], total ] if total
282
+ end.to_h
283
+ rows.map do |row|
284
+ next row if row[:total_steps] || !row[:completed_steps] || !known[row[:task]]
285
+
286
+ row.merge(total_steps: known[row[:task]])
287
+ end
288
+ end
289
+
152
290
  # The rank divides solved by scored, not total: invalid trials measured nothing.
153
291
  def stats(group)
154
292
  totals = self.class.tally(group).merge(cost_usd: group.sum { it[:cost_usd].to_f })
@@ -169,6 +307,8 @@ module Lemans
169
307
 
170
308
  def short_model(model) = self.class.short_model(model)
171
309
 
310
+ def progress(row) = row[:completed_steps] ? "#{row[:completed_steps]}/#{row[:total_steps] || "?"}" : "-"
311
+
172
312
  def display(value)
173
313
  case value
174
314
  when nil then "-"
data/lib/lemans/cli.rb CHANGED
@@ -78,49 +78,80 @@ module Lemans
78
78
  return
79
79
  end
80
80
 
81
- reporter =
82
- if interactive?
83
- BoardReporter.new(tasks: tasks.map(&:name), models: config.models,
84
- attempts: config.attempts, total: runner.attempts.size)
85
- else
86
- ProgressReporter.new(shell:, tasks: tasks.map(&:name))
87
- end
81
+ execute(runner, store, tasks)
82
+ rescue ConfigError => e
83
+ raise Thor::Error, "lemans: #{e.message}"
84
+ rescue Interrupt
85
+ say ""
86
+ exit 130
87
+ end
88
88
 
89
- reporter.start
89
+ desc "restart RUN...", "Continue failed multistep runs from their last settled step in new runs"
90
+ long_desc <<~DESC
91
+ RUN is a run directory or a trial id; every run named restarts under the same options. The new run replays the settled steps' agent patches in a
92
+ fresh sandbox and starts at the next step; the failed run stays as it is. --recover also replays
93
+ the failed step's partial patch and lets the agent go on from its history; --reverify replays the
94
+ graded step's patch and runs its verification again.
95
+ DESC
96
+ option :bench, default: ".", desc: "Directory holding bench.yml"
97
+ option :runs_dir, default: "./runs", desc: "Directory holding the runs; the new runs go there too"
98
+ option :concurrency, type: :numeric, aliases: "-c", desc: "Restarts in flight at once (default: the bench's)"
99
+ option :backend, enum: Environments::BACKENDS.keys, desc: "Sandbox backend (default: daytona)"
100
+ option :max_output_tokens, type: :numeric, banner: "TOKENS",
101
+ desc: "Cap the agent's output per model call (default: the provider's)"
102
+ option :recover, type: :boolean, default: false,
103
+ desc: "Continue the failed step's agent session from its saved history"
104
+ option :reverify, type: :boolean, default: false,
105
+ desc: "Grade the last verified step again (with the current tests) and go on from there"
106
+ option :allow_scored, type: :boolean, default: false, desc: "Restart a scored run (--reverify always may)"
107
+ def restart(*runs)
108
+ raise Thor::Error, "lemans: name the run(s) to restart" if runs.empty?
109
+ raise Thor::Error, "lemans: --recover and --reverify exclude each other" if options[:recover] && options[:reverify]
90
110
 
91
- summary = runner.run(reporter)
111
+ Miniswen.refresh_registry!
92
112
 
93
- say ""
94
- say_status :report, "collecting results from #{options[:runs_dir]}", :cyan
95
- print_report Report.load(store)
113
+ store = Stores::FS.new(options[:runs_dir], filterer: SecretsFilter.default)
114
+ ids = runs.map { File.basename(it) }.uniq
115
+ found = store.fetch.select { ids.include?(it.id) }.to_h { [ it.id, it ] }
116
+ missing = ids - found.keys
117
+ raise Thor::Error, "lemans: no run #{missing.join(", ")} under #{options[:runs_dir]}" if missing.any?
96
118
 
97
- exit 130 if summary.status == :interrupted
98
- exit 1 if summary.status == :invalid
119
+ sources = found.values_at(*ids)
120
+
121
+ # The board lays out every model the runs used, as wide as their highest attempt
122
+ config = Config.load_file(options[:bench])
123
+ config.load_options(**options.transform_keys(&:to_sym), model: sources.map(&:model).uniq,
124
+ attempts: sources.filter_map(&:index).max)
125
+
126
+ tasks = filter_tasks(config.tasks, name: sources.map(&:task).uniq)
127
+
128
+ mode = (:recover if options[:recover]) || (:reverify if options[:reverify])
129
+ runner = Runner.new(config, tasks, store:, restarts: sources, restart_mode: mode, allow_scored: options[:allow_scored])
130
+
131
+ execute(runner, store, tasks)
99
132
  rescue ConfigError => e
100
133
  raise Thor::Error, "lemans: #{e.message}"
101
134
  rescue Interrupt
102
135
  say ""
103
136
  exit 130
104
- ensure
105
- reporter&.stop
106
137
  end
107
138
 
108
- desc "clobber", "Delete run results"
109
- option :runs_dir, default: "./runs", desc: "Directory holding run directories"
139
+ desc "clobber [RUNS_DIR]", "Delete run results"
140
+ option :runs_dir, default: "./runs", desc: "Directory holding run directories (or pass it as RUNS_DIR)"
110
141
  option :task, desc: "Only these tasks' runs", repeatable: true
111
142
  option :ttl, desc: "Only runs older than this (10m, 2h, 1d)"
112
143
  option :invalid, type: :boolean, default: false, desc: "Only runs that measured nothing (invalid or unreadable)"
113
144
  option :force, type: :boolean, default: false, aliases: "-f", desc: "Delete without asking"
114
- def clobber
115
- store = Stores::FS.new(options[:runs_dir])
145
+ def clobber(runs_dir = options[:runs_dir])
146
+ store = Stores::FS.new(runs_dir)
116
147
  clobber = Clobber.new(store, tasks: options[:task], ttl: options[:ttl], invalid: options[:invalid])
117
148
 
118
149
  doomed = clobber.matches
119
- return say "lemans: nothing to clobber under #{options[:runs_dir]}" if doomed.empty?
150
+ return say "lemans: nothing to clobber under #{runs_dir}" if doomed.empty?
120
151
 
121
152
  unless options[:force]
122
153
  doomed.each { say it.id }
123
- return say "lemans: nothing deleted" unless yes?("Delete #{doomed.size} run(s) under #{options[:runs_dir]}? [y/N]")
154
+ return say "lemans: nothing deleted" unless yes?("Delete #{doomed.size} run(s) under #{runs_dir}? [y/N]")
124
155
  end
125
156
 
126
157
  removed = clobber.execute!
@@ -129,38 +160,44 @@ module Lemans
129
160
  raise Thor::Error, "lemans: #{e.message}"
130
161
  end
131
162
 
132
- desc "regrade", "Re-grade stored results from their checks.json after a verification_test.rb grading change"
163
+ desc "regrade [RUNS_DIR]", "Re-grade stored results from their checks.json after a verification_test.rb grading change"
133
164
  option :bench, default: ".", desc: "Directory holding bench.yml"
134
165
  option :task, desc: "Re-grade these tasks' runs", repeatable: true, required: true
135
- option :runs_dir, default: "./runs", desc: "Directory holding run directories"
166
+ option :runs_dir, default: "./runs", desc: "Directory holding run directories (or pass it as RUNS_DIR)"
136
167
  option :mapping, banner: "PATH",
137
168
  desc: "Grade by this checks.json-shaped file (every check `fail` or `fail (allowed)`, plus `grading`) " \
138
169
  "instead of reading verification_test.rb"
139
- def regrade
140
- store = Stores::FS.new(options[:runs_dir])
170
+ def regrade(runs_dir = options[:runs_dir])
171
+ store = Stores::FS.new(runs_dir)
141
172
  tasks = filter_tasks(Config.load_file(options[:bench]).tasks, name: options[:task])
142
173
  raise Thor::Error, "lemans: --mapping re-grades one task at a time" if options[:mapping] && tasks.size > 1
143
174
 
144
175
  tasks.each do |task|
145
176
  mapping = options[:mapping] ? Regrade.mapping_from_file(options[:mapping]) : Regrade.mapping_for(task)
146
177
  regrade = Regrade.new(store, task.name, mapping:)
178
+ mapping.stray_features.to_a.each { say_status :warning, "#{it}: @feature comment outside any test", :yellow }
147
179
  regrade.verify_mapping! unless options[:mapping]
148
180
 
149
181
  changes, skipped = regrade.execute!
150
182
  changes.each { say_status :regraded, "#{it.result.id} #{grade_change(it)}", :green }
151
183
  skipped.each { |result, reason| say_status :skipped, "#{result.id} #{reason}", :yellow }
152
184
  say "#{task.name}: #{changes.size} re-graded, #{skipped.size} skipped"
185
+
186
+ next unless task.multistep?
187
+
188
+ counted = regrade.record_total_steps!(task.steps)
189
+ say "#{task.name}: #{counted.size} run(s) gained total_steps: #{task.steps}" if counted.any?
153
190
  end
154
191
 
155
192
  say ""
156
- say_status :report, "collecting results from #{options[:runs_dir]}", :cyan
193
+ say_status :report, "collecting results from #{runs_dir}", :cyan
157
194
  print_report Report.load(store, names: tasks.map(&:name))
158
195
  rescue ConfigError => e
159
196
  raise Thor::Error, "lemans: #{e.message}"
160
197
  end
161
198
 
162
- desc "report", "Summarize run results as a table or CSV"
163
- option :runs_dir, default: "runs", desc: "Directory holding run directories"
199
+ desc "report [RUNS_DIR]", "Summarize run results as a table or CSV"
200
+ option :runs_dir, default: "runs", desc: "Directory holding run directories (or pass it as RUNS_DIR)"
164
201
  option :tag, desc: "Only runs whose result carries this tag", repeatable: true
165
202
  option :task, desc: "Only these tasks' runs", repeatable: true
166
203
  option :metadata, banner: "KEY:VALUE", desc: "Only runs whose task metadata has this value (every pair must match)",
@@ -168,11 +205,17 @@ module Lemans
168
205
  option :format, default: "table", enum: %w[table csv], desc: "Output format"
169
206
  option :aggregate, aliases: "-A", banner: "COLUMNS", lazy_default: "task-model",
170
207
  desc: "Group results by 1-3 dash-joined columns (task, agent, model)"
171
- option :sort, aliases: "-S", banner: "COLUMN", desc: "Sort by a column"
172
- def report
173
- store = Stores::FS.new(options[:runs_dir])
208
+ option :sort, aliases: "-S", banner: "COLUMNS",
209
+ desc: "Sort by dash-joined columns, e.g. score-credit (numbers high to low, names A-Z; ^column reverses)"
210
+ option :skip_invalid, type: :boolean, default: false, desc: "Leave out trials with an invalid outcome"
211
+ option :show_features, type: :boolean, default: false, desc: "Add a feat:<name> column per tracked feature"
212
+ option :hide_columns, banner: "COLUMNS", desc: "Leave dash-joined columns out of the table, e.g. steps-tokens-trial"
213
+ def report(runs_dir = options[:runs_dir])
214
+ store = Stores::FS.new(runs_dir)
174
215
  results = Report.load(store, tags: options[:tag], names: options[:task],
175
- metadata: Report.metadata_filter(options[:metadata]))
216
+ metadata: Report.metadata_filter(options[:metadata]),
217
+ skip_invalid: options[:skip_invalid], show_features: options[:show_features],
218
+ hide_columns: options[:hide_columns])
176
219
  raise Thor::Error, "lemans: no matching results found" if results.empty?
177
220
 
178
221
  results = Report::Aggregate.new(results, keys: Report::Aggregate.keys(options[:aggregate])) if options[:aggregate]
@@ -184,6 +227,29 @@ module Lemans
184
227
 
185
228
  private
186
229
 
230
+ def execute(runner, store, tasks)
231
+ reporter =
232
+ if interactive?
233
+ BoardReporter.new(tasks: tasks.map(&:name), models: runner.config.models,
234
+ attempts: runner.config.attempts, total: runner.attempts.size)
235
+ else
236
+ ProgressReporter.new(shell:, tasks: tasks.map(&:name))
237
+ end
238
+
239
+ reporter.start
240
+
241
+ summary = runner.run(reporter)
242
+
243
+ say ""
244
+ say_status :report, "collecting results from #{options[:runs_dir]}", :cyan
245
+ print_report Report.load(store)
246
+
247
+ exit 130 if summary.status == :interrupted
248
+ exit 1 if summary.status == :invalid
249
+ ensure
250
+ reporter&.stop
251
+ end
252
+
187
253
  def filter_tasks(tasks, tags: nil, name: nil)
188
254
  tasks = tasks.dup
189
255
 
@@ -199,9 +265,19 @@ module Lemans
199
265
  end
200
266
 
201
267
  def grade_change(change)
202
- %i[reward credit].map { |grade| "#{grade} #{change[grade].map(&:inspect).join(" -> ")}" }.join(" ")
268
+ grades = %i[reward credit].map { |grade| "#{grade} #{change[grade].map(&:inspect).join(" -> ")}" }
269
+ before, after = change.features
270
+ return grades.join(" ") if before == after
271
+
272
+ features = (before.to_h.keys | after.to_h.keys).sort.filter_map do |name|
273
+ was, now = [ before, after ].map { feature_mark(it&.fetch(name, nil)) }
274
+ "#{name} #{was} -> #{now}" if was != now
275
+ end
276
+ [ *grades, "features #{features.join(", ")}" ].join(" ")
203
277
  end
204
278
 
279
+ def feature_mark(passed) = { true => "✓", false => "✗" }.fetch(passed, "-")
280
+
205
281
  def print_report(report)
206
282
  print_table report.to_rows
207
283
  color = report.summary[:invalid].positive? ? :red : nil
@@ -0,0 +1,4 @@
1
+ FROM ruby:3.4-alpine
2
+ COPY server.rb /server.rb
3
+ EXPOSE 3128
4
+ ENTRYPOINT ["ruby", "/server.rb"]
@@ -0,0 +1,49 @@
1
+ # Allowlist proxy
2
+
3
+ This is an HTTP proxy that lets clients reach the allowed hosts only. The Docker backend runs it for the `allowlist` network mode.
4
+
5
+ The proxy supports two request types:
6
+
7
+ - `CONNECT host:port` tunnels (https).
8
+ - Absolute-form requests (`GET http://host/path`) for plain http.
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.
11
+
12
+ ## How the Docker backend uses it
13
+
14
+ 1. Lemans puts the task container on an internal network. An internal network has no route out.
15
+ 2. Lemans connects the proxy container to the internal network and to `bridge`.
16
+ 3. Lemans sets `http_proxy` and `https_proxy` for each command in the task container.
17
+
18
+ A tool that ignores the proxy variables cannot reach the network.
19
+
20
+ When the allowed hosts change, lemans replaces the proxy container.
21
+
22
+ ## Run it standalone
23
+
24
+ Build the image:
25
+
26
+ ```sh
27
+ docker build --tag lemans-proxy lib/lemans/environments/docker/proxy
28
+ ```
29
+
30
+ Start the proxy. Give the allowed hosts as arguments:
31
+
32
+ ```sh
33
+ docker run --rm --publish 3128:3128 lemans-proxy rubygems.org index.rubygems.org
34
+ ```
35
+
36
+ Send requests through it:
37
+
38
+ ```sh
39
+ curl --proxy http://localhost:3128 https://rubygems.org # allowed
40
+ curl --proxy http://localhost:3128 https://example.com # 403 Forbidden
41
+ ```
42
+
43
+ To run it without Docker, use Ruby 3.4 or later:
44
+
45
+ ```sh
46
+ ruby lib/lemans/environments/docker/proxy/server.rb rubygems.org
47
+ ```
48
+
49
+ The `PORT` variable changes the listen port. The default port is 3128.
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require "uri"
5
+
6
+ # An HTTP proxy that lets clients reach the allowed hosts only:
7
+ # CONNECT tunnels for https, absolute-form requests for plain http.
8
+ class AllowlistProxy
9
+ HOP_HEADERS = /\A(?:connection|keep-alive|proxy-[\w-]+):/i
10
+
11
+ def initialize(hosts, port: 3128)
12
+ @hosts = hosts.map(&:downcase)
13
+ @port = port
14
+ end
15
+
16
+ def run
17
+ server = TCPServer.new("0.0.0.0", @port)
18
+ $stdout.puts "listening on #{@port}: #{@hosts.join(", ")}"
19
+ $stdout.flush
20
+ Thread.report_on_exception = false
21
+ loop { Thread.new(server.accept) { handle(it) } }
22
+ end
23
+
24
+ private
25
+
26
+ def handle(client)
27
+ head = client.gets("\r\n\r\n") or return
28
+ request_line, *headers = head.split("\r\n")
29
+ verb, target, version = request_line.split(" ", 3)
30
+
31
+ verb == "CONNECT" ? tunnel(client, target) : forward(client, verb, target, version, headers)
32
+ rescue SystemCallError, IOError, SocketError, URI::Error
33
+ client.write("HTTP/1.1 502 Bad Gateway\r\n\r\n") rescue nil # rubocop:disable Style/RescueModifier
34
+ ensure
35
+ client.close
36
+ end
37
+
38
+ def tunnel(client, target)
39
+ host, port = target.split(":", 2)
40
+ return deny(client) unless allowed?(host)
41
+
42
+ upstream = Socket.tcp(host, port.to_i, connect_timeout: 10)
43
+ client.write("HTTP/1.1 200 Connection Established\r\n\r\n")
44
+ pump(client, upstream)
45
+ end
46
+
47
+ # One request per connection: the upstream is told to close after the response.
48
+ def forward(client, verb, target, version, headers)
49
+ uri = URI(target)
50
+ return deny(client) unless uri.is_a?(URI::HTTP) && allowed?(uri.host)
51
+
52
+ upstream = Socket.tcp(uri.host, uri.port, connect_timeout: 10)
53
+ headers = headers.grep_v(HOP_HEADERS)
54
+ upstream.write("#{verb} #{uri.request_uri} #{version}\r\n#{headers.join("\r\n")}\r\nConnection: close\r\n\r\n")
55
+ pump(client, upstream)
56
+ end
57
+
58
+ def allowed?(host) = @hosts.include?(host.to_s.downcase)
59
+
60
+ def deny(client) = client.write("HTTP/1.1 403 Forbidden\r\n\r\n")
61
+
62
+ def pump(client, upstream)
63
+ Thread.new { copy(client, upstream) }
64
+ copy(upstream, client)
65
+ ensure
66
+ upstream.close
67
+ end
68
+
69
+ # readpartial drains what gets already buffered, so a request body is not lost.
70
+ def copy(from, to)
71
+ loop { to.write(from.readpartial(65_536)) }
72
+ rescue EOFError, IOError, SystemCallError
73
+ nil
74
+ ensure
75
+ to.close_write rescue nil # rubocop:disable Style/RescueModifier
76
+ end
77
+ end
78
+
79
+ AllowlistProxy.new(ARGV, port: Integer(ENV.fetch("PORT", "3128"))).run if $PROGRAM_NAME == __FILE__