ace-lab 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "securerandom"
4
+ require "ace/assign/authority/retained_input"
4
5
  require "ace/assign/authority/ordinary_client"
5
6
  require "ace/assign/authority/ordinary_lifecycle"
6
7
  require "ace/assign/organisms/prepared_work_builder"
@@ -13,6 +14,7 @@ module Ace
13
14
  # Only Assign's installed owner registers, advances and accepts execution.
14
15
  class OrdinaryAssignmentClient
15
16
  A = Ace::Assign::Authority
17
+ Files = A::RetainedInput
16
18
  REQUEST_KEYS = %w[version project_id operation mutation_id params parts].freeze
17
19
 
18
20
  def initialize(project:, config: nil, client_factory: nil)
@@ -31,7 +33,7 @@ module Ace
31
33
  pane.is_a?(String) && Molecules::OrdinaryOwnerConfig::PANE.match?(pane)
32
34
  raise ArgumentError, "preparation requires project and native pane selectors"
33
35
  end
34
- directory = create_output(output)
36
+ directory = Files.create_output(output)
35
37
  prepared = Ace::Assign::Organisms::PreparedWorkBuilder.new(export_root: directory)
36
38
  .call(task_ref: task, project_id: project, dependency_reports: dependency_reports)
37
39
  definition = JSON.parse(prepared.fetch("definition_bytes"))
@@ -43,22 +45,22 @@ module Ace
43
45
  "prepared_head" => prepared.fetch("head"), "prepared_tree" => prepared.fetch("tree"),
44
46
  "manifest_sha256" => reference.fetch("manifest_sha256")},
45
47
  "parts" => [{"file" => "prepared.bundle", "bytes" => prepared.fetch("bytes"), "sha256" => prepared.fetch("sha256")}]}
46
- write_file(directory, "definition.json", prepared.fetch("definition_bytes"))
47
- write_file(directory, "prepared.bundle", prepared.fetch("bundle"))
48
- write_file(directory, "request.json", JSON.generate(request) + "\n")
48
+ Files.write_file(directory, "definition.json", prepared.fetch("definition_bytes"))
49
+ Files.write_file(directory, "prepared.bundle", prepared.fetch("bundle"))
50
+ Files.write_file(directory, "request.json", JSON.generate(request) + "\n")
49
51
  {"project_id" => project, "assignment_id" => prepared.fetch("assignment_id"),
50
52
  "scope" => prepared.fetch("scope"), "request" => File.join(directory, "request.json"), "prepared" => true}
51
53
  end
52
54
 
53
55
  def request(input:, output: nil)
54
- request = JSON.parse(self.class.read_file(input, 16_384), allow_duplicate_key: false, create_additions: false, max_nesting: 24)
56
+ request = JSON.parse(Files.read_file(input, 16_384), allow_duplicate_key: false, create_additions: false, max_nesting: 24)
55
57
  unless request.is_a?(Hash) && request.keys.sort == REQUEST_KEYS.sort && request["version"].is_a?(Integer) && request["version"] == 1 &&
56
58
  request["project_id"] == @project && A::OrdinaryLifecycle::OPERATIONS.include?(request["operation"]) &&
57
59
  request["params"].is_a?(Hash) && !request["params"].key?("transfer") && request["parts"].is_a?(Array)
58
60
  raise ArgumentError, "retained ordinary request selection is invalid"
59
61
  end
60
62
  operation = request.fetch("operation")
61
- read = %w[registration_status assignment_snapshot result_status result_snapshot].include?(operation)
63
+ read = %w[registration_status assignment_snapshot result_status result_snapshot supervisor_status export_candidate review_status merge_evidence].include?(operation)
62
64
  mutation = request.fetch("mutation_id")
63
65
  unless read ? mutation.nil? : mutation.is_a?(String) && Ace::Assign::Molecules::JournalMutation::ID.match?(mutation)
64
66
  raise ArgumentError, "retained mutation ID must be explicit; read operations require null"
@@ -72,7 +74,7 @@ module Ace
72
74
  parts = upload && read_parts(request.fetch("parts"), input, transfer.fetch(:purpose))
73
75
  # No local metadata is interpreted as successful execution. Download
74
76
  # files are merely a projection of the authenticated canonical reply.
75
- directory = download && self.class.create_output(output)
77
+ directory = download && Files.create_output(output)
76
78
  client = @client_factory.call(project_id: @project, service: @config.service,
77
79
  verify_selection: @config.method(:verify_owner!), scratch_root: directory || nil)
