ace-test-runner-e2e 0.40.6 → 0.42.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.
@@ -0,0 +1,212 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "fileutils"
5
+ require "json"
6
+ require "rubygems/version"
7
+
8
+ module Ace
9
+ module Test
10
+ module EndToEndRunner
11
+ module Molecules
12
+ # Validates the frozen release installation manifest used by release
13
+ # verification scenarios (TS-MONO-001).
14
+ #
15
+ # The manifest is an exact-version contract: schema_version 1, a full
16
+ # source commit, and a nonempty package array. Every package entry
17
+ # carries a unique ACE gem name, one exact artifact version (never a
18
+ # requirement), the full source commit the artifact was built from,
19
+ # and an optional explicit supersession list of earlier versions of
20
+ # the same package.
21
+ #
22
+ # Validation is strict on purpose: unknown fields, missing fields,
23
+ # duplicate names, non-exact versions, malformed SHAs, and
24
+ # contradictory supersession entries all fail before any install
25
+ # attempt is made.
26
+ class ReleaseManifest
27
+ SCHEMA_VERSION = 1
28
+ GEM_NAME_PATTERN = /\Aace-[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\z/
29
+ SOURCE_SHA_PATTERN = /\A[0-9a-f]{40}\z/
30
+
31
+ # Raised for any manifest content or source-path failure. Setup
32
+ # converts this into a setup failure before installation runs.
33
+ class Invalid < StandardError; end
34
+
35
+ # Validate the manifest at +path+ and return its parsed content.
36
+ # @raise [Invalid] when the file is missing, unreadable,
37
+ # malformed, or violates the schema.
38
+ def self.load_validated(path)
39
+ data = parse_json(path)
40
+ validate_schema!(data)
41
+ data
42
+ end
43
+
44
+ # Validate the manifest at +source_path+ and copy the exact bytes
45
+ # to +target_path+, creating the target directory when needed.
46
+ # +required_packages+ names the gems the calling scenario's proof
47
+ # depends on; the manifest must cover every one of them. Entries
48
+ # may be plain names or {name, supersedes} maps requiring an
49
+ # explicit supersession declaration. The buffer is read once so
50
+ # the validated content, the written copy, and the returned digest
51
+ # are the same bytes even if the source file changes mid-flight.
52
+ # @raise [Invalid]
53
+ def self.validate_and_copy(source_path:, target_path:, required_packages: nil)
54
+ raw = read_source(source_path)
55
+ data = parse_buffer(raw, source_path)
56
+ validate_schema!(data)
57
+ entries = data["packages"].to_h { |package| [package["name"], package] }
58
+ Array(required_packages).each do |required|
59
+ name, supersedes = required_packages_entry(required)
60
+ entry = entries[name]
61
+ raise Invalid, "release manifest is missing required package: #{name}" unless entry
62
+
63
+ missing = Array(supersedes) - Array(entry["supersedes"])
64
+ next if missing.empty?
65
+
66
+ raise Invalid,
67
+ "release manifest does not declare that #{name} #{entry["artifact_version"]} supersedes #{missing.join(", ")}"
68
+ end
69
+ FileUtils.mkdir_p(File.dirname(target_path))
70
+ File.binwrite(target_path, raw)
71
+ Digest::SHA256.hexdigest(raw)
72
+ end
73
+
74
+ class << self
75
+ private
76
+
77
+ def read_source(path)
78
+ raise Invalid, "release manifest is missing: #{path}" unless File.file?(path)
79
+ raise Invalid, "release manifest is not readable: #{path}" unless File.readable?(path)
80
+
81
+ raw = File.binread(path)
82
+ raise Invalid, "release manifest is empty: #{path}" if raw.strip.empty?
83
+
84
+ raw
85
+ end
86
+
87
+ def parse_buffer(raw, path)
88
+ data = JSON.parse(raw)
89
+ raise Invalid, "release manifest must be a JSON object: #{path}" unless data.is_a?(Hash)
90
+
91
+ data
92
+ rescue JSON::ParserError => e
93
+ raise Invalid, "release manifest is not valid JSON: #{path} (#{e.message})"
94
+ end
95
+
96
+ def parse_json(path)
97
+ parse_buffer(read_source(path), path)
98
+ end
99
+
100
+ def validate_schema!(data)
101
+ reject_unknown_keys!(data, %w[schema_version source_sha packages], "manifest")
102
+
103
+ schema_version = data["schema_version"]
104
+ unless schema_version.is_a?(Integer) && schema_version == SCHEMA_VERSION
105
+ raise Invalid,
106
+ "unsupported schema_version: #{schema_version.inspect} (expected #{SCHEMA_VERSION})"
107
+ end
108
+
109
+ validate_source_sha!(data["source_sha"], "manifest source_sha")
110
+
111
+ packages = data["packages"]
112
+ raise Invalid, "packages must be a nonempty array" unless packages.is_a?(Array) && !packages.empty?
113
+
114
+ seen = {}
115
+ packages.each_with_index do |entry, index|
116
+ name = validate_package!(entry, index)
117
+ raise Invalid, "duplicate package name: #{name}" if seen.key?(name)
118
+
119
+ seen[name] = true
120
+ end
121
+ end
122
+
123
+ def validate_package!(entry, index)
124
+ label = "packages[#{index}]"
125
+ raise Invalid, "#{label} must be an object" unless entry.is_a?(Hash)
126
+
127
+ reject_unknown_keys!(entry, %w[name artifact_version source_sha supersedes], label)
128
+
129
+ name = entry["name"]
130
+ unless name.is_a?(String) && name.match?(GEM_NAME_PATTERN)
131
+ raise Invalid, "#{label} has invalid ACE gem name: #{name.inspect}"
132
+ end
133
+
134
+ validate_exact_version!(entry["artifact_version"], "#{label}(#{name}) artifact_version")
135
+ validate_source_sha!(entry["source_sha"], "#{label}(#{name}) source_sha")
136
+ validate_supersedes!(entry["supersedes"], entry["artifact_version"], "#{label}(#{name})")
137
+ name
138
+ end
139
+
140
+ def validate_exact_version!(raw, label)
141
+ unless raw.is_a?(String) && !raw.strip.empty?
142
+ raise Invalid, "#{label} must be an exact version string, got #{raw.inspect}"
143
+ end
144
+
145
+ version = begin
146
+ Gem::Version.new(raw)
147
+ rescue ArgumentError
148
+ raise Invalid, "#{label} is not a valid version: #{raw.inspect}"
149
+ end
150
+
151
+ return if version.to_s == raw
152
+
153
+ raise Invalid,
154
+ "#{label} must be an exact version, not a requirement or shorthand: #{raw.inspect}"
155
+ end
156
+
157
+ def validate_source_sha!(raw, label)
158
+ return if raw.is_a?(String) && raw.match?(SOURCE_SHA_PATTERN)
159
+
160
+ raise Invalid, "#{label} must be a full 40-character commit SHA, got #{raw.inspect}"
161
+ end
162
+
163
+ def validate_supersedes!(raw, artifact_version, label)
164
+ return if raw.nil?
165
+
166
+ unless raw.is_a?(Array)
167
+ raise Invalid, "#{label} supersedes must be an array of version strings"
168
+ end
169
+
170
+ artifact = Gem::Version.new(artifact_version)
171
+ seen = {}
172
+ raw.each do |entry|
173
+ validate_exact_version!(entry, "#{label} supersedes entry")
174
+ raise Invalid, "#{label} supersedes duplicates version #{entry}" if seen.key?(entry)
175
+
176
+ seen[entry] = true
177
+ next unless Gem::Version.new(entry) >= artifact
178
+
179
+ raise Invalid,
180
+ "#{label} supersedes must list earlier versions of the same package: #{entry} >= #{artifact_version}"
181
+ end
182
+ end
183
+
184
+ def reject_unknown_keys!(hash, allowed, label)
185
+ unknown = hash.keys - allowed
186
+ return if unknown.empty?
187
+
188
+ raise Invalid, "#{label} has unknown fields: #{unknown.sort.join(", ")}"
189
+ end
190
+
191
+ def required_packages_entry(required)
192
+ case required
193
+ when String
194
+ [required, nil]
195
+ when Hash
196
+ reject_unknown_keys!(required, %w[name supersedes], "required package entry")
197
+ name = required["name"]
198
+ unless name.is_a?(String) && name.match?(GEM_NAME_PATTERN)
199
+ raise Invalid, "required package entry has invalid ACE gem name: #{name.inspect}"
200
+ end
201
+
202
+ [name, required["supersedes"]]
203
+ else
204
+ raise Invalid, "required package entries must be names or {name, supersedes} maps"
205
+ end
206
+ end
207
+ end
208
+ end
209
+ end
210
+ end
211
+ end
212
+ end
@@ -222,6 +222,11 @@ module Ace
222
222
  metadata["works_for_end_user"] = result.metadata["works_for_end_user"] if result.metadata.key?("works_for_end_user")
223
223
  metadata["user_friction"] = result.metadata["user_friction"] if result.metadata.key?("user_friction")
224
224
  metadata["user_feedback"] = result.metadata["user_feedback"] if result.metadata.key?("user_feedback")
225
+ metadata["failure_category"] = result.metadata["failure_category"] if result.metadata.key?("failure_category")
226
+ metadata["failure_phase"] = result.metadata["failure_phase"] if result.metadata.key?("failure_phase")
227
+ metadata["uncertain_execution"] = result.metadata["uncertain_execution"] if result.metadata.key?("uncertain_execution")
228
+ metadata["execution_evidence"] = result.metadata["execution_evidence"] if result.metadata.key?("execution_evidence")
229
+ metadata["incomplete_verdict_set"] = result.metadata["incomplete_verdict_set"] if result.metadata.key?("incomplete_verdict_set")
225
230
 
226
231
  if test_case
227
232
  metadata["scenario-id"] = scenario.test_id
@@ -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, runtime-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.
@@ -22,7 +28,22 @@ module Ace
22
28
  STRIPPED_ENV_KEYS = %w[RUBYOPT RUBYLIB].freeze
23
29
  RESERVED_ENV_KEYS = Molecules::SandboxRuntimeBuilder::RESERVED_ENV_KEYS + %w[
24
30
  PATH HOME TMPDIR XDG_RUNTIME_DIR TMUX_TMPDIR ACE_TMUX_SESSION
31
+ ACE_RUNTIME HERDR_SESSION HERDR_PANE HERDR_WORKSPACE_ID
25
32
  ]
33
+ RELEASE_MANIFEST_DEFAULT_PATH = File.join(".ace-local", "release", "installation-manifest.json")
34
+ # Retry trust state lives in the report directory — host-side,
35
+ # outside the runner-writable sandbox. A marker inside the sandbox
36
+ # would let a compromised runner forge reuse state, redirect
37
+ # manifest copies outside the sandbox, or skip revalidation.
38
+ SETUP_STATE_FILE = ".ace-e2e-setup-state.json"
39
+ # Only the sandbox runtime contract is persisted for reuse. The
40
+ # merged setup environment inherits the host process environment,
41
+ # which can carry secrets that must never land in a state file.
42
+ PERSISTED_ENV_KEYS = (
43
+ Molecules::SandboxRuntimeBuilder::RESERVED_ENV_KEYS + %w[
44
+ PATH ACE_E2E_SANDBOX_RUNTIME_ROOT ACE_E2E_SANDBOX_RUBY_VERSION ACE_E2E_SANDBOX_RUBY_ROOT
45
+ ]
46
+ ).freeze
26
47
 
27
48
  def initialize(command_runner: nil, system_runner: nil, time_source: nil, sandbox_backend: nil)
28
49
  @command_runner = command_runner || method(:capture3)
@@ -36,20 +57,45 @@ module Ace
36
57
  # @param setup_steps [Array] Setup steps from scenario.yml
37
58
  # @param sandbox_dir [String] Path to the sandbox directory
38
59
  # @param fixture_source [String, nil] Path to the fixtures/ directory
39
- # @param scenario_name [String, nil] Test ID for tmux session naming (e.g., "TS-OVERSEER-001")
40
- # @param run_id [String, nil] Unique run ID for deterministic tmux session naming
41
- # @return [Hash] Result with :success, :steps_completed, :error, :env, :tmux_session keys
60
+ # @param scenario_name [String, nil] Test ID for runtime session naming (e.g., "TS-OVERSEER-001")
61
+ # @param run_id [String, nil] Unique run ID for deterministic runtime session naming
62
+ # @param release_manifest_path [String, nil] Explicit absolute manifest path carried
63
+ # from the invoking process (ACE_RELEASE_MANIFEST). When nil, the release-manifest
64
+ # step falls back to the default path under ACE_E2E_SOURCE_ROOT.
65
+ # @param state_file [String, nil] Host-side path (report directory) for retry
66
+ # state. When nil, no retry state is recorded and the sandbox is never reused.
67
+ # @return [Hash] Result with :success, :steps_completed, :error, :env, :runtime_session keys
42
68
  def execute(setup_steps:, sandbox_dir:, fixture_source: nil, scenario_name: nil, run_id: nil, initial_env: {},
43
- git_excludes: [])
69
+ git_excludes: [], release_manifest_path: nil, state_file: nil)
70
+ # Initialized before any filesystem operation: an early failure
71
+ # must reach the rescue as a clean setup failure, not as a
72
+ # secondary error from nil state.
73
+ env = {}
74
+ steps_completed = 0
75
+ @runtime_session = nil
76
+ @teardown_env = nil
44
77
  FileUtils.mkdir_p(sandbox_dir)
