ace-assign 0.64.0 → 0.66.1

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 (129) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +11 -0
  3. data/CHANGELOG.md +25 -0
  4. data/docs/protected-authority.md +23 -7
  5. data/docs/usage.md +117 -4
  6. data/exe/ace-scope-ready +55 -0
  7. data/handbook/workflow-instructions/assign/drive.wf.md +26 -1
  8. data/lib/ace/assign/atoms/attempt_state_machine.rb +8 -0
  9. data/lib/ace/assign/atoms/delivery_parameters.rb +4 -39
  10. data/lib/ace/assign/authority/assignment_inventory.rb +290 -0
  11. data/lib/ace/assign/authority/campaign_consumer_policy.rb +100 -0
  12. data/lib/ace/assign/authority/campaign_execution.rb +119 -0
  13. data/lib/ace/assign/authority/campaign_round_transfer.rb +82 -0
  14. data/lib/ace/assign/authority/candidate_transfer.rb +169 -2
  15. data/lib/ace/assign/authority/cleanup_result_protection.rb +53 -0
  16. data/lib/ace/assign/authority/client.rb +156 -6
  17. data/lib/ace/assign/authority/deployment.rb +201 -39
  18. data/lib/ace/assign/authority/deployment_history.rb +146 -0
  19. data/lib/ace/assign/authority/endcap.rb +131 -18
  20. data/lib/ace/assign/authority/endcap_attempts.rb +274 -0
  21. data/lib/ace/assign/authority/endcap_campaign_results.rb +232 -0
  22. data/lib/ace/assign/authority/endcap_campaign_rounds.rb +266 -0
  23. data/lib/ace/assign/authority/endcap_contexts.rb +23 -0
  24. data/lib/ace/assign/authority/endcap_inboxes.rb +3 -280
  25. data/lib/ace/assign/authority/endcap_prepared_work.rb +80 -0
  26. data/lib/ace/assign/authority/endcap_publication.rb +207 -0
  27. data/lib/ace/assign/authority/endcap_results.rb +36 -18
  28. data/lib/ace/assign/authority/endcap_review_requests.rb +183 -0
  29. data/lib/ace/assign/authority/endcap_reviews.rb +130 -0
  30. data/lib/ace/assign/authority/endcap_service_settlement.rb +152 -0
  31. data/lib/ace/assign/authority/endcap_services.rb +221 -18
  32. data/lib/ace/assign/authority/execution_scope_observation.rb +468 -41
  33. data/lib/ace/assign/authority/inbox_context_original.rb +113 -0
  34. data/lib/ace/assign/authority/launch_attempt_consumers.rb +51 -0
  35. data/lib/ace/assign/authority/launch_campaign_registration.rb +214 -0
  36. data/lib/ace/assign/authority/launch_control_channel.rb +313 -0
  37. data/lib/ace/assign/authority/launch_control_exclusions.rb +141 -0
  38. data/lib/ace/assign/authority/launch_driver.rb +298 -19
  39. data/lib/ace/assign/authority/launch_input_inhibition.rb +132 -0
  40. data/lib/ace/assign/authority/launch_lifecycle.rb +275 -100
  41. data/lib/ace/assign/authority/launch_prepared_work.rb +122 -0
  42. data/lib/ace/assign/authority/launch_review.rb +91 -0
  43. data/lib/ace/assign/authority/launch_scope_abort.rb +0 -7
  44. data/lib/ace/assign/authority/launch_scope_admission.rb +136 -5
  45. data/lib/ace/assign/authority/launch_scope_close.rb +68 -37
  46. data/lib/ace/assign/authority/launch_scope_release.rb +696 -17
  47. data/lib/ace/assign/authority/launch_steering.rb +353 -0
  48. data/lib/ace/assign/authority/launch_stop.rb +254 -0
  49. data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +229 -0
  50. data/lib/ace/assign/authority/original_inbox_selection.rb +41 -0
  51. data/lib/ace/assign/authority/posix_acl.rb +5 -2
  52. data/lib/ace/assign/authority/prepared_input.rb +130 -0
  53. data/lib/ace/assign/authority/prepared_queue.rb +185 -0
  54. data/lib/ace/assign/authority/prepared_task_context.rb +116 -0
  55. data/lib/ace/assign/authority/prepared_work.rb +304 -0
  56. data/lib/ace/assign/authority/prepared_worker.rb +69 -0
  57. data/lib/ace/assign/authority/protected_assignment_context.rb +226 -0
  58. data/lib/ace/assign/authority/readiness_hook.rb +120 -0
  59. data/lib/ace/assign/authority/router.rb +24 -2
  60. data/lib/ace/assign/authority/server.rb +103 -33
  61. data/lib/ace/assign/authority/service_cleanup_dispatch.rb +88 -0
  62. data/lib/ace/assign/authority/service_cleanup_evidence.rb +180 -0
  63. data/lib/ace/assign/authority/service_delivery_evidence.rb +101 -0
  64. data/lib/ace/assign/authority/service_evidence.rb +243 -12
  65. data/lib/ace/assign/authority/service_publication_evidence.rb +110 -0
  66. data/lib/ace/assign/authority/task_context_entry.rb +32 -0
  67. data/lib/ace/assign/authority/transfer_codec.rb +44 -12
  68. data/lib/ace/assign/cli/commands/assignment_target.rb +15 -2
  69. data/lib/ace/assign/cli/commands/attempt/base.rb +49 -0
  70. data/lib/ace/assign/cli/commands/attempt/finish.rb +20 -4
  71. data/lib/ace/assign/cli/commands/attempt/reconcile.rb +11 -0
  72. data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +25 -0
  73. data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +28 -0
  74. data/lib/ace/assign/cli/commands/authority/launch.rb +58 -6
  75. data/lib/ace/assign/cli/commands/authority/serve.rb +2 -1
  76. data/lib/ace/assign/cli/commands/authority/status.rb +1 -1
  77. data/lib/ace/assign/cli/commands/authority/task_context.rb +31 -0
  78. data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +25 -0
  79. data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +30 -0
  80. data/lib/ace/assign/cli/commands/authority/worker.rb +23 -0
  81. data/lib/ace/assign/cli/commands/campaign_export_result.rb +55 -0
  82. data/lib/ace/assign/cli/commands/campaign_record_round.rb +57 -0
  83. data/lib/ace/assign/cli/commands/create.rb +2 -0
  84. data/lib/ace/assign/cli/commands/delivery.rb +54 -4
  85. data/lib/ace/assign/cli/commands/fail.rb +6 -0
  86. data/lib/ace/assign/cli/commands/finish.rb +23 -2
  87. data/lib/ace/assign/cli/commands/fork_session.rb +6 -2
  88. data/lib/ace/assign/cli/commands/inbox_bind.rb +34 -0
  89. data/lib/ace/assign/cli/commands/protected_submission.rb +61 -0
  90. data/lib/ace/assign/cli/commands/resume.rb +12 -0
  91. data/lib/ace/assign/cli/commands/select.rb +2 -0
  92. data/lib/ace/assign/cli/commands/start.rb +2 -0
  93. data/lib/ace/assign/cli/commands/status.rb +4 -2
  94. data/lib/ace/assign/cli/commands/step.rb +3 -1
  95. data/lib/ace/assign/cli/commands/submit_candidate.rb +34 -0
  96. data/lib/ace/assign/cli/commands/submit_result.rb +61 -0
  97. data/lib/ace/assign/cli.rb +36 -5
  98. data/lib/ace/assign/errors.rb +139 -0
  99. data/lib/ace/assign/models/assignment.rb +26 -4
  100. data/lib/ace/assign/models/evidence_event.rb +3 -3
  101. data/lib/ace/assign/molecules/assignment_manager.rb +12 -2
  102. data/lib/ace/assign/molecules/attempt_reconciler.rb +20 -0
  103. data/lib/ace/assign/molecules/canonical_attempt_state.rb +151 -0
  104. data/lib/ace/assign/molecules/canonical_evidence.rb +5 -2
  105. data/lib/ace/assign/molecules/canonical_read_snapshot.rb +374 -0
  106. data/lib/ace/assign/molecules/evidence_journal.rb +720 -44
  107. data/lib/ace/assign/molecules/execution_scope_lineage.rb +27 -9
  108. data/lib/ace/assign/molecules/fork_session_launcher.rb +16 -6
  109. data/lib/ace/assign/molecules/journal_input_inhibition.rb +78 -0
  110. data/lib/ace/assign/molecules/journal_mutation.rb +82 -13
  111. data/lib/ace/assign/molecules/journal_prompt_mutation.rb +241 -0
  112. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +178 -26
  113. data/lib/ace/assign/molecules/original_launch_binding.rb +37 -0
  114. data/lib/ace/assign/molecules/protected_control_exclusion.rb +303 -0
  115. data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +330 -0
  116. data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +164 -0
  117. data/lib/ace/assign/molecules/queue_scanner.rb +26 -11
  118. data/lib/ace/assign/molecules/receipt_verifier.rb +21 -15
  119. data/lib/ace/assign/molecules/step_writer.rb +27 -5
  120. data/lib/ace/assign/molecules/terminal_scope_receipt.rb +88 -0
  121. data/lib/ace/assign/organisms/assignment_executor.rb +4 -4
  122. data/lib/ace/assign/organisms/attempt_coordinator.rb +66 -76
  123. data/lib/ace/assign/organisms/prepared_work_builder.rb +194 -0
  124. data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +100 -0
  125. data/lib/ace/assign/organisms/task_assignment_creator.rb +4 -2
  126. data/lib/ace/assign/version.rb +1 -1
  127. data/lib/ace/assign.rb +1 -112
  128. metadata +90 -11
  129. data/lib/ace/assign/cli/commands/inbox_reconcile.rb +0 -36
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "deployment_history"
4
+ require_relative "prepared_input"
5
+ require_relative "prepared_queue"
6
+
7
+ module Ace
8
+ module Assign
9
+ module Authority
10
+ # Installed and retained deployment owners determine protected principals.
11
+ # A removed original mapping never silently restores ordinary local mode.
12
+ class ProtectedAssignmentContext
13
+ attr_reader :uid
14
+
15
+ def self.load
16
+ paths = [Deployment::PATH, DeploymentHistory::PATH]
17
+ installed = paths.any? do |path|
18
+ begin
19
+ File.lstat(path)
20
+ true
21
+ rescue Errno::ENOENT
22
+ false
23
+ end
24
+ end
25
+ return new(deployment: nil, history: nil) unless installed
26
+ deployment = Deployment.load
27
+ history = DeploymentHistory.load
28
+ unless history.selects?(deployment)
29
+ raise AttemptErrors::EvidenceUnavailable, "installed protected descriptor is not retained"
30
+ end
31
+ new(deployment: deployment, history: history)
32
+ end
33
+
34
+ def initialize(deployment:, history:, uid: Process.uid, kernel: nil, env: ENV,
35
+ artifacts_factory: -> { Ace::Runtime::Molecules::ProtectedArtifactSet.new })
36
+ @deployment, @history, @uid, @kernel, @env = deployment, history, uid, kernel, env
37
+ @artifacts_factory = artifacts_factory
38
+ end
39
+
40
+ # A declared installed account never falls through to local authority.
41
+ # This is classification only; each wire operation still authenticates
42
+ # the current kernel peer and its exact permitted role.
43
+ def protected_participant?
44
+ return false unless @deployment
45
+ raise AttemptErrors::EvidenceUnavailable, "protected retained owners are unavailable" unless @history
46
+ [@deployment, *@history.descriptors].any? do |owner|
47
+ data = owner.data
48
+ principals = data.fetch("authorities").values.map { |authority| authority.fetch("uid") }
49
+ principals.concat(data.fetch("launch_mappings").values.flat_map { |map| map.values_at("launcher_uid", "worker_uid") })
50
+ data.fetch("projects").each_value do |project|
51
+ %w[launcher_uids reviewer_uids worker_uids service_executor_uids supervisor_uids].each do |key|
52
+ principals.concat(project.fetch(key, []))
53
+ end
54
+ principals.concat(project.fetch("service_receivers", {}).values.map { |receiver| receiver.fetch("executor_uid") })
55
+ principals.concat(project.fetch("inbox_contexts", {}).values.map { |context| context.fetch("owner_credentials").fetch("uid") })
56
+ end
57
+ principals.include?(@uid)
58
+ end
59
+ rescue KeyError, TypeError, NoMethodError
60
+ raise AttemptErrors::EvidenceUnavailable, "protected installed principal inventory is malformed"
61
+ end
62
+
63
+ # Read-only current selection; no role grant or private Inbox access.
64
+ def inbox_context_selection(project:, mapping:, inbox_context:)
65
+ unless installed? && [project, mapping, inbox_context].all? { |id| id.is_a?(String) && Deployment::TOKEN.match?(id) }
66
+ raise AttemptErrors::EvidenceUnavailable, "installed inbox selectors differ"
67
+ end
68
+ descriptor = @deployment.artifact_reference
69
+ history = @history.artifact_reference
70
+ unless descriptor && history && @history.selects?(@deployment)
71
+ raise AttemptErrors::EvidenceUnavailable, "installed inbox references are unavailable"
72
+ end
73
+ @artifacts_factory.call.with do |held|
74
+ held.read!(descriptor)
75
+ held.read!(history)
76
+ map = @deployment.mapping(mapping)
77
+ unless map.fetch("project_id") == project
78
+ raise AttemptErrors::EvidenceUnavailable, "installed inbox project differs"
79
+ end
80
+ selected = @deployment.inbox_context(mapping, inbox_context)
81
+ authority = @deployment.authority(map.fetch("authority_id"))
82
+ result = {"uid" => @uid, "project_id" => project, "mapping_id" => mapping,
83
+ "inbox_context_id" => inbox_context, "descriptor" => descriptor, "history" => history,
84
+ "context" => selected.slice("control_socket_path", "owner_credentials", "native_mapping_id"),
85
+ "authority" => authority.slice("socket_path", "uid", "gid", "groups")
86
+ .merge("authority_id" => map.fetch("authority_id"))}
87
+ held.verify_unchanged!
88
+ value = yield result
89
+ held.verify_unchanged!
90
+ value
91
+ end
92
+ rescue ArgumentError, KeyError, TypeError, Ace::Runtime::Error
93
+ raise AttemptErrors::EvidenceUnavailable, "installed inbox selection is unavailable"
94
+ end
95
+
96
+ def protected_worker?
97
+ return false unless @deployment
98
+ [@deployment, *@history.descriptors].any? do |owner|
99
+ owner.data.fetch("projects").values.any? { |project| project.fetch("worker_uids").include?(@uid) }
100
+ end
101
+ end
102
+
103
+ def installed?
104
+ !@deployment.nil? && !@history.nil?
105
+ end
106
+
107
+ def refuse_graph_mutation!(options:)
108
+ selected = options.values_at(:mapping, :attempt).any? { |value| value && !value.to_s.empty? } ||
109
+ %w[ACE_ASSIGN_LAUNCH_MAPPING ACE_ASSIGN_ATTEMPT_ID].any? { |key| !@env[key].to_s.empty? }
110
+ if protected_worker? || selected
111
+ raise AttemptErrors::EvidenceUnavailable, "protected queue graph changes require a reviewed new prepared version and attempt"
112
+ end
113
+ end
114
+
115
+ def resolve(options:, assignment_id:, scope:)
116
+ selected = options.values_at(:mapping, :attempt).any? { |value| value && !value.to_s.empty? } ||
117
+ %w[ACE_ASSIGN_LAUNCH_MAPPING ACE_ASSIGN_ATTEMPT_ID].any? { |key| !@env[key].to_s.empty? }
118
+ return nil unless protected_worker? || selected
119
+ unless @deployment && assignment_id && scope && scope.match?(PreparedWork::SCOPE)
120
+ raise AttemptErrors::EvidenceUnavailable, "prepared_input_unavailable: explicit scoped assignment required"
121
+ end
122
+ attempt = selected_hint(options[:attempt], "ACE_ASSIGN_ATTEMPT_ID")
123
+ hint = @env["ACE_ASSIGN_ASSIGNMENT_ID"].to_s
124
+ unless hint.empty? || hint == assignment_id
125
+ raise AttemptErrors::EvidenceUnavailable, "prepared assignment hint differs"
126
+ end
127
+ @kernel ||= Ace::Runtime::Molecules::ProtectedLinux.new
128
+ input = PreparedInput.fetch(client: client(options: options),
129
+ assignment_id: assignment_id, attempt_id: attempt)
130
+ unless input.descriptor.fetch("scope") == scope
131
+ raise AttemptErrors::EvidenceUnavailable, "prepared_input_mismatch: scoped CLI selection"
132
+ end
133
+ input
134
+ rescue KeyError
135
+ raise AttemptErrors::EvidenceUnavailable, "prepared_input_unavailable: original installed mapping is unavailable"
136
+ end
137
+
138
+ def verify_attempt_hints!(assignment_id:, attempt_id:)
139
+ selected_hint(assignment_id, "ACE_ASSIGN_ASSIGNMENT_ID")
140
+ selected_hint(attempt_id, "ACE_ASSIGN_ATTEMPT_ID")
141
+ true
142
+ end
143
+
144
+ def client(options:)
145
+ raise AttemptErrors::EvidenceUnavailable, "protected installed authority is unavailable" unless @deployment
146
+ mapping = selected_hint(options[:mapping], "ACE_ASSIGN_LAUNCH_MAPPING")
147
+ @kernel ||= Ace::Runtime::Molecules::ProtectedLinux.new
148
+ Client.new(mapping_id: mapping, deployment: @deployment, kernel: @kernel)
149
+ end
150
+
151
+ def mapping_hint?
152
+ !@env["ACE_ASSIGN_LAUNCH_MAPPING"].to_s.empty?
153
+ end
154
+
155
+ # Observer/signer commands reuse the installed selection and held
156
+ # descriptor pair. This grants no effect; endpoints authorize the peer.
157
+ def with_inbox_workflow(options:)
158
+ mapping = selected_hint(options[:mapping], "ACE_ASSIGN_LAUNCH_MAPPING")
159
+ project, inbox_context = options.values_at(:project, :inbox_context)
160
+ inbox_context_selection(project: project, mapping: mapping, inbox_context: inbox_context) do |_selection|
161
+ @kernel ||= Ace::Runtime::Molecules::ProtectedLinux.new
162
+ yield @deployment, @kernel, @deployment.mapping(mapping)
163
+ end
164
+ end
165
+
166
+ # Consumers call only after original PreparedInput admission. These
167
+ # existing owners select transport; they do not confer a new grant.
168
+ def with_installed_selection(options:, input:)
169
+ raise AttemptErrors::EvidenceUnavailable, "protected installed authority is unavailable" unless @deployment
170
+ mapping = selected_hint(options[:mapping], "ACE_ASSIGN_LAUNCH_MAPPING")
171
+ begin
172
+ map = @deployment.mapping(mapping)
173
+ unless input && input.descriptor.fetch("mapping_id") == mapping &&
174
+ input.descriptor.fetch("project_id") == map.fetch("project_id")
175
+ raise AttemptErrors::EvidenceUnavailable, "original prepared installed selection differs"
176
+ end
177
+ rescue KeyError
178
+ raise AttemptErrors::EvidenceUnavailable, "original installed mapping is unavailable"
179
+ end
180
+ @kernel ||= Ace::Runtime::Molecules::ProtectedLinux.new
181
+ yield @deployment, @kernel, mapping
182
+ end
183
+
184
+ private
185
+
186
+ def selected_hint(explicit, key)
187
+ hint = @env[key].to_s
188
+ value = explicit || hint
189
+ unless value.is_a?(String) && value.match?(PreparedWork::TOKEN) && (hint.empty? || hint == value)
190
+ raise AttemptErrors::EvidenceUnavailable, "prepared selector hint differs"
191
+ end
192
+ value
193
+ end
194
+ end
195
+
196
+ # Each supported operation holds the existing private queue exclusion and
197
+ # consumes its authenticated held snapshot. No graph-changing API exists.
198
+ class PreparedExecutor
199
+ def initialize(input)
200
+ @queue = PreparedQueue.new(work: input.work, descriptor: input.descriptor)
201
+ @scope = input.descriptor.fetch("scope")
202
+ end
203
+
204
+ def status
205
+ @queue.with_executor(&:status)
206
+ end
207
+
208
+ %i[start_step finish_step fail].each do |operation|
209
+ define_method(operation) do |*args, **options|
210
+ unless options[:fork_root] == @scope && (!options[:step_number] ||
211
+ options[:step_number] == @scope || options[:step_number].start_with?(@scope + "."))
212
+ raise AttemptErrors::EvidenceUnavailable, "prepared operation scope differs"
213
+ end
214
+ @queue.with_executor { |executor| executor.public_send(operation, *args, **options) }
215
+ end
216
+ end
217
+
218
+ def method_missing(*)
219
+ raise AttemptErrors::EvidenceUnavailable, "prepared queue graph changes require a reviewed new prepared version and attempt"
220
+ end
221
+
222
+ def respond_to_missing?(*); false; end
223
+ end
224
+ end
225
+ end
226
+ end
@@ -0,0 +1,120 @@
1
+ # frozen_string_literal: true
2
+ require "ace/runtime/molecules/execution_network_selection"
3
+ require "socket"
4
+ require_relative "../errors"
5
+ require "ace/runtime/molecules/readiness_configuration"
6
+ require "ace/runtime/molecules/server_resource_observation"
7
+ require "ace/runtime/molecules/protected_linux"
8
+ require "ace/runtime/molecules/protected_socket"
9
+ require_relative "transfer_codec"
10
+
11
+ module Ace
12
+ module Assign
13
+ module Authority
14
+ # Fixed ExecStartPost action, deliberately independent of Deployment.load.
15
+ class ReadinessHook
16
+ class Files
17
+ def boundary_manifest!(config)
18
+ bytes = nil
19
+ Ace::Runtime::Molecules::ProtectedArtifactSet.new.with do |reader|
20
+ bytes, = reader.read_path!(config.dig("boundary_manifest", "path"), limit: 65_536)
21
+ unless Digest::SHA256.hexdigest(bytes) == config.dig("boundary_manifest", "sha256")
22
+ raise AttemptErrors::EvidenceUnavailable, "readiness boundary bytes differ"
23
+ end
24
+ reader.verify_unchanged!
25
+ end
26
+ bytes = bytes.dup.force_encoding(Encoding::UTF_8)
27
+ raise AttemptErrors::EvidenceUnavailable, "readiness boundary encoding differs" unless bytes.valid_encoding?
28
+ JSON.parse(bytes, create_additions: false, max_nesting: 8, allow_duplicate_key: false, allow_comments: false)
29
+ end
30
+
31
+ def verify_native!(server, config)
32
+ File.open("/proc/#{server.fetch('pid')}/exe", File::RDONLY) do |executable|
33
+ unless Digest::SHA256.hexdigest(executable.read(268_435_457)) == config.dig("native", "executable_sha256") &&
34
+ File.read("/proc/#{server.fetch('pid')}/cmdline", 16_385).split("\0") == [config.dig("native", "executable"), "server"]
35
+ raise AttemptErrors::EvidenceUnavailable, "readiness native executable differs"
36
+ end
37
+ end
38
+ end
39
+ end
40
+
41
+ def initialize(slot:, configuration: nil, kernel: Ace::Runtime::Molecules::ProtectedLinux.new,
42
+ resources: nil, wire: Ace::Runtime::Molecules::ProtectedSocket, files: Files.new)
43
+ @configuration = configuration || Ace::Runtime::Molecules::ReadinessConfiguration.load(slot: slot)
44
+ @kernel, @wire = kernel, wire
45
+ @files = files
46
+ @resources = resources || Ace::Runtime::Molecules::ServerResourceObservation.new(kernel: kernel)
47
+ end
48
+
49
+ def run
50
+ config = @configuration.data
51
+ @kernel.supported!
52
+ hook = @kernel.capture(Process.pid)
53
+ expected = config.fetch("worker")
54
+ unless hook.values_at("uid", "gid", "groups") == expected.values_at("uid", "gid", "groups")
55
+ raise AttemptErrors::UnauthorizedIdentity, "readiness hook principal differs"
56
+ end
57
+ deadline = @wire.deadline(10)
58
+ authority = config.fetch("authority")
59
+ path = authority.fetch("socket_path")
60
+ @wire.root_path!(File.dirname(path), directory: true, owner: authority.fetch("uid"))
61
+ endpoint = @wire.socket_identity(path)
62
+ @wire.connect(path) do |socket|
63
+ peer = @kernel.peer(socket)
64
+ unless peer.values_at("uid", "gid", "groups") == authority.values_at("uid", "gid", "groups") &&
65
+ @wire.socket_identity(path) == endpoint
66
+ raise AttemptErrors::UnauthorizedIdentity, "readiness authority peer differs"
67
+ end
68
+ @wire.write(socket, {"version" => 1, "operation" => "native_readiness", "mutation_id" => nil,
69
+ "project_id" => config.fetch("project_id"), "params" => {"mapping_id" => config.fetch("mapping_id")}},
70
+ deadline: deadline, limit: 16_384)
71
+ challenge = @wire.read(socket, deadline: deadline, limit: 16_384)
72
+ unless challenge.is_a?(Hash) && challenge.keys.sort == %w[challenge_id server_identity version] &&
73
+ challenge["version"] == 1 && challenge["challenge_id"].is_a?(String) &&
74
+ challenge["challenge_id"].match?(/\A[0-9a-f]{64}\z/)
75
+ raise AttemptErrors::EvidenceUnavailable, "private readiness challenge differs"
76
+ end
77
+ manifest = @files.boundary_manifest!(config)
78
+ unless manifest.is_a?(Hash) && manifest.keys.sort == %w[network_installation resources schema slot_id] &&
79
+ manifest["schema"] == "ace.execution-boundary-manifest/v1" && manifest["slot_id"] == config.fetch("slot_id") &&
80
+ manifest["resources"].is_a?(Array) && manifest["resources"].size.between?(1, 64)
81
+ raise AttemptErrors::EvidenceUnavailable, "readiness boundary shape differs"
82
+ end
83
+ Ace::Runtime::Molecules::ExecutionNetworkSelection.validate_static!(manifest.fetch("network_installation"), slot_id: config.fetch("slot_id"))
84
+ manifest.fetch("resources").each do |entry|
85
+ paths = entry.is_a?(Hash) ? entry.values_at("host_path", "view_path") : []
86
+ unless entry.is_a?(Hash) && entry.keys.sort == %w[host_path read_only stage view_path worker_visible] &&
87
+ paths.size == 2 && paths.all? { |path| path.is_a?(String) && path.bytesize.between?(1, 4096) && path.start_with?("/") && !path.include?("\0") && File.expand_path(path) == path } &&
88
+ %w[parent native].include?(entry["stage"]) && [true, false].include?(entry["worker_visible"]) && [true, false].include?(entry["read_only"])
89
+ raise AttemptErrors::EvidenceUnavailable, "readiness resource declaration differs"
90
+ end
91
+ end
92
+ entries = manifest.fetch("resources").select { |entry| entry.fetch("worker_visible") }
93
+ server = challenge.fetch("server_identity")
94
+ @files.verify_native!(server, config)
95
+ report = @resources.observe!(server: server, entries: entries, authority_socket: path)
96
+ report = report.merge("version" => 1, "challenge_id" => challenge.fetch("challenge_id"),
97
+ "server_identity" => challenge.fetch("server_identity"), "resource_observer_identity" => hook)
98
+ bytes = JSON.generate(report)
99
+ codec = TransferCodec.new
100
+ transfer = codec.descriptor([bytes], purpose: :scope_boundary_observation)
101
+ @wire.write(socket, {"version" => 1, "challenge_id" => challenge.fetch("challenge_id"), "transfer" => transfer},
102
+ deadline: deadline, limit: 16_384)
103
+ codec.send(socket, parts: [bytes], descriptor: transfer, purpose: :scope_boundary_observation, deadline: deadline)
104
+ socket.shutdown(Socket::SHUT_WR)
105
+ acknowledgment = @wire.read(socket, deadline: deadline, limit: 16_384)
106
+ unless acknowledgment == {"version" => 1, "challenge_id" => challenge.fetch("challenge_id"), "status" => "ready"}
107
+ raise AttemptErrors::EvidenceUnavailable, "private readiness acknowledgement differs"
108
+ end
109
+ @kernel.live!(hook)
110
+ @kernel.live!(peer)
111
+ raise AttemptErrors::EvidenceUnavailable, "readiness endpoint changed" unless @wire.socket_identity(path) == endpoint
112
+ end
113
+ true
114
+ rescue SystemCallError, IOError, JSON::ParserError, KeyError, TypeError
115
+ raise AttemptErrors::EvidenceUnavailable, "private readiness observation unavailable"
116
+ end
117
+ end
118
+ end
119
+ end
120
+ end
@@ -17,34 +17,56 @@ module Ace
17
17
  end
