ace-test-runner-e2e 0.40.6 → 0.41.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.
@@ -1,8 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
3
4
  require "fileutils"
5
+ require "json"
4
6
  require "open3"
5
7
  require "shellwords"
8
+ require "time"
9
+
10
+ require_relative "release_manifest"
6
11
 
7
12
  module Ace
8
13
  module Test
@@ -12,7 +17,8 @@ module Ace
12
17
  #
13
18
  # Processes the setup array from scenario.yml, running each action
14
19
  # via Ruby system calls (no LLM involved). Supports: git-init,
15
- # copy-fixtures, run, write-file, agent-env, and tmux-session actions.
20
+ # copy-fixtures, run, write-file, agent-env, tmux-session, and
21
+ # release-manifest actions.
16
22
  #
17
23
  # Note: This is a Molecule because it performs filesystem I/O and
18
24
  # system calls via Open3 and FileUtils.
@@ -23,6 +29,20 @@ module Ace
23
29
  RESERVED_ENV_KEYS = Molecules::SandboxRuntimeBuilder::RESERVED_ENV_KEYS + %w[
24
30
  PATH HOME TMPDIR XDG_RUNTIME_DIR TMUX_TMPDIR ACE_TMUX_SESSION
25
31
  ]
32
+ RELEASE_MANIFEST_DEFAULT_PATH = File.join(".ace-local", "release", "installation-manifest.json")
33
+ # Retry trust state lives in the report directory — host-side,
34
+ # outside the runner-writable sandbox. A marker inside the sandbox
35
+ # would let a compromised runner forge reuse state, redirect
36
+ # manifest copies outside the sandbox, or skip revalidation.
37
+ SETUP_STATE_FILE = ".ace-e2e-setup-state.json"
38
+ # Only the sandbox runtime contract is persisted for reuse. The
39
+ # merged setup environment inherits the host process environment,
40
+ # which can carry secrets that must never land in a state file.
41
+ PERSISTED_ENV_KEYS = (
42
+ Molecules::SandboxRuntimeBuilder::RESERVED_ENV_KEYS + %w[
43
+ PATH ACE_E2E_SANDBOX_RUNTIME_ROOT ACE_E2E_SANDBOX_RUBY_VERSION ACE_E2E_SANDBOX_RUBY_ROOT
44
+ ]
45
+ ).freeze
26
46
 
27
47
  def initialize(command_runner: nil, system_runner: nil, time_source: nil, sandbox_backend: nil)
28
48
  @command_runner = command_runner || method(:capture3)
@@ -38,16 +58,41 @@ module Ace
38
58
  # @param fixture_source [String, nil] Path to the fixtures/ directory
39
59
  # @param scenario_name [String, nil] Test ID for tmux session naming (e.g., "TS-OVERSEER-001")
40
60
  # @param run_id [String, nil] Unique run ID for deterministic tmux session naming
61
+ # @param release_manifest_path [String, nil] Explicit absolute manifest path carried
62
+ # from the invoking process (ACE_RELEASE_MANIFEST). When nil, the release-manifest
63
+ # step falls back to the default path under ACE_E2E_SOURCE_ROOT.
64
+ # @param state_file [String, nil] Host-side path (report directory) for retry
65
+ # state. When nil, no retry state is recorded and the sandbox is never reused.
41
66
  # @return [Hash] Result with :success, :steps_completed, :error, :env, :tmux_session keys
42
67
  def execute(setup_steps:, sandbox_dir:, fixture_source: nil, scenario_name: nil, run_id: nil, initial_env: {},
43
- git_excludes: [])
68
+ git_excludes: [], release_manifest_path: nil, state_file: nil)
69
+ # Initialized before any filesystem operation: an early failure
70
+ # must reach the rescue as a clean setup failure, not as a
71
+ # secondary error from nil state.
72
+ env = {}
73
+ steps_completed = 0
74
+ @tmux_session = nil
75
+ @teardown_env = nil
44
76
  FileUtils.mkdir_p(sandbox_dir)
77
+ # A rerun invalidates any earlier success up front: state is
78
+ # recreated only after every step and the persisted env succeed.
79
+ if state_file
80
+ FileUtils.rm_f(state_file)
81
+ # The prior attempt's completion record must never bless a
82
+ # retried run that fails before rewriting it.
83
+ FileUtils.rm_f(File.join(File.dirname(state_file), "metadata.yml"))
84
+ end
85
+ # Prior-attempt goal evidence must never survive into a retried
86
+ # run: the goals rebuild results/ from scratch.
87
+ FileUtils.rm_rf(File.join(sandbox_dir, "results"))
45
88
  env = if @sandbox_backend
