ace-test-runner-e2e 0.29.8 → 0.38.11

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.
Files changed (49) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/e2e-runner/config.yml +14 -2
  3. data/CHANGELOG.md +178 -0
  4. data/README.md +2 -2
  5. data/exe/ace-test-e2e-sh +9 -4
  6. data/handbook/guides/e2e-testing.g.md +43 -9
  7. data/handbook/guides/scenario-yml-reference.g.md +16 -8
  8. data/handbook/guides/tc-authoring.g.md +12 -5
  9. data/handbook/skills/as-e2e-fix/SKILL.md +2 -2
  10. data/handbook/skills/as-e2e-review/SKILL.md +2 -2
  11. data/handbook/templates/ace-taskflow-fixture.template.md +17 -17
  12. data/handbook/templates/agent-experience-report.template.md +3 -2
  13. data/handbook/templates/scenario.yml.template.yml +7 -2
  14. data/handbook/templates/tc-file.template.md +14 -4
  15. data/handbook/workflow-instructions/e2e/analyze-failures.wf.md +53 -6
  16. data/handbook/workflow-instructions/e2e/create.wf.md +118 -25
  17. data/handbook/workflow-instructions/e2e/execute.wf.md +11 -7
  18. data/handbook/workflow-instructions/e2e/fix.wf.md +65 -15
  19. data/handbook/workflow-instructions/e2e/plan-changes.wf.md +17 -1
  20. data/handbook/workflow-instructions/e2e/review.wf.md +36 -25
  21. data/handbook/workflow-instructions/e2e/rewrite.wf.md +15 -8
  22. data/handbook/workflow-instructions/e2e/run.wf.md +50 -26
  23. data/handbook/workflow-instructions/e2e/setup-sandbox.wf.md +4 -4
  24. data/lib/ace/test/end_to_end_runner/atoms/skill_prompt_builder.rb +7 -5
  25. data/lib/ace/test/end_to_end_runner/atoms/skill_result_parser.rb +73 -7
  26. data/lib/ace/test/end_to_end_runner/cli/commands/run_test.rb +21 -8
  27. data/lib/ace/test/end_to_end_runner/models/test_case.rb +8 -2
  28. data/lib/ace/test/end_to_end_runner/models/test_result.rb +9 -3
  29. data/lib/ace/test/end_to_end_runner/models/test_scenario.rb +4 -2
  30. data/lib/ace/test/end_to_end_runner/molecules/affected_detector.rb +7 -2
  31. data/lib/ace/test/end_to_end_runner/molecules/bwrap_sandbox_backend.rb +271 -0
  32. data/lib/ace/test/end_to_end_runner/molecules/config_loader.rb +28 -1
  33. data/lib/ace/test/end_to_end_runner/molecules/integration_runner.rb +122 -0
  34. data/lib/ace/test/end_to_end_runner/molecules/pipeline_executor.rb +157 -16
  35. data/lib/ace/test/end_to_end_runner/molecules/pipeline_prompt_bundler.rb +121 -8
  36. data/lib/ace/test/end_to_end_runner/molecules/pipeline_report_generator.rb +91 -19
  37. data/lib/ace/test/end_to_end_runner/molecules/pipeline_sandbox_builder.rb +119 -18
  38. data/lib/ace/test/end_to_end_runner/molecules/report_writer.rb +13 -12
  39. data/lib/ace/test/end_to_end_runner/molecules/sandbox_runtime_builder.rb +282 -0
  40. data/lib/ace/test/end_to_end_runner/molecules/scenario_loader.rb +85 -5
  41. data/lib/ace/test/end_to_end_runner/molecules/setup_executor.rb +98 -16
  42. data/lib/ace/test/end_to_end_runner/molecules/suite_report_writer.rb +241 -97
  43. data/lib/ace/test/end_to_end_runner/molecules/test_discoverer.rb +38 -13
  44. data/lib/ace/test/end_to_end_runner/molecules/test_executor.rb +27 -5
  45. data/lib/ace/test/end_to_end_runner/organisms/suite_orchestrator.rb +73 -15
  46. data/lib/ace/test/end_to_end_runner/organisms/test_orchestrator.rb +120 -19
  47. data/lib/ace/test/end_to_end_runner/version.rb +1 -1
  48. data/lib/ace/test/end_to_end_runner.rb +2 -0
  49. metadata +19 -2