18
18
 
19
19
  def dispatch(request:, peer:, role:, transfer: nil)
20
+ validate_review_namespace!(request)
20
21
  handler = @routes.fetch(request.fetch("operation")) { raise ArgumentError, "unknown authority operation" }
21
22
  options = {request: request, peer: peer, role: role}
22
23
  options[:transfer] = transfer if transfer
23
24
  handler.dispatch(**options)
24
25
  end
25
26
 
26
- def transfer_binding(operation)
27
+ def transfer_binding(request)
28
+ validate_review_namespace!(request)
29
+ operation = request.fetch("operation")
27
30
  handler = @routes.fetch(operation) { raise ArgumentError, "unknown authority operation" }
28
31
  return nil unless handler.class.const_defined?(:TRANSFER_OPERATIONS, false)
29
32
  binding = handler.class::TRANSFER_OPERATIONS[operation]
33
+ if operation == "evidence_fetch" && %w[prepared_work campaign_candidate].include?(request.dig("params", "kind"))
34
+ binding = {direction: :download, purpose: :candidate, roles: [:worker]}
35
+ end
30
36
  return nil unless binding
31
37
  unless binding.is_a?(Hash) && binding.keys.sort == %i[direction purpose roles] &&
32
38
  %i[upload download].include?(binding[:direction]) && TransferCodec::LIMITS.key?(binding[:purpose]) &&