78
+ # A rerun invalidates any earlier success up front: state is
79
+ # recreated only after every step and the persisted env succeed.
80
+ if state_file
81
+ FileUtils.rm_f(state_file)
82
+ # The prior attempt's completion record must never bless a
83
+ # retried run that fails before rewriting it.
84
+ FileUtils.rm_f(File.join(File.dirname(state_file), "metadata.yml"))
85
+ end
86
+ # Prior-attempt goal evidence must never survive into a retried
87
+ # run: the goals rebuild results/ from scratch.
88
+ FileUtils.rm_rf(File.join(sandbox_dir, "results"))
45
89
  env = if @sandbox_backend
46
90
  @sandbox_backend.prepared_env(initial_env.dup)
47
91
  else
48
92
  initial_env.dup
49
93
  end
50
94
  @git_excludes = normalize_git_excludes(git_excludes)
95
+ @release_manifest_input = release_manifest_path
96
+ @release_manifest_state = nil
51
97
  steps_completed = 0
52
- @tmux_session = nil
98
+ @runtime_session = nil
53
99
  @scenario_name = scenario_name
54
100
  @run_id = run_id
55
101
  @teardown_env = nil
@@ -58,13 +104,41 @@ module Ace
58
104
  execute_step(step, sandbox_dir, env, fixture_source)