@@ -0,0 +1,282 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "open3"
5
+
6
+ module Ace
7
+ module Test
8
+ module EndToEndRunner
9
+ module Molecules
10
+ # Builds a sandbox-local Ruby/Bundler runtime for E2E execution.
11
+ class SandboxRuntimeBuilder
12
+ DEFAULT_RUBY_VERSION = "3.4.9"
13
+ RESERVED_ENV_KEYS = %w[
14
+ PROJECT_ROOT_PATH
15
+ ACE_E2E_SOURCE_ROOT
16
+ ACE_CONFIG_PATH
17
+ BUNDLE_GEMFILE
18
+ BUNDLE_APP_CONFIG
19
+ BUNDLE_USER_HOME
20
+ BUNDLE_USER_CACHE
21
+ BUNDLE_USER_CONFIG
22
+ BUNDLE_PATH
23
+ BUNDLE_BIN
24
+ BUNDLE_DISABLE_SHARED_GEMS
25
+ BUNDLER_VERSION
26
+ GEM_HOME
27
+ GEM_PATH
28
+ RUBYOPT
29
+ RUBYLIB
30
+ ].freeze
31
+
32
+ def initialize(source_root:, ruby_version: nil, command_runner: nil)
33
+ @source_root = File.expand_path(source_root)
34
+ @ruby_version = (ruby_version || DEFAULT_RUBY_VERSION).to_s.strip
35
+ @command_runner = command_runner || method(:capture3)
36
+ end
37
+
38
+ def prepare(sandbox_root:, env: {}, tool_names: nil)
39
+ sandbox_root = File.expand_path(sandbox_root)
40
+ runtime_root = File.join(sandbox_root, ".ace-local", "e2e-runtime")
41
+ FileUtils.mkdir_p(runtime_root)
42
+
43
+ runtime_env = build_runtime_env(sandbox_root, runtime_root, env)
44
+ ensure_runtime_dirs(runtime_env)
45
+ write_runtime_gemfile(runtime_root)
46
+ write_command_shims(runtime_root, tool_names)
47
+ install_runtime!(runtime_root, runtime_env)
48
+
49
+ {
50
+ runtime_root: runtime_root,
51
+ env: runtime_env
52
+ }
53
+ end
54
+
55
+ private
56
+
57
+ def capture3(env, *cmd, chdir:)
58
+ Open3.capture3(env, *cmd, chdir: chdir, unsetenv_others: true)
59
+ end
60
+
61
+ def build_runtime_env(sandbox_root, runtime_root, env)
62
+ merged = stringify_keys(env).reject { |key, _value| RESERVED_ENV_KEYS.include?(key) }
63
+ bundler_root = File.join(runtime_root, "bundler")
64
+ gem_root = File.join(runtime_root, "gems")
65
+ bin_root = File.join(runtime_root, "bin")
66
+ path = merged["PATH"].to_s
67
+ path = ENV["PATH"].to_s if path.empty?
68
+
69
+ merged.merge(
70
+ "PROJECT_ROOT_PATH" => sandbox_root,
71
+ "ACE_E2E_SOURCE_ROOT" => @source_root,
72
+ "ACE_CONFIG_PATH" => File.join(sandbox_root, ".ace"),
73
+ "ACE_E2E_SANDBOX_RUNTIME_ROOT" => runtime_root,
74
+ "ACE_E2E_SANDBOX_RUBY_VERSION" => @ruby_version,
75
+ "ACE_E2E_SANDBOX_RUBY_ROOT" => (@ruby_version.empty? ? "" : resolve_ruby_install_path!),
76
+ "BUNDLE_GEMFILE" => File.join(runtime_root, "Gemfile"),
77
+ "BUNDLE_APP_CONFIG" => File.join(bundler_root, "app-config"),
78
+ "BUNDLE_USER_HOME" => File.join(bundler_root, "home"),
79
+ "BUNDLE_USER_CACHE" => File.join(bundler_root, "cache"),
80
+ "BUNDLE_USER_CONFIG" => File.join(bundler_root, "config"),
81
+ "BUNDLE_PATH" => gem_root,
82
+ "BUNDLE_DISABLE_SHARED_GEMS" => "true",
83
+ "BUNDLE_WITHOUT" => "",
84
+ "GEM_HOME" => gem_root,
85
+ "GEM_PATH" => gem_root,
86
+ "PATH" => [bin_root, path].reject(&:empty?).join(File::PATH_SEPARATOR)
87
+ )
88
+ end
89
+
90
+ def ensure_runtime_dirs(env)
91
+ [
92
+ env.fetch("ACE_CONFIG_PATH"),
93
+ env.fetch("BUNDLE_APP_CONFIG"),
94
+ env.fetch("BUNDLE_USER_HOME"),
95
+ env.fetch("BUNDLE_USER_CACHE"),
96
+ File.dirname(env.fetch("BUNDLE_USER_CONFIG")),
97
+ env.fetch("BUNDLE_PATH"),
98
+ env.fetch("GEM_HOME"),
99
+ File.join(env.fetch("ACE_E2E_SANDBOX_RUNTIME_ROOT"), "bin")
100
+ ].each do |path|
101
+ FileUtils.mkdir_p(path)
102
+ end
103
+ end
104
+
105
+ def write_runtime_gemfile(runtime_root)
106
+ source_gemfile = File.join(@source_root, "Gemfile")
107
+ content = File.read(source_gemfile)
108
+ rewritten = content.gsub(/(path:\s*["'])([^"']+)(["'])/) do
109
+ prefix = Regexp.last_match(1)
110
+ relative_path = Regexp.last_match(2)
111
+ suffix = Regexp.last_match(3)
112
+ absolute_path = File.expand_path(relative_path, @source_root)
113
+ "#{prefix}#{absolute_path}#{suffix}"
114
+ end
115
+ File.write(File.join(runtime_root, "Gemfile"), rewritten)
116
+ end
117
+
118
+ def write_command_shims(runtime_root, tool_names)
119
+ bin_root = File.join(runtime_root, "bin")
120
+ requested = normalize_tool_names(tool_names)
121
+ executable_map = discover_executable_map
122
+ names = executable_map.keys | requested
123
+
124
+ names.each do |tool_name|
125
+ source_exec = executable_map[tool_name]
126
+ next unless source_exec
127
+
128
+ shim_path = File.join(bin_root, tool_name)
129
+ shim_body = if @ruby_version.empty?
130
+ <<~SH
131
+ #!/usr/bin/env bash
132
+ set -euo pipefail
133
+ exec ruby -rbundler/setup "#{source_exec}" "$@"
134
+ SH
135
+ else
136
+ ruby_exec = ruby_executable_path
137
+ <<~SH
138
+ #!/usr/bin/env bash
139
+ set -euo pipefail
140
+ exec "#{ruby_exec}" -rbundler/setup "#{source_exec}" "$@"
141
+ SH
142
+ end
143
+ File.write(shim_path, shim_body)
144
+ FileUtils.chmod(0o755, shim_path)
145
+ end
146
+ end
147
+
148
+ def discover_executable_map
149
+ @discover_executable_map ||= begin
150
+ paths = Dir.glob(File.join(@source_root, "bin", "ace-*")).sort
151
+ Dir.glob(File.join(@source_root, "ace-*", "exe", "ace-*")).sort.each do |path|
152
+ basename = File.basename(path)
153
+ next if paths.any? { |candidate| File.basename(candidate) == basename }
154
+
155
+ paths << path
156
+ end
157
+
158
+ paths.each_with_object({}) do |path, map|
159
+ map[File.basename(path)] = path
160
+ end
161
+ end
162
+ end
163
+
164
+ def normalize_tool_names(tool_names)
165
+ Array(tool_names)
166
+ .flat_map { |entry| entry.to_s.split(",") }
167
+ .map(&:strip)
168
+ .reject(&:empty?)
169
+ .uniq
170
+ end
171
+
172
+ def install_runtime!(runtime_root, env)
173
+ marker_path = File.join(runtime_root, ".bootstrapped")
174
+ return if File.exist?(marker_path)
175
+
176
+ ensure_ruby_available!
177
+ ensure_no_global_ace_gems!
178
+
179
+ stdout, stderr, status = @command_runner.call(
180
+ install_env(env),
181
+ *runtime_command(%w[bundle install]),
182
+ chdir: runtime_root
183
+ )
184
+ return File.write(marker_path, "ok\n") if status.success?
185
+
186
+ raise [
187
+ "Sandbox bundle install failed for Ruby #{@ruby_version}",
188
+ stdout.to_s.strip,
189
+ stderr.to_s.strip
190
+ ].reject(&:empty?).join("\n")
191
+ end
192
+
193
+ def ensure_ruby_available!
194
+ resolve_ruby_install_path!
195
+ rescue RuntimeError => e
196
+ raise e
197
+
198
+ end
199
+
200
+ def resolve_ruby_install_path!
201
+ return @ruby_install_path if defined?(@ruby_install_path) && @ruby_install_path
202
+
203
+ stdout, stderr, status = @command_runner.call(
204
+ minimal_host_env,
205
+ "mise", "where", "ruby@#{@ruby_version}",
206
+ chdir: @source_root
207
+ )
208
+ if status.success?
209
+ @ruby_install_path = stdout.to_s.strip
210
+ return @ruby_install_path unless @ruby_install_path.empty?
211
+ end
212
+
213
+ raise [
214
+ "Dedicated sandbox Ruby #{ruby_label} is not available.",
215
+ "Install it with: mise install ruby@#{@ruby_version}",
216
+ stderr.to_s.strip
217
+ ].reject(&:empty?).join("\n")
218
+ end
219
+
220
+ def ensure_no_global_ace_gems!
221
+ script = <<~RUBY
222
+ names = Gem::Specification.map(&:name).grep(/^ace-/)
223
+ puts names.join("\\n") unless names.empty?
224
+ exit(names.empty? ? 0 : 42)
225
+ RUBY
226
+
227
+ stdout, stderr, status = @command_runner.call(
228
+ minimal_host_env,
229
+ *runtime_command(["ruby", "-e", script]),
230
+ chdir: @source_root
231
+ )
232
+ return if status.success?
233
+
234
+ raise [
235
+ "Dedicated sandbox Ruby #{ruby_label} already exposes ace-* gems globally.",
236
+ stdout.to_s.strip,
237
+ stderr.to_s.strip
238
+ ].reject(&:empty?).join("\n")
239
+ end
240
+
241
+ def runtime_command(cmd)
242
+ return cmd if @ruby_version.empty?
243
+ ruby = ruby_executable_path
244
+ command = Array(cmd)
245
+ executable = command.shift
246
+
247
+ case executable
248
+ when "ruby"
249
+ [ruby] + command
250
+ when "bundle", "gem"
251
+ [ruby, "-S", executable] + command
252
+ else
253
+ [ruby, "-S", executable] + command
254
+ end
255
+ end
256
+
257
+ def ruby_executable_path
258
+ File.join(resolve_ruby_install_path!, "bin", "ruby")
259
+ end
260
+
261
+ def minimal_host_env
262
+ {"HOME" => ENV["HOME"].to_s, "PATH" => ENV["PATH"].to_s}
263
+ end
264
+
265
+ def install_env(env)
266
+ minimal_host_env.merge(env).merge("PATH" => env.fetch("PATH"))
267
+ end
268
+
269
+ def ruby_label
270
+ @ruby_version.empty? ? "(default ruby)" : @ruby_version
271
+ end
272
+
273
+ def stringify_keys(hash)
274
+ hash.each_with_object({}) do |(key, value), acc|
275
+ acc[key.to_s] = value
276
+ end
277
+ end
278
+ end
279
+ end
280
+ end
281
+ end
282
+ end
@@ -47,7 +47,8 @@ module Ace
47
47
  test_cases: test_cases,