78
80
  reply = client.call(operation, request.fetch("params"), mutation_id: request.fetch("mutation_id"),
@@ -81,48 +83,17 @@ module Ace
81
83
  if download
82
84
  files = reply.parts.each_with_index.map do |bytes, index|
83
85
  name = format("part-%02d.bytes", index)
84
- self.class.write_file(directory, name, bytes)
86
+ Files.write_file(directory, name, bytes)
85
87
  {"file" => File.join(directory, name), "bytes" => bytes.bytesize, "sha256" => Digest::SHA256.hexdigest(bytes)}
86
88
  end
87
89
  envelope["files"] = files
88
- self.class.write_file(directory, "response.json", JSON.generate(envelope) + "\n")
90
+ Files.write_file(directory, "response.json", JSON.generate(envelope) + "\n")
89
91
  end
90
92
  envelope
91
93
  rescue JSON::ParserError
92
94
  raise ArgumentError, "retained request is not valid bounded JSON"
93
95
  end
94
96
 
95
- def self.read_file(path, limit)
96
- File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |file|
97
- before = file.stat
98
- raise ArgumentError, "retained input is not a bounded regular file" unless before.file? && before.size.between?(0, limit)
99
- bytes = (file.read(limit + 1) || "").b
100
- after, selected = file.stat, File.lstat(path)
101
- identity = ->(stat) { [stat.dev, stat.ino, stat.size, stat.mtime, stat.ctime] }
102
- unless bytes.bytesize == before.size && identity.call(before) == identity.call(after) &&
103
- identity.call(after) == identity.call(selected) && !selected.symlink?
104
- raise ArgumentError, "retained input changed during read"
105
- end
106
- bytes.freeze
107
- end
108
- end
109
-
110
- def self.create_output(path)
111
- raise ArgumentError, "output must be a new directory path" unless path.is_a?(String) && !path.empty?
112
- directory = File.expand_path(path)
113
- Dir.mkdir(directory, 0o700)
114
- A::PrivateDirectory.verify!(directory)
115
- directory
116
- end
117
-
118
- def self.write_file(directory, name, bytes)
119
- File.open(File.join(directory, name), File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600) do |file|
120
- file.write(bytes)
121
- file.flush
122
- file.fsync
123
- end
124
- end
125
-
126
97
  private
127
98
 
128
99
  def read_parts(entries, input, purpose)
@@ -136,7 +107,7 @@ module Ace
136
107
  raise ArgumentError, "retained upload inventory is invalid"
137
108
  end
138
109
  entries.map do |entry|
139
- bytes = self.class.read_file(File.expand_path(entry.fetch("file"), File.dirname(File.expand_path(input))), per_part)
110
+ bytes = Files.read_file(File.expand_path(entry.fetch("file"), File.dirname(File.expand_path(input))), per_part)
140
111
  unless bytes.bytesize == entry.fetch("bytes") && Digest::SHA256.hexdigest(bytes) == entry.fetch("sha256")
141
112
  raise ArgumentError, "retained upload bytes changed; preserve the original mutation"
142
113
  end
@@ -8,6 +8,8 @@ require "ace/hitl/lifecycle/service"
8
8
  require "ace/hitl/lifecycle/tls_authority"
9
9
  require "ace/hitl/lifecycle/tls_service"
10
10
  require_relative "../molecules/ordinary_owner_admission"
11
+ require_relative "../molecules/caller_authorizer"
12
+ require_relative "../molecules/command_owner_admission"
11
13
  require_relative "../molecules/ordinary_listener"
12
14
  require_relative "ordinary_merge_receiver"
13
15
 
@@ -40,12 +42,18 @@ module Ace
40
42
  evidence_reader: ->(reference, record, state, pending) { reader.call(reference, record, state, pending) })
41
43
  reader = Ace::Assign::Authority::ServiceEvidence.new(journal: journal)
42
44
  exclusion = Ace::Assign::Molecules::LifecycleExclusion.new(root: @config.lifecycle_root)