59
105
  steps_completed += 1
60
106
  end
107
+ merged = merged_environment(env)
108
+ if state_file
109
+ FileUtils.mkdir_p(File.dirname(state_file))
110
+ persisted = merged.slice(*PERSISTED_ENV_KEYS).compact
111
+ # The live value may be relative (agent-env PROJECT_ROOT_PATH: .);
112
+ # the persisted contract must be absolute so a reused sandbox is
113
+ # recognized as prepared by the pipeline.
114
+ if persisted["PROJECT_ROOT_PATH"] && !persisted["PROJECT_ROOT_PATH"].start_with?("/")
115
+ persisted["PROJECT_ROOT_PATH"] = File.expand_path(persisted["PROJECT_ROOT_PATH"], sandbox_dir)
116
+ end
117
+ generated_gemfile = File.join(sandbox_dir, "Gemfile")
118
+ source_root = env["ACE_E2E_SOURCE_ROOT"].to_s
119
+ source_gemfile = source_root.empty? ? nil : File.join(source_root, "Gemfile")
120
+ File.write(state_file, JSON.generate({
121
+ "completed_at" => Time.now.utc.iso8601,
122
+ "sandbox_dir" => sandbox_dir,
123
+ "setup_steps" => self.class.normalize_state_steps(setup_steps),
124
+ "release_manifest" => @release_manifest_state,
125
+ "env" => persisted,
126
+ "gemfile" => {
127
+ # The sandbox Gemfile is derived from the source root's
128
+ # Gemfile; a retry must regenerate it when either changes.
129
+ "source_root" => source_root,
130
+ "source_digest" => source_gemfile && File.file?(source_gemfile) ? Digest::SHA256.file(source_gemfile).hexdigest : nil,
131
+ "generated_digest" => File.file?(generated_gemfile) ? Digest::SHA256.file(generated_gemfile).hexdigest : nil
132
+ }
133
+ }))
134
+ end
61
135
 