48
48
  tags: parse_tags(frontmatter["tags"]),
49
49
  tool_under_test: frontmatter["tool-under-test"],
50
- sandbox_layout: frontmatter["sandbox-layout"] || {}
50
+ sandbox_layout: frontmatter["sandbox-layout"] || {},
51
+ sandbox_profile: parse_sandbox_profile(frontmatter["sandbox-profile"], yml_path)
51
52
  )
52
53
  end
53
54
 
@@ -154,6 +155,13 @@ module Ace
154
155
  def parse_standalone_test_case(tc_id, runner_file, verify_file)
155
156
  runner_content = File.read(runner_file)
156
157
  verify_content = File.read(verify_file)
158
+ scenario_dir = File.dirname(runner_file)
159
+ declared_artifacts, optional_artifacts = declared_artifacts_for(
160
+ scenario_dir,
161
+ tc_id,
162
+ runner_content,
163
+ verify_content
164
+ )
157
165
 
158
166
  Models::TestCase.new(
159
167
  tc_id: tc_id,
@@ -161,7 +169,9 @@ module Ace
161
169
  content: build_standalone_content(runner_content, verify_content),
162
170
  file_path: File.expand_path(runner_file),
163
171
  pending: nil,
164
- goal_format: "standalone"
172
+ goal_format: "standalone",
173
+ declared_artifacts: declared_artifacts,
174
+ optional_artifacts: optional_artifacts
165
175
  )
