ace-assign 0.64.0 → 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/CHANGELOG.md +19 -0
- data/docs/protected-authority.md +23 -7
- data/docs/usage.md +117 -4
- data/exe/ace-scope-ready +55 -0
- data/handbook/workflow-instructions/assign/drive.wf.md +26 -1
- data/lib/ace/assign/atoms/attempt_state_machine.rb +8 -0
- data/lib/ace/assign/atoms/delivery_parameters.rb +4 -39
- 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 +169 -2
- data/lib/ace/assign/authority/cleanup_result_protection.rb +53 -0
- data/lib/ace/assign/authority/client.rb +156 -6
- data/lib/ace/assign/authority/deployment.rb +201 -39
- data/lib/ace/assign/authority/deployment_history.rb +146 -0
- data/lib/ace/assign/authority/endcap.rb +131 -18
- 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 +3 -280
- 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 +36 -18
- 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 +221 -18
- data/lib/ace/assign/authority/execution_scope_observation.rb +468 -41
- 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 +298 -19
- data/lib/ace/assign/authority/launch_input_inhibition.rb +132 -0
- data/lib/ace/assign/authority/launch_lifecycle.rb +275 -100
- 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 +0 -7
- data/lib/ace/assign/authority/launch_scope_admission.rb +136 -5
- data/lib/ace/assign/authority/launch_scope_close.rb +68 -37
- data/lib/ace/assign/authority/launch_scope_release.rb +696 -17
- 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 +5 -2
- 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/protected_assignment_context.rb +226 -0
- data/lib/ace/assign/authority/readiness_hook.rb +120 -0
- data/lib/ace/assign/authority/router.rb +24 -2
- data/lib/ace/assign/authority/server.rb +103 -33
- 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 +243 -12
- 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 +44 -12
- 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 +58 -6
- data/lib/ace/assign/cli/commands/authority/serve.rb +2 -1
- data/lib/ace/assign/cli/commands/authority/status.rb +1 -1
- 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/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 +2 -0
- data/lib/ace/assign/cli/commands/delivery.rb +54 -4
- 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 +12 -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 +36 -5
- 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 +3 -3
- data/lib/ace/assign/molecules/assignment_manager.rb +12 -2
- data/lib/ace/assign/molecules/attempt_reconciler.rb +20 -0
- data/lib/ace/assign/molecules/canonical_attempt_state.rb +151 -0
- data/lib/ace/assign/molecules/canonical_evidence.rb +5 -2
- data/lib/ace/assign/molecules/canonical_read_snapshot.rb +374 -0
- data/lib/ace/assign/molecules/evidence_journal.rb +720 -44
- data/lib/ace/assign/molecules/execution_scope_lineage.rb +27 -9
- 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 +82 -13
- data/lib/ace/assign/molecules/journal_prompt_mutation.rb +241 -0
- data/lib/ace/assign/molecules/lifecycle_exclusion.rb +178 -26
- data/lib/ace/assign/molecules/original_launch_binding.rb +37 -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 +21 -15
- 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 +4 -4
- data/lib/ace/assign/organisms/attempt_coordinator.rb +66 -76
- 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 +4 -2
- data/lib/ace/assign/version.rb +1 -1
- data/lib/ace/assign.rb +1 -112
- metadata +88 -9
- data/lib/ace/assign/cli/commands/inbox_reconcile.rb +0 -36
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "digest"
|
|
4
|
+
require "json"
|
|
4
5
|
require "fileutils"
|
|
5
6
|
require "tmpdir"
|
|
6
|
-
require "
|
|
7
|
+
require "tempfile"
|
|
8
|
+
require "ace/herdr/molecules/bounded_process"
|
|
7
9
|
require_relative "private_directory"
|
|
8
10
|
|
|
9
11
|
module Ace
|
|
@@ -16,11 +18,103 @@ module Ace
|
|
|
16
18
|
DEADLINE = 30
|
|
17
19
|
SHA = /\A[0-9a-f]{40}\z/
|
|
18
20
|
|
|
21
|
+
class CampaignReadView
|
|
22
|
+
def initialize(owner:, repository:, base:, deadline:)
|
|
23
|
+
@owner, @repository, @base, @deadline = owner, repository, base, deadline
|
|
24
|
+
@thread, @active = Thread.current, true
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def close = @active = false
|
|
28
|
+
def head = command("rev-parse", "HEAD").strip
|
|
29
|
+
def base
|
|
30
|
+
commit!(@base)
|
|
31
|
+
@base
|
|
32
|
+
end
|
|
33
|
+
def commit!(revision)
|
|
34
|
+
raise ArgumentError, "campaign revision is invalid" unless revision.is_a?(String) && revision.match?(SHA)
|
|
35
|
+
command("cat-file", "-e", "#{revision}^{commit}")
|
|
36
|
+
true
|
|
37
|
+
end
|
|
38
|
+
def full_diff?(base, head)
|
|
39
|
+
commit!(base)
|
|
40
|
+
commit!(head)
|
|
41
|
+
!command("diff", "--raw", "--no-ext-diff", "--no-textconv", base, head, "--").empty?
|
|
42
|
+
end
|
|
43
|
+
def clean?
|
|
44
|
+
command("status", "--porcelain", "-z", "--untracked-files=all").split("\0")
|
|
45
|
+
.all? { |entry| entry.start_with?("?? .ace-local/") }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def command(*arguments)
|
|
51
|
+
unless @active && Thread.current.equal?(@thread)
|
|
52
|
+
raise AttemptErrors::EvidenceUnavailable, "campaign Git read view escaped its source lifetime"
|
|
53
|
+
end
|
|
54
|
+
@owner.send(:git, @repository, @deadline, *arguments)
|
|
55
|
+
rescue Timeout::Error
|
|
56
|
+
raise AttemptErrors::EvidenceUnavailable, "campaign Git read deadline expired"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def with_campaign_repository(bytes:, sha256:, size:, head:, tree:, base:)
|
|
61
|
+
raise ArgumentError, "campaign repository requires a block" unless block_given?
|
|
62
|
+
deadline = monotonic + DEADLINE
|
|
63
|
+
materialized = materialize_repository(bytes: bytes, sha256: sha256, size: size, head: head, tree: tree, root: @root)
|
|
64
|
+
directory = materialized.fetch("directory")
|
|
65
|
+
reader = CampaignReadView.new(owner: self, repository: directory, base: base, deadline: deadline)
|
|
66
|
+
unless reader.head == head
|
|
67
|
+
raise AttemptErrors::EvidenceUnavailable, "campaign Git head differs from original candidate"
|
|
68
|
+
end
|
|
69
|
+
reader.base
|
|
70
|
+
yield reader
|
|
71
|
+
ensure
|
|
72
|
+
reader&.close
|
|
73
|
+
FileUtils.remove_entry(directory) if directory && File.directory?(directory)
|
|
74
|
+
end
|
|
75
|
+
|
|
19
76
|
def initialize(root:)
|
|
20
77
|
@root = File.expand_path(root)
|
|
21
78
|
verify_root!
|
|
22
79
|
end
|
|
23
80
|
|
|
81
|
+
# Export the already validated prepared tree using the same isolated Git
|
|
82
|
+
# boundary as admission. The returned bytes are retained by the producer;
|
|
83
|
+
# registration must not rebuild them after a lost response.
|
|
84
|
+
def export_prepared(work:)
|
|
85
|
+
require_relative "prepared_work"
|
|
86
|
+
reject!("Prepared export requires validated work") unless work.is_a?(PreparedWork)
|
|
87
|
+
verify_root!
|
|
88
|
+
deadline = monotonic + DEADLINE
|
|
89
|
+
Dir.mktmpdir("prepared-export-", @root) do |directory|
|
|
90
|
+
File.chmod(0700, directory)
|
|
91
|
+
repository = File.join(directory, "repository")
|
|
92
|
+
git(directory, deadline, "init", "--template=", "--initial-branch=prepared", repository)
|
|
93
|
+
work.files.each do |path, bytes|
|
|
94
|
+
target = File.join(repository, path)
|
|
95
|
+
FileUtils.mkdir_p(File.dirname(target), mode: 0700)
|
|
96
|
+
File.open(target, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0600) do |file|
|
|
97
|
+
file.write(bytes)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
git(repository, deadline, "add", "--", ".")
|
|
101
|
+
git(repository, deadline, "-c", "user.name=ACE prepared input", "-c", "user.email=prepared@ace.invalid",
|
|
102
|
+
"-c", "commit.gpgSign=false", "commit", "--no-verify", "-m", "Prepared assignment input")
|
|
103
|
+
head = git(repository, deadline, "rev-parse", "HEAD").strip
|
|
104
|
+
tree = git(repository, deadline, "rev-parse", "HEAD^{tree}").strip
|
|
105
|
+
bundle = File.join(directory, "prepared.bundle")
|
|
106
|
+
git(repository, deadline, "bundle", "create", bundle, "refs/heads/prepared")
|
|
107
|
+
bytes = read_bundle(bundle).freeze
|
|
108
|
+
{"head" => head.freeze, "tree" => tree.freeze, "bundle" => bytes,
|
|
109
|
+
"bytes" => bytes.bytesize, "sha256" => Digest::SHA256.hexdigest(bytes).freeze,
|
|
110
|
+
"definition_bytes" => work.definition_bytes(head: head, tree: tree)}.freeze
|
|
111
|
+
end
|
|
112
|
+
rescue Timeout::Error
|
|
113
|
+
reject!("Prepared export deadline exceeded")
|
|
114
|
+
rescue SystemCallError
|
|
115
|
+
reject!("Prepared export filesystem is unavailable")
|
|
116
|
+
end
|
|
117
|
+
|
|
24
118
|
def admit(bytes:, sha256:, size:, head:, &consumer)
|
|
25
119
|
unless bytes.is_a?(String) && size.is_a?(Integer) && size.positive? && size <= MAX_BYTES &&
|
|
26
120
|
size == bytes.bytesize && sha256.is_a?(String) &&
|
|
@@ -70,7 +164,7 @@ module Ace
|
|
|
70
164
|
destination = Dir.mktmpdir("candidate-", root)
|
|
71
165
|
File.chmod(0700, destination)
|
|
72
166
|
begin
|
|
73
|
-
entries = git(repository, deadline, "ls-tree", "-r", "-z", head, limit: MAX_BYTES).split("\0")
|
|
167
|
+
entries = git(repository, deadline, "ls-tree", "-r", "-z", head, limit: MAX_BYTES).b.split("\0")
|
|
74
168
|
total = 0
|
|
75
169
|
links = []
|
|
76
170
|
entries.each do |entry|
|
|
@@ -98,6 +192,7 @@ module Ace
|
|
|
98
192
|
File.open(target, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW,
|
|
99
193
|
mode == "100755" ? 0700 : 0600) { |file| file.write(content) }
|
|
100
194
|
end
|
|
195
|
+
yield(path.freeze, mode.freeze, oid.freeze, content.freeze) if block_given?
|
|
101
196
|
end
|
|
102
197
|
links.each { |target, content| File.symlink(content, target) }
|
|
103
198
|
links.each do |target, _content|
|
|
@@ -116,8 +211,80 @@ module Ace
|
|
|
116
211
|
reject!("Candidate filesystem materialization is unverifiable")
|
|
117
212
|
end
|
|
118
213
|
|
|
214
|
+
# Executing campaign children need local Git history as well as files.
|
|
215
|
+
# Create only our own empty administration; no uploader configuration,
|
|
216
|
+
# checkout filters or hooks enter this private repository.
|
|
217
|
+
def materialize_repository(bytes:, sha256:, size:, head:, tree:, root:)
|
|
218
|
+
deadline = monotonic + DEADLINE
|
|
219
|
+
result = materialize(bytes: bytes, sha256: sha256, size: size, head: head, tree: tree, root: root)
|
|
220
|
+
directory = result.fetch("directory")
|
|
221
|
+
Tempfile.create(["campaign-candidate-", ".bundle"], root) do |file|
|
|
222
|
+
file.binmode
|
|
223
|
+
file.write(bytes); file.flush
|
|
224
|
+
git(directory, deadline, "init", "--template=", "--initial-branch=work")
|
|
225
|
+
git(directory, deadline, "fetch", "--no-tags", file.path, "#{head}:refs/heads/candidate")
|
|
226
|
+
git(directory, deadline, "symbolic-ref", "HEAD", "refs/heads/candidate")
|
|
227
|
+
git(directory, deadline, "read-tree", head)
|
|
228
|
+
reject!("Campaign private repository differs") unless git(directory, deadline, "rev-parse", "HEAD^{tree}").strip == tree
|
|
229
|
+
end
|
|
230
|
+
result
|
|
231
|
+
rescue Exception
|
|
232
|
+
FileUtils.remove_entry(directory) if directory && File.directory?(directory) && !File.symlink?(directory)
|
|
233
|
+
raise
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# Full immutable review input comes from the same admitted Git objects
|
|
237
|
+
# used for materialization, never a second read of the scratch checkout.
|
|
238
|
+
def review_snapshot(bytes:, sha256:, size:, head:, tree:, root:)
|
|
239
|
+
subject = JSON.generate("schema" => "ace.candidate-review-snapshot/v1", "head" => head, "tree" => tree)
|
|
240
|
+
.delete_suffix("}") + ',"files":['
|
|
241
|
+
first = true
|
|
242
|
+
result = materialize(bytes: bytes, sha256: sha256, size: size, head: head, tree: tree, root: root) do |path, mode, oid, content|
|
|
243
|
+
entry = JSON.generate("path" => review_bytes(path), "mode" => mode, "oid" => oid, "content" => review_bytes(content))
|
|
244
|
+
separator = first ? "" : ","
|
|
245
|
+
reject!("Complete candidate review snapshot is oversized") if subject.bytesize + separator.bytesize + entry.bytesize + 2 > MAX_BYTES
|
|
246
|
+
subject << separator << entry
|
|
247
|
+
first = false
|
|
248
|
+
end
|
|
249
|
+
subject << "]}"
|
|
250
|
+
result.merge("subject" => subject.freeze)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Prepared instructions have a narrower tree contract than candidates:
|
|
254
|
+
# no executable entries or symlinks, and bounded UTF-8 files. Read only
|
|
255
|
+
# verified Git objects; never run checkout filters or reopen uploader paths.
|
|
256
|
+
def prepared_files(repository:, deadline:, head:)
|
|
257
|
+
entries = git(repository, deadline, "ls-tree", "-r", "-z", head, limit: MAX_BYTES).b.split("\0")
|
|
258
|
+
reject!("Prepared file inventory is oversized") unless entries.size.between?(4, 4096)
|
|
259
|
+
total = 0
|
|
260
|
+
files = entries.to_h do |entry|
|
|
261
|
+
metadata, path = entry.split("\t", 2)
|
|
262
|
+
mode, kind, oid = metadata.to_s.split(" ")
|
|
263
|
+
unless path && mode == "100644" && kind == "blob" && oid.match?(SHA) &&
|
|
264
|
+
!path.start_with?("/") && path.split("/", -1).none? { |part| part.empty? || %w[. .. .git].include?(part.downcase) }
|
|
265
|
+
reject!("Prepared tree contains unsafe entry")
|
|
266
|
+
end
|
|
267
|
+
size = git(repository, deadline, "cat-file", "-s", oid).strip
|
|
268
|
+
reject!("Prepared text is oversized") unless size.match?(/\A[0-9]+\z/) && size.to_i.between?(1, 1024 * 1024)
|
|
269
|
+
total += size.to_i
|
|
270
|
+
reject!("Prepared expanded content is oversized") if total > MAX_BYTES
|
|
271
|
+
[path, git(repository, deadline, "cat-file", "blob", oid, limit: 1024 * 1024).b]
|
|
272
|
+
end
|
|
273
|
+
reject!("Prepared inventory has duplicate paths") unless files.size == entries.size
|
|
274
|
+
files
|
|
275
|
+
end
|
|
276
|
+
|
|
119
277
|
private
|
|
120
278
|
|
|
279
|
+
def review_bytes(bytes)
|
|
280
|
+
text = bytes.dup.force_encoding(Encoding::UTF_8)
|
|
281
|
+
if text.valid_encoding? && !text.include?("\0")
|
|
282
|
+
{"encoding" => "utf-8", "bytes" => text}
|
|
283
|
+
else
|
|
284
|
+
{"encoding" => "base64", "bytes" => [bytes].pack("m0")}
|
|
285
|
+
end
|
|
286
|
+
end
|
|
287
|
+
|
|
121
288
|
def verify_root!
|
|
122
289
|
PrivateDirectory.verify!(@root)
|
|
123
290
|
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fiddle"
|
|
4
|
+
require "ace/runtime/molecules/protected_artifact_set"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Assign
|
|
8
|
+
module Authority
|
|
9
|
+
# Extends the existing held artifact protection solely for the bounded
|
|
10
|
+
# root result view. Private archive storage is never admitted here.
|
|
11
|
+
class CleanupResultProtection < Ace::Runtime::Molecules::ProtectedArtifactSet::Protection
|
|
12
|
+
class LinuxACL
|
|
13
|
+
def absent!(handle, attribute)
|
|
14
|
+
unless RUBY_PLATFORM.include?("linux")
|
|
15
|
+
raise Ace::Runtime::RuntimeUnavailableError, "cleanup result ACL evidence requires Linux"
|
|
16
|
+
end
|
|
17
|
+
function = Fiddle::Function.new(Fiddle::Handle::DEFAULT["fgetxattr"],
|
|
18
|
+
[Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T], Fiddle::TYPE_SSIZE_T)
|
|
19
|
+
size = function.call(handle.fileno, attribute, 0, 0)
|
|
20
|
+
error = Fiddle.last_error
|
|
21
|
+
unless size == -1 && error == Errno::ENODATA::Errno
|
|
22
|
+
raise Ace::Runtime::RuntimeUnavailableError, "cleanup result ACL protection is unavailable"
|
|
23
|
+
end
|
|
24
|
+
rescue Fiddle::DLError
|
|
25
|
+
raise Ace::Runtime::RuntimeUnavailableError, "cleanup result ACL reader is unavailable"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initialize(result_path:, authority_gid:, acl: LinuxACL.new, **options)
|
|
30
|
+
unless result_path.is_a?(String) && result_path.start_with?("/") && !result_path.include?("\0") &&
|
|
31
|
+
File.expand_path(result_path) == result_path && authority_gid.is_a?(Integer) && authority_gid.positive?
|
|
32
|
+
raise ArgumentError, "invalid fixed cleanup result view"
|
|
33
|
+
end
|
|
34
|
+
super(**options)
|
|
35
|
+
@result_path, @parent, @authority_gid, @acl = result_path.dup.freeze, File.dirname(result_path).freeze, authority_gid, acl
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def verify!(path, handle, directory:)
|
|
39
|
+
super
|
|
40
|
+
return unless path == @result_path || path == @parent
|
|
41
|
+
parent = path == @parent
|
|
42
|
+
stat = handle.stat
|
|
43
|
+
unless directory == parent && stat.gid == @authority_gid &&
|
|
44
|
+
(stat.mode & 0o7777) == (parent ? 0o2750 : 0o640)
|
|
45
|
+
raise Ace::Runtime::RuntimeUnavailableError, "cleanup result view mode or group differs"
|
|
46
|
+
end
|
|
47
|
+
@acl.absent!(handle, "system.posix_acl_access")
|
|
48
|
+
@acl.absent!(handle, "system.posix_acl_default") if parent
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
require "securerandom"
|
|
3
3
|
require_relative "deployment"
|
|
4
4
|
require_relative "transfer_codec"
|
|
5
|
+
require_relative "campaign_execution"
|
|
6
|
+
require_relative "candidate_transfer"
|
|
5
7
|
require_relative "../molecules/canonical_evidence"
|
|
8
|
+
require_relative "../molecules/execution_scope_lineage"
|
|
6
9
|
|
|
7
10
|
module Ace
|
|
8
11
|
module Assign
|
|
@@ -17,22 +20,32 @@ module Ace
|
|
|
17
20
|
end
|
|
18
21
|
|
|
19
22
|
def call(operation, params, mutation_id: nil, timeout: nil, upload_parts: nil, download: false, purpose: nil)
|
|
20
|
-
if operation == "
|
|
21
|
-
raise ArgumentError, "
|
|
23
|
+
if operation == "register_assignment" && (!upload_parts || purpose != :candidate || download)
|
|
24
|
+
raise ArgumentError, "prepared registration requires fixed candidate upload"
|
|
25
|
+
end
|
|
26
|
+
if operation == "evidence_fetch" && (!download || purpose != (%w[prepared_work campaign_candidate].include?(params["kind"]) ? :candidate : :artifacts) || upload_parts)
|
|
27
|
+
raise ArgumentError, "evidence fetch requires source-fixed kind download"
|
|
22
28
|
end
|
|
23
29
|
if operation == "submit_result" && (!upload_parts || purpose != :receipt_artifacts || download)
|
|
24
30
|
raise ArgumentError, "result submission requires fixed receipt upload"
|
|
25
31
|
end
|
|
32
|
+
if operation == "campaign_export_result" && (!download || purpose != :artifacts || upload_parts || mutation_id)
|
|
33
|
+
raise ArgumentError, "campaign result requires fixed read-only artifact download"
|
|
34
|
+
end
|
|
35
|
+
if operation == "campaign_record_round" && (!upload_parts || purpose != :receipt_artifacts || download)
|
|
36
|
+
raise ArgumentError, "campaign round requires fixed bounded upload"
|
|
37
|
+
end
|
|
26
38
|
@deployment.verify!(mapping_id, kernel: @kernel)
|
|
27
39
|
wire = Ace::Runtime::Molecules::ProtectedSocket
|
|
28
40
|
path = @service.fetch("socket_path")
|
|
29
41
|
before = wire.socket_identity(path)
|
|
30
42
|
raise Ace::Runtime::RuntimeUnavailableError, "authority endpoint owner differs" unless before.last == @service.fetch("uid")
|
|
31
43
|
raise ArgumentError, "cannot upload and download on one request" if upload_parts && download
|
|
32
|
-
|
|
44
|
+
prepared_download = operation == "evidence_fetch" && %w[prepared_work campaign_candidate].include?(params["kind"])
|
|
45
|
+
codec = transfer_codec if upload_parts || download && !prepared_download
|
|
33
46
|
descriptor = codec.descriptor(upload_parts, purpose: purpose) if upload_parts
|
|
34
47
|
parameters = upload_parts ? params.merge("transfer" => descriptor) : params
|
|
35
|
-
cap = %w[reserve_attempt close_execution_scope].include?(operation) ? 90 : 30
|
|
48
|
+
cap = %w[reserve_attempt close_execution_scope stop_attempt].include?(operation) ? 90 : 30
|
|
36
49
|
duration = timeout || (cap == 90 ? 90 : 5)
|
|
37
50
|
unless duration.is_a?(Numeric) && duration.positive? && duration.finite?
|
|
38
51
|
raise ArgumentError, "authority timeout must be finite and positive"
|
|
@@ -47,7 +60,7 @@ module Ace
|
|
|
47
60
|
wire.write(socket, {"version" => 1, "operation" => operation,
|
|
48
61
|
"mutation_id" => mutation_id, "project_id" => @map.fetch("project_id"),
|
|
49
62
|
"params" => parameters.merge("mapping_id" => mapping_id)}, deadline: deadline, limit: upload_parts || download ? 16_384 : wire::LIMIT)
|
|
50
|
-
if %w[evidence_fetch observe_execution_scope close_execution_scope].include?(operation) || (operation == "attempt_status" && params.key?("result_candidate_generation"))
|
|
63
|
+
if %w[bind_inbox finish recover request_review review_status launch_review_intent cancel_review assignment_inventory evidence_fetch observe_execution_scope close_execution_scope stop_attempt prompt_status launch_input_inhibit_selection launch_input_inhibit_completion launch_prompt_intent launch_prompt_completion claim_service_settlement workspace_prune_preview_context].include?(operation) || (operation == "attempt_status" && params.key?("result_candidate_generation"))
|
|
51
64
|
socket.shutdown(Socket::SHUT_WR)
|
|
52
65
|
end
|
|
53
66
|
if upload_parts
|
|
@@ -57,14 +70,21 @@ module Ace
|
|
|
57
70
|
result = wire.read(socket, deadline: deadline, limit: 16_384)
|
|
58
71
|
unless result.is_a?(Hash) && result["status"] == "ok" && result["data"].is_a?(Hash)
|
|
59
72
|
code = result.is_a?(Hash) ? result.dig("error", "code") : nil
|
|
73
|
+
raise AttemptErrors::BoundedResultUnavailable, "protected authority result exceeds frame bound" if code == "bounded_result"
|
|
60
74
|
raise AttemptErrors::EvidenceUnavailable, "protected authority refused (#{code || 'invalid_response'})"
|
|
61
75
|
end
|
|
62
76
|
transport = result.fetch("transport")
|
|
63
77
|
unless transport.is_a?(Hash) && transport.keys == ["replayed"] && [true, false].include?(transport["replayed"])
|
|
64
78
|
raise AttemptErrors::EvidenceUnavailable, "authority replay metadata is unavailable"
|
|
65
79
|
end
|
|
66
|
-
|
|
80
|
+
if operation == "evidence_fetch"
|
|
81
|
+
if %w[prepared_work campaign_candidate].include?(params["kind"]) && transport.fetch("replayed")
|
|
82
|
+
raise AttemptErrors::EvidenceUnavailable, "prepared fetch cannot replay a mutation"
|
|
83
|
+
end
|
|
84
|
+
validate_evidence_download!(result.fetch("data"), params)
|
|
85
|
+
end
|
|
67
86
|
parts = if download
|
|
87
|
+
codec = prepared_transfer_codec(result.fetch("data").fetch("descriptor")) if prepared_download
|
|
68
88
|
codec.receive(socket, descriptor: result.fetch("data").fetch("transfer"), purpose: purpose, deadline: deadline) do |input|
|
|
69
89
|
Array.new(input.count) { |index| input.bytes(index: index) }
|
|
70
90
|
end
|
|
@@ -74,10 +94,56 @@ module Ace
|
|
|
74
94
|
rescue SystemCallError, IOError
|
|
75
95
|
raise AttemptErrors::EvidenceUnavailable, "protected authority connection unavailable; no local fallback"
|
|
76
96
|
end
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# Fixed original-launcher stream. Public calls retain their existing
|
|
100
|
+
# complete-upload EOF contract; no caller-selected framing flag exists.
|
|
101
|
+
def with_launch_control(state:)
|
|
102
|
+
@deployment.verify!(mapping_id, kernel: @kernel)
|
|
103
|
+
wire = Ace::Runtime::Molecules::ProtectedSocket
|
|
104
|
+
path = @service.fetch("socket_path")
|
|
105
|
+
before = wire.socket_identity(path)
|
|
106
|
+
raise Ace::Runtime::RuntimeUnavailableError, "Authority endpoint owner differs" unless before.last == @service.fetch("uid")
|
|
107
|
+
deadline = wire.deadline(30)
|
|
108
|
+
wire.connect(path, deadline: deadline) do |socket|
|
|
109
|
+
peer = @kernel.peer(socket)
|
|
110
|
+
unless peer.values_at("uid", "gid", "groups") == @service.values_at("uid", "gid", "groups") && wire.socket_identity(path) == before
|
|
111
|
+
raise AttemptErrors::UnauthorizedIdentity, "Authority stream peer changed"
|
|
112
|
+
end
|
|
113
|
+
wire.write(socket, {"version" => 1, "operation" => "launch_control", "mutation_id" => nil,
|
|
114
|
+
"project_id" => @map.fetch("project_id"), "params" => state.slice("assignment_id", "attempt_id").merge("mapping_id" => mapping_id)},
|
|
115
|
+
deadline: deadline, limit: 16_384)
|
|
116
|
+
ready = wire.read(socket, deadline: deadline, limit: 16_384)
|
|
117
|
+
validate_launch_control_ready!(ready, state)
|
|
118
|
+
yield socket, transfer_codec, ready
|
|
119
|
+
end
|
|
120
|
+
rescue IOError, SystemCallError
|
|
121
|
+
raise AttemptErrors::EvidenceUnavailable, "Original launch control connection is unavailable"
|
|
122
|
+
end
|
|
123
|
+
|
|
77
124
|
private
|
|
78
125
|
|
|
126
|
+
def validate_launch_control_ready!(ready, state)
|
|
127
|
+
unless ready.is_a?(Hash) && ready.keys.sort == %w[attempt_id generation journal_commit original_binding_digest type version] &&
|
|
128
|
+
ready["version"].is_a?(Integer) && ready["version"] == 1 && ready["type"] == "launch_control_ready" &&
|
|
129
|
+
ready["attempt_id"] == state.fetch("attempt_id") && ready["generation"].is_a?(Integer) && ready["generation"].positive? &&
|
|
130
|
+
ready["journal_commit"].is_a?(String) && ready["journal_commit"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/) &&
|
|
131
|
+
ready["original_binding_digest"].is_a?(String) && ready["original_binding_digest"].match?(/\A[0-9a-f]{64}\z/)
|
|
132
|
+
raise AttemptErrors::EvidenceUnavailable, "Original launch control admission is unavailable"
|
|
133
|
+
end
|
|
134
|
+
true
|
|
135
|
+
end
|
|
136
|
+
|
|
79
137
|
def validate_evidence_download!(data, params)
|
|
80
138
|
descriptor, transfer = data.values_at("descriptor", "transfer")
|
|
139
|
+
if params["kind"] == "prepared_work"
|
|
140
|
+
validate_prepared_download!(data, params)
|
|
141
|
+
return
|
|
142
|
+
end
|
|
143
|
+
if params["kind"] == "campaign_candidate"
|
|
144
|
+
validate_campaign_candidate_download!(data, params)
|
|
145
|
+
return
|
|
146
|
+
end
|
|
81
147
|
unless data.keys.sort == %w[descriptor generation journal_commit transfer] &&
|
|
82
148
|
Molecules::CanonicalEvidence.valid_descriptor?(descriptor) &&
|
|
83
149
|
descriptor["project_id"] == @map.fetch("project_id") &&
|
|
@@ -101,6 +167,90 @@ module Ace
|
|
|
101
167
|
end
|
|
102
168
|
TransferCodec.new(root: installed.fetch("scratch_root"))
|
|
103
169
|
end
|
|
170
|
+
|
|
171
|
+
def validate_prepared_download!(data, params)
|
|
172
|
+
descriptor, transfer = data.values_at("descriptor", "transfer")
|
|
173
|
+
fields = %w[version kind purpose artifact project_id mapping_id assignment_id attempt_id task_id scope definition_digest selection_sha256 prepared_head prepared_tree manifest_bytes manifest_sha256 registration_generation registration_commit original_binding_digest ref bytes sha256 task_context_entry original_worker_identity original_worker_scratch_root workspace_exclusion].sort
|
|
174
|
+
valid = data.keys.sort == %w[descriptor generation journal_commit transfer] && descriptor.is_a?(Hash) && descriptor.keys.sort == fields &&
|
|
175
|
+
descriptor["version"].is_a?(Integer) && descriptor["version"] == 1 &&
|
|
176
|
+
params.values_at("kind", "purpose_id", "artifact_id") == %w[prepared_work original_prepared_work prepared_bundle] &&
|
|
177
|
+
descriptor.values_at("kind", "purpose", "artifact") == %w[prepared_work original_prepared_work prepared_bundle] &&
|
|
178
|
+
descriptor["project_id"] == @map.fetch("project_id") && descriptor["mapping_id"] == mapping_id &&
|
|
179
|
+
%w[assignment_id attempt_id].all? { |key| descriptor[key] == params[key] } &&
|
|
180
|
+
descriptor["task_id"].is_a?(String) && descriptor["task_id"].match?(Deployment::TOKEN) &&
|
|
181
|
+
descriptor["scope"].is_a?(String) && descriptor["scope"].bytesize.between?(1, 128) && descriptor["scope"].match?(/\A[0-9]+(?:\.[0-9]+)*\z/) &&
|
|
182
|
+
%w[definition_digest selection_sha256 manifest_sha256 original_binding_digest sha256].all? { |key| descriptor[key].is_a?(String) && descriptor[key].match?(/\A[0-9a-f]{64}\z/) } &&
|
|
183
|
+
%w[prepared_head prepared_tree].all? { |key| descriptor[key].is_a?(String) && descriptor[key].match?(/\A[0-9a-f]{40}\z/) } &&
|
|
184
|
+
descriptor["manifest_bytes"].is_a?(Integer) && descriptor["manifest_bytes"].between?(1, 32_768) &&
|
|
185
|
+
descriptor["registration_generation"].is_a?(Integer) && descriptor["registration_generation"].positive? &&
|
|
186
|
+
descriptor["bytes"].is_a?(Integer) && descriptor["bytes"].between?(1, TransferCodec::LIMITS.fetch(:candidate).first) &&
|
|
187
|
+
descriptor["registration_commit"].is_a?(String) && descriptor["registration_commit"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/) &&
|
|
188
|
+
descriptor["ref"] == "execution/prepared/#{params.fetch('assignment_id')}-#{descriptor['sha256']}.bundle" &&
|
|
189
|
+
data["generation"].is_a?(Integer) && data["generation"].positive? &&
|
|
190
|
+
data["journal_commit"].is_a?(String) && data["journal_commit"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/) &&
|
|
191
|
+
transfer.is_a?(Hash) && transfer.keys.sort == %w[bytes parts sha256 version] && transfer["version"].is_a?(Integer) && transfer["version"] == 1 &&
|
|
192
|
+
transfer.values_at("bytes", "sha256") == descriptor.values_at("bytes", "sha256") && transfer["parts"] == [descriptor.slice("bytes", "sha256")]
|
|
193
|
+
raise AttemptErrors::EvidenceUnavailable, "original prepared download descriptor differs" unless valid
|
|
194
|
+
TaskContextEntry.validate!(descriptor.fetch("task_context_entry"))
|
|
195
|
+
Molecules::LifecycleExclusion.workspace_reader(projection: descriptor.fetch("workspace_exclusion"))
|
|
196
|
+
unless descriptor.dig("workspace_exclusion", "root_resource", "view_path") ==
|
|
197
|
+
"/run/ace/lifecycle-exclusion/#{descriptor.fetch('mapping_id')}"
|
|
198
|
+
raise AttemptErrors::EvidenceUnavailable, "original prepared lifecycle mapping differs"
|
|
199
|
+
end
|
|
200
|
+
identity = descriptor.fetch("original_worker_identity")
|
|
201
|
+
birth = identity.is_a?(Hash) && identity["started_at"]
|
|
202
|
+
boot = birth.is_a?(String) && birth.split(":", -1)[1]
|
|
203
|
+
Molecules::ExecutionScopeLineage.validate_process_identity!(identity, boot_id: boot)
|
|
204
|
+
root = descriptor.fetch("original_worker_scratch_root")
|
|
205
|
+
unless root.is_a?(String) && root.valid_encoding? && root.bytesize.between?(2, 4096) &&
|
|
206
|
+
root.start_with?("/") && !root.include?("\0") && File.expand_path(root) == root
|
|
207
|
+
raise AttemptErrors::EvidenceUnavailable, "original prepared scratch root differs"
|
|
208
|
+
end
|
|
209
|
+
rescue ArgumentError, KeyError
|
|
210
|
+
raise AttemptErrors::EvidenceUnavailable, "original prepared download descriptor differs"
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def validate_campaign_candidate_download!(data, params)
|
|
214
|
+
descriptor, transfer = data.values_at("descriptor", "transfer")
|
|
215
|
+
fields = %w[head tree bytes sha256 candidate_generation campaign_execution mapping_id assignment_id attempt_id
|
|
216
|
+
project_id original_worker_identity original_worker_scratch_root original_binding_digest].sort
|
|
217
|
+
unless data.keys.sort == %w[descriptor generation journal_commit transfer] &&
|
|
218
|
+
descriptor.is_a?(Hash) && descriptor.keys.sort == fields &&
|
|
219
|
+
params.values_at("purpose_id", "artifact_id") == %w[original_campaign_candidate candidate_bundle] &&
|
|
220
|
+
descriptor["mapping_id"] == mapping_id && descriptor["project_id"] == @map.fetch("project_id") &&
|
|
221
|
+
%w[assignment_id attempt_id].all? { |key| descriptor[key] == params[key] } &&
|
|
222
|
+
%w[head tree].all? { |key| descriptor[key].is_a?(String) && descriptor[key].match?(CandidateTransfer::SHA) } &&
|
|
223
|
+
%w[sha256 original_binding_digest].all? { |key| descriptor[key].is_a?(String) && descriptor[key].match?(/\A[0-9a-f]{64}\z/) } &&
|
|
224
|
+
descriptor["candidate_generation"].is_a?(Integer) && descriptor["candidate_generation"].positive? &&
|
|
225
|
+
descriptor["bytes"].is_a?(Integer) && descriptor["bytes"].between?(1, CandidateTransfer::MAX_BYTES) &&
|
|
226
|
+
descriptor["campaign_execution"].is_a?(Hash) && descriptor["campaign_execution"].keys.sort == CampaignExecution::FIELDS &&
|
|
227
|
+
data["generation"].is_a?(Integer) && data["generation"].positive? &&
|
|
228
|
+
data["journal_commit"].is_a?(String) && data["journal_commit"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/) &&
|
|
229
|
+
transfer.is_a?(Hash) && transfer.keys.sort == %w[bytes parts sha256 version] && transfer["version"] == 1 &&
|
|
230
|
+
transfer["version"].is_a?(Integer) &&
|
|
231
|
+
transfer.values_at("bytes", "sha256") == descriptor.values_at("bytes", "sha256") && transfer["parts"] == [descriptor.slice("bytes", "sha256")]
|
|
232
|
+
raise AttemptErrors::EvidenceUnavailable, "original campaign candidate download differs"
|
|
233
|
+
end
|
|
234
|
+
identity = descriptor.fetch("original_worker_identity")
|
|
235
|
+
birth = identity.is_a?(Hash) && identity["started_at"]
|
|
236
|
+
Molecules::ExecutionScopeLineage.validate_process_identity!(identity, boot_id: birth.to_s.split(":", -1)[1])
|
|
237
|
+
root = descriptor.fetch("original_worker_scratch_root")
|
|
238
|
+
unless root.is_a?(String) && root.valid_encoding? && root.bytesize.between?(2, 4096) &&
|
|
239
|
+
root.start_with?("/") && !root.include?("\0") && File.expand_path(root) == root
|
|
240
|
+
raise AttemptErrors::EvidenceUnavailable, "original campaign scratch root differs"
|
|
241
|
+
end
|
|
242
|
+
rescue ArgumentError, KeyError
|
|
243
|
+
raise AttemptErrors::EvidenceUnavailable, "original campaign candidate download differs"
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def prepared_transfer_codec(descriptor)
|
|
247
|
+
peer = @kernel.capture(Process.pid)
|
|
248
|
+
original = descriptor.fetch("original_worker_identity")
|
|
249
|
+
unless peer.values_at("uid", "gid", "groups") == original.values_at("uid", "gid", "groups")
|
|
250
|
+
raise AttemptErrors::UnauthorizedIdentity, "original prepared scratch principal differs"
|
|
251
|
+
end
|
|
252
|
+
TransferCodec.new(root: descriptor.fetch("original_worker_scratch_root"))
|
|
253
|
+
end
|
|
104
254
|
end
|
|
105
255
|
end
|
|
106
256
|
end
|