62
136
  {
63
137
  success: true,
64
138
  steps_completed: steps_completed,
65
139
  error: nil,
66
- env: merged_environment(env),
67
- tmux_session: @tmux_session
140
+ env: merged,
141
+ runtime_session: @runtime_session
68
142
  }
69
143
  rescue => e
70
144
  {
@@ -72,24 +146,24 @@ module Ace
72
146
  steps_completed: steps_completed,
73
147
  error: e.message,
74
148
  env: merged_environment(env),
75
- tmux_session: @tmux_session
149
+ runtime_session: @runtime_session
76
150
  }
77
151
  end
78
152
 
79
- # Clean up resources created during setup (e.g. tmux session)
153
+ # Clean up resources created during setup (e.g. tmux runtime session)
80
154
  def teardown
81
- return unless @tmux_session
155
+ return unless @runtime_session
82
156
 
83
157
  if @sandbox_backend
84
158
  @sandbox_backend.capture3(
85
- ["tmux", "kill-session", "-t", @tmux_session],
159
+ ["tmux", "kill-session", "-t", @runtime_session],
86
160
  chdir: @teardown_env&.fetch("PROJECT_ROOT_PATH", Dir.pwd) || Dir.pwd,
87
161
  env: @teardown_env || {}
88
162
  )