166
176
  end
167
177
 
@@ -223,6 +233,18 @@ module Ace
223
233
  tags.map(&:to_s).map(&:strip).reject(&:empty?).map(&:downcase)
224
234
  end
225
235
 
236
+ def parse_sandbox_profile(raw_profile, source_path)
237
+ profile = raw_profile.to_s.strip
238
+ return Molecules::ConfigLoader.default_sandbox_profile if profile.empty?
239
+
240
+ allowed = %w[ace-default bundle-only custom]
241
+ return profile if allowed.include?(profile)
242
+
243
+ raise ArgumentError,
244
+ "Invalid sandbox-profile in #{source_path}: #{profile.inspect}. " \
245
+ "Allowed values: #{allowed.join(", ")}"
246
+ end
247
+
226
248
  # Detect fixtures directory if it exists
227
249
  #
228
250
  # @param scenario_dir [String] Path to the scenario directory
@@ -232,6 +254,66 @@ module Ace
232
254
  Dir.exist?(path) ? File.expand_path(path) : nil
233
255
  end
234
256
 
257
+ def declared_artifacts_for(scenario_dir, tc_id, runner_content, verify_content)
258
+ scenario_frontmatter = parse_scenario_yml(File.join(scenario_dir, "scenario.yml"))
259
+ required_artifacts = []
260
+ optional_artifacts = []
261
+
262
+ required_artifacts.concat(
263
+ Array((scenario_frontmatter["sandbox-layout"] || {}).keys).select do |path|
264
+ declared_artifact_matches_tc?(path, tc_id)
265
+ end
266
+ )
267
+
268
+ [runner_content, verify_content].each do |content|
269
+ extract_declared_artifacts(content).each do |entry|
270
+ if entry[:optional]
271
+ optional_artifacts << entry[:path]
272
+ else
273
+ required_artifacts << entry[:path]
274
+ end
275
+ end
276
+ end
277
+
278
+ required = required_artifacts.map { |path| normalize_declared_artifact(path) }.compact.uniq
279
+ optional = optional_artifacts.map { |path| normalize_declared_artifact(path) }.compact.uniq
280
+ optional = optional.reject { |path| required.include?(path) }
281
+
282
+ [required.sort, optional.sort]
283
+ end
284
+
285
+ def declared_artifact_matches_tc?(path, tc_id)
286
+ artifact_index = extract_declared_artifact_index(path)
287
+ return true if artifact_index.nil?
288
+
289
+ tc_index = extract_tc_index(tc_id)
290
+ tc_index && artifact_index == tc_index
291
+ end
292
+
293
+ def extract_declared_artifact_index(path)
294
+ match = path.to_s.match(%r{\Aresults/tc/(\d{1,3})(?:/|$)})
295
+ match ? match[1].to_i : nil
296
+ end
297
+
298
+ def extract_tc_index(tc_id)
299
+ match = tc_id.to_s.match(/\ATC-(\d+)/i)
300
+ match ? match[1].to_i : nil
301
+ end
302
+
303
+ def extract_declared_artifacts(markdown)
304
+ markdown.to_s.scan(%r{(?:`|"|')?(results/tc/\d{2}/[^\s`)"']+|results/tc/\d{2}/)(?:`|"|')?(\s*\(optional\))?}i).map do |match|
305
+ path, optional = match
306
+ {path: path, optional: !optional.to_s.empty?}
307
+ end
308
+ end
309
+
310
+ def normalize_declared_artifact(path)
311
+ value = path.to_s.strip
312
+ return nil unless value.start_with?("results/tc/")
313
+
314
+ value.sub(%r{/+\z}, "")
315
+ end
316
+
235
317
  # Infer package name from scenario directory path
236
318
  #
237
319
  # @param scenario_dir [String] Path to scenario directory
@@ -240,9 +322,7 @@ module Ace
240
322
  # Expected path: {package}/test/e2e/TS-{AREA}-{NNN}-{slug}/
241
323
  parts = File.expand_path(scenario_dir).split("/")
242
324
  parts.each_with_index do |part, idx|
243
- next unless part == "test" && idx > 0 && parts[idx + 1] == "e2e"
244
-
245
- return parts[idx - 1]
325
+ return parts[idx - 1] if part == "test" && idx > 0 && parts[idx + 1] == "e2e"
246
326
  end
247
327
 
248
328
  "unknown"
@@ -17,6 +17,20 @@ module Ace
17
17
  # Note: This is a Molecule because it performs filesystem I/O and
18
18
  # system calls via Open3 and FileUtils.
19
19
  class SetupExecutor
20
+ AMBIENT_TMUX_ENV_VARS = %w[TMUX TMUX_PANE].freeze
21
+ BUNDLER_ENV_PREFIXES = %w[BUNDLE BUNDLER].freeze
22
+ STRIPPED_ENV_KEYS = %w[RUBYOPT RUBYLIB].freeze
23
+ RESERVED_ENV_KEYS = Molecules::SandboxRuntimeBuilder::RESERVED_ENV_KEYS + %w[
24
+ PATH HOME TMPDIR XDG_RUNTIME_DIR TMUX_TMPDIR ACE_TMUX_SESSION
25
+ ]
26
+
27
+ def initialize(command_runner: nil, system_runner: nil, time_source: nil, sandbox_backend: nil)
28
+ @command_runner = command_runner || method(:capture3)
29
+ @system_runner = system_runner || method(:system)
30
+ @time_source = time_source || -> { Time.now.to_i }
31
+ @sandbox_backend = sandbox_backend
32
+ end
33
+
20
34
  # Execute all setup steps in a sandbox directory
21
35
  #
22
36
  # @param setup_steps [Array] Setup steps from scenario.yml
@@ -25,29 +39,54 @@ module Ace
25
39
  # @param scenario_name [String, nil] Test ID for tmux session naming (e.g., "TS-OVERSEER-001")
26
40
  # @param run_id [String, nil] Unique run ID for deterministic tmux session naming
27
41
  # @return [Hash] Result with :success, :steps_completed, :error, :env, :tmux_session keys
28
- def execute(setup_steps:, sandbox_dir:, fixture_source: nil, scenario_name: nil, run_id: nil)
42
+ def execute(setup_steps:, sandbox_dir:, fixture_source: nil, scenario_name: nil, run_id: nil, initial_env: {})
29
43
  FileUtils.mkdir_p(sandbox_dir)
30
- env = {}
44
+ env = if @sandbox_backend
45
+ @sandbox_backend.prepared_env(initial_env.dup)
46
+ else
47
+ initial_env.dup
48
+ end
31
49
  steps_completed = 0
32
50
  @tmux_session = nil
33
51
  @scenario_name = scenario_name
34
52
  @run_id = run_id
53
+ @teardown_env = nil
35
54
 
36
55
  setup_steps.each do |step|
37
56
  execute_step(step, sandbox_dir, env, fixture_source)
38
57
  steps_completed += 1
39
58
  end
40
59
 
41
- {success: true, steps_completed: steps_completed, error: nil, env: env, tmux_session: @tmux_session}
60
+ {
61
+ success: true,
62
+ steps_completed: steps_completed,
63
+ error: nil,
64
+ env: merged_environment(env),
65
+ tmux_session: @tmux_session
66
+ }
42
67
  rescue => e
43
- {success: false, steps_completed: steps_completed, error: e.message, env: env, tmux_session: @tmux_session}
68
+ {
69
+ success: false,
70
+ steps_completed: steps_completed,
71
+ error: e.message,
72
+ env: merged_environment(env),
73
+ tmux_session: @tmux_session
74
+ }
44
75
  end
45
76
 
46
77
  # Clean up resources created during setup (e.g. tmux session)
47
78
  def teardown
48
79
  return unless @tmux_session
49
80
 
50
- system("tmux", "kill-session", "-t", @tmux_session, out: File::NULL, err: File::NULL)
81
+ if @sandbox_backend
82
+ @sandbox_backend.capture3(
83
+ ["tmux", "kill-session", "-t", @tmux_session],
84
+ chdir: @teardown_env&.fetch("PROJECT_ROOT_PATH", Dir.pwd) || Dir.pwd,
85
+ env: @teardown_env || {}
86
+ )
87
+ else
88
+ @system_runner.call("tmux", "kill-session", "-t", @tmux_session, out: File::NULL, err: File::NULL)
89
+ end
51
90
  @tmux_session = nil
52
91
  end
53
92
 
@@ -99,13 +138,23 @@ module Ace
99
138
  session_name = if name_source == "run-id" && @run_id && !@run_id.to_s.empty?
100
139
  @run_id
101
140
  else
102
- @scenario_name ? "#{@scenario_name}-e2e" : "ace-e2e-#{Time.now.to_i}"
141
+ @scenario_name ? "#{@scenario_name}-e2e" : "ace-e2e-#{@time_source.call}"
142
+ end
143
+ tmux_env = merged_environment(env).merge("TMUX_TMPDIR" => env["TMUX_TMPDIR"].to_s.empty? ? nil : env["TMUX_TMPDIR"])
144
+ if @sandbox_backend
145
+ _stdout, stderr, status = @sandbox_backend.capture3(
146
+ ["tmux", "new-session", "-d", "-s", session_name],
147
+ chdir: env["PROJECT_ROOT_PATH"] || Dir.pwd,
148
+ env: tmux_env
149
+ )
150
+ else
151
+ _stdout, stderr, status = @command_runner.call(tmux_env, "tmux", "new-session", "-d", "-s", session_name)
103
152
  end
104
- _stdout, stderr, status = Open3.capture3("tmux", "new-session", "-d", "-s", session_name)
105
153
  raise "Failed to create tmux session '#{session_name}': #{stderr.strip}" unless status.success?
106
154
 
107
155
  @tmux_session = session_name
108
156
  env["ACE_TMUX_SESSION"] = session_name
157
+ @teardown_env = merged_environment(env)
109
158
  end
110
159
 
111
160
  # Initialize a git repo with test user config
@@ -123,20 +172,25 @@ module Ace
123
172
  end
124
173
 
125
174
  # Execute a shell command in the sandbox
126
- # NOTE: Uses shell invocation (bash -lc) intentionally to support
127
- # shell operators (&&, |, >) in scenario.yml setup steps. Commands originate from
175
+ # NOTE: Uses shell invocation intentionally to support shell operators
176
+ # (&&, |, >) in scenario.yml setup steps. Commands originate from
128
177
  # committed scenario.yml files, not user input, so shell injection risk is mitigated.
178
+ # We explicitly disable profile/rc loading to keep sandbox env authoritative.
129
179
  def handle_run(command, sandbox_dir, env)
130
180
  full_env = merged_environment(env)
131
- # Re-export env vars after profile sourcing to protect against
132
- # mise's shell hook clobbering.
181
+ # Re-export env vars inside the command to keep explicit sandbox
182
+ # values authoritative across compound shell expressions.
133
183
  export_vars = env.dup
134
184
  %w[PROJECT_ROOT_PATH].each do |key|
135
185
  export_vars[key] ||= ENV[key] if ENV[key]
136
186
  end
137
187
  exports = export_vars.map { |k, v| "export #{k}=#{Shellwords.shellescape(v.to_s)}" }.join("; ")
138
188
  wrapped = exports.empty? ? command : "#{exports}; #{command}"
139
- stdout, stderr, status = Open3.capture3(full_env, "bash", "-lc", wrapped, chdir: sandbox_dir)
189
+ stdout, stderr, status = if @sandbox_backend
190
+ @sandbox_backend.capture3(["bash", "--noprofile", "--norc", "-c", wrapped], chdir: sandbox_dir, env: full_env)
191
+ else
192
+ Open3.capture3(full_env, "bash", "--noprofile", "--norc", "-c", wrapped, chdir: sandbox_dir)
193
+ end
140
194
 
141
195
  unless status.success?
142
196
  raise "Setup step 'run' failed (exit #{status.exitstatus}): #{command}\n#{stderr}"
@@ -154,7 +208,12 @@ module Ace
154
208
 
155
209
  # Merge environment variables for subsequent steps
156
210
  def handle_env(vars, env)
157
- vars.each { |k, v| env[k.to_s] = v.to_s }
211
+ vars.each do |k, v|
212
+ key = k.to_s
213
+ next if RESERVED_ENV_KEYS.include?(key)
214
+
215
+ env[key] = v.to_s
216
+ end
158
217
  end
159
218
 
160
219
  # Merge custom env vars with the process environment
@@ -162,18 +221,41 @@ module Ace
162
221
  # @param env [Hash] Custom environment variables
163
222
  # @return [Hash] Merged environment
164
223
  def merged_environment(env)
165
- return ENV.to_h if env.empty?
166
- ENV.to_h.merge(env.transform_keys(&:to_s))
224
+ base_env = sanitized_process_environment
225
+ return base_env if env.empty?
226
+
227
+ base_env.merge(env.transform_keys(&:to_s))
167
228
  end
168
229
 
169
230
  # Run a command and raise on failure
170
231
  def run_command(*args, chdir:, env: {})
171
- _stdout, stderr, status = Open3.capture3(merged_environment(env), *args, chdir: chdir)
232
+ merged_env = merged_environment(env)
233
+ _stdout, stderr, status = if @sandbox_backend
234
+ @sandbox_backend.capture3(args, chdir: chdir, env: merged_env)
235
+ else
236
+ @command_runner.call(merged_env, *args, chdir: chdir)
237
+ end
172
238
 
173
239
  unless status.success?
174
240
  raise "Command failed (exit #{status.exitstatus}): #{args.join(" ")}\n#{stderr}"
175
241
  end
176
242
  end
243
+
244
+ def capture3(*args, **kwargs)
245
+ Open3.capture3(*args, **kwargs)
246
+ end
247
+
248
+ def sanitized_process_environment
249
+ ENV.to_h.each_with_object({}) do |(key, value), env|
250
+ if AMBIENT_TMUX_ENV_VARS.include?(key) || STRIPPED_ENV_KEYS.include?(key) ||
251
+ BUNDLER_ENV_PREFIXES.any? { |prefix| key.start_with?(prefix) }
252
+ env[key] = nil
253
+ next
254
+ end
255
+
256
+ env[key] = value
257
+ end
258
+ end
177
259
  end
178
260
  end
179
261
  end