ace-assign 0.63.1 → 0.66.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/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +11 -0
- data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +2 -2
- data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
- data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +4 -2
- data/.ace-defaults/assign/catalog/steps/release.step.yml +4 -2
- data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +1 -1
- data/.ace-defaults/assign/presets/fix-bug.yml +15 -3
- data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +16 -4
- data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +15 -3
- data/.ace-defaults/assign/presets/work-on-task.yml +16 -4
- data/CHANGELOG.md +62 -0
- data/docs/demo/canonical-skill-source.tape.yml +4 -4
- data/docs/protected-authority.md +282 -0
- data/docs/usage.md +205 -3
- data/exe/ace-scope-ready +55 -0
- data/handbook/workflow-instructions/assign/drive.wf.md +28 -3
- data/handbook/workflow-instructions/assign/prepare.wf.md +2 -2
- data/lib/ace/assign/atoms/attempt_state_machine.rb +8 -0
- data/lib/ace/assign/atoms/delivery_parameters.rb +19 -0
- data/lib/ace/assign/authority/assignment_inventory.rb +290 -0
- data/lib/ace/assign/authority/campaign_consumer_policy.rb +100 -0
- data/lib/ace/assign/authority/campaign_execution.rb +119 -0
- data/lib/ace/assign/authority/campaign_round_transfer.rb +82 -0
- data/lib/ace/assign/authority/candidate_transfer.rb +327 -0
- data/lib/ace/assign/authority/cleanup_result_protection.rb +53 -0
- data/lib/ace/assign/authority/client.rb +257 -0
- data/lib/ace/assign/authority/deployment.rb +627 -0
- data/lib/ace/assign/authority/deployment_history.rb +146 -0
- data/lib/ace/assign/authority/endcap.rb +455 -0
- data/lib/ace/assign/authority/endcap_attempts.rb +274 -0
- data/lib/ace/assign/authority/endcap_campaign_results.rb +232 -0
- data/lib/ace/assign/authority/endcap_campaign_rounds.rb +266 -0
- data/lib/ace/assign/authority/endcap_contexts.rb +23 -0
- data/lib/ace/assign/authority/endcap_inboxes.rb +37 -0
- data/lib/ace/assign/authority/endcap_prepared_work.rb +80 -0
- data/lib/ace/assign/authority/endcap_publication.rb +207 -0
- data/lib/ace/assign/authority/endcap_results.rb +398 -0
- data/lib/ace/assign/authority/endcap_review_requests.rb +183 -0
- data/lib/ace/assign/authority/endcap_reviews.rb +130 -0
- data/lib/ace/assign/authority/endcap_service_settlement.rb +152 -0
- data/lib/ace/assign/authority/endcap_services.rb +598 -0
- data/lib/ace/assign/authority/evidence_transfer.rb +67 -0
- data/lib/ace/assign/authority/execution_scope_observation.rb +903 -0
- data/lib/ace/assign/authority/inbox_context_original.rb +113 -0
- data/lib/ace/assign/authority/launch_attempt_consumers.rb +51 -0
- data/lib/ace/assign/authority/launch_campaign_registration.rb +214 -0
- data/lib/ace/assign/authority/launch_control_channel.rb +313 -0
- data/lib/ace/assign/authority/launch_control_exclusions.rb +141 -0
- data/lib/ace/assign/authority/launch_driver.rb +393 -0
- data/lib/ace/assign/authority/launch_input_inhibition.rb +132 -0
- data/lib/ace/assign/authority/launch_lifecycle.rb +997 -0
- data/lib/ace/assign/authority/launch_prepared_work.rb +122 -0
- data/lib/ace/assign/authority/launch_review.rb +91 -0
- data/lib/ace/assign/authority/launch_scope_abort.rb +81 -0
- data/lib/ace/assign/authority/launch_scope_admission.rb +215 -0
- data/lib/ace/assign/authority/launch_scope_close.rb +146 -0
- data/lib/ace/assign/authority/launch_scope_parent.rb +44 -0
- data/lib/ace/assign/authority/launch_scope_release.rb +859 -0
- data/lib/ace/assign/authority/launch_steering.rb +353 -0
- data/lib/ace/assign/authority/launch_stop.rb +254 -0
- data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +229 -0
- data/lib/ace/assign/authority/original_inbox_selection.rb +41 -0
- data/lib/ace/assign/authority/posix_acl.rb +59 -0
- data/lib/ace/assign/authority/prepared_input.rb +130 -0
- data/lib/ace/assign/authority/prepared_queue.rb +185 -0
- data/lib/ace/assign/authority/prepared_task_context.rb +116 -0
- data/lib/ace/assign/authority/prepared_work.rb +304 -0
- data/lib/ace/assign/authority/prepared_worker.rb +69 -0
- data/lib/ace/assign/authority/private_directory.rb +33 -0
- data/lib/ace/assign/authority/protected_assignment_context.rb +226 -0
- data/lib/ace/assign/authority/readiness_hook.rb +120 -0
- data/lib/ace/assign/authority/receipt_transfer.rb +48 -0
- data/lib/ace/assign/authority/router.rb +76 -0
- data/lib/ace/assign/authority/server.rb +300 -0
- data/lib/ace/assign/authority/service_cleanup_dispatch.rb +88 -0
- data/lib/ace/assign/authority/service_cleanup_evidence.rb +180 -0
- data/lib/ace/assign/authority/service_delivery_evidence.rb +101 -0
- data/lib/ace/assign/authority/service_evidence.rb +286 -0
- data/lib/ace/assign/authority/service_publication_evidence.rb +110 -0
- data/lib/ace/assign/authority/task_context_entry.rb +32 -0
- data/lib/ace/assign/authority/transfer_codec.rb +187 -0
- data/lib/ace/assign/cli/commands/assignment_target.rb +15 -2
- data/lib/ace/assign/cli/commands/attempt/base.rb +49 -0
- data/lib/ace/assign/cli/commands/attempt/finish.rb +20 -4
- data/lib/ace/assign/cli/commands/attempt/reconcile.rb +11 -0
- data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +25 -0
- data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +28 -0
- data/lib/ace/assign/cli/commands/authority/launch.rb +97 -0
- data/lib/ace/assign/cli/commands/authority/serve.rb +31 -0
- data/lib/ace/assign/cli/commands/authority/status.rb +26 -0
- data/lib/ace/assign/cli/commands/authority/task_context.rb +31 -0
- data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +25 -0
- data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +30 -0
- data/lib/ace/assign/cli/commands/authority/terminate.rb +29 -0
- data/lib/ace/assign/cli/commands/authority/worker.rb +23 -0
- data/lib/ace/assign/cli/commands/campaign_export_result.rb +55 -0
- data/lib/ace/assign/cli/commands/campaign_record_round.rb +57 -0
- data/lib/ace/assign/cli/commands/create.rb +11 -1
- data/lib/ace/assign/cli/commands/delivery.rb +107 -0
- data/lib/ace/assign/cli/commands/fail.rb +6 -0
- data/lib/ace/assign/cli/commands/finish.rb +23 -2
- data/lib/ace/assign/cli/commands/fork_session.rb +6 -2
- data/lib/ace/assign/cli/commands/inbox_bind.rb +34 -0
- data/lib/ace/assign/cli/commands/protected_submission.rb +61 -0
- data/lib/ace/assign/cli/commands/resume.rb +38 -0
- data/lib/ace/assign/cli/commands/select.rb +2 -0
- data/lib/ace/assign/cli/commands/start.rb +2 -0
- data/lib/ace/assign/cli/commands/status.rb +4 -2
- data/lib/ace/assign/cli/commands/step.rb +3 -1
- data/lib/ace/assign/cli/commands/submit_candidate.rb +34 -0
- data/lib/ace/assign/cli/commands/submit_result.rb +61 -0
- data/lib/ace/assign/cli.rb +56 -4
- data/lib/ace/assign/errors.rb +139 -0
- data/lib/ace/assign/models/assignment.rb +26 -4
- data/lib/ace/assign/models/evidence_event.rb +4 -2
- data/lib/ace/assign/molecules/assignment_manager.rb +12 -2
- data/lib/ace/assign/molecules/attempt_reconciler.rb +51 -12
- data/lib/ace/assign/molecules/canonical_attempt_state.rb +151 -0
- data/lib/ace/assign/molecules/canonical_evidence.rb +153 -0
- data/lib/ace/assign/molecules/canonical_read_snapshot.rb +374 -0
- data/lib/ace/assign/molecules/evidence_journal.rb +922 -91
- data/lib/ace/assign/molecules/execution_identity_resolver.rb +49 -9
- data/lib/ace/assign/molecules/execution_scope_lineage.rb +345 -0
- data/lib/ace/assign/molecules/fork_session_launcher.rb +16 -6
- data/lib/ace/assign/molecules/journal_input_inhibition.rb +78 -0
- data/lib/ace/assign/molecules/journal_mutation.rb +271 -0
- data/lib/ace/assign/molecules/journal_prompt_mutation.rb +241 -0
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +182 -26
- data/lib/ace/assign/molecules/original_launch_binding.rb +37 -0
- data/lib/ace/assign/molecules/proposal_journal.rb +151 -0
- data/lib/ace/assign/molecules/protected_control_exclusion.rb +303 -0
- data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +330 -0
- data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +164 -0
- data/lib/ace/assign/molecules/queue_scanner.rb +26 -11
- data/lib/ace/assign/molecules/receipt_verifier.rb +68 -32
- data/lib/ace/assign/molecules/step_writer.rb +27 -5
- data/lib/ace/assign/molecules/terminal_scope_receipt.rb +88 -0
- data/lib/ace/assign/organisms/assignment_executor.rb +10 -4
- data/lib/ace/assign/organisms/attempt_coordinator.rb +302 -13
- data/lib/ace/assign/organisms/delivery_coordinator.rb +342 -0
- data/lib/ace/assign/organisms/prepared_work_builder.rb +194 -0
- data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +100 -0
- data/lib/ace/assign/organisms/task_assignment_creator.rb +11 -4
- data/lib/ace/assign/version.rb +1 -1
- data/lib/ace/assign.rb +4 -102
- metadata +186 -8
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "protected_workspace_exclusion"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Assign
|
|
7
|
+
module Molecules
|
|
8
|
+
class LifecycleExclusion
|
|
9
|
+
# Original full-privilege operator phase, before publication/start.
|
|
10
|
+
# It only provisions derived exclusion artifacts, never a workspace.
|
|
11
|
+
class WorkspaceProvisioner
|
|
12
|
+
class Protection < WorkspaceHostReader::Protection
|
|
13
|
+
private
|
|
14
|
+
def root_path(_root) = nil
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def initialize(mapping_id:, project_id:, authority:, cwd_resource:, files: File, directories: Dir, protection: nil)
|
|
18
|
+
@selection = LifecycleExclusion.workspace_selection(mapping_id: mapping_id, project_id: project_id,
|
|
19
|
+
authority: authority, cwd_resource: cwd_resource)
|
|
20
|
+
@authority = authority.slice("state_root", "uid", "gid").freeze
|
|
21
|
+
@files, @directories = files, directories
|
|
22
|
+
@protection = protection || Protection.new(projection: {"authority_uid" => authority.fetch("uid"),
|
|
23
|
+
"authority_gid" => authority.fetch("gid"), "root_resource" => {}})
|
|
24
|
+
@handles = {}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def provision!
|
|
28
|
+
root = @authority.fetch("state_root")
|
|
29
|
+
parts = root.split("/").reject(&:empty?)
|
|
30
|
+
(["/"] + parts.each_index.map { |index| "/" + parts.take(index + 1).join("/") }).each do |path|
|
|
31
|
+
hold_directory!(path)
|
|
32
|
+
end
|
|
33
|
+
selected_directory!(@handles.fetch(root), mode: 0o700)
|
|
34
|
+
relative = @selection.fetch("host_path").delete_prefix(root + "/").split("/")
|
|
35
|
+
parent = root
|
|
36
|
+
relative.each_with_index do |part, index|
|
|
37
|
+
path = File.join(parent, part)
|
|
38
|
+
mode = index == relative.length - 1 ? 0o755 : 0o700
|
|
39
|
+
provision_directory!(path, parent, mode)
|
|
40
|
+
verify_unchanged!
|
|
41
|
+
parent = path
|
|
42
|
+
end
|
|
43
|
+
project = File.dirname(File.dirname(File.dirname(@selection.fetch("host_path"))))
|
|
44
|
+
provision_directory!(File.join(project, "control"), project, 0o700)
|
|
45
|
+
root_stat = @handles.fetch(@selection.fetch("host_path")).stat
|
|
46
|
+
host_identity = {"device" => root_stat.dev, "inode" => root_stat.ino,
|
|
47
|
+
"uid" => root_stat.uid, "gid" => root_stat.gid}.freeze
|
|
48
|
+
# Resource mount identity is observed by the existing boundary owner;
|
|
49
|
+
# provision returns only association/host inode, not a namespace pin.
|
|
50
|
+
provision_file!("lock", "")
|
|
51
|
+
marker = provision_file!("state.json", LifecycleExclusion.workspace_initial_marker(key: @selection.fetch("key")))
|
|
52
|
+
bytes = marker.read(8193)
|
|
53
|
+
state = LifecycleExclusion.workspace_marker!(bytes: bytes, key: @selection.fetch("key"))
|
|
54
|
+
raise AttemptErrors::Conflict, "Original lifecycle workspace is already fenced" if state.fetch("removed")
|
|
55
|
+
unless [marker.stat.uid, marker.stat.gid] == @authority.values_at("uid", "gid")
|
|
56
|
+
raise AttemptErrors::EvidenceUnavailable, "Initial lifecycle marker owner differs"
|
|
57
|
+
end
|
|
58
|
+
verify_unchanged!
|
|
59
|
+
result = @selection.merge("host_identity" => host_identity)
|
|
60
|
+
result.fetch("host_identity").freeze
|
|
61
|
+
result.each_value(&:freeze)
|
|
62
|
+
result.freeze
|
|
63
|
+
rescue Ace::Runtime::RuntimeUnavailableError, SystemCallError, IOError
|
|
64
|
+
raise AttemptErrors::EvidenceUnavailable, "Original lifecycle provisioning is unavailable"
|
|
65
|
+
ensure
|
|
66
|
+
close!
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def provision_directory!(path, parent, mode)
|
|
72
|
+
created = begin
|
|
73
|
+
@directories.mkdir(path, 0o700)
|
|
74
|
+
true
|
|
75
|
+
rescue Errno::EEXIST
|
|
76
|
+
false
|
|
77
|
+
end
|
|
78
|
+
handle = hold_directory!(path)
|
|
79
|
+
if created
|
|
80
|
+
handle.chown(@authority.fetch("uid"), @authority.fetch("gid"))
|
|
81
|
+
handle.chmod(mode)
|
|
82
|
+
handle.fsync
|
|
83
|
+
@handles.fetch(parent).fsync
|
|
84
|
+
end
|
|
85
|
+
selected_directory!(handle, mode: mode)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def hold_directory!(path)
|
|
89
|
+
handle = @files.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK)
|
|
90
|
+
unless handle.stat.directory? && identity(handle.stat) == identity(@files.lstat(path))
|
|
91
|
+
raise AttemptErrors::EvidenceUnavailable, "Lifecycle provisioning directory differs"
|
|
92
|
+
end
|
|
93
|
+
@protection.verify!(path, handle, directory: true)
|
|
94
|
+
@handles[path] = handle
|
|
95
|
+
ensure
|
|
96
|
+
handle&.close unless @handles[path] == handle
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def selected_directory!(handle, mode:)
|
|
100
|
+
stat = handle.stat
|
|
101
|
+
unless [stat.uid, stat.gid, stat.mode & 0o7777] == @authority.values_at("uid", "gid") + [mode]
|
|
102
|
+
raise AttemptErrors::EvidenceUnavailable, "Lifecycle provisioning selected directory differs"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def provision_file!(suffix, bytes)
|
|
107
|
+
root = @selection.fetch("host_path")
|
|
108
|
+
path = File.join(root, "#{Digest::SHA256.hexdigest(@selection.fetch('key'))}.#{suffix}")
|
|
109
|
+
created = true
|
|
110
|
+
handle = begin
|
|
111
|
+
@files.open(path, File::RDWR | File::CREAT | File::EXCL | File::NOFOLLOW | File::NONBLOCK, 0o600)
|
|
112
|
+
rescue Errno::EEXIST
|
|
113
|
+
created = false
|
|
114
|
+
@files.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK)
|
|
115
|
+
end
|
|
116
|
+
if created
|
|
117
|
+
handle.write(bytes)
|
|
118
|
+
handle.flush
|
|
119
|
+
handle.chown(@authority.fetch("uid"), @authority.fetch("gid"))
|
|
120
|
+
handle.chmod(0o644)
|
|
121
|
+
handle.fsync
|
|
122
|
+
@handles.fetch(root).fsync
|
|
123
|
+
end
|
|
124
|
+
stat = handle.stat
|
|
125
|
+
unless stat.file? && stat.nlink == 1 && (stat.mode & 0o7777) == 0o644 &&
|
|
126
|
+
identity(stat) == identity(@files.lstat(path)) && stat.size <= 8192 &&
|
|
127
|
+
(suffix != "lock" || stat.size.zero? && [stat.uid, stat.gid] == @authority.values_at("uid", "gid"))
|
|
128
|
+
raise AttemptErrors::EvidenceUnavailable, "Lifecycle provisioning file differs"
|
|
129
|
+
end
|
|
130
|
+
@protection.verify!(path, handle, directory: false)
|
|
131
|
+
handle.rewind
|
|
132
|
+
@handles[path] = handle
|
|
133
|
+
ensure
|
|
134
|
+
handle&.close unless @handles[path] == handle
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def verify_unchanged!
|
|
138
|
+
@handles.each do |path, handle|
|
|
139
|
+
unless identity(handle.stat) == identity(@files.lstat(path))
|
|
140
|
+
raise AttemptErrors::EvidenceUnavailable, "Lifecycle provisioned object was replaced"
|
|
141
|
+
end
|
|
142
|
+
@protection.verify!(path, handle, directory: handle.stat.directory?)
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def identity(stat) = [stat.dev, stat.ino, stat.uid, stat.gid, stat.mode]
|
|
147
|
+
|
|
148
|
+
def close!
|
|
149
|
+
error = nil
|
|
150
|
+
@handles.values.reverse_each do |handle|
|
|
151
|
+
begin
|
|
152
|
+
handle.close unless handle.closed?
|
|
153
|
+
rescue SystemCallError, IOError => caught
|
|
154
|
+
error ||= caught
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
@handles.clear
|
|
158
|
+
raise error if error
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -53,19 +53,42 @@ module Ace
|
|
|
53
53
|
end.compact
|
|
54
54
|
end
|
|
55
55
|
|
|
56
|
+
# Source-authenticated callers retain exact step/report bytes through
|
|
57
|
+
# validation and parsing. No pathname reopen chooses executable work.
|
|
58
|
+
def scan_captured(steps:, assignment:, reports:)
|
|
59
|
+
parsed = Atoms::StepSorter.sort(steps.keys).map do |filename|
|
|
60
|
+
path = File.join(assignment.steps_dir, filename)
|
|
61
|
+
info = Atoms::StepFileParser.parse_filename(filename)
|
|
62
|
+
report_name = Atoms::StepFileParser.generate_report_filename(info.fetch(:number), info.fetch(:name))
|
|
63
|
+
report = reports[report_name]
|
|
64
|
+
report = Atoms::StepFileParser.parse(report).fetch(:body).strip if report
|
|
65
|
+
parse_step_content(path, steps.fetch(filename), report)
|
|
66
|
+
end
|
|
67
|
+
Models::QueueState.new(steps: parsed, assignment: assignment)
|
|
68
|
+
end
|
|
69
|
+
|
|
56
70
|
private
|
|
57
71
|
|
|
58
72
|
def parse_step_file(file_path)
|
|
59
73
|
content = File.read(file_path)
|
|
74
|
+
filename_info = Atoms::StepFileParser.parse_filename(File.basename(file_path))
|
|
75
|
+
report = load_report(file_path, filename_info[:number], filename_info[:name])
|
|
76
|
+
parse_step_content(file_path, content, report)
|
|
77
|
+
rescue ArgumentError => e
|
|
78
|
+
warn "Invalid step file #{file_path}: #{e.message}"
|
|
79
|
+
nil
|
|
80
|
+
rescue => e
|
|
81
|
+
warn "Failed to parse step file #{file_path}: #{e.message}" if Ace::Assign.debug?
|
|
82
|
+
nil
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def parse_step_content(file_path, content, report)
|
|
60
86
|
parsed = Atoms::StepFileParser.parse(content)
|
|
61
87
|
fields = Atoms::StepFileParser.extract_fields(parsed)
|
|
62
88
|
|
|
63
89
|
# Extract number and name from filename
|
|
64
90
|
filename_info = Atoms::StepFileParser.parse_filename(File.basename(file_path))
|
|
65
91
|
|
|
66
|
-
# Load report from separate file if it exists
|
|
67
|
-
report = load_report(file_path, filename_info[:number], filename_info[:name])
|
|
68
|
-
|
|
69
92
|
Models::Step.new(
|
|
70
93
|
number: filename_info[:number],
|
|
71
94
|
name: fields[:name] || filename_info[:name],
|
|
@@ -93,14 +116,6 @@ module Ace
|
|
|
93
116
|
stall_reason: fields[:stall_reason],
|
|
94
117
|
file_path: file_path
|
|
95
118
|
)
|
|
96
|
-
rescue ArgumentError => e
|
|
97
|
-
# ArgumentError indicates invalid data (e.g., invalid context value)
|
|
98
|
-
# Surface these errors visibly to help users fix configuration
|
|
99
|
-
warn "Invalid step file #{file_path}: #{e.message}"
|
|
100
|
-
nil
|
|
101
|
-
rescue => e
|
|
102
|
-
warn "Failed to parse step file #{file_path}: #{e.message}" if Ace::Assign.debug?
|
|
103
|
-
nil
|
|
104
119
|
end
|
|
105
120
|
|
|
106
121
|
# Load report from the reports/ directory
|
|
@@ -22,8 +22,18 @@ module Ace
|
|
|
22
22
|
APPROVED_VERDICT = "approved"
|
|
23
23
|
|
|
24
24
|
# @param identity_resolver [ExecutionIdentityResolver] Trust boundary
|
|
25
|
-
|
|
25
|
+
# @param artifact_reader [#call, nil] Source-owned canonical byte
|
|
26
|
+
# reader for protected composition; nil selects explicit local files
|
|
27
|
+
def initialize(identity_resolver: nil, artifact_reader: nil, campaign_verifier: nil)
|
|
26
28
|
@identity_resolver = identity_resolver || ExecutionIdentityResolver.new
|
|
29
|
+
unless artifact_reader.nil? || artifact_reader.respond_to?(:call)
|
|
30
|
+
raise ArgumentError, "artifact reader must be a source-owned callable"
|
|
31
|
+
end
|
|
32
|
+
@artifact_reader = artifact_reader
|
|
33
|
+
unless campaign_verifier.nil? || campaign_verifier.respond_to?(:call)
|
|
34
|
+
raise ArgumentError, "campaign verifier must be a source-owned callable"
|
|
35
|
+
end
|
|
36
|
+
@campaign_verifier = campaign_verifier
|
|
27
37
|
end
|
|
28
38
|
|
|
29
39
|
# Validate a submitted receipt against an attempt.
|
|
@@ -36,6 +46,18 @@ module Ace
|
|
|
36
46
|
# @return [Models::ExecutionReceipt] Normalized receipt with digest
|
|
37
47
|
# @raise [AttemptErrors::ReceiptRejected] on any validation failure
|
|
38
48
|
def verify!(data, attempt:, identity:, live_head:, repo_root:)
|
|
49
|
+
verify_receipt!(data, attempt: attempt, live_head: live_head, repo_root: repo_root) do
|
|
50
|
+
verify_authority(data, identity)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Submission validates evidence without granting terminal acceptance.
|
|
55
|
+
# The authenticated owner separately checks the exact worker attribution.
|
|
56
|
+
def verify_result!(data, attempt:, live_head:, repo_root:)
|
|
57
|
+
verify_receipt!(data, attempt: attempt, live_head: live_head, repo_root: repo_root)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def verify_receipt!(data, attempt:, live_head:, repo_root:)
|
|
39
61
|
reject_unless(data.is_a?(Hash), "receipt must be a JSON object")
|
|
40
62
|
|
|
41
63
|
if Models::ExecutionReceipt.forbidden_field?(data)
|
|
@@ -51,7 +73,7 @@ module Ace
|
|
|
51
73
|
|
|
52
74
|
verify_binding(data, attempt)
|
|
53
75
|
verify_producer(data)
|
|
54
|
-
|
|
76
|
+
yield if block_given?
|
|
55
77
|
verify_head(data, live_head)
|
|
56
78
|
verify_artifacts(data, repo_root) if data["verdict"] == "succeeded"
|
|
57
79
|
verify_checks(data)
|
|
@@ -63,18 +85,26 @@ module Ace
|
|
|
63
85
|
|
|
64
86
|
receipt
|
|
65
87
|
end
|
|
88
|
+
private :verify_receipt!
|
|
89
|
+
|
|
66
90
|
# Recheck the source artifacts/checks of an already accepted receipt.
|
|
67
|
-
#
|
|
91
|
+
# Local historical reads (recorded at a past head) validate acceptance from
|
|
68
92
|
# the append-only journal without re-hashing artifacts: the review
|
|
69
93
|
# lifecycle legitimately archives and annotates finding files after
|
|
70
94
|
# collection, and `.ace-local` working files are disposable, so their
|
|
71
95
|
# current existence proves nothing about an accepted past receipt.
|
|
72
|
-
# Current-head reads keep full artifact re-verification.
|
|
96
|
+
# Current-head reads keep full artifact re-verification. Protected
|
|
97
|
+
# readers always reverify imported bytes, including historical reads.
|
|
73
98
|
def verify_accepted_evidence!(data, live_head:, repo_root:, historical: false)
|
|
74
99
|
verify_head(data, live_head)
|
|
75
|
-
|
|
100
|
+
artifacts = data["artifacts"]
|
|
101
|
+
reject_unless(artifacts.is_a?(Array), "accepted artifacts must be an array")
|
|
102
|
+
if (@artifact_reader || !historical) && (data["verdict"] == "succeeded" || !artifacts.empty?)
|
|
103
|
+
verify_artifacts(data, repo_root)
|
|
104
|
+
end
|
|
76
105
|
verify_checks(data)
|
|
77
106
|
verify_review(data)
|
|
107
|
+
verify_campaign(data, repo_root: repo_root, live_head: live_head) if @artifact_reader
|
|
78
108
|
end
|
|
79
109
|
|
|
80
110
|
# @param operation [String] Receipt operation
|
|
@@ -147,17 +177,7 @@ module Ace
|
|
|
147
177
|
recorded = artifact["sha256"].to_s
|
|
148
178
|
reject("artifact missing path or sha256") if path.empty? || recorded.empty?
|
|
149
179
|
|
|
150
|
-
|
|
151
|
-
File.expand_path(path, File.realpath(repo_root))
|
|
152
|
-
rescue Errno::ENOENT, Errno::EACCES
|
|
153
|
-
nil
|
|
154
|
-
end
|
|
155
|
-
reject("artifact file not found: #{path}") if expanded.nil? || !File.exist?(expanded)
|
|
156
|
-
|
|
157
|
-
resolved = safe_resolve(repo_root, path)
|
|
158
|
-
reject("artifact path escapes project root: #{path}") if resolved.nil?
|
|
159
|
-
|
|
160
|
-
actual = Digest::SHA256.hexdigest(File.read(resolved))
|
|
180
|
+
actual = Digest::SHA256.hexdigest(artifact_bytes(data, artifact, repo_root))
|
|
161
181
|
reject("artifact digest mismatch for #{path}") unless actual == recorded
|
|
162
182
|
end
|
|
163
183
|
end
|
|
@@ -225,28 +245,44 @@ module Ace
|
|
|
225
245
|
unless Array(data["artifacts"]).include?(reference)
|
|
226
246
|
reject("campaign result must be included in verified receipt artifacts")
|
|
227
247
|
end
|
|
228
|
-
|
|
229
|
-
reject("campaign result path escapes repository or is missing") unless resolved
|
|
230
|
-
result = JSON.parse(File.read(resolved))
|
|
248
|
+
result = JSON.parse(artifact_bytes(data, reference, repo_root))
|
|
231
249
|
reject_unless(result.is_a?(Hash), "campaign result must be an object")
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
result["dry_run"] == false && current["accepted"] == true &&
|
|
236
|
-
result["result_identity"] == current["result_identity"] &&
|
|
237
|
-
result.dig("evidence", "current_head") == live_head && current.dig("evidence", "current_head") == live_head
|
|
238
|
-
reject("campaign result is stale, incomplete, blocked or does not match live authority")
|
|
239
|
-
end
|
|
240
|
-
approval = current["rounds"].last["approval"]
|
|
241
|
-
unless approval["producer"] == data.dig("producer", "actor") &&
|
|
242
|
-
approval["reviewer"] == data.dig("review", "reviewer", "actor") &&
|
|
243
|
-
approval["head"] == data["head"]
|
|
244
|
-
reject("campaign producer/reviewer attribution does not match independently validated receipt")
|
|
250
|
+
if @campaign_verifier
|
|
251
|
+
@campaign_verifier.call(data, result, live_head)
|
|
252
|
+
return
|
|
245
253
|
end
|
|
254
|
+
require "ace/review"
|
|
255
|
+
manager = Ace::Review::Organisms::CampaignManager.new(repo_root: repo_root)
|
|
256
|
+
current = manager.status(campaign["id"])
|
|
257
|
+
reject("campaign result ID differs from receipt") unless result["campaign_id"] == campaign["id"]
|
|
258
|
+
manager.with_verified_result!(result: result, subject: current.fetch("subject"),
|
|
259
|
+
contract_identity: current.fetch("contract_identity"), policy: current.fetch("effective_policy"),
|
|
260
|
+
head: live_head, base: current.fetch("evidence").fetch("current_base"),
|
|
261
|
+
producer: data.dig("producer", "actor"), reviewer: data.dig("review", "reviewer", "actor")) { true }
|
|
246
262
|
rescue JSON::ParserError, ArgumentError, TypeError, KeyError => e
|
|
247
263
|
reject("invalid campaign result: #{e.message}")
|
|
248
264
|
end
|
|
249
265
|
|
|
266
|
+
# Protected composition injects the canonical imported-byte owner.
|
|
267
|
+
# Its failure never falls through to the disposable workspace copy.
|
|
268
|
+
def artifact_bytes(data, artifact, repo_root)
|
|
269
|
+
if @artifact_reader
|
|
270
|
+
bytes = @artifact_reader.call(data, artifact)
|
|
271
|
+
reject("canonical artifact reader did not return bytes") unless bytes.is_a?(String)
|
|
272
|
+
return bytes.b
|
|
273
|
+
end
|
|
274
|
+
path = artifact["path"].to_s
|
|
275
|
+
expanded = begin
|
|
276
|
+
File.expand_path(path, File.realpath(repo_root))
|
|
277
|
+
rescue Errno::ENOENT, Errno::EACCES
|
|
278
|
+
nil
|
|
279
|
+
end
|
|
280
|
+
reject("artifact file not found: #{path}") if expanded.nil? || !File.exist?(expanded)
|
|
281
|
+
resolved = safe_resolve(repo_root, path)
|
|
282
|
+
reject("artifact path escapes project root: #{path}") if resolved.nil?
|
|
283
|
+
File.read(resolved)
|
|
284
|
+
end
|
|
285
|
+
|
|
250
286
|
# Resolve a project-relative artifact path to its real location,
|
|
251
287
|
# rejecting traversal and symlink escapes (an in-repo symlink must
|
|
252
288
|
# not smuggle in an external file); returns nil when the path leaves
|
|
@@ -11,6 +11,10 @@ module Ace
|
|
|
11
11
|
# Handles creation of new step files and updating existing ones,
|
|
12
12
|
# including appending reports and updating frontmatter.
|
|
13
13
|
class StepWriter
|
|
14
|
+
PRIVATE_MAX_BYTES = 1024 * 1024
|
|
15
|
+
def initialize(private_files: false)
|
|
16
|
+
@private_files = private_files
|
|
17
|
+
end
|
|
14
18
|
# Create a new step file
|
|
15
19
|
#
|
|
16
20
|
# @param steps_dir [String] Path to steps directory
|
|
@@ -101,6 +105,8 @@ module Ace
|
|
|
101
105
|
|
|
102
106
|
# Extract number and name from filename for report file
|
|
103
107
|
filename_info = Atoms::StepFileParser.parse_filename(File.basename(file_path))
|
|
108
|
+
encoded_report = report_bytes(filename_info[:number], filename_info[:name], report_content)
|
|
109
|
+
validate_private_bytes!(encoded_report)
|
|
104
110
|
|
|
105
111
|
# Update frontmatter only (status + completed_at)
|
|
106
112
|
new_frontmatter = parsed[:frontmatter].merge({
|
|
@@ -119,7 +125,7 @@ module Ace
|
|
|
119
125
|
)
|
|
120
126
|
report_path = File.join(reports_dir, report_filename)
|
|
121
127
|
|
|
122
|
-
write_report(report_path, filename_info[:number], filename_info[:name], report_content)
|
|
128
|
+
write_report(report_path, filename_info[:number], filename_info[:name], report_content, encoded: encoded_report)
|
|
123
129
|
|
|
124
130
|
file_path
|
|
125
131
|
end
|
|
@@ -204,8 +210,15 @@ module Ace
|
|
|
204
210
|
# Write content atomically using temp file + rename pattern.
|
|
205
211
|
# Prevents partial writes if process crashes mid-write.
|
|
206
212
|
def atomic_write(path, content)
|
|
213
|
+
validate_private_bytes!(content)
|
|
207
214
|
temp_path = "#{path}.tmp.#{Process.pid}"
|
|
208
|
-
|
|
215
|
+
if @private_files
|
|
216
|
+
File.open(temp_path, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0600) do |file|
|
|
217
|
+
file.write(content); file.flush; file.fsync
|
|
218
|
+
end
|
|
219
|
+
else
|
|
220
|
+
File.write(temp_path, content)
|
|
221
|
+
end
|
|
209
222
|
File.rename(temp_path, path)
|
|
210
223
|
end
|
|
211
224
|
|
|
@@ -230,15 +243,24 @@ module Ace
|
|
|
230
243
|
# @param number [String] Step number
|
|
231
244
|
# @param name [String] Step name
|
|
232
245
|
# @param content [String] Report content
|
|
233
|
-
def write_report(report_path, number, name, content)
|
|
246
|
+
def write_report(report_path, number, name, content, encoded: nil)
|
|
247
|
+
atomic_write(report_path, encoded || report_bytes(number, name, content))
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def report_bytes(number, name, content)
|
|
234
251
|
frontmatter = {
|
|
235
252
|
"step" => number,
|
|
236
253
|
"name" => name,
|
|
237
254
|
"completed_at" => Time.now.utc.iso8601
|
|
238
255
|
}
|
|
239
256
|
yaml = frontmatter.to_yaml
|
|
240
|
-
|
|
241
|
-
|
|
257
|
+
"#{yaml}---\n\n#{content}\n"
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def validate_private_bytes!(content)
|
|
261
|
+
if @private_files && (!content.is_a?(String) || !content.bytesize.between?(1, PRIVATE_MAX_BYTES))
|
|
262
|
+
raise ArgumentError, "private queue output exceeds captured text bound"
|
|
263
|
+
end
|
|
242
264
|
end
|
|
243
265
|
end
|
|
244
266
|
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require_relative "receipt_verifier"
|
|
3
|
+
require_relative "canonical_evidence"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Assign
|
|
7
|
+
module Molecules
|
|
8
|
+
# Pure reader over already accepted terminal owner evidence. A submitted
|
|
9
|
+
# result alone carries no terminal authority; local filesystem artifacts
|
|
10
|
+
# are never substituted for its imported protected bytes.
|
|
11
|
+
class TerminalScopeReceipt
|
|
12
|
+
BINDING_FIELDS = %w[mapping_id project_id assignment_id attempt_id result_id worker_uid worker_actor
|
|
13
|
+
launch_ticket process_binding head candidate_generation uploaded_receipt_sha256 original_receipt_digest].freeze
|
|
14
|
+
PAYLOAD_FIELDS = %w[version result_id binding uploaded_receipt_sha256 original_receipt_digest receipt_digest receipt artifacts].freeze
|
|
15
|
+
|
|
16
|
+
def self.verify!(events:, lineage:, journal:, commit:, mapping:)
|
|
17
|
+
raise AttemptErrors::EvidenceUnavailable, "terminal receipt chain differs" unless Models::EvidenceEvent.chain_valid?(events)
|
|
18
|
+
terminal = events.select { |event| event["type"] == "receipt_accepted" &&
|
|
19
|
+
%w[succeeded failed].include?(event.dig("payload", "receipt", "verdict")) }
|
|
20
|
+
raise AttemptErrors::EvidenceUnavailable, "no unique accepted terminal receipt" unless terminal.size == 1
|
|
21
|
+
accepted = terminal.first
|
|
22
|
+
receipt = accepted.fetch("payload").fetch("receipt")
|
|
23
|
+
before = events.take_while { |event| event.fetch("digest") != accepted.fetch("digest") }
|
|
24
|
+
submitted = before.select { |event| event["type"] == "result_submitted" &&
|
|
25
|
+
event.dig("payload", "receipt_digest") == receipt.fetch("digest") }
|
|
26
|
+
raise AttemptErrors::EvidenceUnavailable, "accepted terminal result provenance differs" unless submitted.size == 1
|
|
27
|
+
payload = submitted.first.fetch("payload")
|
|
28
|
+
binding = payload.fetch("binding")
|
|
29
|
+
reservation = before.find { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "reserve_attempt" }
|
|
30
|
+
origin = reservation.fetch("payload").fetch("data")
|
|
31
|
+
expected = {"mapping_id" => origin.fetch("mapping_id"), "project_id" => mapping.fetch("project_id"),
|
|
32
|
+
"assignment_id" => origin.fetch("assignment_id"), "attempt_id" => accepted.fetch("attempt_id"),
|
|
33
|
+
"worker_uid" => mapping.fetch("worker_uid"), "worker_actor" => mapping.fetch("worker_actor"),
|
|
34
|
+
"launch_ticket" => origin.fetch("launch_ticket"), "process_binding" => lineage.child_event.fetch("payload").fetch("original_process_binding")}
|
|
35
|
+
candidates = before.select { |event| event["type"] == "authority_mutation" &&
|
|
36
|
+
event.dig("payload", "operation") == "submit_candidate" &&
|
|
37
|
+
event.dig("payload", "data", "candidate_generation") == binding.fetch("candidate_generation") }
|
|
38
|
+
candidate = candidates.one? && candidates.first.dig("payload", "data")
|
|
39
|
+
unless payload.keys.sort == PAYLOAD_FIELDS.sort && binding.keys.sort == BINDING_FIELDS.sort &&
|
|
40
|
+
payload["version"] == 1 && expected.all? { |key, value| binding[key] == value } &&
|
|
41
|
+
candidate && candidate["head"] == binding["head"] && receipt["head"] == binding["head"] &&
|
|
42
|
+
payload["result_id"] == binding["result_id"] && payload["uploaded_receipt_sha256"] == binding["uploaded_receipt_sha256"] &&
|
|
43
|
+
payload["original_receipt_digest"] == binding["original_receipt_digest"] &&
|
|
44
|
+
Models::ExecutionReceipt.from_h(payload.fetch("receipt")).digest_payload == Models::ExecutionReceipt.from_h(receipt).digest_payload &&
|
|
45
|
+
(receipt.keys - Models::ExecutionReceipt.from_h(receipt).to_h.keys).empty? &&
|
|
46
|
+
payload["artifacts"] == receipt["artifacts"] &&
|
|
47
|
+
receipt["producer"] == {"actor" => mapping.fetch("worker_actor"), "role" => "worker", "runtime" => "herdr"} &&
|
|
48
|
+
journal.canonical_attempt_state(events) == receipt.fetch("verdict")
|
|
49
|
+
raise AttemptErrors::EvidenceUnavailable, "accepted terminal receipt selectors differ"
|
|
50
|
+
end
|
|
51
|
+
model = Models::ExecutionReceipt.from_h(receipt)
|
|
52
|
+
unless model.digest == Atoms::EvidenceDigest.digest(model.digest_payload) && model.digest == payload.fetch("receipt_digest")
|
|
53
|
+
raise AttemptErrors::EvidenceUnavailable, "accepted terminal receipt digest differs"
|
|
54
|
+
end
|
|
55
|
+
finishes = events.select { |event| event["type"] == "authority_mutation" && event.dig("payload", "operation") == "finish" }
|
|
56
|
+
unless finishes.empty?
|
|
57
|
+
transition = events[events.index(accepted) + 1]
|
|
58
|
+
finish = events[events.index(accepted) + 2]
|
|
59
|
+
expected_reply = binding.slice("attempt_id", "result_id", "head", "candidate_generation").merge(
|
|
60
|
+
"receipt_digest" => payload.fetch("receipt_digest"), "state" => receipt.fetch("verdict"))
|
|
61
|
+
unless finishes.one? && finishes.first == finish && transition && transition["type"] == "transition" &&
|
|
62
|
+
transition.fetch("payload") == {"from" => "running", "to" => receipt.fetch("verdict"), "reason" => "protected_result_accepted"} &&
|
|
63
|
+
finish.dig("payload", "data").except("generation") == expected_reply &&
|
|
64
|
+
finish.dig("payload", "data", "generation") == events.take(events.index(finish) + 1).count { |event| event["type"] == "authority_mutation" }
|
|
65
|
+
raise AttemptErrors::EvidenceUnavailable, "accepted finish reply or terminal transition differs"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
canonical = CanonicalEvidence.new(journal: journal)
|
|
69
|
+
context = {kind: "result", project_id: binding.fetch("project_id"), assignment_id: binding.fetch("assignment_id"),
|
|
70
|
+
attempt_id: binding.fetch("attempt_id"), peer_uid: binding.fetch("worker_uid"), binding: binding,
|
|
71
|
+
request_id_or_event_id: binding.fetch("result_id"), generation: binding.fetch("candidate_generation")}
|
|
72
|
+
reader = ->(_data, artifact) { canonical.read({"ref" => artifact.fetch("path"), "sha256" => artifact.fetch("sha256")},
|
|
73
|
+
**context, commit: commit) }
|
|
74
|
+
Array(receipt["artifacts"]).each { |artifact| reader.call(receipt, artifact) }
|
|
75
|
+
intent = before.find { |event| event["type"] == "intent" }
|
|
76
|
+
attempt = journal.send(:build_attempt, binding.fetch("assignment_id"), binding.fetch("attempt_id"),
|
|
77
|
+
intent.fetch("payload"), before, "running")
|
|
78
|
+
verifier = ReceiptVerifier.new(artifact_reader: reader)
|
|
79
|
+
verifier.verify_result!(receipt, attempt: attempt, live_head: binding.fetch("head"), repo_root: journal.repo_root)
|
|
80
|
+
verifier.verify_accepted_evidence!(receipt, live_head: binding.fetch("head"), repo_root: journal.repo_root, historical: true)
|
|
81
|
+
accepted
|
|
82
|
+
rescue KeyError, TypeError, ArgumentError, NoMethodError, AttemptErrors::ReceiptRejected
|
|
83
|
+
raise AttemptErrors::EvidenceUnavailable, "authenticated terminal scope receipt unavailable"
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -37,10 +37,10 @@ module Ace
|
|
|
37
37
|
end
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
-
def initialize(cache_base: nil, skill_source_resolver: nil, step_catalog: nil)
|
|
41
|
-
@assignment_manager = Molecules::AssignmentManager.new(cache_base: cache_base)
|
|
42
|
-
@queue_scanner = Molecules::QueueScanner.new
|
|
43
|
-
@step_writer = Molecules::StepWriter.new
|
|
40
|
+
def initialize(cache_base: nil, skill_source_resolver: nil, step_catalog: nil, selected_assignment: nil, queue_scanner: nil)
|
|
41
|
+
@assignment_manager = Molecules::AssignmentManager.new(cache_base: cache_base, selected_assignment: selected_assignment)
|
|
42
|
+
@queue_scanner = queue_scanner || Molecules::QueueScanner.new
|
|
43
|
+
@step_writer = Molecules::StepWriter.new(private_files: !selected_assignment.nil?)
|
|
44
44
|
@skill_source_resolver = skill_source_resolver || Molecules::SkillAssignSourceResolver.new
|
|
45
45
|
@step_catalog = nil
|
|
46
46
|
@step_catalog_from_fixture = step_catalog
|
|
@@ -68,6 +68,12 @@ module Ace
|
|
|
68
68
|
|
|
69
69
|
assignment_config = config["assignment"] || {}
|
|
70
70
|
steps_config = config["steps"] || []
|
|
71
|
+
if config.key?("delivery")
|
|
72
|
+
Atoms::DeliveryParameters.validate(config["delivery"])
|
|
73
|
+
unless Array(steps_config).any? { |step| %w[create-pr update-pr-desc mark-pr-ready].include?(step["name"]) }
|
|
74
|
+
raise ArgumentError, "Explicit delivery inputs require remote steps"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
71
77
|
|
|
72
78
|
raise Error, "No steps defined in config" if steps_config.empty?
|
|
73
79
|
|