33
- binding[:roles].is_a?(Array) && binding[:roles].all? { |role| %i[worker launcher reviewer executor supervisor].include?(role) }
39
+ binding[:roles].is_a?(Array) && binding[:roles].all? { |role| %i[worker launcher reviewer executor supervisor observer signer].include?(role) }
34
40
  raise ArgumentError, "invalid source transfer binding"
35
41
  end
36
42
  binding
37
43
  end
38
44
 
39
45
  def authorize_transfer!(request:, peer:, role:)
46
+ validate_review_namespace!(request)
40
47
  handler = @routes.fetch(request.fetch("operation"))
41
48
  handler.authorize_transfer!(request: request, peer: peer, role: role)
42
49
  end
43
50
 
51
+ def validate_review_namespace!(request)
52
+ if request["mutation_id"].is_a?(String) && request["mutation_id"].start_with?("review-reply.")
53
+ raise ArgumentError, "reserved review reply namespace"
54
+ end
55
+ if request["mutation_id"].is_a?(String) && request["mutation_id"].start_with?("review-delegate.") &&
56
+ request["operation"] != "assign_review"
57
+ raise ArgumentError, "reserved review delegation namespace"
58
+ end
59
+ end
60
+ private :validate_review_namespace!
61
+
44
62
  def close
