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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/handbook/guides/scenario-yml-reference.g.md +6 -5
- data/handbook/templates/scenario.yml.template.yml +2 -2
- data/lib/ace/test/end_to_end_runner/cli/commands/run_suite.rb +38 -6
- data/lib/ace/test/end_to_end_runner/molecules/failure_finder.rb +59 -10
- data/lib/ace/test/end_to_end_runner/molecules/pipeline_executor.rb +49 -3
- data/lib/ace/test/end_to_end_runner/molecules/pipeline_prompt_bundler.rb +37 -4
- data/lib/ace/test/end_to_end_runner/molecules/pipeline_report_generator.rb +27 -3
- data/lib/ace/test/end_to_end_runner/molecules/release_manifest.rb +212 -0
- data/lib/ace/test/end_to_end_runner/molecules/report_writer.rb +5 -0
- data/lib/ace/test/end_to_end_runner/molecules/setup_executor.rb +300 -21
- data/lib/ace/test/end_to_end_runner/organisms/suite_orchestrator.rb +25 -1
- data/lib/ace/test/end_to_end_runner/organisms/test_orchestrator.rb +287 -14
- data/lib/ace/test/end_to_end_runner/version.rb +1 -1
- data/lib/ace/test/end_to_end_runner.rb +1 -0
- metadata +5 -4
|
@@ -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,
|
|
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
|
|
40
|
-
# @param run_id [String, nil] Unique run ID for deterministic
|
|
41
|
-
# @
|
|
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
|
-
@
|
|
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:
|
|
67
|
-
|
|
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
|
-
|
|
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 @
|
|
155
|
+
return unless @runtime_session
|
|
82
156
|
|
|
83
157
|
if @sandbox_backend
|
|
84
158
|
@sandbox_backend.capture3(
|
|
85
|
-
["tmux", "kill-session", "-t", @
|
|
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", @
|
|
164
|
+
@system_runner.call("tmux", "kill-session", "-t", @runtime_session, out: File::NULL, err: File::NULL)
|
|
91
165
|
end
|
|
92
|
-
@
|
|
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 "
|
|
110
|
-
|
|
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 "
|
|
131
|
-
|
|
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
|
-
#
|
|
138
|
-
|
|
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
|
-
@
|
|
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
|