89
163
  else
90
- @system_runner.call("tmux", "kill-session", "-t", @tmux_session, out: File::NULL, err: File::NULL)
164
+ @system_runner.call("tmux", "kill-session", "-t", @runtime_session, out: File::NULL, err: File::NULL)
91
165
  end
92
- @tmux_session = nil
166
+ @runtime_session = nil
93
167
  end
94
168
 
95
169
  private
@@ -106,8 +180,8 @@ module Ace
106
180
  handle_git_init(sandbox_dir, env)
107
181
  when "copy-fixtures"
108
182
  handle_copy_fixtures(sandbox_dir, fixture_source)
109
- when "tmux-session"
110
- handle_tmux_session(env)
183
+ when "runtime-session"
184
+ handle_runtime_session(env)
111
185
  when Hash
112
186
  execute_hash_step(step, sandbox_dir, env)
113
187
  else
@@ -127,15 +201,31 @@ module Ace
127
201
  handle_write_file(value["path"], value["content"], sandbox_dir)
128
202
  when "agent-env"
129
203
  handle_env(value, env)
130
- when "tmux-session"
131
- handle_tmux_session(env, value)
204
+ when "runtime-session"
205
+ handle_runtime_session(env, value)
206
+ when "release-manifest"
207
+ handle_release_manifest(value, sandbox_dir, env)
132
208
  else
133
209
  raise ArgumentError, "Unknown setup step type: #{key.inspect}"
134
210
  end
135
211
  end
136
212
 
137
- # Create an isolated detached tmux session and store its name in env
138
- def handle_tmux_session(env, config = nil)
213
+ # Establish a live terminal runtime context for the scenario.
214
+ # runtime "tmux" (default) creates an isolated detached tmux
215
+ # session and exports ACE_TMUX_SESSION + ACE_RUNTIME=tmux.
216
+ # runtime "herdr" requires the caller's live HERDR_SESSION and
217
+ # HERDR_PANE (herdr workspaces are operator-owned; the runner
218
+ # never fabricates one) and exports ACE_RUNTIME=herdr. An absent
219
+ # herdr context fails the step explicitly — no scripted stand-in.
220
+ def handle_runtime_session(env, config = nil)
221
+ runtime = config.is_a?(Hash) ? config["runtime"].to_s.strip : ""
222
+ runtime = "tmux" if runtime.empty?
223
+ unless %w[tmux herdr].include?(runtime)
224
+ raise ArgumentError, "runtime-session supports runtimes: tmux, herdr (got '#{runtime}')"
225
+ end
226
+
227
+ return handle_herdr_session(env) if runtime == "herdr"
228
+
139
229
  name_source = config.is_a?(Hash) ? config["name-source"] : nil
140
230
  session_name = if name_source == "run-id" && @run_id && !@run_id.to_s.empty?
141
231
  @run_id
@@ -154,8 +244,23 @@ module Ace
154
244
  end
155
245
  raise "Failed to create tmux session '#{session_name}': #{stderr.strip}" unless status.success?
156
246
 
157
- @tmux_session = session_name
247
+ @runtime_session = session_name
158
248
  env["ACE_TMUX_SESSION"] = session_name