45
63
  @launch.close
46
64
  end
47
65
 
66
+ def serve_launch_control!(**options)
67
+ @launch.serve_launch_control!(**options)
68
+ end
69
+
48
70
  def gate_ready(**options)
49
71
  @launch.gate_ready(**options)
50
72
  end
@@ -3,6 +3,7 @@ require "socket"
3
3
  require "fileutils"
4
4
  require_relative "deployment"
5
5
  require_relative "transfer_codec"
6
+ require_relative "../molecules/evidence_journal"
6
7
 
7
8
  module Ace
8
9
  module Assign
@@ -11,9 +12,10 @@ module Ace
11
12
  class Server
12
13
  CONNECTIONS = 16
13
14
  def initialize(authority_id:, lifecycle:, deployment: Deployment.load,
14
- kernel: Ace::Runtime::Molecules::ProtectedLinux.new, composition: "launch")
15
+ kernel: Ace::Runtime::Molecules::ProtectedLinux.new, composition: "launch", hitl_service: nil)
15
16
  @authority_id, @lifecycle, @deployment, @kernel = authority_id, lifecycle, deployment, kernel
16
17
  @composition = composition
18
+ @hitl_service = hitl_service
17
19
  deployment.verify_composition!(authority_id, composition: composition)
18
20
  @service = deployment.authority(authority_id)