46
89
  @sandbox_backend.prepared_env(initial_env.dup)
47
90
  else
48
91
  initial_env.dup
49
92
  end
50
93
  @git_excludes = normalize_git_excludes(git_excludes)
94
+ @release_manifest_input = release_manifest_path
95
+ @release_manifest_state = nil
51
96
  steps_completed = 0
52
97
  @tmux_session = nil
53
98
  @scenario_name = scenario_name
@@ -58,12 +103,40 @@ module Ace
58
103
  execute_step(step, sandbox_dir, env, fixture_source)
59
104
  steps_completed += 1
60
105
  end
106
+ merged = merged_environment(env)
107
+ if state_file
108
+ FileUtils.mkdir_p(File.dirname(state_file))
109
+ persisted = merged.slice(*PERSISTED_ENV_KEYS).compact
110
+ # The live value may be relative (agent-env PROJECT_ROOT_PATH: .);
111
+ # the persisted contract must be absolute so a reused sandbox is
112
+ # recognized as prepared by the pipeline.
113
+ if persisted["PROJECT_ROOT_PATH"] && !persisted["PROJECT_ROOT_PATH"].start_with?("/")
114
+ persisted["PROJECT_ROOT_PATH"] = File.expand_path(persisted["PROJECT_ROOT_PATH"], sandbox_dir)
115
+ end
116
+ generated_gemfile = File.join(sandbox_dir, "Gemfile")
117
+ source_root = env["ACE_E2E_SOURCE_ROOT"].to_s
118
+ source_gemfile = source_root.empty? ? nil : File.join(source_root, "Gemfile")
119
+ File.write(state_file, JSON.generate({
120
+ "completed_at" => Time.now.utc.iso8601,
121
+ "sandbox_dir" => sandbox_dir,
122
+ "setup_steps" => self.class.normalize_state_steps(setup_steps),
123
+ "release_manifest" => @release_manifest_state,
124
+ "env" => persisted,
125
+ "gemfile" => {
126
+ # The sandbox Gemfile is derived from the source root's
127
+ # Gemfile; a retry must regenerate it when either changes.
128
+ "source_root" => source_root,
129
+ "source_digest" => source_gemfile && File.file?(source_gemfile) ? Digest::SHA256.file(source_gemfile).hexdigest : nil,
130
+ "generated_digest" => File.file?(generated_gemfile) ? Digest::SHA256.file(generated_gemfile).hexdigest : nil
131
+ }
132
+ }))
133
+ end
61
134
 
62
135
  {
63
136
  success: true,
64
137
  steps_completed: steps_completed,
65
138
  error: nil,
66
- env: merged_environment(env),
139
+ env: merged,
67
140
  tmux_session: @tmux_session
68
141
  }
69
142
  rescue => e
@@ -129,6 +202,8 @@ module Ace
129
202
  handle_env(value, env)
130
203
  when "tmux-session"
131
204
  handle_tmux_session(env, value)
205
+ when "release-manifest"
206
+ handle_release_manifest(value, sandbox_dir, env)
132
207
  else
133
208
  raise ArgumentError, "Unknown setup step type: #{key.inspect}"
134
209
  end
@@ -219,6 +294,180 @@ module Ace
219
294
  end
220
295
  end
221
296
 
