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,361 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "ace/runtime/molecules/protected_socket"
4
- require "ace/runtime/molecules/protected_artifact_set"
5
- require "ace/assign/authority/transfer_codec"
6
- require_relative "../molecules/protected_cleanup_owner_admission"
7
-
8
- module Ace
9
- module Lab
10
- module Organisms
11
- # The immutable direct root entry supplies all collaborators. Durable
12
- # request/outcome authority stays in its original canonical journal and
13
- # Installer's retained physical result, never this transport owner.
14
- class ProtectedCleanupOwner
15
- Wire = Ace::Runtime::Molecules::ProtectedSocket
16
- Client = Molecules::ProtectedCleanupOwnerClient
17
- MAX_RETAINED = 256
18
- MAX_CONNECTIONS = 16
19
-
20
- def initialize(observer:, admission:, snapshots:, journals:, kernel:, installer:, scratch_root:,
21
- protection: Ace::Runtime::Molecules::ProtectedArtifactSet::Protection.new)
22
- @observer, @admission, @snapshots, @journals = observer, admission, snapshots, journals
23
- @kernel, @installer = kernel, installer
24
- @protection = protection
25
- @codec = Ace::Assign::Authority::TransferCodec.new(root: scratch_root)
26
- @lock, @consumed = Mutex.new, {}
27
- @connections, @changed = [], ConditionVariable.new
28
- @stopping = false
29
- end
30
-
31
- # Only the trusted fixed entry supplies the installed connection group.
32
- # Endpoint and lock paths remain source constants, never request inputs.
33
- def serve(connect_gid:)
34
- @admission.connection_group!(connect_gid)
35
- @observer.observe_self!(deadline: Wire.deadline(5))
36
- path = Client::PATH
37
- parent = File.dirname(path)
38
- Wire.root_path!(parent, directory: true)
39
- parents = hold_parent!(parent)
40
- parent_stat = File.lstat(parent)
41
- unless parent_stat.directory? && parent_stat.uid == Process.uid && parent_stat.gid == connect_gid && (parent_stat.mode & 0o7777) == 0o2750
42
- raise SecurityError, "cleanup listener parent differs"
43
- end
44
- parent_identity = [parent_stat.dev, parent_stat.ino]
45
- lifetime = File.open("#{path}.lock", File::RDWR | File::CREAT | File::NOFOLLOW | File::NONBLOCK, 0o600)
46
- lifetime.close_on_exec = true
47
- stat = lifetime.stat
48
- unless stat.file? && stat.uid == Process.uid && stat.nlink == 1 && (stat.mode & 0o7777) == 0o600
49
- raise SecurityError, "cleanup lifetime lock differs"
50
- end
51
- @protection.verify!("#{path}.lock", lifetime, directory: false)
52
- raise SecurityError, "cleanup lifetime lock path changed" unless file_identity(File.lstat("#{path}.lock")) == file_identity(lifetime.stat)
53
- raise Ace::Runtime::RuntimeUnavailableError, "cleanup listener already active" unless lifetime.flock(File::LOCK_EX | File::LOCK_NB)
54
- raise SecurityError, "cleanup lifetime lock path changed after flock" unless file_identity(File.lstat("#{path}.lock")) == file_identity(lifetime.stat)
55
- verify_parents!(parents)
56
- begin
57
- stale = File.lstat(path)
58
- unless stale.socket? && stale.uid == Process.uid && stale.gid == connect_gid && (stale.mode & 0o7777) == 0o660
59
- raise SecurityError, "cleanup stale endpoint differs"
60
- end
61
- File.unlink(path)
62
- rescue Errno::ENOENT
63
- nil
64
- end
65
- @listener = UNIXServer.new(path)
66
- @listener.close_on_exec = true
67
- created = File.lstat(path)
68
- unless created.socket? && created.uid == Process.uid && created.gid == connect_gid &&
69
- [File.lstat(parent).dev, File.lstat(parent).ino] == parent_identity
70
- raise SecurityError, "cleanup endpoint creation differs"
71
- end
72
- endpoint = [created.dev, created.ino, created.uid, created.gid]
73
- File.chmod(0o660, path)
74
- own = File.lstat(path)
75
- unless own.socket? && (own.mode & 0o7777) == 0o660 && [own.dev, own.ino, own.uid, own.gid] == endpoint && [File.lstat(parent).dev, File.lstat(parent).ino] == parent_identity
76
- raise SecurityError, "cleanup endpoint publication differs"
77
- end
78
- published = true
79
- until @stopping
80
- socket = @listener.accept
81
- accepted_at = Process.clock_gettime(Process::CLOCK_MONOTONIC)
82
- socket.close_on_exec = true
83
- admitted = @lock.synchronize do
84
- @connections.reject! { |thread| !thread.alive? }
85
- if @connections.size >= MAX_CONNECTIONS
86
- false
87
- else
88
- worker = Thread.new(socket, accepted_at) do |owned_socket, original_acceptance|
89
- begin
90
- verify_parents!(parents)
91
- current = File.lstat(path)
92
- unless current.socket? && (current.mode & 0o7777) == 0o660 && [current.dev, current.ino, current.uid, current.gid] == endpoint
93
- raise SecurityError, "cleanup endpoint changed"
94
- end
95
- handle(owned_socket, accepted_at: original_acceptance)
96
- rescue SecurityError, Ace::Runtime::RuntimeUnavailableError, Ace::Assign::Error, IOError, SystemCallError => error
97
- # Refusal/transport failure closes only this connection.
98
- # Its consumed state, including uncertainty, is preserved.
99
- code = error.is_a?(SecurityError) ? "unauthorized_or_changed_selection" : "unavailable"
100
- warn("cleanup connection refused: #{code}")
101
- ensure
102
- owned_socket.close unless owned_socket.closed?
103
- @lock.synchronize { @changed.broadcast }
104
- end
105
- end
106
- @connections << worker
107
- true
108
- end
109
- end
110
- socket.close unless admitted
111
- end
112
- rescue IOError, Errno::EBADF
113
- raise unless @stopping
114
- ensure
115
- @listener.close if @listener && !@listener.closed?
116
- @lock.synchronize do
117
- # Wait for actual callbacks, never mistake a dead callback's
118
- # unconfirmed outcome for activity or physical settlement.
119
- while @connections.any?(&:alive?) || @consumed.values.any? { |entry| entry[:state] == :invoked }
120
- @changed.wait(@lock, 0.1)
121
- end
122
- end
123
- unconfirmed = @lock.synchronize { @consumed.values.any? { |entry| entry[:state] == :unconfirmed } }
124
- if endpoint
125
- begin
126
- verify_parents!(parents)
127
- current = File.lstat(path)
128
- File.unlink(path) if current.socket? && (!published || (current.mode & 0o7777) == 0o660) && [current.dev, current.ino, current.uid, current.gid] == endpoint
129
- rescue Errno::ENOENT
130
- nil
131
- end
132
- end
133
- lifetime&.close
134
- parents&.each { |entry| entry.fetch(:handle).close }
135
- if unconfirmed && $!.nil?
136
- raise Ace::Runtime::RuntimeUnavailableError, "cleanup invocation outcome remains unconfirmed after callback termination"
137
- end
138
- end
139
-
140
- def stop
141
- @stopping = true
142
- @listener.close if @listener && !@listener.closed?
143
- end
144
-
145
- # Listener construction and fixed socket ownership are separately
146
- # provisioned; every accepted connection still authenticates its peer.
147
- def handle(socket, accepted_at: Process.clock_gettime(Process::CLOCK_MONOTONIC))
148
- admission_deadline = accepted_at + 5
149
- peer = @kernel.peer(socket)
150
- frame = Wire.read(Wire::Ingress.new(socket), deadline: admission_deadline, limit: 65_536)
151
- eof!(socket, admission_deadline)
152
- original = @observer.observe_self!(deadline: admission_deadline)
153
- if frame == {"schema" => Client::SCHEMA, "kind" => "identity"}
154
- @admission.identity_reader!(peer)
155
- Wire.write(socket, {"schema" => Client::SCHEMA, "kind" => "identity", "operation_owner_binding" => original},
156
- deadline: admission_deadline, limit: Client::LIMIT)
157
- return
158
- end
159
- @admission.receiver!(peer)
160
- receiver_peer = Atoms::ProtectedWorkspacePruneInput.freeze_value(JSON.parse(JSON.generate(peer)))
161
- deadline = frame["kind"] == "preview" ? admission_deadline : accepted_at + 300
162
- context = nil
163
- @snapshots.call do |snapshot|
164
- # Reuse exact immutable event bytes only during this held read.
165
- # Peer admission remains fresh; neither the view nor its memo
166
- # survives into Installer effects or the next connection.
167
- Ace::Assign::Molecules::EvidenceJournal.with_event_read_operation do
168
- snapshot.with(deadline: deadline) do |view|
169
- context = if frame["kind"] == "preview"
170
- @admission.preview!(frame: frame, peer: peer, operation_owner_binding: original, journal: @journals.call(view))
171
- elsif frame["kind"] == "inspect"
172
- @admission.inspect!(frame: frame, peer: peer, operation_owner_binding: original, journal: @journals.call(view))
173
- else
174
- @admission.admit!(frame: frame, peer: peer, operation_owner_binding: original, journal: @journals.call(view))
175
- end
176
- end
177
- end
178
- end
179
- if frame["kind"] == "preview"
180
- unless @installer.respond_to?(:preview_cleanup!)
181
- raise SecurityError, "same Installer preview producer unavailable"
182
- end
183
- result = @installer.preview_cleanup!(context: context, receiver_peer: receiver_peer, deadline: deadline)
184
- unless Ace::Assign::Atoms::EvidenceDigest.digest(@observer.observe_self!(deadline: deadline)) ==
185
- Ace::Assign::Atoms::EvidenceDigest.digest(original)
186
- raise SecurityError, "cleanup original owner changed during preview"
187
- end
188
- result = Ace::Lab::Atoms::ProtectedWorkspacePrunePreview.result!(result,
189
- intent: frame.fetch("intent"), context: frame.fetch("maintenance_context"))
190
- Wire.write(socket, result, deadline: deadline, limit: Client::LIMIT)
191
- return
192
- end
193
- if frame["kind"] == "inspect"
194
- inspection(socket, frame, context, original, deadline, receiver_peer: receiver_peer)
195
- return
196
- end
197
- key = Ace::Assign::Atoms::EvidenceDigest.digest(frame)
198
- retained = @lock.synchronize do
199
- if (entry = @consumed[context.fetch("record").fetch("request_id")])
200
- raise SecurityError, "cleanup original invocation input differs" unless entry.fetch(:key) == key
201
- entry
202
- else
203
- raise Ace::Runtime::RuntimeUnavailableError, "cleanup lifetime capacity unavailable" if @consumed.size >= MAX_RETAINED
204
- @consumed[context.fetch("record").fetch("request_id")] = {key: key, state: :invoking}
205
- end
206
- end
207
- result = @lock.synchronize { retained[:result] }
208
- unless result
209
- authorized = @lock.synchronize do
210
- next false unless retained[:state] == :invoking && !retained[:inhibited]
211
- retained[:state] = :invoked
212
- true
213
- end
214
- raise Ace::Runtime::RuntimeUnavailableError, "cleanup original result unavailable" unless authorized
215
- begin
216
- # No canonical/source lock is held over fixed privileged work.
217
- unless @observer.observe_self!(deadline: [deadline, Wire.deadline(5)].min) == original
218
- raise SecurityError, "cleanup original owner changed before invocation"
219
- end
220
- result = @installer.execute_cleanup!(context: context, receiver_peer: receiver_peer, deadline: deadline)
221
- result = result!(result, frame)
222
- @lock.synchronize { retained[:result] = result; retained[:state] = :completed }
223
- ensure
224
- @lock.synchronize do
225
- if retained[:state] == :invoked
226
- retained[:state] = :unconfirmed
227
- retained[:inhibited] = true
228
- end
229
- @changed.broadcast
230
- end
231
- end
232
- end
233
- unless @observer.observe_self!(deadline: [deadline, Wire.deadline(5)].min) == original
234
- raise SecurityError, "cleanup original owner changed before result"
235
- end
236
- ref = result.fetch(:receipt_ref)
237
- Wire.write(socket, {"schema" => Client::SCHEMA, "kind" => "result", "request_id" => frame.fetch("request_id"),
238
- "input_digest" => frame.fetch("input_digest"), "receipt_ref" => ref}, deadline: deadline, limit: Client::LIMIT)
239
- descriptor = @codec.descriptor([result.fetch(:bytes)], purpose: :artifacts)
240
- @codec.send(socket, parts: [result.fetch(:bytes)], descriptor: descriptor, purpose: :artifacts, deadline: deadline)
241
- ensure
242
- socket.close unless socket.closed?
243
- end
244
-
245
- private
246
-
247
- def inspection(socket, frame, context, original, deadline, receiver_peer:)
248
- original_owner = Ace::Assign::Atoms::EvidenceDigest.digest(context.fetch("record").fetch("operation_owner_binding")) ==
249
- Ace::Assign::Atoms::EvidenceDigest.digest(original)
250
- if original_owner
251
- executable = frame.except("challenge_ref").merge("kind" => "execute")
252
- key = Ace::Assign::Atoms::EvidenceDigest.digest(executable)
253
- @lock.synchronize do
254
- request = context.fetch("record").fetch("request_id")
255
- retained = @consumed[request]
256
- if retained
257
- raise SecurityError, "cleanup original inspection input differs" unless retained.fetch(:key) == key
258
- else
259
- raise Ace::Runtime::RuntimeUnavailableError, "cleanup lifetime capacity unavailable" if @consumed.size >= MAX_RETAINED
260
- retained = @consumed[request] = {key: key, state: :inhibited}
261
- end
262
- retained[:inhibited] = true
263
- retained[:state] = :inhibited if retained[:state] == :invoking
264
- while retained[:state] == :invoked
265
- remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
266
- raise Ace::Runtime::RuntimeUnavailableError, "cleanup original effect remains unconfirmed" unless remaining.positive?
267
- @changed.wait(@lock, remaining)
268
- end
269
- end
270
- # This is callback inhibition only. Child/writer/physical absence
271
- # still requires the actual domain inspector's independent proof.
272
- inhibition = {"operation_owner_binding_digest" => frame.fetch("operation_owner_binding_digest"),
273
- "dispatch_event_digest" => frame.fetch("dispatch_event_digest"), "state" => "inhibited", "pending_effects" => 0}
274
- context = Atoms::ProtectedWorkspacePruneInput.freeze_value(context.merge("input_inhibition" => inhibition))
275
- end
276
- unless @observer.observe_self!(deadline: [deadline, Wire.deadline(5)].min) == original
277
- raise SecurityError, "cleanup readback owner changed before inspection"
278
- end
279
- inspected = @installer.inspect_cleanup!(context: context, receiver_peer: receiver_peer, deadline: deadline)
280
- unless inspected.is_a?(Hash) && inspected.keys.sort == %i[bytes kind receipt_ref] &&
281
- %w[succeeded failed-no-effect].include?(inspected[:kind])
282
- raise SecurityError, "cleanup inspection result unavailable"
283
- end
284
- kind = inspected.fetch(:kind).dup.freeze
285
- unless original_owner || kind == "succeeded"
286
- raise Ace::Runtime::RuntimeUnavailableError, "original cleanup lifetime exclusion unavailable"
287
- end
288
- result = result!(inspected.except(:kind), frame)
289
- unless @observer.observe_self!(deadline: [deadline, Wire.deadline(5)].min) == original
290
- raise SecurityError, "cleanup readback owner changed before inspection result"
291
- end
292
- success = kind == "succeeded"
293
- Wire.write(socket, {"schema" => Client::SCHEMA, "kind" => success ? "completed-result" : "inspection",
294
- "request_id" => frame.fetch("request_id"), "input_digest" => frame.fetch("input_digest"),
295
- (success ? "receipt_ref" : "inspection_ref") => result.fetch(:receipt_ref)}, deadline: deadline, limit: Client::LIMIT)
296
- descriptor = @codec.descriptor([result.fetch(:bytes)], purpose: :artifacts)
297
- @codec.send(socket, parts: [result.fetch(:bytes)], descriptor: descriptor, purpose: :artifacts, deadline: deadline)
298
- end
299
-
300
- def hold_parent!(parent)
301
- entries = []
302
- paths = []
303
- cursor = parent
304
- loop do
305
- paths << cursor
306
- break if cursor == "/"
307
- cursor = File.dirname(cursor)
308
- end
309
- paths.reverse_each do |path|
310
- handle = File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK)
311
- handle.close_on_exec = true
312
- entries << {path: path, handle: handle, identity: file_identity(handle.stat)}
313
- @protection.verify!(path, handle, directory: true)
314
- raise SecurityError, "cleanup parent changed while pinned" unless file_identity(File.lstat(path)) == entries.last.fetch(:identity)
315
- end
316
- complete = true
317
- entries
318
- ensure
319
- entries&.each { |entry| entry.fetch(:handle).close } unless complete
320
- end
321
-
322
- def verify_parents!(entries)
323
- entries.each do |entry|
324
- unless file_identity(entry.fetch(:handle).stat) == entry.fetch(:identity) &&
325
- file_identity(File.lstat(entry.fetch(:path))) == entry.fetch(:identity)
326
- raise SecurityError, "cleanup protected ancestry changed"
327
- end
328
- end
329
- end
330
-
331
- def file_identity(stat) = [stat.dev, stat.ino, stat.uid, stat.gid, stat.mode]
332
-
333
- def result!(result, frame)
334
- unless result.is_a?(Hash) && result.keys.sort == %i[bytes receipt_ref] && result.fetch(:bytes).is_a?(String)
335
- raise SecurityError, "cleanup fixed result unavailable"
336
- end
337
- ref = result.fetch(:receipt_ref)
338
- Atoms::ProtectedWorkspacePruneInput.reference!(ref)
339
- bytes = result.fetch(:bytes)
340
- unless bytes.bytesize == ref.fetch("bytes") && Digest::SHA256.hexdigest(bytes) == ref.fetch("sha256")
341
- raise SecurityError, "cleanup fixed result bytes differ"
342
- end
343
- result = {receipt_ref: JSON.parse(JSON.generate(ref)), bytes: bytes.dup}
344
- Atoms::ProtectedWorkspacePruneInput.freeze_value(result)
345
- end
346
-
347
- def eof!(socket, deadline)
348
- guarded = Wire::Ingress.new(socket)
349
- loop do
350
- remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
351
- raise Ace::Runtime::RuntimeUnavailableError, "cleanup request deadline expired" unless remaining.positive? && IO.select([socket], nil, nil, remaining)
352
- value = guarded.read_nonblock(1, exception: false)
353
- next if value == :wait_readable
354
- raise SecurityError, "cleanup request contains trailing bytes" unless value.nil?
355
- return
356
- end
357
- end
358
- end
359
- end
360
- end
361
- end
@@ -1,129 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "protected_service_listener"
4
-
5
- module Ace
6
- module Lab
7
- module Organisms
8
- # Worker-side short claim exchange. No local journal/executor fallback;
9
- # outcome/recovery remains the existing protected authority service API.
10
- class ProtectedServiceClient
11
- def initialize(mapping_id:, service_id:, deployment: Ace::Assign::Authority::Deployment.load,
12
- kernel: Ace::Runtime::Molecules::ProtectedLinux.new, wire: Ace::Runtime::Molecules::ProtectedSocket)
13
- @deployment, @kernel, @wire, @mapping_id = deployment, kernel, wire, mapping_id
14
- @map = deployment.mapping(mapping_id)
15
- project = deployment.project(@map.fetch("project_id"))
16
- @selection = project.fetch("service_receivers").fetch(service_id)
17
- @credentials = project.fetch("peer_credentials").fetch(@selection.fetch("executor_uid").to_s)
18
- @scratch = project.fetch("peer_credentials").fetch(@map.fetch("worker_uid").to_s).fetch("scratch_root")
19
- end
20
-
21
- def preview_workspace_prune(intent:)
22
- intent = Atoms::ProtectedWorkspacePrunePreview.intent!(JSON.parse(JSON.generate(intent)))
23
- bytes = JSON.generate(intent)
24
- deadline = @wire.deadline(5)
25
- @deployment.verify!(@mapping_id, kernel: @kernel)
26
- caller = @kernel.capture(Process.pid)
27
- unless caller.values_at("uid", "gid", "groups") == @map.values_at("worker_uid", "worker_gid", "worker_groups")
28
- raise SecurityError, "preview caller differs"
29
- end
30
- codec = Ace::Assign::Authority::TransferCodec.new(root: @scratch)
31
- descriptor = codec.descriptor([bytes], purpose: :service_input)
32
- path = @selection.fetch("socket_path")
33
- before = @wire.socket_identity(path)
34
- raise SecurityError, "preview receiver owner differs" unless before.last == @selection.fetch("executor_uid")
35
- @wire.connect(path, deadline: deadline) do |socket|
36
- peer = @kernel.peer(socket)
37
- unless peer.values_at("uid", "gid", "groups") == [@selection.fetch("executor_uid"), @credentials.fetch("gid"), @credentials.fetch("groups")] &&
38
- @wire.socket_identity(path) == before
39
- raise SecurityError, "preview receiver peer changed"
40
- end
41
- @wire.write(socket, {"version" => 1, "kind" => "preview_workspace_prune", "transfer" => descriptor},
42
- deadline: deadline, limit: ProtectedServiceListener::LIMIT)
43
- codec.send(socket, parts: [bytes], descriptor: descriptor, purpose: :service_input, deadline: deadline)
44
- socket.shutdown(Socket::SHUT_WR)
45
- guarded = Ace::Runtime::Molecules::ProtectedSocket::Ingress.new(socket)
46
- result = @wire.read(guarded, deadline: deadline, limit: ProtectedServiceListener::LIMIT)
47
- eof!(guarded, deadline)
48
- raise SecurityError, "preview result unavailable" unless result.is_a?(Hash) && result.key?("maintenance_context")
49
- Atoms::ProtectedWorkspacePrunePreview.context!(result.fetch("maintenance_context"))
50
- Atoms::ProtectedWorkspacePrunePreview.result!(result, intent: intent, context: result.fetch("maintenance_context"))
51
- end
52
- end
53
-
54
- def submit(submission:, input_bytes:, mutation_id:)
55
- unless submission.is_a?(Hash) && submission.keys.sort == ProtectedServiceReceiver::SUBMISSION.sort &&
56
- input_bytes.is_a?(String) && mutation_id.is_a?(String) && mutation_id.match?(Ace::Assign::Molecules::JournalMutation::ID)
57
- raise ArgumentError, "receiver submission is invalid"
58
- end
59
- submission = JSON.parse(JSON.generate(submission))
60
- bytes = input_bytes.dup.freeze
61
- mutation_id = mutation_id.dup.freeze
62
- deadline = @wire.deadline(5)
63
- @deployment.verify!(@mapping_id, kernel: @kernel)
64
- caller = @kernel.capture(Process.pid)
65
- unless caller.values_at("uid", "gid", "groups") == @map.values_at("worker_uid", "worker_gid", "worker_groups")
66
- raise SecurityError, "receiver caller differs"
67
- end
68
- codec = Ace::Assign::Authority::TransferCodec.new(root: @scratch)
69
- descriptor = codec.descriptor([bytes], purpose: :service_input)
70
- path = @selection.fetch("socket_path")
71
- before = @wire.socket_identity(path)
72
- raise SecurityError, "receiver endpoint owner differs" unless before.last == @selection.fetch("executor_uid")
73
- @wire.connect(path, deadline: deadline) do |socket|
74
- peer = @kernel.peer(socket)
75
- unless peer.values_at("uid", "gid", "groups") == [@selection.fetch("executor_uid"), @credentials.fetch("gid"), @credentials.fetch("groups")] &&
76
- @wire.socket_identity(path) == before
77
- raise SecurityError, "receiver endpoint peer changed"
78
- end
79
- @wire.write(socket, {"version" => 1, "submission" => submission,
80
- "mutation_id" => mutation_id, "transfer" => descriptor}, deadline: deadline, limit: ProtectedServiceListener::LIMIT)
81
- codec.send(socket, parts: [bytes], descriptor: descriptor, purpose: :service_input, deadline: deadline)
82
- socket.shutdown(Socket::SHUT_WR)
83
- guarded = Ace::Runtime::Molecules::ProtectedSocket::Ingress.new(socket)
84
- reply = @wire.read(guarded, deadline: deadline, limit: ProtectedServiceListener::LIMIT)
85
- eof!(guarded, deadline)
86
- validate_reply!(reply, submission.fetch("request_id"))
87
- reply
88
- end
89
- rescue Ace::Runtime::RuntimeUnavailableError, IOError, SystemCallError, Timeout::Error
90
- {"version" => 1, "type" => "service_claim_unconfirmed", "required_action" => "inspect_canonical_service_status"}
91
- end
92
-
93
- private
94
-
95
- def eof!(socket, deadline)
96
- loop do
97
- remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
98
- unless remaining.positive? && IO.select([socket], nil, nil, remaining)
99
- raise Ace::Runtime::RuntimeUnavailableError, "receiver reply EOF deadline expired"
100
- end
101
- byte = socket.read_nonblock(1, exception: false)
102
- next if byte == :wait_readable
103
- raise SecurityError, "receiver reply has trailing bytes" unless byte.nil?
104
- return
105
- end
106
- end
107
-
108
- def validate_reply!(reply, request_id)
109
- unless reply.is_a?(Hash) && reply["version"].is_a?(Integer) && reply["version"] == 1
110
- raise SecurityError, "receiver claim reply is invalid"
111
- end
112
- if reply["type"] == "service_claim_refused"
113
- raise SecurityError, "receiver claim refusal is invalid" unless reply.keys.sort == %w[code type version] && %w[busy unavailable].include?(reply["code"])
114
- return
115
- end
116
- data = reply["data"]
117
- unless reply.keys.sort == %w[data type version] && reply["type"] == "service_claim_accepted" &&
118
- data.is_a?(Hash) && (data.keys - %w[request_id generation journal_commit state claim replayed]).empty? &&
119
- data["request_id"] == request_id && data["generation"].is_a?(Integer) && data["generation"].positive? &&
120
- data["journal_commit"].is_a?(String) && data["journal_commit"].match?(/\A(?:[0-9a-f]{40}|[0-9a-f]{64})\z/) &&
121
- %w[uncertain succeeded failed failed-settled].include?(data["state"]) && [true, false].include?(data["replayed"]) &&
122
- (!data.key?("claim") || %w[created retained].include?(data["claim"]))
123
- raise SecurityError, "receiver canonical claim identity differs"
124
- end
125
- end
126
- end
127
- end
128
- end
129
- end
@@ -1,181 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "socket"
4
- require_relative "protected_service_worker"
5
-
6
- module Ace
7
- module Lab
8
- module Organisms
9
- # Fixed receiver ingress. The short connection observes canonical claim
10
- # acceptance; it does not own or cancel the admitted execution lifetime.
11
- class ProtectedServiceListener
12
- LIMIT = 16_384
13
- REQUEST_KEYS = %w[mutation_id submission transfer version].freeze
14
-
15
- def initialize(mapping_id:, service_id:, deployment: Ace::Assign::Authority::Deployment.load,
16
- kernel: Ace::Runtime::Molecules::ProtectedLinux.new, receiver: nil,
17
- wire: Ace::Runtime::Molecules::ProtectedSocket)
18
- @deployment, @kernel, @wire = deployment, kernel, wire
19
- @map = deployment.mapping(mapping_id)
20
- project = deployment.project(@map.fetch("project_id"))
21
- @selection = project.fetch("service_receivers").fetch(service_id)
22
- @credentials = project.fetch("peer_credentials").fetch(@selection.fetch("executor_uid").to_s)
23
- @path = @selection.fetch("socket_path")
24
- @receiver = receiver || ProtectedServiceReceiver.new(
25
- mapping_id: mapping_id, service_id: service_id, deployment: deployment, kernel: kernel)
26
- @worker = ProtectedServiceWorker.new(receiver: @receiver)
27
- @stopping = false
28
- end
29
-
30
- def serve
31
- own_principal!
32
- parent = File.dirname(@path)
33
- @wire.root_path!(parent, directory: true, owner: @selection.fetch("executor_uid"))
34
- # The installed receiver must actually be able to publish a socket
35
- # accessible to its mapped worker. No ambient ACL/root fixup exists.
36
- unless @credentials.fetch("gid") == @map.fetch("worker_gid") || @credentials.fetch("groups").include?(@map.fetch("worker_gid"))
37
- raise SecurityError, "receiver socket group is unavailable"
38
- end
39
- @lock = File.open("#{@path}.lock", File::RDWR | File::CREAT | File::NOFOLLOW, 0o600)
40
- stat = @lock.stat
41
- unless stat.file? && stat.uid == Process.uid && stat.nlink == 1 && (stat.mode & 0o7777) == 0o600
42
- raise SecurityError, "receiver listener lock differs"
43
- end
44
- unless @lock.flock(File::LOCK_EX | File::LOCK_NB)
45
- raise Ace::Assign::AttemptErrors::Conflict, "receiver listener already active"
46
- end
47
- remove_stale_endpoint!
48
- @listener = UNIXServer.new(@path)
49
- File.chown(nil, @map.fetch("worker_gid"), @path)
50
- File.chmod(0o660, @path)
51
- @identity = @wire.socket_identity(@path)
52
- until @stopping
53
- begin
54
- socket = @listener.accept
55
- receive(socket)
56
- ensure
57
- socket&.close
58
- socket = nil
59
- end
60
- end
61
- rescue IOError, Errno::EBADF
62
- raise unless @stopping
63
- ensure
64
- # Never release listener exclusion while an admitted worker exists.
65
- # Actual operation transport supplies its separate bounded lifetime.
66
- @worker.close(timeout: 1) until @worker.close(timeout: 0)
67
- @listener&.close
68
- remove_owned_endpoint!
69
- @lock&.close
70
- end
71
-
72
- def stop
73
- @stopping = true
74
- @worker.close(timeout: 0)
75
- @listener&.close
76
- end
77
-
78
- private
79
-
80
- def receive(socket)
81
- deadline = @wire.deadline(5)
82
- peer = @kernel.peer(socket)
83
- unless peer.values_at("uid", "gid", "groups") == @map.values_at("worker_uid", "worker_gid", "worker_groups")
84
- raise SecurityError, "receiver worker peer differs"
85
- end
86
- guarded = Ace::Runtime::Molecules::ProtectedSocket::Ingress.new(socket)
87
- request = @wire.read(guarded, deadline: deadline, limit: LIMIT)
88
- if request.is_a?(Hash) && request["kind"] == "preview_workspace_prune"
89
- unless request.keys.sort == %w[kind transfer version] && request["version"].is_a?(Integer) && request["version"] == 1
90
- raise ArgumentError, "receiver preview header differs"
91
- end
92
- codec = Ace::Assign::Authority::TransferCodec.new(root: @selection.fetch("staging_root"))
93
- bytes = codec.receive(guarded, descriptor: request.fetch("transfer"), purpose: :service_input, deadline: deadline) do |input|
94
- raise ArgumentError, "preview requires one intent part" unless input.count == 1
95
- input.bytes
96
- end
97
- intent = JSON.parse(bytes, create_additions: false, max_nesting: 16, allow_nan: false,
98
- allow_comments: false, allow_duplicate_key: false)
99
- intent = Atoms::ProtectedWorkspacePrunePreview.intent!(intent)
100
- result = @receiver.preview_workspace_prune(intent: intent, peer: peer, deadline: deadline)
101
- @wire.write(socket, result, deadline: deadline, limit: LIMIT)
102
- return
103
- end
104
- strict_request!(request)
105
- codec = Ace::Assign::Authority::TransferCodec.new(root: @selection.fetch("staging_root"))
106
- bytes = codec.receive(guarded, descriptor: request.fetch("transfer"), purpose: :service_input, deadline: deadline) { |input| input.bytes }
107
- completed = Queue.new
108
- @worker.start(submission: request.fetch("submission"), peer: peer,
109
- input_bytes: bytes, mutation_id: request.fetch("mutation_id"), deadline: deadline,
110
- on_claim: lambda do |identity|
111
- begin
112
- @wire.write(socket, {"version" => 1, "type" => "service_claim_accepted", "data" => identity},
113
- deadline: deadline, limit: LIMIT)
114
- rescue Ace::Runtime::RuntimeUnavailableError, IOError, SystemCallError
115
- # Only bounded transport failure is swallowed. Original work
116
- # continues and its accepted identity is observed via status.
117
- nil
118
- ensure
119
- completed << true
120
- end
121
- end)
122
- remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
123
- completed.pop(timeout: remaining) if remaining.positive?
124
- rescue Ace::Assign::AttemptErrors::Conflict
125
- refuse(socket, "busy", deadline)
126
- rescue SecurityError, ArgumentError, KeyError, TypeError, Ace::Assign::Error,
127
- Ace::Runtime::RuntimeUnavailableError, IOError, SystemCallError, Timeout::Error
128
- refuse(socket, "unavailable", deadline)
129
- end
130
-
131
- def strict_request!(request)
132
- unless request.is_a?(Hash) && request.keys.sort == REQUEST_KEYS &&
133
- request["version"].is_a?(Integer) && request["version"] == 1 &&
134
- request["transfer"].is_a?(Hash) && request["transfer"]["version"].is_a?(Integer) && request["transfer"]["version"] == 1 &&
135
- request["mutation_id"].is_a?(String) && request["mutation_id"].match?(Ace::Assign::Molecules::JournalMutation::ID) &&
136
- request["submission"].is_a?(Hash) && request["submission"].keys.sort == ProtectedServiceReceiver::SUBMISSION.sort
137
- raise ArgumentError, "receiver request is invalid"
138
- end
139
- walk = [[request, 0]]
140
- until walk.empty?
141
- value, depth = walk.pop
142
- raise ArgumentError, "receiver request depth exceeds bound" if depth > 16
143
- children = value.is_a?(Hash) ? value.values : (value.is_a?(Array) ? value : [])
144
- children.each { |child| walk << [child, depth + 1] }
145
- end
146
- end
147
-
148
- def refuse(socket, code, deadline)
149
- @wire.write(socket, {"version" => 1, "type" => "service_claim_refused", "code" => code}, deadline: deadline, limit: LIMIT)
150
- rescue Ace::Runtime::RuntimeUnavailableError, IOError, SystemCallError
151
- nil
152
- end
153
-
154
- def own_principal!
155
- me = @kernel.capture(Process.pid)
156
- unless me.values_at("uid", "gid", "groups") == [@selection.fetch("executor_uid"), @credentials.fetch("gid"), @credentials.fetch("groups")]
157
- raise SecurityError, "receiver listener principal differs"
158
- end
159
- @kernel.live!(me)
160
- end
161
-
162
- def remove_stale_endpoint!
163
- stat = File.lstat(@path)
164
- unless stat.socket? && stat.uid == Process.uid
165
- raise SecurityError, "receiver endpoint differs"
166
- end
167
- File.unlink(@path)
168
- rescue Errno::ENOENT
169
- nil
170
- end
171
-
172
- def remove_owned_endpoint!
173
- File.unlink(@path) if @identity && @wire.socket_identity(@path) == @identity
174
- rescue Errno::ENOENT
175
- nil
176
- end
177
-
178
- end
179
- end
180
- end
181
- end