19
21
  @mutex = Mutex.new
@@ -36,6 +38,7 @@ module Ace
36
38
  unless lock.flock(File::LOCK_EX | File::LOCK_NB)
37
39
  raise AttemptErrors::Conflict, "assignment authority already owns its listener"
38
40
  end
41
+ start_hitl_service!
39
42
  path = @service.fetch("socket_path")
40
43
  if File.exist?(path) || File.symlink?(path)
41
44
  raise AttemptErrors::Conflict, "existing authority endpoint requires verified installer recovery"
@@ -44,6 +47,9 @@ module Ace
44
47
  File.chmod(0o660, path)
45
48
  @endpoint = wire.socket_identity(path)
46
49
  until @stopped
50
+ if @hitl_thread && !@hitl_thread.alive?
51
+ raise Ace::Runtime::RuntimeUnavailableError, "protected HITL boundary stopped before its authority"
52
+ end
47
53
  ready = IO.select([@listener], nil, nil, 0.25)
48
54
  next unless ready
49
55
  socket = @listener.accept_nonblock(exception: false)
@@ -68,6 +74,7 @@ module Ace
68
74
  raise unless @stopped
69
75
  ensure
70
76
  stop
77
+ stop_hitl_service!
71
78
  # Closing a stream does not cancel a handler already inside dispatch.
