ace-assign 0.63.1 → 0.64.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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +2 -2
  3. data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
  4. data/.ace-defaults/assign/catalog/steps/release-minor.step.yml +4 -2
  5. data/.ace-defaults/assign/catalog/steps/release.step.yml +4 -2
  6. data/.ace-defaults/assign/catalog/steps/update-pr-desc.step.yml +1 -1
  7. data/.ace-defaults/assign/presets/fix-bug.yml +15 -3
  8. data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +16 -4
  9. data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +15 -3
  10. data/.ace-defaults/assign/presets/work-on-task.yml +16 -4
  11. data/CHANGELOG.md +43 -0
  12. data/docs/demo/canonical-skill-source.tape.yml +4 -4
  13. data/docs/protected-authority.md +266 -0
  14. data/docs/usage.md +91 -2
  15. data/handbook/workflow-instructions/assign/drive.wf.md +2 -2
  16. data/handbook/workflow-instructions/assign/prepare.wf.md +2 -2
  17. data/lib/ace/assign/atoms/delivery_parameters.rb +54 -0
  18. data/lib/ace/assign/authority/candidate_transfer.rb +160 -0
  19. data/lib/ace/assign/authority/client.rb +107 -0
  20. data/lib/ace/assign/authority/deployment.rb +465 -0
  21. data/lib/ace/assign/authority/endcap.rb +342 -0
  22. data/lib/ace/assign/authority/endcap_inboxes.rb +314 -0
  23. data/lib/ace/assign/authority/endcap_results.rb +380 -0
  24. data/lib/ace/assign/authority/endcap_services.rb +395 -0
  25. data/lib/ace/assign/authority/evidence_transfer.rb +67 -0
  26. data/lib/ace/assign/authority/execution_scope_observation.rb +476 -0
  27. data/lib/ace/assign/authority/launch_driver.rb +114 -0
  28. data/lib/ace/assign/authority/launch_lifecycle.rb +822 -0
  29. data/lib/ace/assign/authority/launch_scope_abort.rb +88 -0
  30. data/lib/ace/assign/authority/launch_scope_admission.rb +84 -0
  31. data/lib/ace/assign/authority/launch_scope_close.rb +115 -0
  32. data/lib/ace/assign/authority/launch_scope_parent.rb +44 -0
  33. data/lib/ace/assign/authority/launch_scope_release.rb +180 -0
  34. data/lib/ace/assign/authority/posix_acl.rb +56 -0
  35. data/lib/ace/assign/authority/private_directory.rb +33 -0
  36. data/lib/ace/assign/authority/receipt_transfer.rb +48 -0
  37. data/lib/ace/assign/authority/router.rb +54 -0
  38. data/lib/ace/assign/authority/server.rb +230 -0
  39. data/lib/ace/assign/authority/service_evidence.rb +55 -0
  40. data/lib/ace/assign/authority/transfer_codec.rb +155 -0
  41. data/lib/ace/assign/cli/commands/authority/launch.rb +45 -0
  42. data/lib/ace/assign/cli/commands/authority/serve.rb +30 -0
  43. data/lib/ace/assign/cli/commands/authority/status.rb +26 -0
  44. data/lib/ace/assign/cli/commands/authority/terminate.rb +29 -0
  45. data/lib/ace/assign/cli/commands/create.rb +9 -1
  46. data/lib/ace/assign/cli/commands/delivery.rb +57 -0
  47. data/lib/ace/assign/cli/commands/inbox_reconcile.rb +36 -0
  48. data/lib/ace/assign/cli/commands/resume.rb +26 -0
  49. data/lib/ace/assign/cli.rb +22 -1
  50. data/lib/ace/assign/models/evidence_event.rb +3 -1
  51. data/lib/ace/assign/molecules/attempt_reconciler.rb +31 -12
  52. data/lib/ace/assign/molecules/canonical_evidence.rb +150 -0
  53. data/lib/ace/assign/molecules/evidence_journal.rb +218 -63
  54. data/lib/ace/assign/molecules/execution_identity_resolver.rb +49 -9
  55. data/lib/ace/assign/molecules/execution_scope_lineage.rb +327 -0
  56. data/lib/ace/assign/molecules/journal_mutation.rb +202 -0
  57. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +4 -0
  58. data/lib/ace/assign/molecules/proposal_journal.rb +151 -0
  59. data/lib/ace/assign/molecules/receipt_verifier.rb +49 -19
  60. data/lib/ace/assign/organisms/assignment_executor.rb +6 -0
  61. data/lib/ace/assign/organisms/attempt_coordinator.rb +312 -13
  62. data/lib/ace/assign/organisms/delivery_coordinator.rb +342 -0
  63. data/lib/ace/assign/organisms/task_assignment_creator.rb +8 -3
  64. data/lib/ace/assign/version.rb +1 -1
  65. data/lib/ace/assign.rb +13 -0
  66. metadata +107 -8
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "digest"
5
+
6
+ module Ace
7
+ module Assign
8
+ module Authority
9
+ # Receipt-first binary framing. Field names come from source operations,
10
+ # never from the wire. Artifact order and declared digests are exact.
11
+ module ReceiptTransfer
12
+ def self.decode(input:, receipt_sha256:, artifact_field:, reference_key:)
13
+ unless input && input.count.between?(1, 17) && %w[artifacts evidence].include?(artifact_field) &&
14
+ %w[path ref].include?(reference_key) && receipt_sha256.is_a?(String) && receipt_sha256.match?(/\A[0-9a-f]{64}\z/)
15
+ raise AttemptErrors::ReceiptRejected, "receipt transfer binding is invalid"
16
+ end
17
+ bytes = input.bytes(index: 0)
18
+ unless bytes.bytesize.between?(1, 16 * 1024) && Digest::SHA256.hexdigest(bytes) == receipt_sha256
19
+ raise AttemptErrors::ReceiptRejected, "first transfer part must be the exact receipt"
20
+ end
21
+ receipt = JSON.parse(bytes)
22
+ unless receipt.is_a?(Hash) && !Models::ExecutionReceipt.forbidden_field?(receipt)
23
+ raise AttemptErrors::ReceiptRejected, "receipt must contain bounded structured evidence"
24
+ end
25
+ references = receipt.fetch(artifact_field)
26
+ unless references.is_a?(Array) && references.length == input.count - 1 && references.length <= 16 &&
27
+ references.all? { |entry| entry.is_a?(Hash) && entry.keys.sort == [reference_key, "sha256"].sort &&
28
+ entry[reference_key].is_a?(String) && entry[reference_key].bytesize.between?(1, 256) &&
29
+ entry["sha256"].is_a?(String) && entry["sha256"].match?(/\A[0-9a-f]{64}\z/) } &&
30
+ references.map { |entry| entry[reference_key] }.uniq.length == references.length
31
+ raise AttemptErrors::ReceiptRejected, "receipt artifact parts are incomplete or duplicated"
32
+ end
33
+ artifacts = references.each_with_index.map do |reference, index|
34
+ artifact = input.bytes(index: index + 1)
35
+ unless artifact.bytesize <= 64 * 1024 && Digest::SHA256.hexdigest(artifact) == reference.fetch("sha256")
36
+ raise AttemptErrors::ReceiptRejected, "receipt artifact part differs from declaration"
37
+ end
38
+ artifact
39
+ end
40
+ raise AttemptErrors::ReceiptRejected, "receipt evidence exceeds its transfer limit" if artifacts.sum(&:bytesize) > 256 * 1024
41
+ {receipt: receipt, artifacts: artifacts, receipt_sha256: receipt_sha256}
42
+ rescue JSON::ParserError, KeyError
43
+ raise AttemptErrors::ReceiptRejected, "receipt transfer schema is invalid"
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+ module Ace
3
+ module Assign
4
+ module Authority
5
+ # Source-owned handlers share one listener. Requests never name a method.
6
+ class Router
7
+ def initialize(launch:, handlers: [])
8
+ @launch = launch
9
+ @routes = {}
10
+ ([launch] + handlers).each do |handler|
11
+ handler.class::OPERATIONS.each do |operation|
12
+ raise ArgumentError, "duplicate authority operation" if @routes.key?(operation)
13
+ @routes[operation] = handler
14
+ end
15
+ end
16
+ @routes.freeze
17
+ end
18
+
19
+ def dispatch(request:, peer:, role:, transfer: nil)
20
+ handler = @routes.fetch(request.fetch("operation")) { raise ArgumentError, "unknown authority operation" }
21
+ options = {request: request, peer: peer, role: role}
22
+ options[:transfer] = transfer if transfer
23
+ handler.dispatch(**options)
24
+ end
25
+
26
+ def transfer_binding(operation)
27
+ handler = @routes.fetch(operation) { raise ArgumentError, "unknown authority operation" }
28
+ return nil unless handler.class.const_defined?(:TRANSFER_OPERATIONS, false)
29
+ binding = handler.class::TRANSFER_OPERATIONS[operation]
30
+ return nil unless binding
31
+ unless binding.is_a?(Hash) && binding.keys.sort == %i[direction purpose roles] &&
32
+ %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) }
34
+ raise ArgumentError, "invalid source transfer binding"
35
+ end
36
+ binding
37
+ end
38
+
39
+ def authorize_transfer!(request:, peer:, role:)
40
+ handler = @routes.fetch(request.fetch("operation"))
41
+ handler.authorize_transfer!(request: request, peer: peer, role: role)
42
+ end
43
+
44
+ def close
45
+ @launch.close
46
+ end
47
+
48
+ def gate_ready(**options)
49
+ @launch.gate_ready(**options)
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+ require "socket"
3
+ require "fileutils"
4
+ require_relative "deployment"
5
+ require_relative "transfer_codec"
6
+
7
+ module Ace
8
+ module Assign
9
+ module Authority
10
+ # Existing assignment owner transport; kernel peers select roles, never argv.
11
+ class Server
12
+ CONNECTIONS = 16
13
+ def initialize(authority_id:, lifecycle:, deployment: Deployment.load,
14
+ kernel: Ace::Runtime::Molecules::ProtectedLinux.new, composition: "launch")
15
+ @authority_id, @lifecycle, @deployment, @kernel = authority_id, lifecycle, deployment, kernel
16
+ @composition = composition
17
+ deployment.verify_composition!(authority_id, composition: composition)
18
+ @service = deployment.authority(authority_id)
19
+ @mutex = Mutex.new
20
+ @connections = []
21
+ @handlers = []
22
+ @transfers = 0
23
+ @stopped = false
24
+ end
25
+
26
+ def serve
27
+ @kernel.supported!
28
+ me = @kernel.capture(Process.pid)
29
+ unless principal?(me, @service, "uid", "gid", "groups")
30
+ raise AttemptErrors::UnauthorizedIdentity, "listener principal differs from installed authority"
31
+ end
32
+ @deployment.verify_receiver_paths!(@authority_id)
33
+ directory = File.dirname(@service.fetch("socket_path"))
34
+ wire.root_path!(directory, directory: true, owner: @service.fetch("uid"))
35
+ lock = File.open(File.join(directory, "listener.lock"), File::RDWR | File::CREAT | File::NOFOLLOW, 0o600)
36
+ unless lock.flock(File::LOCK_EX | File::LOCK_NB)
37
+ raise AttemptErrors::Conflict, "assignment authority already owns its listener"
38
+ end
39
+ path = @service.fetch("socket_path")
40
+ if File.exist?(path) || File.symlink?(path)
41
+ raise AttemptErrors::Conflict, "existing authority endpoint requires verified installer recovery"
42
+ end
43
+ @listener = UNIXServer.new(path)
44
+ File.chmod(0o660, path)
45
+ @endpoint = wire.socket_identity(path)
46
+ until @stopped
47
+ ready = IO.select([@listener], nil, nil, 0.25)
48
+ next unless ready
49
+ socket = @listener.accept_nonblock(exception: false)
50
+ next if socket == :wait_readable
51
+ admitted = @mutex.synchronize do
52
+ if @connections.size < CONNECTIONS
53
+ @connections << socket
54
+ true
55
+ end
56
+ end
57
+ unless admitted
58
+ refusal(socket, "busy")
59
+ socket.close
60
+ next
61
+ end
62
+ @mutex.synchronize do
63
+ @handlers.reject! { |handler| !handler.alive? }
64
+ @handlers << Thread.new(socket) { |connection| receive(connection) }
65
+ end
66
+ end
67
+ rescue IOError, Errno::EBADF
68
+ raise unless @stopped
69
+ ensure
70
+ stop
71
+ # Closing a stream does not cancel a handler already inside dispatch.
72
+ # The owner lock and endpoint remain held until every handler ends.
73
+ @mutex.synchronize { @handlers.dup }.each(&:join)
74
+ @lifecycle.close if @endpoint && @lifecycle.respond_to?(:close)
75
+ if @endpoint && same_endpoint?(path)
76
+ File.unlink(path)
77
+ end
78
+ lock&.close
79
+ end
80
+
81
+ def request_stop
82
+ @stopped = true
83
+ end
84
+
85
+ def stop
86
+ @stopped = true
87
+ @listener&.close unless @listener&.closed?
88
+ @mutex.synchronize { @connections.each { |socket| socket.close unless socket.closed? } }
89
+ end
90
+
91
+ private
92
+
93
+ def same_endpoint?(path)
94
+ wire.socket_identity(path) == @endpoint
95
+ rescue StandardError
96
+ false
97
+ end
98
+
99
+ def receive(socket)
100
+ deadline = wire.deadline(30)
101
+ peer = @kernel.peer(socket)
102
+ frame = wire.read(socket, deadline: [deadline, wire.deadline(5)].min, with_size: true)
103
+ request = frame.fetch(:data)
104
+ 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)
106
+ raise ArgumentError, "invalid authority envelope"
107
+ end
108
+ deadline = wire.deadline(90) if %w[reserve_attempt close_execution_scope].include?(request.fetch("operation"))
109
+ params = request.fetch("params")
110
+ map = @deployment.verify!(params.fetch("mapping_id"), kernel: @kernel, authority_state: true)
111
+ unless map["authority_id"] == @authority_id && map["project_id"] == request["project_id"]
112
+ raise AttemptErrors::UnauthorizedIdentity, "mapping belongs to another authority or project"
113
+ end
114
+ role = if principal?(peer, map, "launcher_uid", "launcher_gid", "launcher_groups")
115
+ :launcher
116
+ elsif principal?(peer, map, "worker_uid", "worker_gid", "worker_groups")
117
+ :worker
118
+ end
119
+ unless role
120
+ project = @deployment.project(map.fetch("project_id"))
121
+ configured = project.fetch("peer_credentials")[peer.fetch("uid").to_s]
122
+ if configured && peer["gid"] == configured["gid"] && peer["groups"] == configured["groups"]
123
+ {reviewer: "reviewer_uids", executor: "service_executor_uids", supervisor: "supervisor_uids"}.each do |candidate, key|
124
+ role ||= candidate if project.fetch(key).include?(peer.fetch("uid"))
125
+ end
126
+ end
127
+ end
128
+ 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"])
131
+ bodyless_read!(socket, deadline)
132
+ end
133
+ if request["operation"] == "gate_ready"
134
+ raise AttemptErrors::UnauthorizedIdentity, "gate readiness requires worker peer" unless role == :worker
135
+ @lifecycle.gate_ready(request: request, peer: peer, socket: socket, deadline: deadline)
136
+ 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
146
+ 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)
153
+ end
154
+ else
155
+ 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
+ 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)
168
+ end
169
+ end
170
+ rescue ArgumentError, KeyError, AttemptErrors::MalformedTransfer
171
+ refusal(socket, "invalid_input")
172
+ rescue AttemptErrors::UnauthorizedIdentity
173
+ refusal(socket, "unauthorized")
174
+ rescue AttemptErrors::Conflict
175
+ refusal(socket, "conflict")
176
+ rescue AttemptErrors::NotFound
177
+ refusal(socket, "missing")
178
+ rescue StandardError
179
+ refusal(socket, "evidence_unavailable")
180
+ ensure
181
+ socket.close unless socket.closed?
182
+ @mutex.synchronize do
183
+ @connections.delete(socket)
184
+ @transfers -= 1 if admitted_transfer
185
+ end
186
+ end
187
+
188
+ # These closed read operations have no request body. Half-close proves
189
+ # framing completion before status or artifact data can be exposed.
190
+ def bodyless_read!(socket, deadline)
191
+ loop do
192
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
193
+ raise ArgumentError, "read request framing is incomplete" unless remaining.positive? && IO.select([socket], nil, nil, remaining)
194
+ bytes = socket.read_nonblock(1, exception: false)
195
+ next if bytes == :wait_readable
196
+ raise ArgumentError, "unexpected read request body" unless bytes.nil?
197
+ return
198
+ end
199
+ end
200
+
201
+ def transfer_codec
202
+ @mutex.synchronize do
203
+ @transfer_codec ||= begin
204
+ root = @service.fetch("state_root")
205
+ PrivateDirectory.verify!(root)
206
+ spool = File.join(root, "transfers")
207
+ Dir.mkdir(spool, 0o700) unless File.exist?(spool) || File.symlink?(spool)
208
+ TransferCodec.new(root: spool)
209
+ end
210
+ end
211
+ end
212
+
213
+ def principal?(identity, mapping, uid, gid, groups)
214
+ identity["uid"] == mapping[uid] && identity["gid"] == mapping[gid] && identity["groups"] == mapping[groups]
215
+ end
216
+
217
+ def refusal(socket, code)
218
+ wire.write(socket, {"status" => "error", "error" => {"code" => code, "message" => "Protected authority refused #{code}"}},
219
+ deadline: wire.deadline(0.1))
220
+ rescue StandardError
221
+ nil
222
+ end
223
+
224
+ def wire
225
+ Ace::Runtime::Molecules::ProtectedSocket
226
+ end
227
+ end
228
+ end
229
+ end
230
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../molecules/canonical_evidence"
4
+
5
+ module Ace
6
+ module Assign
7
+ module Authority
8
+ # One source-owned context for initial and retained service evidence.
9
+ # The journal supplies the record and transient pending view; callers
10
+ # never supply an alternate reader, artifact path or trusted flag.
11
+ class ServiceEvidence
12
+ FIELDS = (Molecules::EvidenceJournal::TERMINAL_BINDING_FIELDS +
13
+ %w[dispatch_ticket_id claim_binding candidate_generation claim_generation policy_digest]).freeze
14
+
15
+ def initialize(journal:)
16
+ @journal = journal
17
+ @canonical = Molecules::CanonicalEvidence.new(journal: journal)
18
+ end
19
+
20
+ def context(record, no_effect: false)
21
+ binding = FIELDS.to_h { |key| [key, record.fetch(key)] }
22
+ unless %w[claim_binding policy_digest].all? { |key| binding[key].is_a?(String) && binding[key].match?(/\A[0-9a-f]{64}\z/) } &&
23
+ %w[candidate_generation claim_generation].all? { |key| binding[key].is_a?(Integer) && binding[key].positive? } &&
24
+ binding["dispatch_ticket_id"].is_a?(String) && binding["dispatch_ticket_id"].match?(Molecules::JournalMutation::ID)
25
+ raise AttemptErrors::EvidenceUnavailable, "canonical dispatch binding is invalid"
26
+ end
27
+ if no_effect
28
+ binding.merge!(%w[no_effect_challenge challenge_generation challenge_event_digest].to_h do |key|
29
+ [key, record.fetch(key)]
30
+ end)
31
+ unless binding["no_effect_challenge"].is_a?(String) && !binding["no_effect_challenge"].empty? &&
32
+ binding["challenge_generation"].is_a?(Integer) && binding["challenge_generation"].positive? &&
33
+ binding["challenge_event_digest"].is_a?(String) && binding["challenge_event_digest"].match?(/\A[0-9a-f]{64}\z/)
34
+ raise AttemptErrors::EvidenceUnavailable, "canonical no-effect challenge is invalid"
35
+ end
36
+ end
37
+ {kind: "service", project_id: record.fetch("project_id"), assignment_id: record.fetch("assignment_id"),
38
+ attempt_id: record.fetch("attempt_id"), peer_uid: record.fetch("executor_uid"), binding: binding,
39
+ request_id_or_event_id: record.fetch("request_id"), generation: record.fetch("claim_generation")}
40
+ rescue KeyError
41
+ raise AttemptErrors::EvidenceUnavailable, "canonical service evidence binding is incomplete"
42
+ end
43
+
44
+ def call(reference, record, state, pending)
45
+ expected = context(record, no_effect: state == "failed-settled")
46
+ if pending && pending[:pending_events]
47
+ @canonical.read_pending(reference, **expected, **pending.slice(:current_events, :pending_events, :blobs, :commit))
48
+ else
49
+ @canonical.read(reference, **expected, commit: pending && pending[:commit] || @journal.ref_value)
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "tempfile"
5
+ require "timeout"
6
+ require_relative "private_directory"
7
+
8
+ module Ace
9
+ module Assign
10
+ module Authority
11
+ # Binary framing after the existing authenticated control header. The
12
+ # Server selects purpose from a source handler's fixed operation table,
13
+ # admits a transfer slot before calling, and holds no journal lock here.
14
+ class TransferCodec
15
+ LIMITS = {candidate: [64 * 1024 * 1024, 1, 64 * 1024 * 1024],
16
+ artifacts: [256 * 1024, 16, 64 * 1024],
17
+ receipt_artifacts: [272 * 1024, 17, 64 * 1024],
18
+ service_input: [64 * 1024, 1, 64 * 1024],
19
+ inbox_proof: [32 * 1024, 2, 16 * 1024]}.freeze
20
+ SHA256 = /\A[0-9a-f]{64}\z/
21
+
22
+ class Input
23
+ def initialize(file, descriptor)
24
+ @file, @descriptor = file, descriptor
25
+ end
26
+
27
+ def bytes(index: 0)
28
+ unless index.is_a?(Integer) && index.between?(0, count - 1)
29
+ raise ArgumentError, "Invalid transferred part index"
30
+ end
31
+ part = @descriptor.fetch("parts").fetch(index)
32
+ offset = @descriptor.fetch("parts").take(index).sum { |entry| entry.fetch("bytes") }
33
+ @file.seek(offset)
34
+ bytes = (@file.read(part.fetch("bytes")) || "").b
35
+ unless bytes.bytesize == part["bytes"] && Digest::SHA256.hexdigest(bytes) == part["sha256"]
36
+ raise AttemptErrors::ReceiptRejected, "Transferred bytes no longer match accepted binding"
37
+ end
38
+ bytes
39
+ end
40
+
41
+ def count
42
+ @descriptor.fetch("parts").length
43
+ end
44
+ end
45
+
46
+ def initialize(root:)
47
+ @root = PrivateDirectory.verify!(root)
48
+ end
49
+
50
+ # Upload write-EOF is required before invoking the mutation consumer.
51
+ # Persistent launch gate connections never use this codec.
52
+ def receive(socket, descriptor:, purpose:, deadline:)
53
+ validate!(descriptor, purpose)
54
+ descriptor = descriptor.merge("parts" => descriptor.fetch("parts").map { |part| part.dup.freeze }.freeze).freeze
55
+ deadline = [deadline, Process.clock_gettime(Process::CLOCK_MONOTONIC) + 30].min
56
+ PrivateDirectory.verify!(@root)
57
+ Tempfile.create(["transfer-", ".bytes"], @root, binmode: true) do |file|
58
+ digest = Digest::SHA256.new
59
+ descriptor.fetch("parts").each do |part|
60
+ part_digest = Digest::SHA256.new
61
+ remaining = part.fetch("bytes")
62
+ while remaining.positive?
63
+ chunk = read(socket, [remaining, 65_536].min, deadline)
64
+ reject!("Transferred bytes ended early") if chunk.nil?
65
+ file.write(chunk)
66
+ digest.update(chunk)
67
+ part_digest.update(chunk)
68
+ remaining -= chunk.bytesize
69
+ end
70
+ reject!("Transferred part digest differs") unless part_digest.hexdigest == part["sha256"]
71
+ end
72
+ reject!("Transferred aggregate digest differs") unless digest.hexdigest == descriptor["sha256"]
73
+ reject!("Transferred bytes exceed declared size") unless read(socket, 1, deadline).nil?
74
+ file.flush
75
+ file.rewind
76
+ yield Input.new(file, descriptor)
77
+ end
78
+ end
79
+
80
+ def descriptor(parts, purpose:)
81
+ reject!("Invalid transfer parts") unless parts.is_a?(Array) && parts.all? { |part| part.is_a?(String) }
82
+ digest = Digest::SHA256.new
83
+ parts.each { |part| digest.update(part.b) }
84
+ result = {"version" => 1, "bytes" => parts.sum(&:bytesize), "sha256" => digest.hexdigest,
85
+ "parts" => parts.map { |part| {"bytes" => part.bytesize, "sha256" => Digest::SHA256.hexdigest(part.b)} }}
86
+ validate!(result, purpose)
87
+ result
88
+ end
89
+
90
+ # The caller writes the bounded JSON descriptor first, then these raw
91
+ # bytes. Both directions share the Server's original monotonic deadline.
92
+ def send(socket, parts:, descriptor:, purpose:, deadline:)
93
+ expected = self.descriptor(parts, purpose: purpose)
94
+ reject!("Export descriptor differs from bytes") unless expected == descriptor
95
+ deadline = [deadline, Process.clock_gettime(Process::CLOCK_MONOTONIC) + 30].min
96
+ parts.each do |part|
97
+ offset = 0
98
+ while offset < part.bytesize
99
+ wait(socket, deadline, write: true)
100
+ written = socket.write_nonblock(part.byteslice(offset, [65_536, part.bytesize - offset].min), exception: false)
101
+ offset += written unless written == :wait_writable
102
+ end
103
+ end
104
+ end
105
+
106
+ def validate!(descriptor, purpose)
107
+ total, count, each = LIMITS.fetch(purpose) { reject!("Unknown source transfer purpose") }
108
+ unless descriptor.is_a?(Hash) && descriptor.keys.sort == %w[bytes parts sha256 version] &&
109
+ descriptor["version"] == 1 && descriptor["bytes"].is_a?(Integer) &&
110
+ descriptor["bytes"].between?(0, total) && descriptor["sha256"].is_a?(String) && descriptor["sha256"].match?(SHA256) &&
111
+ descriptor["parts"].is_a?(Array) && descriptor["parts"].length.between?(1, count) &&
112
+ descriptor["parts"].all? { |part| part.is_a?(Hash) && part.keys.sort == %w[bytes sha256] &&
113
+ part["bytes"].is_a?(Integer) && part["bytes"].between?(0, each) &&
114
+ part["sha256"].is_a?(String) && part["sha256"].match?(SHA256) } &&
115
+ descriptor["bytes"] == descriptor["parts"].sum { |part| part["bytes"] } &&
116
+ (!%i[candidate service_input].include?(purpose) || descriptor["bytes"].positive?)
117
+ reject!("Invalid or oversized transfer descriptor")
118
+ end
119
+ if purpose == :inbox_proof &&
120
+ (descriptor.fetch("parts").length != 2 || descriptor.fetch("parts").any? { |part| part.fetch("bytes").zero? })
121
+ reject!("Inbox proof requires exactly two nonempty bounded parts")
122
+ end
123
+ if purpose == :receipt_artifacts
124
+ receipt, *evidence = descriptor.fetch("parts")
125
+ unless receipt.fetch("bytes").between?(1, 16 * 1024) && evidence.sum { |part| part.fetch("bytes") } <= 256 * 1024
126
+ reject!("Receipt or evidence exceeds its fixed transfer limit")
127
+ end
128
+ end
129
+ true
130
+ end
131
+
132
+ private
133
+
134
+ def read(socket, count, deadline)
135
+ loop do
136
+ wait(socket, deadline)
137
+ bytes = socket.read_nonblock(count, exception: false)
138
+ return bytes unless bytes == :wait_readable
139
+ end
140
+ end
141
+
142
+ def wait(socket, deadline, write: false)
143
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
144
+ raise Timeout::Error, "Transfer deadline exceeded" unless remaining.positive?
145
+ ready = IO.select(write ? nil : [socket], write ? [socket] : nil, nil, remaining)
146
+ raise Timeout::Error, "Transfer deadline exceeded" unless ready
147
+ end
148
+
149
+ def reject!(message)
150
+ raise AttemptErrors::MalformedTransfer, message
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+ require_relative "../../../authority/launch_driver"
4
+ module Ace
5
+ module Assign
6
+ module CLI
7
+ module Commands
8
+ module Authority
9
+ class Launch < Ace::Support::Cli::Command
10
+ desc "Launch one mapped native worker through the protected gate"
11
+ option :mapping, required: true, desc: "Installed launch mapping ID"
12
+ option :assignment, desc: "Managed assignment ID"
13
+ option :definition, desc: "Current managed assignment definition JSON file"
14
+ option :step, desc: "Assignment subtree scope"
15
+ option :base_head, desc: "Exact base commit SHA"
16
+ option :mutation, desc: "Stable invocation ID for safe replay"
17
+ option :dry_run, type: :boolean, default: false, desc: "Verify installed boundary without mutation or creation"
18
+ def call(**options)
19
+ driver = build_driver(options.fetch(:mapping))
20
+ result = if options[:dry_run]
21
+ driver.preflight
22
+ else
23
+ %i[assignment definition step base_head].each do |key|
24
+ raise ArgumentError, "Missing --#{key.to_s.tr('_', '-')}" unless options[key].is_a?(String) && !options[key].empty?
25
+ end
26
+ raise ArgumentError, "assignment definition is oversized" if File.size(options[:definition]) > 32_768
27
+ args = {assignment_id: options[:assignment], definition_bytes: File.read(options[:definition]),
28
+ scope: options[:step], base_head: options[:base_head]}
29
+ args[:mutation_id] = options[:mutation] if options[:mutation]
30
+ driver.launch(**args)
31
+ end
32
+ puts JSON.generate(result)
33
+ rescue ArgumentError, SystemCallError, Ace::Runtime::Error, Ace::Assign::Error => error
34
+ raise Ace::Support::Cli::Error, error.message
35
+ end
36
+ private
37
+ def build_driver(mapping)
38
+ Ace::Assign::Authority::LaunchDriver.new(mapping_id: mapping)
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+ require_relative "../../../authority/server"
3
+ require_relative "../../../authority/router"
4
+ require_relative "../../../authority/launch_lifecycle"
5
+ module Ace
6
+ module Assign
7
+ module CLI
8
+ module Commands
9
+ module Authority
10
+ class Serve < Ace::Support::Cli::Command
11
+ desc "Serve the installed protected assignment authority"
12
+ option :authority, required: true, desc: "Installed authority ID"
13
+ def call(**options)
14
+ deployment = Ace::Assign::Authority::Deployment.load
15
+ launch = Ace::Assign::Authority::LaunchLifecycle.new(deployment: deployment)
16
+ router = Ace::Assign::Authority::Router.new(launch: launch)
17
+ server = Ace::Assign::Authority::Server.new(authority_id: options.fetch(:authority), deployment: deployment, lifecycle: router)
18
+ signals = %w[INT TERM].to_h { |signal| [signal, Signal.trap(signal) { server.request_stop }] }
19
+ server.serve
20
+ rescue ArgumentError, Ace::Runtime::Error, Ace::Assign::Error => error
21
+ raise Ace::Support::Cli::Error, error.message
22
+ ensure
23
+ signals&.each { |signal, handler| Signal.trap(signal, handler) }
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+ require "json"
3
+ require_relative "../../../authority/client"
4
+ module Ace
5
+ module Assign
6
+ module CLI
7
+ module Commands
8
+ module Authority
9
+ class Status < Ace::Support::Cli::Command
10
+ desc "Inspect canonical protected launch ownership"
11
+ option :mapping, required: true, desc: "Installed launch mapping ID"
12
+ option :assignment, required: true, desc: "Assignment ID"
13
+ option :attempt, required: true, desc: "Attempt ID"
14
+ def call(**options)
15
+ reply = Ace::Assign::Authority::Client.new(mapping_id: options.fetch(:mapping)).call("attempt_status",
16
+ {"assignment_id" => options.fetch(:assignment), "attempt_id" => options.fetch(:attempt)})
17
+ puts JSON.generate(reply.data)
18
+ rescue ArgumentError, Ace::Runtime::Error, Ace::Assign::Error => error
19
+ raise Ace::Support::Cli::Error, error.message
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end