249
+ env["ACE_RUNTIME"] = "tmux"
250
+ @teardown_env = merged_environment(env)
251
+ end
252
+
253
+ def handle_herdr_session(env)
254
+ merged = merged_environment(env)
255
+ herdr_session = merged["HERDR_SESSION"].to_s.strip
256
+ herdr_pane = merged["HERDR_PANE"].to_s.strip
257
+ if herdr_session.empty? || herdr_pane.empty?
258
+ raise "runtime-session herdr requires a live herdr context (HERDR_SESSION and HERDR_PANE); run this scenario inside herdr"
259
+ end
260
+
261
+ env["HERDR_SESSION"] = herdr_session
262
+ env["HERDR_PANE"] = herdr_pane
263
+ env["ACE_RUNTIME"] = "herdr"
159
264
  @teardown_env = merged_environment(env)
160
265
  end
161
266
 
@@ -219,6 +324,180 @@ module Ace
219
324
  end
220
325
  end
221
326
 
327
+ # Validate the frozen release manifest and copy its exact bytes into
328
+ # the sandbox. The source is the explicitly allowed input carried
329
+ # from the invoking process (ACE_RELEASE_MANIFEST, absolute path);
330
+ # without it, the default path under ACE_E2E_SOURCE_ROOT is used.
331
+ # Any missing, unreadable, malformed, or conflicting manifest raises
332
+ # here so setup fails before any install goal can run.
333
+ def handle_release_manifest(config, sandbox_dir, env)
334
+ target = config.is_a?(Hash) ? config["to"] : config
335
+ raise ArgumentError, "release-manifest step requires a target path" if target.to_s.strip.empty?
336
+
337
+ required = config.is_a?(Hash) ? Array(config["require"]) : []
338
+ source = release_manifest_source(env)
339
+ # The scenario declares the destination; the copy itself is still
340
+ # confined to the sandbox with no symlinked path components.
341
+ target_path = self.class.validated_manifest_target(sandbox_dir, target)
342
+ raise ArgumentError, "release-manifest target escapes the sandbox: #{target}" unless target_path
343
+
344
+ FileUtils.mkdir_p(File.dirname(target_path))
345
+ digest = Molecules::ReleaseManifest.validate_and_copy(
346
+ source_path: source,
347
+ target_path: target_path,
348
+ required_packages: required
349
+ )
350
+ @release_manifest_state = {
351
+ "source" => File.expand_path(source),
352
+ "target" => target,
353
+ "digest" => digest
354
+ }
355
+ end
356
+
357
+ def release_manifest_source(env)
358
+ self.class.release_manifest_source_for(env, @release_manifest_input)
359
+ end
360
+
361
+ class << self
362
+ # Retry state for a completed setup. Returns nil when there is no
363
+ # usable state (no file, unreadable, from another sandbox, or
364
+ # recorded for different setup steps).
365
+ def setup_state_for(state_file, sandbox_dir, setup_steps: nil)
366
+ return nil unless state_file && File.file?(state_file)
367
+
368
+ state = JSON.parse(File.read(state_file))
369
+ return nil unless state.is_a?(Hash)
370
+ return nil unless state["sandbox_dir"] &&
371
+ File.expand_path(state["sandbox_dir"]) == File.expand_path(sandbox_dir)
372
+ return nil if setup_steps && state["setup_steps"] != normalize_state_steps(setup_steps)
373
+
374
+ state
375
+ rescue JSON::ParserError, SystemCallError
376
+ nil
377
+ end
378
+
379
+ def normalize_state_steps(setup_steps)
380
+ Array(setup_steps).map { |step| step.is_a?(Hash) ? [step.keys.first, step.values.first] : step }
381
+ end
382
+
383
+ # The manifest copy destination comes from trusted setup state,
384
+ # but the runner controls the sandbox filesystem in between: a
385
+ # symlinked path component could redirect the host-side write
386
+ # outside the sandbox. Parent components must be real
387
+ # directories; the final component may be an existing regular
388
+ # file (the previous validated copy).
389
+ def validated_manifest_target(sandbox_dir, target)
390
+ sandbox_root = File.expand_path(sandbox_dir)
391
+ # A symlinked sandbox root would redirect every confinement
392
+ # check below it.
393
+ return nil if File.lstat(sandbox_root).symlink?
394
+ relative = target.to_s.split("/")
395
+ current = sandbox_root
396
+ components = relative.reject { |part| part.empty? || part == "." }
397
+ components.each_with_index do |part, index|
398
+ current = File.join(current, part)
399
+ # lstat every component: File.exist? follows symlinks, so a
400
+ # dangling symlink would slip past an existence check and the
401
+ # write would create its target outside the sandbox. ENOENT
402
+ # means the component is genuinely absent (created fresh by
403
+ # mkdir_p); any other stat failure rejects reuse.
404
+ lstat = begin
405
+ File.lstat(current)
406
+ rescue Errno::ENOENT
407
+ next
408
+ rescue SystemCallError
409
+ return nil
410
+ end
411
+ return nil if lstat.symlink?
412
+
413
+ last = index == components.size - 1
414
+ if last
415
+ return nil if lstat.directory?
416
+ elsif !lstat.directory?
417
+ return nil
418
+ end
419
+ end
420
+ resolved = File.expand_path(current)
421
+ return nil unless resolved.start_with?(sandbox_root + File::SEPARATOR)
422
+
423
+ resolved
424
+ rescue SystemCallError
425
+ nil
426
+ end
427
+
428
+ # Revalidate the current manifest input against a completed
429
+ # sandbox before it is reused: the validated copy in the sandbox
430
+ # must still match the invoking process's manifest. Returns true
431
+ # when reuse may proceed (no manifest gate recorded, or the
432
+ # current input hashes to the recorded digest); false when the
433
+ # sandbox must be set up again.
434
+ def revalidate_release_manifest(state_file:, sandbox_dir:, explicit: nil, setup_steps: nil)
435
+ state = setup_state_for(state_file, sandbox_dir, setup_steps: setup_steps)
436
+ return false unless state
437
+
438
+ recorded = state["release_manifest"]
439
+ return true if recorded.nil?
440
+
441
+ target_path = validated_manifest_target(sandbox_dir, recorded["target"])
442
+ return false unless target_path
443
+
444
+ source = begin
445
+ release_manifest_source_for(state["env"] || {}, explicit)
446
+ rescue Molecules::ReleaseManifest::Invalid
447
+ return false
448
+ end
449
+
450
+ # The source path itself must be the recorded one: an identical
451
+ # copy at a different path would be rejected by finalization's
452
+ # recorded-source binding, so reuse must not accept it either.
453
+ return false unless File.expand_path(source) == File.expand_path(recorded["source"].to_s)
454
+
455
+ # Read once: the digest check and the copy must see the same
456
+ # bytes even if the source changes mid-revalidation.
457
+ raw = begin
458
+ File.binread(source)
459
+ rescue SystemCallError
460
+ return false
461
+ end
462
+ return false if Digest::SHA256.hexdigest(raw) != recorded["digest"]
463
+
464
+ FileUtils.mkdir_p(File.dirname(target_path))
465
+ File.binwrite(target_path, raw)
466
+ true
467
+ end
468
+
469
+ # Retry reuse rebuilds the environment from the allow-listed
470
+ # runtime keys only (same boundary as initial setup), overlaid
471
+ # with the recorded sandbox runtime paths. Host secrets — even
472
+ # ones present in the current process — never reach the runner.
473
+ def build_reuse_env(state_env)
474
+ (PERSISTED_ENV_KEYS | %w[HOME PATH TMPDIR]).each_with_object({}) do |key, env|
475
+ value = state_env[key]
476
+ env[key] = value unless value.nil?
477
+ end
478
+ end
479
+
480
+ def release_manifest_source_for(env, explicit)
481
+ explicit = explicit.to_s.strip
482
+ if explicit.empty?
483
+ source_root = env["ACE_E2E_SOURCE_ROOT"].to_s.strip
484
+ if source_root.empty?
485
+ raise Molecules::ReleaseManifest::Invalid,
486
+ "no release manifest input and no ACE_E2E_SOURCE_ROOT to derive the default path"
487
+ end
488
+ return File.join(source_root, RELEASE_MANIFEST_DEFAULT_PATH)
489
+ end
490
+
491
+ unless explicit.start_with?("/")
492
+ raise Molecules::ReleaseManifest::Invalid,
493
+ "ACE_RELEASE_MANIFEST must be an absolute JSON file path, got: #{explicit}"
494
+ end
495
+
496
+ explicit
497
+ end
498
+ end
499
+
500
+
222
501
  # Merge custom env vars with the process environment
223
502
  #
224
503
  # @param env [Hash] Custom environment variables