72
79
  # The owner lock and endpoint remain held until every handler ends.
73
80
  @mutex.synchronize { @handlers.dup }.each(&:join)
@@ -90,6 +97,29 @@ module Ace
90
97
 
91
98
  private
92
99
 
100
+ # The existing HITL boundary is a sibling owned by this listener's
101
+ # lifetime. It must be ready before assignment ingress is opened.
102
+ def start_hitl_service!
103
+ return unless @hitl_service
104
+ ready = Queue.new
105
+ @hitl_thread = Thread.new do
106
+ @hitl_service.run(on_ready: -> { ready << :ready })
107
+ rescue Exception => error
108
+ @hitl_failure = error
109
+ ready << error
110
+ end
111
+ @hitl_thread.report_on_exception = false
112
+ result = ready.pop(timeout: 5)
113
+ raise Ace::Runtime::RuntimeUnavailableError, "protected HITL boundary startup timed out" if result.nil?
114
+ raise result if result.is_a?(Exception)
115
+ end
116
+
117
+ def stop_hitl_service!
118
+ return unless @hitl_thread
119
+ @hitl_service.stop
120
+ @hitl_thread.join
121
+ end
122
+
93
123
  def same_endpoint?(path)
94
124
  wire.socket_identity(path) == @endpoint
95
125
  rescue StandardError