297
+ # Validate the frozen release manifest and copy its exact bytes into
298
+ # the sandbox. The source is the explicitly allowed input carried
299
+ # from the invoking process (ACE_RELEASE_MANIFEST, absolute path);
300
+ # without it, the default path under ACE_E2E_SOURCE_ROOT is used.
301
+ # Any missing, unreadable, malformed, or conflicting manifest raises
302
+ # here so setup fails before any install goal can run.
303
+ def handle_release_manifest(config, sandbox_dir, env)
304
+ target = config.is_a?(Hash) ? config["to"] : config
305
+ raise ArgumentError, "release-manifest step requires a target path" if target.to_s.strip.empty?
306
+
307
+ required = config.is_a?(Hash) ? Array(config["require"]) : []
308
+ source = release_manifest_source(env)
309
+ # The scenario declares the destination; the copy itself is still
310
+ # confined to the sandbox with no symlinked path components.
311
+ target_path = self.class.validated_manifest_target(sandbox_dir, target)
312
+ raise ArgumentError, "release-manifest target escapes the sandbox: #{target}" unless target_path
313
+
314
+ FileUtils.mkdir_p(File.dirname(target_path))
315
+ digest = Molecules::ReleaseManifest.validate_and_copy(
316
+ source_path: source,
317
+ target_path: target_path,
318
+ required_packages: required
319
+ )
320
+ @release_manifest_state = {
321
+ "source" => File.expand_path(source),
322
+ "target" => target,
323
+ "digest" => digest
324
+ }
325
+ end
326
+
327
+ def release_manifest_source(env)
328
+ self.class.release_manifest_source_for(env, @release_manifest_input)
329
+ end
330
+
331
+ class << self
332
+ # Retry state for a completed setup. Returns nil when there is no
333
+ # usable state (no file, unreadable, from another sandbox, or
334
+ # recorded for different setup steps).
335
+ def setup_state_for(state_file, sandbox_dir, setup_steps: nil)
336
+ return nil unless state_file && File.file?(state_file)
337
+
338
+ state = JSON.parse(File.read(state_file))
339
+ return nil unless state.is_a?(Hash)
340
+ return nil unless state["sandbox_dir"] &&
341
+ File.expand_path(state["sandbox_dir"]) == File.expand_path(sandbox_dir)
342
+ return nil if setup_steps && state["setup_steps"] != normalize_state_steps(setup_steps)
343
+
344
+ state
345
+ rescue JSON::ParserError, SystemCallError
346
+ nil
347
+ end
348
+
349
+ def normalize_state_steps(setup_steps)
350
+ Array(setup_steps).map { |step| step.is_a?(Hash) ? [step.keys.first, step.values.first] : step }
351
+ end
352
+
353
+ # The manifest copy destination comes from trusted setup state,
354
+ # but the runner controls the sandbox filesystem in between: a
355
+ # symlinked path component could redirect the host-side write
356
+ # outside the sandbox. Parent components must be real
357
+ # directories; the final component may be an existing regular
358
+ # file (the previous validated copy).
359
+ def validated_manifest_target(sandbox_dir, target)
360
+ sandbox_root = File.expand_path(sandbox_dir)
361
+ # A symlinked sandbox root would redirect every confinement
362
+ # check below it.
363
+ return nil if File.lstat(sandbox_root).symlink?
364
+ relative = target.to_s.split("/")
365
+ current = sandbox_root
366
+ components = relative.reject { |part| part.empty? || part == "." }
367
+ components.each_with_index do |part, index|
368
+ current = File.join(current, part)
369
+ # lstat every component: File.exist? follows symlinks, so a
370
+ # dangling symlink would slip past an existence check and the
371
+ # write would create its target outside the sandbox. ENOENT
372
+ # means the component is genuinely absent (created fresh by
373
+ # mkdir_p); any other stat failure rejects reuse.
374
+ lstat = begin
375
+ File.lstat(current)
376
+ rescue Errno::ENOENT
377
+ next
378
+ rescue SystemCallError
379
+ return nil
380
+ end
381
+ return nil if lstat.symlink?
382
+
383
+ last = index == components.size - 1
384
+ if last
385
+ return nil if lstat.directory?
386
+ elsif !lstat.directory?
387
+ return nil
388
+ end
389
+ end
390
+ resolved = File.expand_path(current)
391
+ return nil unless resolved.start_with?(sandbox_root + File::SEPARATOR)
392
+
393
+ resolved
394
+ rescue SystemCallError
395
+ nil
396
+ end
397
+
398
+ # Revalidate the current manifest input against a completed
399
+ # sandbox before it is reused: the validated copy in the sandbox
400
+ # must still match the invoking process's manifest. Returns true
401
+ # when reuse may proceed (no manifest gate recorded, or the
402
+ # current input hashes to the recorded digest); false when the
403
+ # sandbox must be set up again.
404
+ def revalidate_release_manifest(state_file:, sandbox_dir:, explicit: nil, setup_steps: nil)
405
+ state = setup_state_for(state_file, sandbox_dir, setup_steps: setup_steps)
406
+ return false unless state
407
+
408
+ recorded = state["release_manifest"]
409
+ return true if recorded.nil?
410
+
411
+ target_path = validated_manifest_target(sandbox_dir, recorded["target"])
412
+ return false unless target_path
413
+
414
+ source = begin
415
+ release_manifest_source_for(state["env"] || {}, explicit)
416
+ rescue Molecules::ReleaseManifest::Invalid
417
+ return false
418
+ end
419
+
420
+ # The source path itself must be the recorded one: an identical
421
+ # copy at a different path would be rejected by finalization's
422
+ # recorded-source binding, so reuse must not accept it either.
423
+ return false unless File.expand_path(source) == File.expand_path(recorded["source"].to_s)
424
+
425
+ # Read once: the digest check and the copy must see the same
426
+ # bytes even if the source changes mid-revalidation.
427
+ raw = begin
428
+ File.binread(source)
429
+ rescue SystemCallError
430
+ return false
431
+ end
432
+ return false if Digest::SHA256.hexdigest(raw) != recorded["digest"]
433
+
434
+ FileUtils.mkdir_p(File.dirname(target_path))
435
+ File.binwrite(target_path, raw)
436
+ true
437
+ end
438
+
439
+ # Retry reuse rebuilds the environment from the allow-listed
440
+ # runtime keys only (same boundary as initial setup), overlaid
441
+ # with the recorded sandbox runtime paths. Host secrets — even
442
+ # ones present in the current process — never reach the runner.
443
+ def build_reuse_env(state_env)
444
+ (PERSISTED_ENV_KEYS | %w[HOME PATH TMPDIR]).each_with_object({}) do |key, env|
445
+ value = state_env[key]
446
+ env[key] = value unless value.nil?
447
+ end
448
+ end
449
+
450
+ def release_manifest_source_for(env, explicit)
451
+ explicit = explicit.to_s.strip
452
+ if explicit.empty?
453
+ source_root = env["ACE_E2E_SOURCE_ROOT"].to_s.strip
454
+ if source_root.empty?
455
+ raise Molecules::ReleaseManifest::Invalid,
456
+ "no release manifest input and no ACE_E2E_SOURCE_ROOT to derive the default path"
457
+ end
458
+ return File.join(source_root, RELEASE_MANIFEST_DEFAULT_PATH)
459
+ end
460
+
461
+ unless explicit.start_with?("/")
462
+ raise Molecules::ReleaseManifest::Invalid,
463
+ "ACE_RELEASE_MANIFEST must be an absolute JSON file path, got: #{explicit}"
464
+ end
465
+
466
+ explicit
467
+ end
468
+ end
469
+
470
+
222
471
  # Merge custom env vars with the process environment
