ace-lab 0.9.0 → 0.13.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.
Files changed (79) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +363 -104
  3. data/README.md +66 -100
  4. data/docs/usage.md +529 -592
  5. data/lib/ace/lab/atoms/install_schema.rb +373 -0
  6. data/lib/ace/lab/cli/commands/install.rb +161 -0
  7. data/lib/ace/lab/cli/commands/prepare.rb +118 -0
  8. data/lib/ace/lab/cli/commands/project_add.rb +101 -0
  9. data/lib/ace/lab/cli/commands/status.rb +109 -0
  10. data/lib/ace/lab/cli/commands/verify.rb +115 -0
  11. data/lib/ace/lab/cli.rb +21 -23
  12. data/lib/ace/lab/install_manifest_path.rb +23 -0
  13. data/lib/ace/lab/models/status_report.rb +38 -0
  14. data/lib/ace/lab/molecules/account_provisioner.rb +196 -0
  15. data/lib/ace/lab/molecules/config_state_classifier.rb +145 -0
  16. data/lib/ace/lab/molecules/directory_provisioner.rb +208 -0
  17. data/lib/ace/lab/molecules/grant_provisioner.rb +250 -0
  18. data/lib/ace/lab/molecules/grant_resolver.rb +34 -23
  19. data/lib/ace/lab/molecules/hitl_routes.rb +263 -0
  20. data/lib/ace/lab/molecules/install_manifest_loader.rb +64 -0
  21. data/lib/ace/lab/molecules/key_provisioner.rb +736 -0
  22. data/lib/ace/lab/molecules/lab_state_scanner.rb +55 -0
  23. data/lib/ace/lab/molecules/overseer_provisioner.rb +188 -0
  24. data/lib/ace/lab/molecules/project_registry.rb +251 -0
  25. data/lib/ace/lab/molecules/repository_provisioner.rb +141 -0
  26. data/lib/ace/lab/molecules/role_verifier.rb +223 -0
  27. data/lib/ace/lab/organisms/install.rb +213 -0
  28. data/lib/ace/lab/organisms/prepare.rb +156 -0
  29. data/lib/ace/lab/organisms/project_add.rb +170 -0
  30. data/lib/ace/lab/organisms/status_service.rb +70 -0
  31. data/lib/ace/lab/organisms/verify.rb +768 -0
  32. data/lib/ace/lab/version.rb +1 -1
  33. data/lib/ace/lab.rb +34 -6
  34. metadata +34 -108
  35. data/lib/ace/lab/atoms/protected_publication_input.rb +0 -50
  36. data/lib/ace/lab/atoms/protected_workspace_prune_input.rb +0 -114
  37. data/lib/ace/lab/atoms/protected_workspace_prune_preview.rb +0 -86
  38. data/lib/ace/lab/atoms/service_input.rb +0 -92
  39. data/lib/ace/lab/cli/commands/assignment.rb +0 -92
  40. data/lib/ace/lab/cli/commands/command_execute.rb +0 -28
  41. data/lib/ace/lab/cli/commands/run.rb +0 -65
  42. data/lib/ace/lab/cli/commands/service.rb +0 -240
  43. data/lib/ace/lab/models/service_receipt.rb +0 -18
  44. data/lib/ace/lab/molecules/command_owner_admission.rb +0 -183
  45. data/lib/ace/lab/molecules/command_policy.rb +0 -158
  46. data/lib/ace/lab/molecules/hitl_authorizer.rb +0 -63
  47. data/lib/ace/lab/molecules/ordinary_admission_resolver.rb +0 -131
  48. data/lib/ace/lab/molecules/ordinary_listener.rb +0 -262
  49. data/lib/ace/lab/molecules/ordinary_merge_read.rb +0 -45
  50. data/lib/ace/lab/molecules/ordinary_native_read.rb +0 -106
  51. data/lib/ace/lab/molecules/ordinary_owner_admission.rb +0 -152
  52. data/lib/ace/lab/molecules/ordinary_owner_config.rb +0 -232
  53. data/lib/ace/lab/molecules/ordinary_receiver_config.rb +0 -94
  54. data/lib/ace/lab/molecules/ordinary_socket.rb +0 -126
  55. data/lib/ace/lab/molecules/ordinary_worktree_read.rb +0 -216
  56. data/lib/ace/lab/molecules/protected_cleanup_owner_admission.rb +0 -163
  57. data/lib/ace/lab/molecules/protected_cleanup_owner_client.rb +0 -165
  58. data/lib/ace/lab/molecules/protected_cleanup_owner_identity.rb +0 -239
  59. data/lib/ace/lab/molecules/protected_publication_publisher.rb +0 -110
  60. data/lib/ace/lab/molecules/protected_service_handler.rb +0 -43
  61. data/lib/ace/lab/molecules/protected_service_policy.rb +0 -156
  62. data/lib/ace/lab/molecules/service_executor.rb +0 -99
  63. data/lib/ace/lab/molecules/service_policy.rb +0 -289
  64. data/lib/ace/lab/organisms/authority_composition.rb +0 -87
  65. data/lib/ace/lab/organisms/command_executor.rb +0 -220
  66. data/lib/ace/lab/organisms/command_run_client.rb +0 -131
  67. data/lib/ace/lab/organisms/ordinary_assignment_client.rb +0 -120
  68. data/lib/ace/lab/organisms/ordinary_authority_composition.rb +0 -153
  69. data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +0 -679
  70. data/lib/ace/lab/organisms/ordinary_merge_request.rb +0 -456
  71. data/lib/ace/lab/organisms/protected_cleanup_owner.rb +0 -361
  72. data/lib/ace/lab/organisms/protected_service_client.rb +0 -129
  73. data/lib/ace/lab/organisms/protected_service_listener.rb +0 -181
  74. data/lib/ace/lab/organisms/protected_service_publication.rb +0 -268
  75. data/lib/ace/lab/organisms/protected_service_receiver.rb +0 -267
  76. data/lib/ace/lab/organisms/protected_service_recovery.rb +0 -185
  77. data/lib/ace/lab/organisms/protected_service_request.rb +0 -128
  78. data/lib/ace/lab/organisms/protected_service_worker.rb +0 -89
  79. data/lib/ace/lab/organisms/service_request_service.rb +0 -249