@@ -102,16 +132,45 @@ module Ace
102
132
  frame = wire.read(socket, deadline: [deadline, wire.deadline(5)].min, with_size: true)
103
133
  request = frame.fetch(:data)
104
134
  unless request.is_a?(Hash) && request.keys.sort == %w[mutation_id operation params project_id version] &&
105
- request["version"] == 1 && request["operation"].is_a?(String) && request["params"].is_a?(Hash)
135
+ request["version"].is_a?(Integer) && request["version"] == 1 && request["operation"].is_a?(String) && request["params"].is_a?(Hash)
106
136
  raise ArgumentError, "invalid authority envelope"
107
137
  end
108
- deadline = wire.deadline(90) if %w[reserve_attempt close_execution_scope].include?(request.fetch("operation"))
138
+ deadline = wire.deadline(90) if %w[reserve_attempt close_execution_scope stop_attempt].include?(request.fetch("operation"))
109
139
  params = request.fetch("params")
110
140
  map = @deployment.verify!(params.fetch("mapping_id"), kernel: @kernel, authority_state: true)
111
141
  unless map["authority_id"] == @authority_id && map["project_id"] == request["project_id"]
112
142
  raise AttemptErrors::UnauthorizedIdentity, "mapping belongs to another authority or project"
113
143
  end
114
- role = if principal?(peer, map, "launcher_uid", "launcher_gid", "launcher_groups")
144
+ project = @deployment.project(map.fetch("project_id"))
145
+ owner_contexts = project.fetch("inbox_contexts", {}).select do |_id, context|
146
+ context.fetch("native_mapping_id") == params.fetch("mapping_id") &&
147
+ context.fetch("owner_credentials").values_at("uid", "gid", "groups") == peer.values_at("uid", "gid", "groups")
148
+ end
149
+ context_query = %w[inbox_context_original].include?(request["operation"])
150
+ if context_query || !owner_contexts.empty?
151
+ unless context_query && @composition == "services" && request["mutation_id"].nil? &&
152
+ owner_contexts.key?(params.fetch("inbox_context_id")) && frame.fetch(:bytesize) <= 16_384
153
+ raise AttemptErrors::UnauthorizedIdentity, "context owner is exclusive to its fixed read-only inbox queries"
154
+ end
155
+ role = :context_owner
156
+ end
157
+ if request["operation"] == "native_readiness"
158
+ unless @composition == "launch" && request["mutation_id"].nil? && params.keys == ["mapping_id"] &&
159
+ frame.fetch(:bytesize) <= 16_384
160
+ raise ArgumentError, "private readiness envelope differs"
161
+ end
162
+ admitted_transfer = @mutex.synchronize do
163
+ if @transfers < 2
164
+ @transfers += 1
165
+ true
166
+ end
167
+ end
168
+ raise AttemptErrors::Conflict, "readiness transfer capacity is busy" unless admitted_transfer
169
+ @lifecycle.native_readiness!(mapping_id: params.fetch("mapping_id"), peer: peer, socket: socket,
170
+ codec: transfer_codec, deadline: wire.deadline(10))
171
+ return
172
+ end
173
+ role ||= if principal?(peer, map, "launcher_uid", "launcher_gid", "launcher_groups")
115
174
  :launcher
116
175
  elsif principal?(peer, map, "worker_uid", "worker_gid", "worker_groups")
117
176
  :worker
@@ -126,55 +185,66 @@ module Ace
126
185
  end
127
186
  end
128
187
  raise AttemptErrors::UnauthorizedIdentity, "unmapped kernel peer" unless role