223
472
  #
224
473
  # @param env [Hash] Custom environment variables
@@ -55,6 +55,25 @@ module Ace
55
55
  @shared_runtime_root = nil
56
56
  end
57
57
 
58
+ # Scenarios whose most recent cached run ended in uncertain execution.
59
+ # They are excluded from automatic retry surfaces; callers must
60
+ # reconcile them manually.
61
+ #
62
+ # @param packages [Array<String>, nil] restrict to these packages
63
+ # @return [Array<String>] sorted test-ids with uncertain latest runs
64
+ def uncertain_scenarios(packages: nil)
65
+ names = packages ? packages.split(",").map(&:strip) : @discoverer.list_packages(base_dir: @base_dir)
66
+ return [] if names.empty?
67
+
68
+ @failure_finder.find_uncertain_scenarios(packages: names, base_dir: @base_dir)
69
+ .values
70
+ .flatten
71
+ .sort
72
+ rescue => e
73
+ warn "Warning: could not scan for uncertain scenarios: #{e.message}" if ENV["DEBUG"]
74
+ []
75
+ end
76
+
58
77
  # Run E2E tests across all packages
59
78
  #
60
79
  # @param options [Hash] Execution options
@@ -103,7 +122,9 @@ module Ace
103
122
  scenario_failures = nil
104
123
  if options[:only_failures]
105
124
  scenario_failures = @failure_finder.find_failures_by_scenario(
106
- packages: packages, base_dir: @base_dir
125
+ packages: packages,
126
+ base_dir: @base_dir,
127
+ include_uncertain: options.fetch(:include_uncertain_failures, false)
107
128
  )
108
129
 
109
130
  if scenario_failures.empty?
@@ -574,6 +595,9 @@ module Ace
574
595
  metadata = YAML.safe_load_file(metadata_path, permitted_classes: [Date])
575
596
  status = metadata["status"]
576
597
  return result unless status
598
+ # Retained metadata must never upgrade a fresh pipeline ERROR to
599
+ # PASS — an error stays authoritative until explicitly reconciled.
600
+ return result if result[:status] == "error" && status == "pass"
577
601
 
578
602
  passed = metadata["tcs-passed"] || metadata.dig("results", "passed") || result[:passed_cases] || 0
579
603
  total = metadata["tcs-total"] || metadata.dig("results", "total") || result[:total_cases] || 0