@@ -1,268 +0,0 @@
1
- # frozen_string_literal: true
2
- require "securerandom"
3
- require "rubygems/package"
4
- require "ace/hitl/providers/lab"
5
- require_relative "../molecules/protected_publication_publisher"
6
-
7
- module Ace
8
- module Lab
9
- module Organisms
10
- class ProtectedServiceReceiver
11
- # One invocation is retained by the existing receiver worker, not a
12
- # second effect queue. Only canonical continuation can authorize a push.
13
- def begin_publication!(admitted:, materialized:, deadline:)
14
- raise SecurityError, "publication receiver already owns an invocation" if @publication
15
- @publication_hitl ||= Ace::Hitl::Providers::Lab.boundary_client(
16
- socket_path: Ace::Hitl::Providers::Lab::DEFAULT_SOCKET_PATH,
17
- policy: Ace::Hitl::Providers::Lab.grants_policy(grants_path: Ace::Hitl::Providers::Lab::DEFAULT_GRANTS_PATH))
18
- params = admitted.fetch(:params)
19
- started = admitted.fetch(:started)
20
- unless started.fetch("executor_process_binding") == @kernel.capture(Process.pid)
21
- raise SecurityError, "publication original receiver differs"
22
- end
23
- @publication = admitted.merge(materialized: materialized, handles: [], challenge: nil,
24
- ask_id: nil, disposition: "dispatch_started")
25
- hold_publication_artifact!(@publication)
26
- verified = publication_registry!(@publication, deadline)
27
- return complete_publication!(@publication, verified, deadline) if %w[succeeded failed].include?(verified.fetch("classification"))
28
- unless verified.fetch("classification") == "absent"
29
- @publication[:disposition] = "uncertain"
30
- return publication_status("uncertain", verified.fetch("code"))
31
- end
32
- publication_push!(@publication, nil, deadline)
33
- rescue StandardError, SecurityError
34
- publication_status("uncertain", "publication_admission_unavailable")
35
- end
36
-
37
- def tick_publication!(request_id:, deadline:)
38
- entry = @publication
39
- return {"request_id" => request_id, "state" => "publication-blocked", "code" => "original-executor-unavailable"} unless entry && entry.dig(:params, "request_id") == request_id
40
- check_publication!(entry)
41
- status = publication_call!(entry, "service_status", {}, deadline).data
42
- if %w[succeeded failed].include?(status["state"])
43
- close_publication!(entry)
44
- return projection(status)
45
- end
46
- if entry[:disposition] == "uncertain"
47
- verified = publication_registry!(entry, deadline)
48
- return complete_publication!(entry, verified, deadline) if verified["classification"] == "succeeded"
49
- return publication_status("uncertain", "publication_outcome_unconfirmed")
50
- end
51
- unless status["dispatch_phase"] == "otp_pending" && status["publication_challenge_digest"] == entry.fetch(:challenge).fetch("publication_challenge_digest")
52
- return publication_status("uncertain", "publication_challenge_changed")
53
- end
54
- publication_ask!(entry, deadline) unless entry[:ask_created]
55
- ask = @publication_hitl.read(entry.fetch(:ask_id), deadline: deadline)
56
- check_publication_ask!(entry, ask)
57
- if %w[cancelled consumed].include?(ask["state"])
58
- entry[:ask_id] = nil
59
- entry[:ask_created] = false
60
- entry[:ask_otp] = nil
61
- return publication_status("publication-pending", "otp-answer-unavailable")
62
- end
63
- if ask.fetch("otp").fetch("expires_at") <= Time.now.to_i
64
- @publication_hitl.cancel(entry.fetch(:ask_id), reason: "otp-answer-expired", deadline: deadline)
65
- return publication_status("publication-pending", "otp-answer-expired")
66
- end
67
- return publication_status("publication-pending", "otp-answer-pending") unless ask["state"] == "answer-delivered"
68
- return publication_status("publication-pending", "otp-tick-budget") if publication_remaining!(deadline) < 1.5
69
- answer = @publication_hitl.consume(entry.fetch(:ask_id), timeout: 1, operation: "publish", deadline: deadline)
70
- otp = answer["answer"]
71
- unless otp.is_a?(String) && otp.match?(/\A[0-9]{6}\z/) && ask.fetch("otp").fetch("expires_at") > Time.now.to_i
72
- return publication_status("publication-pending", "otp-answer-unavailable")
73
- end
74
- check_publication!(entry)
75
- return publication_status("publication-pending", "otp-tick-budget") if publication_remaining!(deadline) < 1
76
- status = publication_call!(entry, "service_status", {}, deadline).data
77
- return publication_status("publication-pending", "otp-answer-expired") unless ask.fetch("otp").fetch("expires_at") > Time.now.to_i
78
- entry[:disposition] = "uncertain" # A lost issuing reply is never retried.
79
- issued = publication_call!(entry, "publication_continue", {
80
- "expected_generation" => status.fetch("generation"), "input_digest" => entry.fetch(:params).fetch("input_digest"),
81
- "challenge_digest" => entry.fetch(:challenge).fetch("publication_challenge_digest")}, deadline,
82
- mutation_id: Digest::SHA256.hexdigest("publication-continue:#{entry.fetch(:challenge).fetch('publication_challenge_digest')}")).data
83
- return publication_status("uncertain", "publication_issuing_unconfirmed") unless issued["invocation"] == "permitted" && issued["dispatch_phase"] == "issuing"
84
- entry[:started] = issued
85
- publication_push!(entry, otp, deadline)
86
- rescue StandardError, SecurityError
87
- publication_status("uncertain", "publication_tick_unavailable")
88
- ensure
89
- otp&.clear if otp.is_a?(String) && !otp.frozen?
90
- answer&.delete("answer")
91
- end
92
-
93
- def publication_active?
94
- !@publication.nil?
95
- end
96
-
97
- # Called only after the existing worker has stopped its bounded tick.
98
- # Durable uncertain state and candidate bytes remain for read-only recovery.
99
- def release_publication_lifetime!
100
- @publication&.fetch(:handles)&.each { |_path, handle, _identity| handle.close unless handle.closed? }
101
- @publication = nil
102
- end
103
-
104
- private
105
-
106
- def hold_publication_artifact!(entry)
107
- root = entry.fetch(:materialized).fetch("directory")
108
- publication = entry.fetch(:envelope).fetch("input").fetch("publication")
109
- components = publication.fetch("artifact_relative_path").split("/")
110
- paths = [root]
111
- components.each { |component| paths << File.join(paths.last, component) }
112
- paths.each_with_index do |path, index|
113
- handle = File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK)
114
- stat = handle.stat
115
- valid = index == paths.size - 1 ? stat.file? && stat.size.between?(1, 64 * 1024 * 1024) : stat.directory?
116
- unless valid && stat.uid == Process.uid && (stat.mode & 0o022).zero?
117
- handle.close
118
- raise SecurityError, "publication staging protection differs"
119
- end
120
- entry.fetch(:handles) << [path, handle, publication_identity(stat)]
121
- end
122
- entry[:artifact] = paths.last
123
- spec = Gem::Package.new(paths.last).spec
124
- unless spec.name == publication.fetch("gem_name") && spec.version.to_s == publication.fetch("version")
125
- raise SecurityError, "publication artifact metadata differs"
126
- end
127
- entry[:platform] = spec.platform.to_s
128
- check_publication!(entry)
129
- end
130
-
131
- def check_publication!(entry)
132
- @kernel.live!(entry.fetch(:started).fetch("executor_process_binding"))
133
- raise SecurityError, "publication receiver birth differs" unless @kernel.capture(Process.pid) == entry.fetch(:started).fetch("executor_process_binding")
134
- entry.fetch(:handles).each do |path, handle, expected|
135
- unless publication_identity(handle.stat) == expected && publication_identity(File.lstat(path)) == expected
136
- raise SecurityError, "publication held staging changed"
137
- end
138
- end
139
- artifact = entry.fetch(:handles).last.fetch(1)
140
- artifact.rewind
141
- digest = Digest::SHA256.new
142
- while (chunk = artifact.read(65_536))
143
- digest.update(chunk)
144
- end
145
- unless digest.hexdigest == entry.fetch(:envelope).fetch("input").fetch("target").fetch("artifact_digest")
146
- raise SecurityError, "publication artifact bytes differ"
147
- end
148
- end
149
-
150
- def publication_identity(stat)
151
- stat.directory? ? [stat.dev, stat.ino, stat.uid, stat.gid, stat.mode] :
152
- [stat.dev, stat.ino, stat.uid, stat.gid, stat.mode, stat.size, stat.mtime, stat.ctime]
153
- end
154
-
155
- def publication_registry!(entry, deadline)
156
- check_publication!(entry)
157
- @publication_publisher.verify!(publication: entry.fetch(:envelope).fetch("input").fetch("publication"),
158
- artifact_digest: entry.fetch(:params).fetch("target").fetch("artifact_digest"), platform: entry.fetch(:platform), deadline: deadline)
159
- end
160
-
161
- def publication_push!(entry, otp, deadline)
162
- check_publication!(entry)
163
- entry[:disposition] = "uncertain"
164
- result = @publication_publisher.push!(operation: entry.fetch(:operation),
165
- publication: entry.fetch(:envelope).fetch("input").fetch("publication"), artifact: entry.fetch(:artifact),
166
- artifact_digest: entry.fetch(:params).fetch("target").fetch("artifact_digest"), otp: otp, deadline: deadline)
167
- check_publication!(entry)
168
- verified = publication_registry!(entry, deadline)
169
- return complete_publication!(entry, verified, deadline) if %w[succeeded failed].include?(verified["classification"])
170
- if %w[otp-required otp-rejected otp-expired].include?(result["classification"]) && verified["classification"] == "absent"
171
- return retain_publication_challenge!(entry, result, deadline)
172
- end
173
- return complete_publication!(entry, result, deadline) if result["classification"] == "failed" && verified["classification"] == "absent"
174
- publication_status("uncertain", "publication_outcome_unconfirmed")
175
- end
176
-
177
- def retain_publication_challenge!(entry, result, deadline)
178
- input = entry.fetch(:envelope).fetch("input")
179
- params = entry.fetch(:params)
180
- previous = entry[:challenge]&.fetch("publication_challenge_digest")
181
- need = params.slice("request_id", "input_digest", "candidate_generation", "head").merge(
182
- "schema" => "ace.publication-otp-need/v1", "classification" => result.fetch("classification"),
183
- "claim_binding" => entry.fetch(:claim).fetch("claim_binding"),
184
- "registry" => input.fetch("publication").fetch("registry"), "gem_name" => input.fetch("publication").fetch("gem_name"),
185
- "version" => input.fetch("publication").fetch("version"), "artifact_digest" => input.fetch("target").fetch("artifact_digest"),
186
- "dispatch_event_digest" => entry.fetch(:dispatch_event_digest), "previous_challenge_digest" => previous)
187
- bytes = JSON.generate(need)
188
- status = publication_call!(entry, "service_status", {}, deadline).data
189
- entry[:challenge] = publication_call!(entry, "publication_challenge", {
190
- "input_digest" => params.fetch("input_digest"), "expected_generation" => status.fetch("generation"),
191
- "receipt_sha256" => Digest::SHA256.hexdigest(bytes)}, deadline,
192
- mutation_id: Digest::SHA256.hexdigest("publication-challenge:#{Digest::SHA256.hexdigest(bytes)}"),
193
- upload_parts: [bytes], purpose: :receipt_artifacts).data
194
- entry[:disposition] = "otp_pending"
195
- entry[:ask_id] = nil
196
- entry[:ask_created] = false
197
- entry[:ask_otp] = nil
198
- publication_ask!(entry, deadline)
199
- publication_status("publication-pending", result.fetch("classification"))
200
- end
201
-
202
- def publication_ask!(entry, deadline)
203
- entry[:ask_id] ||= "publication-#{SecureRandom.hex(12)}"
204
- binding = entry.fetch(:params).slice("assignment_id", "attempt_id", "request_id", "input_digest", "candidate_generation", "head").merge(
205
- "schema" => "ace.hitl-publication-binding/v1", "mapping_id" => @mapping_id,
206
- "claim_binding" => entry.fetch(:claim).fetch("claim_binding"),
207
- "challenge_digest" => entry.fetch(:challenge).fetch("publication_challenge_digest"))
208
- entry[:ask_binding] = immutable(binding)
209
- entry[:ask_otp] ||= immutable({"operation" => "publish", "input_digest" => binding.fetch("input_digest"),
210
- "result_ref" => entry.fetch(:challenge).fetch("publication_challenge_ref").fetch("ref"), "expires_at" => Time.now.to_i + 900})
211
- @publication_hitl.create(id: entry.fetch(:ask_id), assignment: binding.fetch("assignment_id"), attempt: binding.fetch("attempt_id"),
212
- project: @map.fetch("project_id"), harness: "lab-admin", plan: "Scoped RubyGems publication", kind: "otp",
213
- question: "Provide the RubyGems OTP for this authorized artifact publication.", ace_hitl_id: entry.fetch(:ask_id),
214
- publication_binding: binding, otp: entry.fetch(:ask_otp), deadline: deadline)
215
- entry[:ask_created] = true
216
- end
217
-
218
- def check_publication_ask!(entry, ask)
219
- unless ask["id"] == entry.fetch(:ask_id) && ask["publication_binding"] == entry.fetch(:ask_binding) &&
220
- ask["publication_requester_peer"] == entry.fetch(:started).fetch("executor_process_binding") &&
221
- ask.dig("otp", "input_digest") == entry.fetch(:params).fetch("input_digest") &&
222
- ask.dig("otp", "result_ref") == entry.fetch(:challenge).fetch("publication_challenge_ref").fetch("ref")
223
- raise SecurityError, "publication HITL retained request differs"
224
- end
225
- end
226
-
227
- def publication_call!(entry, operation, extra, deadline, **options)
228
- params = entry.fetch(:params).slice("assignment_id", "attempt_id", "candidate_generation", "head", "request_id")
229
- params["claim_binding"] = entry.fetch(:claim).fetch("claim_binding") unless operation == "service_status"
230
- @client.call(operation, params.merge(extra), timeout: publication_remaining!(deadline), **options)
231
- end
232
-
233
- def complete_publication!(entry, result, deadline)
234
- check_publication!(entry)
235
- input = entry.fetch(:envelope).fetch("input")
236
- evidence = JSON.generate(input.merge("schema" => "ace.publication-result/v1", "classification" => result.fetch("classification"),
237
- "code" => result.fetch("code"), "request_id" => entry.fetch(:params).fetch("request_id"),
238
- "input_digest" => entry.fetch(:params).fetch("input_digest"), "claim_binding" => entry.fetch(:claim).fetch("claim_binding")))
239
- receipt = entry.fetch(:envelope).fetch("request").slice(*Ace::Assign::Molecules::EvidenceJournal::TERMINAL_BINDING_FIELDS).merge(
240
- "outcome" => result.fetch("classification") == "succeeded" ? "succeeded" : "failed",
241
- "evidence" => [{"ref" => "publication-result", "sha256" => Digest::SHA256.hexdigest(evidence)}])
242
- bytes = JSON.generate(receipt)
243
- completed = publication_call!(entry, "complete_service", {"receipt_sha256" => Digest::SHA256.hexdigest(bytes)}, deadline,
244
- mutation_id: Digest::SHA256.hexdigest("publication-complete:#{entry.fetch(:mutation_id)}"), upload_parts: [bytes, evidence], purpose: :receipt_artifacts).data
245
- return publication_status("uncertain", "publication_completion_unconfirmed") unless %w[succeeded failed].include?(completed["state"])
246
- close_publication!(entry)
247
- projection(completed)
248
- end
249
-
250
- def close_publication!(entry)
251
- entry.fetch(:handles).each { |_path, handle, _identity| handle.close }
252
- cleanup_staging(entry.fetch(:materialized).fetch("directory"), entry.fetch(:staging_identity))
253
- @publication = nil
254
- end
255
-
256
- def publication_remaining!(deadline)
257
- remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
258
- raise IOError, "publication tick deadline expired" unless remaining.positive?
259
- remaining
260
- end
261
-
262
- def publication_status(state, code)
263
- {"request_id" => @publication&.dig(:params, "request_id"), "state" => state, "code" => code}.freeze
264
- end
265
- end
266
- end
267
- end
268
- end
@@ -1,267 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "digest"
5
- require "fileutils"
6
- require "ace/assign/authority/client"
7
- require "ace/assign/authority/candidate_transfer"
8
- require "ace/assign/authority/evidence_transfer"
9
- require_relative "../molecules/protected_service_handler"
10
- require_relative "../molecules/protected_service_policy"
11
- require_relative "../atoms/protected_workspace_prune_preview"
12
- require_relative "protected_service_publication"
13
-
14
- module Ace
15
- module Lab
16
- module Organisms
17
- # Fixed executor-side orchestration. All durable permission and outcome
18
- # state remains in the existing authority journal. Transport supplies
19
- # the kernel peer; submitted actor strings are never accepted.
20
- class ProtectedServiceReceiver
21
- SUBMISSION = %w[assignment_id attempt_id expected_generation candidate_generation head request_id
22
- operation input_digest target authorization].freeze
23
-
24
- def initialize(mapping_id:, service_id:, deployment: Ace::Assign::Authority::Deployment.load,
25
- kernel: Ace::Runtime::Molecules::ProtectedLinux.new, client: nil,
26
- handler: Molecules::ProtectedServiceHandler.new, cleanup_owner: nil, publication_publisher: nil, publication_hitl: nil)
27
- @mapping_id, @service_id, @deployment, @kernel, @handler = mapping_id, service_id, deployment, kernel, handler
28
- @cleanup_owner = cleanup_owner
29
- @publication_publisher = publication_publisher || Molecules::ProtectedPublicationPublisher.new
30
- @publication_hitl = publication_hitl
31
- @map = deployment.mapping(mapping_id)
32
- deployment.verify_composition!(@map.fetch("authority_id"), composition: "services")
33
- @project = deployment.project(@map.fetch("project_id"))
34
- @receiver = @project.fetch("service_receivers").fetch(service_id)
35
- @client = client || Ace::Assign::Authority::Client.new(mapping_id: mapping_id, deployment: deployment, kernel: kernel)
36
- @inputs = Molecules::ProtectedServicePolicy.new(proposal_resolver: ->(*) { raise SecurityError, "proposal decisions belong to authority" })
37
- end
38
-
39
- def preview_workspace_prune(intent:, peer:, deadline:)
40
- raise SecurityError, "fixed cleanup preview composition unavailable" unless @cleanup_owner
41
- intent = Atoms::ProtectedWorkspacePrunePreview.intent!(JSON.parse(JSON.generate(intent)))
42
- maintenance = intent.fetch("maintenance")
43
- unless maintenance.fetch("mapping_id") == @mapping_id && maintenance.fetch("project_id") == @map.fetch("project_id") &&
44
- peer.values_at("uid", "gid", "groups") == @map.values_at("worker_uid", "worker_gid", "worker_groups")
45
- raise SecurityError, "cleanup preview worker or installed scope differs"
46
- end
47
- @kernel.live!(peer)
48
- me = @kernel.capture(Process.pid)
49
- credentials = @project.fetch("peer_credentials").fetch(@receiver.fetch("executor_uid").to_s)
50
- unless me.values_at("uid", "gid", "groups") == [@receiver.fetch("executor_uid"), credentials.fetch("gid"), credentials.fetch("groups")]
51
- raise SecurityError, "cleanup preview receiver principal differs"
52
- end
53
- remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
54
- raise Ace::Runtime::RuntimeUnavailableError, "cleanup preview deadline expired" unless remaining.positive?
55
- selected = @client.call("workspace_prune_preview_context", maintenance.except("project_id", "mapping_id").merge(
56
- "service_id" => @service_id, "worker_process_binding" => peer), timeout: remaining).data
57
- selected = Atoms::ProtectedWorkspacePrunePreview.context!(selected)
58
- result = @cleanup_owner.preview!(intent: intent, maintenance_context: selected, deadline: deadline)
59
- Atoms::ProtectedWorkspacePrunePreview.result!(result, intent: intent, context: selected)
60
- end
61
-
62
- def execute(submission:, peer:, input_bytes:, mutation_id:, on_claim: nil, claim_timeout: nil)
63
- contacted = false
64
- request_id = nil
65
- unless submission.is_a?(Hash) && submission.keys.sort == SUBMISSION.sort &&
66
- peer.fetch("uid") == @map.fetch("worker_uid") && peer["gid"] == @map.fetch("worker_gid") &&
67
- peer["groups"] == @map.fetch("worker_groups")
68
- raise SecurityError, "receiver submission or peer differs"
69
- end
70
- params = immutable(submission.merge("service_id" => @service_id, "worker_process_binding" => peer))
71
- id = params.fetch("request_id")
72
- unless id.is_a?(String) && id.match?(Ace::Assign::Molecules::JournalMutation::ID)
73
- raise ArgumentError, "receiver request identity is invalid"
74
- end
75
- request_id = id
76
- peer = params.fetch("worker_process_binding")
77
- @kernel.live!(peer)
78
- me = @kernel.capture(Process.pid)
79
- credentials = @project.fetch("peer_credentials").fetch(@receiver.fetch("executor_uid").to_s)
80
- unless me["uid"] == @receiver["executor_uid"] && me["gid"] == credentials["gid"] && me["groups"] == credentials["groups"]
81
- raise SecurityError, "receiver principal differs"
82
- end
83
- unless input_bytes.is_a?(String) && mutation_id.is_a?(String) && mutation_id.match?(Ace::Assign::Molecules::JournalMutation::ID)
84
- raise ArgumentError, "receiver input or mutation identity is invalid"
85
- end
86
- if claim_timeout && (!claim_timeout.is_a?(Numeric) || !claim_timeout.finite? ||
87
- !claim_timeout.positive? || claim_timeout > 5)
88
- raise ArgumentError, "receiver claim deadline is invalid"
89
- end
90
- bytes = input_bytes.dup.freeze
91
- mutation_id = mutation_id.dup.freeze
92
- input = @inputs.input_binding(bytes, expected_digest: params.fetch("input_digest"),
93
- expected_target: params.fetch("target"), operation: params.fetch("operation"))
94
- cleanup = params.fetch("operation") == "prune-preserved-workspace"
95
- raise SecurityError, "fixed cleanup owner composition is unavailable" if cleanup && !@cleanup_owner
96
- contacted = true
97
- claim_options = {mutation_id: mutation_id, upload_parts: [bytes], purpose: :service_input}
98
- claim_options[:timeout] = claim_timeout if claim_timeout
99
- claim = @client.call("request_service", params, **claim_options)
100
- notify_claim(on_claim, claim, request_id) if on_claim
101
- unless !claim.replayed && claim.data["claim"] == "created"
102
- return projection(claim.data)
103
- end
104
- original_cleanup_owner = @cleanup_owner.identity! if cleanup
105
- binding = params.slice("assignment_id", "attempt_id", "candidate_generation", "head", "request_id")
106
- exported = @client.call("export_candidate", binding.except("request_id").merge("purpose_id" => params.fetch("request_id")),
107
- download: true, purpose: :candidate, timeout: 30)
108
- unless exported.parts.is_a?(Array) && exported.parts.length == 1
109
- raise SecurityError, "candidate export is incomplete"
110
- end
111
- materialized = Ace::Assign::Authority::CandidateTransfer.new(root: @receiver.fetch("staging_root")).materialize(
112
- bytes: exported.parts.first, sha256: exported.data.fetch("sha256"), size: exported.data.fetch("bytes"),
113
- head: binding.fetch("head"), tree: exported.data.fetch("tree"), root: @receiver.fetch("staging_root"))
114
- staging_identity = File.lstat(materialized.fetch("directory"))
115
- begin_params = binding.merge("claim_binding" => claim.data.fetch("claim_binding"), "expected_generation" => claim.data.fetch("generation"))
116
- started = @client.call("begin_dispatch", begin_params, mutation_id: Digest::SHA256.hexdigest("begin:#{mutation_id}"), upload_parts: [bytes], purpose: :service_input)
117
- return projection(started.data) unless !started.replayed && started.data["invocation"] == "permitted"
118
- if cleanup && Ace::Assign::Atoms::EvidenceDigest.digest(original_cleanup_owner) !=
119
- Ace::Assign::Atoms::EvidenceDigest.digest(started.data.fetch("operation_owner_binding"))
120
- raise SecurityError, "original cleanup admission owner differs"
121
- end
122
-
123
- document = Molecules::GrantResolver.trusted_document(Ace::Lab.authorization_path)
124
- operation = Molecules::ServicePolicy.new(document).operation!(params.fetch("operation"),
125
- project: @map.fetch("project_id"), service_id: @service_id)
126
- authorization = @client.call("service_authorization", binding.merge("claim_binding" => claim.data.fetch("claim_binding"),
127
- "input_digest" => params.fetch("input_digest")), upload_parts: [bytes], purpose: :service_input)
128
- expected = binding.slice("head", "candidate_generation", "request_id").merge("claim_binding" => claim.data.fetch("claim_binding"),
129
- "policy_digest" => claim.data.fetch("policy_digest"), "operation_digest" => Ace::Assign::Atoms::EvidenceDigest.digest(operation))
130
- unless !authorization.replayed && authorization.data == expected
131
- raise SecurityError, "current authority authorization differs"
132
- end
133
- # This successful fresh authority read is the admission point. A
134
- # later veto cannot retroactively undo it; no read creates permission.
135
- reloaded = Molecules::ServicePolicy.new(Molecules::GrantResolver.trusted_document(Ace::Lab.authorization_path)).operation!(
136
- params.fetch("operation"), project: @map.fetch("project_id"), service_id: @service_id)
137
- unless Ace::Assign::Atoms::EvidenceDigest.digest(reloaded) == expected.fetch("operation_digest")
138
- raise SecurityError, "local operation changed after admission"
139
- end
140
- canonical = params.slice(*Ace::Assign::Molecules::EvidenceJournal::TERMINAL_BINDING_FIELDS, "authorization", "service_id").merge(
141
- "project_id" => @map.fetch("project_id"), "candidate_head" => binding.fetch("head"),
142
- "caller_uid" => peer.fetch("uid"), "executor_uid" => @receiver.fetch("executor_uid"), "transport" => "unix")
143
- envelope = immutable("version" => 1, "request" => canonical, "input" => input.fetch(:input), "execution" => {
144
- "executor_uid" => @receiver.fetch("executor_uid"), "authority_id" => @map.fetch("authority_id"),
145
- "claim_binding" => claim.data.fetch("claim_binding"), "candidate_generation" => binding.fetch("candidate_generation"),
146
- "head" => binding.fetch("head"), "staging_id" => File.basename(materialized.fetch("directory"))})
147
- if params.fetch("operation") == "publish"
148
- return begin_publication!(admitted: {params: params, claim: claim.data, started: started.data,
149
- dispatch_event_digest: started.data.fetch("dispatch_event_digest"), operation: immutable(operation), envelope: envelope,
150
- staging_identity: staging_identity, mutation_id: mutation_id}, materialized: materialized,
151
- deadline: Process.clock_gettime(Process::CLOCK_MONOTONIC) + 30)
152
- end
153
- if cleanup
154
- artifacts = cleanup_result_evidence!(params, input.fetch(:input), claim.data, started.data)
155
- response = {"outcome" => "succeeded", "evidence" => artifacts.each_with_index.map do |artifact, index|
156
- {"ref" => "cleanup-#{index}", "sha256" => Digest::SHA256.hexdigest(artifact)}
157
- end}
158
- else
159
- response = @handler.execute(operation: immutable(operation), envelope: envelope, candidate_root: materialized.fetch("directory"))
160
- return uncertain(params.fetch("request_id")) unless response
161
- artifacts = Ace::Assign::Authority::EvidenceTransfer.read_staged(root: materialized.fetch("directory"), evidence: response.fetch("evidence"))
162
- end
163
- receipt = canonical.slice(*Ace::Assign::Molecules::EvidenceJournal::TERMINAL_BINDING_FIELDS).merge(
164
- "outcome" => response.fetch("outcome"), "evidence" => response.fetch("evidence"))
165
- receipt_bytes = JSON.generate(receipt)
166
- completion = @client.call("complete_service", binding.merge("claim_binding" => claim.data.fetch("claim_binding"),
167
- "receipt_sha256" => Digest::SHA256.hexdigest(receipt_bytes)),
168
- mutation_id: Digest::SHA256.hexdigest("complete:#{mutation_id}"), upload_parts: [receipt_bytes] + artifacts, purpose: :receipt_artifacts)
169
- unless completion.data["request_id"] == request_id && %w[succeeded failed].include?(completion.data["state"])
170
- return uncertain(request_id)
171
- end
172
- cleanup_staging(materialized.fetch("directory"), staging_identity)
173
- projection(completion.data)
174
- rescue Ace::Assign::Error, Ace::Runtime::RuntimeUnavailableError, Ace::Lab::InvalidConfigurationError,
175
- SecurityError, ArgumentError, KeyError, SystemCallError, Timeout::Error
176
- contacted ? uncertain(request_id) : {"request_id" => request_id, "state" => "refused", "code" => "invalid_receiver_admission"}
177
- end
178
-
179
- private
180
-
181
- def cleanup_result_evidence!(params, input, claim, started)
182
- owner = started.fetch("operation_owner_binding")
183
- result = @cleanup_owner.execute!(request: {
184
- "request_id" => params.fetch("request_id"), "input_digest" => params.fetch("input_digest"),
185
- "claim_binding" => claim.fetch("claim_binding"), "request_event_digest" => claim.fetch("request_event_digest"),
186
- "dispatch_event_digest" => started.fetch("dispatch_event_digest"), "input" => input
187
- }, operation_owner_binding: owner)
188
- selection = {"schema" => "ace.protected-workspace-prune-root-selection/v1",
189
- "request_id" => params.fetch("request_id"), "input_digest" => params.fetch("input_digest"),
190
- "operation_owner_binding_digest" => Ace::Assign::Atoms::EvidenceDigest.digest(owner),
191
- "receipt_ref" => result.fetch(:receipt_ref)}
192
- [result.fetch(:bytes), JSON.generate(selection)]
193
- end
194
-
195
- # A source-owned listener callback, never a wire-selected handler. Its
196
- # reply exposes accepted canonical identity before long work. Transport
197
- # loss in the callback is handled by that listener; it cannot resend.
198
- def notify_claim(callback, claim, request_id)
199
- data = claim.data
200
- unless data.is_a?(Hash) && data["request_id"] == request_id &&
201
- data["generation"].is_a?(Integer) && data["generation"].positive? &&
202
- data["journal_commit"].is_a?(String) && data["journal_commit"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/) &&
203
- [true, false].include?(claim.replayed)
204
- raise SecurityError, "canonical service acceptance identity is unavailable"
205
- end
206
- callback.call(immutable(data.slice("request_id", "generation", "journal_commit", "state", "claim")
207
- .merge("replayed" => claim.replayed)))
208
- end
209
-
210
- def cleanup_staging(directory, identity, prefix: "candidate-")
211
- quarantine = nil
212
- root = File.expand_path(@receiver.fetch("staging_root"))
213
- Ace::Assign::Authority::PrivateDirectory.verify!(root)
214
- current = File.lstat(directory)
215
- return unless File.dirname(directory) == root && File.basename(directory).start_with?(prefix) &&
216
- current.directory? && current.uid == Process.uid && current.dev == identity.dev && current.ino == identity.ino
217
- # Capture the pathname atomically before checking the object we will
218
- # delete. A concurrent replacement at the original name must survive.
219
- quarantine = Dir.mktmpdir(".receiver-cleanup-", root)
220
- File.chmod(0o700, quarantine)
221
- captured = File.join(quarantine, "candidate")
222
- File.rename(directory, captured)
223
- moved = File.lstat(captured)
224
- return unless moved.directory? && moved.uid == Process.uid &&
225
- moved.dev == identity.dev && moved.ino == identity.ino
226
- FileUtils.remove_entry_secure(captured)
227
- rescue Ace::Assign::Error, SystemCallError
228
- # A cleanup failure cannot change already-confirmed canonical truth.
229
- # Never fall back to cleaning the staging root or another directory.
230
- nil
231
- ensure
232
- if quarantine
233
- begin
234
- # Never recursively remove a mismatched captured object.
235
- Dir.rmdir(quarantine)
236
- rescue SystemCallError
237
- nil
238
- end
239
- end
240
- end
241
-
242
- def projection(data)
243
- data.slice("request_id", "state", "dispatch_phase", "claim", "generation", "journal_commit").tap do |result|
244
- result.merge!(data.slice("receipt", "completion_digest")) if data["operation"] == "publish"
245
- end
246
- end
247
-
248
- def uncertain(id)
249
- {"request_id" => id, "state" => "uncertain", "required_action" => "inspect_canonical_service_status_and_record_exact_outcome"}
250
- end
251
-
252
- def immutable(value)
253
- copy = JSON.parse(JSON.generate(value))
254
- freeze_value(copy)
255
- end
256
-
257
- def freeze_value(value)
258
- value.each_value { |child| freeze_value(child) } if value.is_a?(Hash)
259
- value.each { |child| freeze_value(child) } if value.is_a?(Array)
260
- value.freeze
261
- end
262
- end
263
- end
264
- end
265
- end
266
-
267
- require_relative "protected_service_recovery"