129
- if %w[observe_execution_scope close_execution_scope].include?(request["operation"]) ||
130
- @composition == "services" && %w[attempt_status evidence_fetch].include?(request["operation"])
188
+ if request["operation"] == "launch_control"
189
+ raise AttemptErrors::UnauthorizedIdentity, "Private launcher control requires launcher peer" unless role == :launcher
190
+ raise ArgumentError, "Private launcher header is oversized" unless frame.fetch(:bytesize) <= 16_384
191
+ @lifecycle.serve_launch_control!(request: request, peer: peer, socket: socket, codec: transfer_codec, deadline: deadline)
192
+ return
193
+ end
194
+ if %w[bind_inbox finish recover request_review review_status launch_review_intent cancel_review evidence_fetch assignment_inventory 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?(request["operation"]) ||
195
+ @composition == "services" && %w[attempt_status inbox_context_original].include?(request["operation"])
196
+ raise ArgumentError, "review control header is oversized" if %w[request_review review_status cancel_review launch_review_intent].include?(request["operation"]) && frame.fetch(:bytesize) > 16_384
131
197
  bodyless_read!(socket, deadline)
132
198
  end
133
199
  if request["operation"] == "gate_ready"
134
200
  raise AttemptErrors::UnauthorizedIdentity, "gate readiness requires worker peer" unless role == :worker
135
201
  @lifecycle.gate_ready(request: request, peer: peer, socket: socket, deadline: deadline)
136
202
  else
137
- binding = @lifecycle.transfer_binding(request.fetch("operation")) if @lifecycle.respond_to?(:transfer_binding)
138
- if binding
139
- raise AttemptErrors::UnauthorizedIdentity, "transfer role differs" unless binding.fetch(:roles).include?(role)
140
- raise ArgumentError, "transfer control header is oversized" if frame.fetch(:bytesize) > 16_384
141
- @lifecycle.authorize_transfer!(request: request, peer: peer, role: role)
142
- admitted_transfer = @mutex.synchronize do
143
- if @transfers < 2
144
- @transfers += 1
145
- true
203
+ Molecules::EvidenceJournal.with_event_read_operation do
204
+ binding = @lifecycle.transfer_binding(request) if @lifecycle.respond_to?(:transfer_binding)
205
+ if binding
206
+ raise AttemptErrors::UnauthorizedIdentity, "transfer role differs" unless binding.fetch(:roles).include?(role)
207
+ raise ArgumentError, "transfer control header is oversized" if frame.fetch(:bytesize) > 16_384
208
+ @lifecycle.authorize_transfer!(request: request, peer: peer, role: role)
209
+ admitted_transfer = @mutex.synchronize do
210
+ if @transfers < 2
211
+ @transfers += 1
212
+ true
213
+ end
146
214
  end
147
- end
148
- raise AttemptErrors::Conflict, "transfer capacity is busy" unless admitted_transfer
149
- codec = transfer_codec
150
- if binding.fetch(:direction) == :upload
151
- result = codec.receive(socket, descriptor: params.fetch("transfer"), purpose: binding.fetch(:purpose), deadline: deadline) do |input|
152
- @lifecycle.dispatch(request: request, peer: peer, role: role, transfer: input)
215
+ raise AttemptErrors::Conflict, "transfer capacity is busy" unless admitted_transfer
216
+ codec = transfer_codec
217
+ if binding.fetch(:direction) == :upload
218
+ result = codec.receive(socket, descriptor: params.fetch("transfer"), purpose: binding.fetch(:purpose), deadline: deadline) do |input|
219
+ @lifecycle.dispatch(request: request, peer: peer, role: role, transfer: input)
220
+ end
221
+ else
222
+ result = @lifecycle.dispatch(request: request, peer: peer, role: role)
223
+ parts = result.fetch(:transfer_parts)
224
+ descriptor = codec.descriptor(parts, purpose: binding.fetch(:purpose))
225
+ result = result.merge(data: result.fetch(:data).merge("transfer" => descriptor))
153
226
  end
154
227
  else
228
+ raise ArgumentError, "unexpected transfer descriptor" if params.key?("transfer")
155
229
  result = @lifecycle.dispatch(request: request, peer: peer, role: role)
156
- parts = result.fetch(:transfer_parts)
157
- descriptor = codec.descriptor(parts, purpose: binding.fetch(:purpose))
158
- result = result.merge(data: result.fetch(:data).merge("transfer" => descriptor))
159
230
  end
160
- else
161
- raise ArgumentError, "unexpected transfer descriptor" if params.key?("transfer")
162
- result = @lifecycle.dispatch(request: request, peer: peer, role: role)
163
- end
164
- wire.write(socket, {"status" => "ok", "data" => result.fetch(:data),
165
- "transport" => {"replayed" => result.fetch(:replayed, false)}}, deadline: deadline, limit: 16_384)
166
- if binding && binding.fetch(:direction) == :download
167
- codec.send(socket, parts: parts, descriptor: descriptor, purpose: binding.fetch(:purpose), deadline: deadline)
231
+ wire.write(socket, {"status" => "ok", "data" => result.fetch(:data),
232
+ "transport" => {"replayed" => result.fetch(:replayed, false)}}, deadline: deadline, limit: 16_384)
233
+ if binding && binding.fetch(:direction) == :download
234
+ codec.send(socket, parts: parts, descriptor: descriptor, purpose: binding.fetch(:purpose), deadline: deadline)
235
+ end
168
236
  end
169
237
  end
170
238
  rescue ArgumentError, KeyError, AttemptErrors::MalformedTransfer
171
239
  refusal(socket, "invalid_input")
172
- rescue AttemptErrors::UnauthorizedIdentity
240
+ rescue AttemptErrors::UnauthorizedIdentity, SecurityError
173
241
  refusal(socket, "unauthorized")
174
242
  rescue AttemptErrors::Conflict
175
243
  refusal(socket, "conflict")
176
244
  rescue AttemptErrors::NotFound
177
245
  refusal(socket, "missing")
246
+ rescue AttemptErrors::BoundedResultUnavailable
247
+ refusal(socket, "bounded_result")
178
248
  rescue StandardError
179
249
  refusal(socket, "evidence_unavailable")
180
250
  ensure