45
+ command_admission = Molecules::CommandOwnerAdmission.new(base: admission, project: @config.project)
46
+ command = Ace::Assign::Authority::CommandExecution.new(project: @config.project, journal: journal,
47
+ admission: command_admission)
48
+ coordinator = nil
49
+ lifecycle = Ace::Assign::Authority::OrdinaryLifecycle.new(project: @config.project,
50
+ admission: admission, journal: journal, exclusion: exclusion, input_root: @config.assignment_root,
51
+ command_evidence: command,
52
+ release_authorizer: ->(binding) { authorize_release_artifact!(binding, coordinator) })
43
53
  coordinator = Ace::Assign::Organisms::AttemptCoordinator.new(cache_base: @config.assignment_root,
44
54
  repo_root: @config.journal.fetch("repository"), journal: journal, lifecycle_exclusion: exclusion,
45
- recorded_binding_observer: admission.method(:recorded_binding!))
46
- lifecycle = Ace::Assign::Authority::OrdinaryLifecycle.new(project: @config.project,
47
- admission: admission, journal: journal, exclusion: exclusion, input_root: @config.assignment_root)
48
- router = Ace::Assign::Authority::Router.new(handlers: [lifecycle])
55
+ recorded_binding_observer: admission.method(:recorded_binding!), ordinary_merge_evidence: lifecycle)
56
+ router = Ace::Assign::Authority::Router.new(handlers: [lifecycle, command])
49
57
  merge = OrdinaryMergeReceiver.new(config: @receiver, coordinator: coordinator,
50
58
  policy_document: policy_document, owner_guard: -> { @config.selected_principal!(@receiver.principal_uid) },
51
59
  merge_factory: ->(document) {
@@ -55,12 +63,34 @@ module Ace
55
63
  listener = Molecules::OrdinaryListener.new(socket_path: @receiver.socket_path,
56
64
  peer_uid: @receiver.principal_uid, socket_gid: @receiver.socket_gid, handler: merge,
57
65
  logger: ->(message) { warn message })
58
- Ace::Assign::Authority::OrdinaryServer.new(admission: admission, lifecycle: router,
66
+ Ace::Assign::Authority::OrdinaryServer.new(admission: command_admission, lifecycle: router,
59
67
  services: hitl_services(coordinator) + [listener])
60
68
  end
61
69
 
62
70
  private
63
71
 
72
+ # Retention reads the existing exact release decision. It neither
73
+ # consumes an authorization nor dispatches a publication effect.
74
+ def authorize_release_artifact!(binding, coordinator)
75
+ @config.verify_owner!
76
+ document = Molecules::GrantResolver.trusted_document(@receiver.policy_path)
77
+ verify_join!(document)
78
+ uid = binding.fetch("caller_uid")
79
+ unless uid.is_a?(Integer) && uid.positive? && binding.fetch("project_id") == @config.project
80
+ raise SecurityError, "release artifact caller or project differs"
81
+ end
82
+ username = begin
83
+ Etc.getpwuid(uid)&.name
84
+ rescue ArgumentError
85
+ nil
86
+ end
87
+ visibility = Molecules::CallerAuthorizer.new(principals: document.fetch("principals", {}),
88
+ identity: Molecules::CallerAuthorizer.matchable_identities(username, uid))
89
+ raise SecurityError, "release artifact project grant is unavailable" unless visibility.authorized?(@config.project)
90
+ Molecules::ServicePolicy.new(document, coordinator.method(:proposal_authorization))
91
+ .authorize!(binding.fetch("authorization"), binding)
92
+ end
93
+
64
94
  def verify_join!(document)
65
95
  configured = Molecules::ServicePolicy.new(document).ordinary_receiver!(@config.project)
66
96
  expected = {"receiver_id" => @receiver.receiver_id, "socket_path" => @receiver.socket_path,
@@ -140,11 +140,9 @@ module Ace
140
140
  unless digest == submission.fetch("input_digest")
141
141
  invalid!("canonical request digest does not re-derive from the submission", request_id)
142
142
  end
143
- # Selection and evidence integrity before authorization: the PR
144
- # must target THIS receiver's configured forge repository, and the
145
- # handed-over review must be independent of the executor.
143
+ # Forge selection precedes authorization. Reviewer independence is
144
+ # resolved by the canonical owner from native starts, not actor names.
146
145
  forge_identity!(document, request_id)
147
- reviewer_independence!(document, request_id)
148
146
  decision = @resolver.decide(submission.fetch("authorization"), authorization_binding(submission))
149
147
  unless decision.authorized?
150
148
  return OrdinaryMergeRequest.refusal_envelope(decision.code, decision.message,
@@ -190,7 +188,12 @@ module Ace
190
188
  settle_no_effect(request_id, record, "canonical proposal is no longer authorized before effect")
191
189
  return :refused
192
190
  end
193
- execute_effect(record)
191
+ begin
192
+ @coordinator.with_fixed_merge_evidence(record) { execute_effect(record) }
193
+ rescue Ace::Assign::AttemptErrors::ReceiptRejected
194
+ settle_no_effect(request_id, record, "canonical review or executed test proof changed before effect")
195
+ return :refused
196
+ end
194
197
  :executed
195
198
  rescue Ace::Assign::AttemptErrors::InvalidState, Ace::Assign::AttemptErrors::NotFound,
196
199
  Ace::Assign::AttemptErrors::Conflict
@@ -378,6 +381,7 @@ module Ace
378
381
  # immediately before each privileged effect. No cached decision is a lease.
379
382
  def current_authorization_refusal(record)
380
383
  @owner_guard&.call
384
+ @coordinator.verify_fixed_merge_evidence!(record)
381
385
  decision = @resolver.decide(record.fetch("authorization"), authorization_binding(record))
382
386
  decision.message unless decision.authorized?
383
387
  rescue Ace::Lab::InvalidConfigurationError, Ace::Assign::Error => e
@@ -645,19 +649,6 @@ module Ace
645
649
  {"owner" => match[1], "repo" => match[2], "index" => match[3].to_i}
646
650
  end
647
651
 
648
- # The independent final-head review is handed over in the request;
649
- # the executor's own identity is never an independent reviewer.
650
- # Evidence naming the receiver or its principal refuses pre-effect —
651
- # it can never be attached as independent review and never grounds
652
- # the merge.
653
- def reviewer_independence!(document, request_id)
654
- reviewer = document.dig("review_evidence", "reviewer")
655
- return unless [config.receiver_id, config.principal].include?(reviewer)
656
- raise OrdinaryMergeRefusal.new("ordinary_merge_evidence_invalid",
657
- "review evidence names the receiver executor, not an independent reviewer",
658
- request_id: request_id)
659
- end
660
-
661
652
  def selection_refusal!(message, request_id)
662
653
  raise OrdinaryMergeRefusal.new("ordinary_merge_selection_mismatch", message,
663
654
  request_id: request_id)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Lab
5
- VERSION = "0.8.0"
5
+ VERSION = "0.9.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-lab
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
@@ -234,9 +234,11 @@ files:
234
234
  - lib/ace/lab/cli.rb
235
235
  - lib/ace/lab/cli/commands/agents.rb
236
236
  - lib/ace/lab/cli/commands/assignment.rb
237
+ - lib/ace/lab/cli/commands/command_execute.rb
237
238
  - lib/ace/lab/cli/commands/projects.rb
238
239
  - lib/ace/lab/cli/commands/resolve.rb
239
240
  - lib/ace/lab/cli/commands/route.rb
241
+ - lib/ace/lab/cli/commands/run.rb
240
242
  - lib/ace/lab/cli/commands/service.rb
241
243
  - lib/ace/lab/cli/commands/services.rb
242
244
  - lib/ace/lab/cli/commands/support.rb
@@ -247,6 +249,8 @@ files:
247
249
  - lib/ace/lab/models/topology_entry.rb
248
250
  - lib/ace/lab/molecules/caller_authorizer.rb
249
251
  - lib/ace/lab/molecules/capability_router.rb
252
+ - lib/ace/lab/molecules/command_owner_admission.rb
253
+ - lib/ace/lab/molecules/command_policy.rb
250
254
  - lib/ace/lab/molecules/exact_resolver.rb
251
255
  - lib/ace/lab/molecules/forge_merge_config.rb
252
256
  - lib/ace/lab/molecules/forgejo_merge_adapter.rb
@@ -272,6 +276,8 @@ files:
272
276
  - lib/ace/lab/molecules/service_policy.rb
273
277
  - lib/ace/lab/molecules/topology_loader.rb
274
278
  - lib/ace/lab/organisms/authority_composition.rb
279
+ - lib/ace/lab/organisms/command_executor.rb
280
+ - lib/ace/lab/organisms/command_run_client.rb
275
281
  - lib/ace/lab/organisms/ordinary_assignment_client.rb
276
282
  - lib/ace/lab/organisms/ordinary_authority_composition.rb
277
283
  - lib/ace/lab/organisms/ordinary_merge_receiver.rb