ace-lab 0.8.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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +363 -92
  3. data/README.md +66 -100
  4. data/docs/usage.md +529 -404
  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 -13
  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 +49 -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 -102
  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 -45
  40. data/lib/ace/lab/cli/commands/service.rb +0 -240
  41. data/lib/ace/lab/models/service_receipt.rb +0 -18
  42. data/lib/ace/lab/molecules/hitl_authorizer.rb +0 -63
  43. data/lib/ace/lab/molecules/ordinary_admission_resolver.rb +0 -131
  44. data/lib/ace/lab/molecules/ordinary_listener.rb +0 -262
  45. data/lib/ace/lab/molecules/ordinary_merge_read.rb +0 -45
  46. data/lib/ace/lab/molecules/ordinary_native_read.rb +0 -106
  47. data/lib/ace/lab/molecules/ordinary_owner_admission.rb +0 -139
  48. data/lib/ace/lab/molecules/ordinary_owner_config.rb +0 -232
  49. data/lib/ace/lab/molecules/ordinary_receiver_config.rb +0 -94
  50. data/lib/ace/lab/molecules/ordinary_socket.rb +0 -126
  51. data/lib/ace/lab/molecules/ordinary_worktree_read.rb +0 -216
  52. data/lib/ace/lab/molecules/protected_cleanup_owner_admission.rb +0 -163
  53. data/lib/ace/lab/molecules/protected_cleanup_owner_client.rb +0 -165
  54. data/lib/ace/lab/molecules/protected_cleanup_owner_identity.rb +0 -239
  55. data/lib/ace/lab/molecules/protected_publication_publisher.rb +0 -110
  56. data/lib/ace/lab/molecules/protected_service_handler.rb +0 -43
  57. data/lib/ace/lab/molecules/protected_service_policy.rb +0 -156
  58. data/lib/ace/lab/molecules/service_executor.rb +0 -99
  59. data/lib/ace/lab/molecules/service_policy.rb +0 -289
  60. data/lib/ace/lab/organisms/authority_composition.rb +0 -87
  61. data/lib/ace/lab/organisms/ordinary_assignment_client.rb +0 -149
  62. data/lib/ace/lab/organisms/ordinary_authority_composition.rb +0 -123
  63. data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +0 -688
  64. data/lib/ace/lab/organisms/ordinary_merge_request.rb +0 -456
  65. data/lib/ace/lab/organisms/protected_cleanup_owner.rb +0 -361
  66. data/lib/ace/lab/organisms/protected_service_client.rb +0 -129
  67. data/lib/ace/lab/organisms/protected_service_listener.rb +0 -181
  68. data/lib/ace/lab/organisms/protected_service_publication.rb +0 -268
  69. data/lib/ace/lab/organisms/protected_service_receiver.rb +0 -267
  70. data/lib/ace/lab/organisms/protected_service_recovery.rb +0 -185
  71. data/lib/ace/lab/organisms/protected_service_request.rb +0 -128
  72. data/lib/ace/lab/organisms/protected_service_worker.rb +0 -89
  73. data/lib/ace/lab/organisms/service_request_service.rb +0 -249
@@ -1,262 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "socket"
5
- require_relative "../errors"
6
- require_relative "ordinary_socket"
7
-
8
- module Ace
9
- module Lab
10
- module Molecules
11
- # The ordinary receiver's serve-side transport (spec 8x3.t.xz9.4 serve
12
- # entrypoint). Binds the configured unix socket and answers the exact
13
- # framed protocol the OrdinarySocket client speaks: one bounded JSON
14
- # document each way behind the shared handshake line. Authentication is
15
- # MUTUAL through OS peer credentials: every connection's peer UID must equal
16
- # the configured producer principal UID, and the listener itself runs
17
- # as the configured receiver UID — the identity the client verifies on
18
- # its side. Every identity, framing, deadline or parse failure is
19
- # fail-closed: the peer receives the closed refusal envelope (or the
20
- # connection closes) and nothing is ever dispatched. This is not an
21
- # HTTP transport and there is no cross-VM fallback.
22
- class OrdinaryListener
23
- HANDSHAKE = OrdinarySocket::HANDSHAKE
24
- LIMIT = OrdinarySocket::LIMIT
25
- DEADLINE_SECONDS = 30
26
-
27
- def initialize(socket_path:, peer_uid:, handler:, socket_gid: nil, lock_path: nil, logger: nil)
28
- unless socket_path.is_a?(String) && socket_path.start_with?("/") &&
29
- peer_uid.is_a?(Integer) && peer_uid >= 0 && handler.respond_to?(:call)
30
- raise ArgumentError, "ordinary listener needs an absolute socket path, a peer uid and a handler"
31
- end
32
- unless socket_gid.nil? || (socket_gid.is_a?(Integer) && socket_gid >= 0)
33
- raise ArgumentError, "ordinary listener socket_gid must be a non-negative integer"
34
- end
35
- @socket_gid = socket_gid
36
- @socket_path = socket_path
37
- @peer_uid = peer_uid
38
- @handler = handler
39
- @lock_path = lock_path || "#{@socket_path}.lock"
40
- @logger = logger
41
- @stopping = false
42
- end
43
-
44
- # Serve until #stop. Excludes a second listener through one owned
45
- # lock file, removes only its own stale endpoint, and always removes
46
- # the endpoint it created.
47
- def serve(on_ready: nil)
48
- acquire_lock!
49
- remove_stale_endpoint!
50
- @listener = UNIXServer.new(@socket_path)
51
- @endpoint_identity = File.stat(@socket_path)
52
- File.chown(nil, @socket_gid, @socket_path) if @socket_gid
53
- File.chmod(@socket_gid ? 0o660 : 0o600, @socket_path)
54
- on_ready.call if on_ready
55
- @handler.recover_pending! if @handler.respond_to?(:recover_pending!)
56
- until @stopping
57
- socket = @listener.accept
58
- serve_connection(socket)
59
- end
60
- rescue IOError, Errno::EBADF
61
- raise unless @stopping
62
- ensure
63
- @listener&.close
64
- remove_owned_endpoint!
65
- @lock&.flock(File::LOCK_UN)
66
- @lock&.close
67
- end
68
-
69
- # The existing listener participates in the Assign owner's lifetime.
70
- def run(on_ready:)
71
- serve(on_ready: on_ready)
72
- end
73
-
74
- def stop
75
- @stopping = true
76
- @listener&.close
77
- rescue IOError
78
- nil
79
- end
80
-
81
- # One bounded connection: verify the peer identity, read one framed
82
- # request, answer one framed envelope, close. Fail-closed on every
83
- # violation; a dispatch never happens from an unverified frame.
84
- def serve_connection(socket)
85
- deadline = Time.now.utc + DEADLINE_SECONDS
86
- begin
87
- verify_peer!(socket)
88
- submission = read_request!(socket, deadline)
89
- response = @handler.call(submission)
90
- write_response_safely(socket, response, deadline)
91
- if response.is_a?(Hash) && response["status"] == "ok" && @handler.respond_to?(:execute)
92
- begin
93
- # Admission is durable even if the acknowledgment was lost; the
94
- # execution lifetime belongs to the canonical record, not
95
- # the short connection. A lost lifecycle stays pending or
96
- # uncertain and reconciles by the original reference.
97
- @handler.execute(response.dig("data", "request_id"))
98
- rescue StandardError => error
99
- log_failure("effect", error)
100
- end
101
- end
102
- rescue OrdinaryRefused => e
103
- write_response_safely(socket, e.envelope, deadline)
104
- rescue OrdinaryListenerError, IOError, SystemCallError => error
105
- # Fail closed without a fabricated envelope; the client classifies
106
- # a lost/unreadable response as ordinary_merge_unavailable.
107
- log_failure("connection", error)
108
- rescue StandardError => error
109
- log_failure("request", error)
110
- write_response_safely(socket, refusal_envelope("ordinary_merge_unavailable",
111
- "receiver failed to handle the request"), deadline)
112
- ensure
113
- begin
114
- socket.close
115
- rescue IOError
116
- nil
117
- end
118
- end
119
- end
120
-
121
- class OrdinaryListenerError < ::StandardError; end
122
-
123
- # A verified-connection refusal that still carries a closed envelope.
124
- class OrdinaryRefused < OrdinaryListenerError
125
- attr_reader :envelope
126
-
127
- def initialize(envelope)
128
- @envelope = envelope
129
- super(envelope.dig("error", "message").to_s)
130
- end
131
- end
132
-
133
- private
134
-
135
- def write_response_safely(socket, response, deadline)
136
- write_response!(socket, response, deadline)
137
- rescue OrdinaryListenerError, IOError, SystemCallError => error
138
- log_failure("response", error)
139
- end
140
-
141
- def log_failure(phase, error)
142
- # Arbitrary exception messages may contain provider credentials or
143
- # request bodies. Journal references remain the source of effect state.
144
- @logger&.call("ordinary receiver #{phase} failed (#{error.class.name})")
145
- end
146
-
147
- # Mutual half of OS peer authentication: the connecting process must run as the
148
- # configured producer principal UID. Anything else is refused before
149
- # one byte of request is read.
150
- def verify_peer!(socket)
151
- peer_uid, = socket.getpeereid
152
- return if peer_uid == @peer_uid
153
- raise OrdinaryRefused, refusal_envelope("ordinary_merge_unauthenticated",
154
- "connecting principal is not the configured ordinary overseer")
155
- rescue SystemCallError, IOError, SocketError, NotImplementedError
156
- raise OrdinaryListenerError, "peer credentials are unavailable"
157
- end
158
-
159
- def read_request!(socket, deadline)
160
- header = read_line!(socket, deadline)
161
- match = /\A#{Regexp.escape(HANDSHAKE)} (\d{1,5})\n\z/o.match(header)
162
- raise OrdinaryListenerError, "request header is invalid" unless match
163
- length = Integer(match[1])
164
- raise OrdinaryListenerError, "request exceeds the wire bound" if length > LIMIT
165
- payload = read_exact!(socket, length, deadline)
166
- JSON.parse(payload, allow_duplicate_key: false, create_additions: false, max_nesting: 16)
167
- rescue JSON::ParserError
168
- raise OrdinaryRefused, refusal_envelope("ordinary_merge_invalid_request",
169
- "request document is not valid JSON")
170
- end
171
-
172
- def write_response!(socket, response, deadline)
173
- payload = JSON.generate(response)
174
- raise OrdinaryListenerError, "response exceeds the wire bound" if payload.bytesize > LIMIT
175
- frame = "#{HANDSHAKE} #{payload.bytesize}\n#{payload}"
176
- written = 0
177
- while written < frame.bytesize
178
- raise OrdinaryListenerError, "response write deadline expired" if Time.now.utc >= deadline
179
- begin
180
- written += socket.write_nonblock(frame.byteslice(written..))
181
- rescue IO::WaitWritable
182
- socket.wait_writable(remaining!(deadline)) or
183
- raise OrdinaryListenerError, "response write deadline expired"
184
- end
185
- end
186
- rescue JSON::GeneratorError
187
- raise OrdinaryListenerError, "response is not serializable"
188
- end
189
-
190
- def refusal_envelope(code, message)
191
- {"status" => "error", "error" => {
192
- "schema" => "ace.lab.service.ordinary-merge-refusal/v1",
193
- "code" => code,
194
- "message" => message.to_s.scrub.gsub(/\s+/, " ").strip[0, 512],
195
- "request_id" => "unknown",
196
- "input_digest" => nil
197
- }}
198
- end
199
-
200
- def read_line!(socket, deadline)
201
- line = +""
202
- until line.end_with?("\n")
203
- raise OrdinaryListenerError, "request header is unbounded" if line.bytesize > 128
204
- line << read_exact!(socket, 1, deadline)
205
- end
206
- line
207
- end
208
-
209
- def read_exact!(socket, length, deadline)
210
- buffer = +""
211
- while buffer.bytesize < length
212
- raise OrdinaryListenerError, "request read deadline expired" if Time.now.utc >= deadline
213
- begin
214
- chunk = socket.read_nonblock(length - buffer.bytesize)
215
- raise OrdinaryListenerError, "peer closed the connection" if chunk.nil?
216
- buffer << chunk
217
- rescue IO::WaitReadable
218
- socket.wait_readable(remaining!(deadline)) or
219
- raise OrdinaryListenerError, "request read deadline expired"
220
- end
221
- end
222
- buffer
223
- end
224
-
225
- def remaining!(deadline)
226
- left = deadline - Time.now.utc
227
- raise OrdinaryListenerError, "connection deadline expired" if left <= 0
228
- left
229
- end
230
-
231
- def acquire_lock!
232
- @lock = File.open(@lock_path, File::RDWR | File::CREAT | File::NOFOLLOW, 0o600)
233
- stat = @lock.stat
234
- unless stat.file? && stat.uid == Process.uid && stat.nlink == 1 && (stat.mode & 0o7777) == 0o600
235
- raise Ace::Lab::InvalidConfigurationError, "receiver listener lock differs"
236
- end
237
- unless @lock.flock(File::LOCK_EX | File::LOCK_NB)
238
- raise Ace::Lab::InvalidConfigurationError, "receiver listener already active"
239
- end
240
- end
241
-
242
- def remove_stale_endpoint!
243
- stat = File.lstat(@socket_path)
244
- unless stat.socket? && stat.uid == Process.uid
245
- raise Ace::Lab::InvalidConfigurationError, "receiver endpoint differs"
246
- end
247
- File.unlink(@socket_path)
248
- rescue Errno::ENOENT
249
- nil
250
- end
251
-
252
- def remove_owned_endpoint!
253
- return unless @endpoint_identity
254
- stat = File.stat(@socket_path)
255
- File.unlink(@socket_path) if stat.socket? && stat.ino == @endpoint_identity.ino
256
- rescue Errno::ENOENT
257
- nil
258
- end
259
- end
260
- end
261
- end
262
- end
@@ -1,45 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "ordinary_worktree_read"
4
-
5
- module Ace
6
- module Lab
7
- module Molecules
8
- # Local checks are bounded read-only observations. Only the forge provider
9
- # executes merge; moving a local ref can never prove a delivered merge.
10
- class OrdinaryMergeRead
11
- STRATEGY = "ff"
12
-
13
- def initialize(repo_path:, base_ref:, root: repo_path, common_dir: File.join(repo_path, ".git"),
14
- deadline: Process.clock_gettime(Process::CLOCK_MONOTONIC) + 30)
15
- unless repo_path.is_a?(String) && repo_path.start_with?("/") &&
16
- base_ref.is_a?(String) && base_ref.start_with?("refs/")
17
- raise ArgumentError, "ordinary merge read needs an absolute repository and full base ref"
18
- end
19
- @repo_path, @base_ref = repo_path, base_ref
20
- @reader = OrdinaryWorktreeRead.new(root: root, common_dir: common_dir, deadline: deadline)
21
- end
22
-
23
- def base_head
24
- @reader.ref_head(@repo_path, @base_ref)
25
- rescue Ace::Assign::Error => error
26
- raise GitUnavailable, error.message
27
- end
28
-
29
- def candidate_commit?(head)
30
- @reader.commit?(@repo_path, head)
31
- rescue Ace::Assign::Error => error
32
- raise GitUnavailable, error.message
33
- end
34
-
35
- def fast_forwardable?(head)
36
- @reader.ancestor?(@repo_path, base_head, head)
37
- rescue Ace::Assign::Error => error
38
- raise GitUnavailable, error.message
39
- end
40
-
41
- class GitUnavailable < StandardError; end
42
- end
43
- end
44
- end
45
- end
@@ -1,106 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "securerandom"
4
- require "ace/runtime/molecules/protected_socket"
5
- require "ace/herdr/molecules/herdr_executor"
6
- require "ace/assign/authority/ordinary_peer"
7
- require_relative "ordinary_owner_config"
8
-
9
- module Ace
10
- module Lab
11
- module Molecules
12
- # Read-only use of Herdr's existing native protocol. Root selection pins
13
- # the endpoint inode and live server; no ambient CLI environment chooses
14
- # the daemon, and no protected runtime guarantees are borrowed here.
15
- class OrdinaryNativeRead
16
- def initialize(config:, deadline:, peer: Ace::Assign::Authority::OrdinaryPeer.new)
17
- raise ArgumentError, "native reader needs installed selection" unless config.is_a?(OrdinaryOwnerConfig)
18
- @config, @deadline, @peer = config, deadline, peer
19
- @native = config.native
20
- end
21
-
22
- def preflight!
23
- result = request("ping", {})
24
- unless result["type"] == "pong" && result.values_at("version", "protocol") == @native.values_at("version", "protocol")
25
- unavailable!("native protocol differs from installed selection")
26
- end
27
- true
28
- end
29
-
30
- def pane_get(pane)
31
- result = request("pane.get", {"pane_id" => pane})
32
- unless result["type"] == "pane_info" && result["pane"].is_a?(Hash) && result["pane"]["pane_id"] == pane
33
- unavailable!("native pane metadata differs from requested pane")
34
- end
35
- response(result)
36
- end
37
-
38
- def pane_process_info(pane)
39
- @process_info = nil
40
- result = request("pane.process_info", {"pane_id" => pane})
41
- unless result["type"] == "pane_process_info" && result["process_info"].is_a?(Hash) && result["process_info"]["pane_id"] == pane
42
- unavailable!("native process inventory differs from requested pane")
43
- end
44
- @process_info = result.fetch("process_info")
45
- response(result)
46
- end
47
-
48
- # This per-PID native field comes from process_cwd, not the terminal's
49
- # OSC/UI working directory. The caller separately verifies PID birth.
50
- def process_cwd(pid)
51
- processes = @process_info.is_a?(Hash) && @process_info["foreground_processes"]
52
- unavailable!("native process inventory is unavailable") unless processes.is_a?(Array)
53
- matches = processes.select { |entry| entry.is_a?(Hash) && entry["pid"] == pid }
54
- path = matches.one? && matches.first["cwd"]
55
- unless path.is_a?(String) && path.bytesize.between?(1, 4096) && !path.include?("\0") &&
56
- path.start_with?("/") && File.expand_path(path) == path
57
- unavailable!("exact native process working directory is unavailable")
58
- end
59
- path
60
- end
61
-
62
- private
63
-
64
- def request(method, params)
65
- verify_endpoint!
66
- wire.connect(@native.fetch("socket_path"), deadline: @deadline) do |socket|
67
- unless @peer.capture(socket, deadline: @deadline) == @native.fetch("server_identity")
68
- unavailable!("native peer differs from installed server")
69
- end
70
- verify_endpoint!
71
- id = SecureRandom.hex(12)
72
- wire.write(socket, {"id" => id, "method" => method, "params" => params}, deadline: @deadline, limit: 16_384)
73
- value = wire.read(wire::Ingress.new(socket), deadline: @deadline, limit: 65_536)
74
- unless value.is_a?(Hash) && value.keys.sort == %w[id result] && value["id"] == id && value["result"].is_a?(Hash)
75
- unavailable!("native read response is invalid")
76
- end
77
- verify_endpoint!
78
- @peer.live!(@native.fetch("server_identity"), deadline: @deadline)
79
- value.fetch("result")
80
- end
81
- rescue SystemCallError, IOError, KeyError, Ace::Assign::AttemptErrors::UnauthorizedIdentity
82
- unavailable!("native read is unavailable")
83
- end
84
-
85
- def verify_endpoint!
86
- @config.verify_selection!
87
- path = @native.fetch("socket_path")
88
- wire.root_path!(File.dirname(path), directory: true)
89
- unless wire.socket_identity(path) == @native.fetch("socket_identity") && (File.lstat(path).mode & 0o007).zero?
90
- unavailable!("native endpoint generation or access differs")
91
- end
92
- end
93
-
94
- def response(result)
95
- Ace::Herdr::Molecules::ExecutionResult.new(stdout: JSON.generate({"result" => result}), stderr: "", success: true, exit_code: 0)
96
- end
97
-
98
- def wire = Ace::Runtime::Molecules::ProtectedSocket
99
-
100
- def unavailable!(reason)
101
- raise Ace::Runtime::RuntimeUnavailableError, reason
102
- end
103
- end
104
- end
105
- end
106
- end
@@ -1,139 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "ace/assign/authority/ordinary_peer"
4
- require "ace/assign/molecules/execution_identity_resolver"
5
- require "ace/herdr"
6
- require_relative "ordinary_owner_config"
7
- require_relative "ordinary_native_read"
8
- require_relative "ordinary_worktree_read"
9
-
10
- module Ace
11
- module Lab
12
- module Molecules
13
- # The source-selected ordinary peer policy. Native selectors are hints;
14
- # only actual ancestry in the installed project's Herdr grants attribution.
15
- class OrdinaryOwnerAdmission
16
- def initialize(config:, peer: Ace::Assign::Authority::OrdinaryPeer.new, runtime: nil)
17
- raise ArgumentError, "ordinary owner requires validated installed selection" unless config.is_a?(OrdinaryOwnerConfig)
18
- @config, @peer = config, peer
19
- @runtime = runtime
20
- end
21
-
22
- def service = @config.service
23
- def verify_listener!
24
- @config.verify_listener!
25
- OrdinaryNativeRead.new(config: @config, deadline: default_deadline, peer: @peer).preflight!
26
- end
27
- def peer(socket, deadline:) = @peer.capture(socket, deadline: deadline)
28
-
29
- def authorize!(request:, peer:, deadline: default_deadline)
30
- @peer.live!(peer, deadline: deadline)
31
- unless request.fetch("project_id") == @config.project
32
- raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "ordinary request belongs to another project"
33
- end
34
- @config.participant!(peer)
35
- pane = request.fetch("params", {})["pane"]
36
- # Reads need project visibility only. Every mutating lifecycle route
37
- # validates a pane; supervisor authority additionally requires actual
38
- # caller ancestry in one of the root-installed native placements.
39
- return :worker unless pane
40
- identity!(peer: peer, pane: pane, deadline: deadline).role == "coordinator" ? :supervisor : :worker
41
- end
42
-
43
- def identity!(peer:, pane:, deadline: default_deadline)
44
- observe_identity(peer: peer, pane: pane, deadline: deadline, with_cwd: false).first
45
- end
46
-
47
- # The caller's claimed head is only a comparison. The accepted start
48
- # uses the observed worker's checkout and process, never daemon cwd.
49
- def execution!(peer:, pane:, head:, deadline: default_deadline)
50
- unless head.is_a?(String) && head.match?(/\A[0-9a-f]{40}\z/)
51
- raise ArgumentError, "ordinary start HEAD is invalid"
52
- end
53
- identity, cwd = observe_identity(peer: peer, pane: pane, deadline: deadline, with_cwd: true)
54
- checkout = OrdinaryWorktreeRead.new(root: @config.worktrees.fetch("root"),
55
- common_dir: @config.worktrees.fetch("common_dir"), deadline: deadline).observe(cwd)
56
- after, after_cwd = observe_identity(peer: peer, pane: pane, deadline: deadline, with_cwd: true)
57
- unless after == identity && after_cwd == cwd && checkout.fetch("head") == head
58
- raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "ordinary worker process, working directory or HEAD changed"
59
- end
60
- {identity: identity, checkout: checkout}
61
- end
62
-
63
- # Read the original canonical native owner for a trusted consumer.
64
- # The binding must come from accepted history, never requester fields.
65
- # This observes ownership; it does not grant the target's privileges to
66
- # the caller or manufacture a kernel-authenticated connection identity.
67
- def recorded_binding!(binding:, caller_pid: nil, deadline: default_deadline)
68
- observe_recorded_binding(binding, caller_pid, deadline).first
69
- end
70
-
71
- def recorded_execution!(binding:, head:, deadline: default_deadline)
72
- before, cwd = observe_recorded_binding(binding, nil, deadline)
73
- checkout = OrdinaryWorktreeRead.new(root: @config.worktrees.fetch("root"),
74
- common_dir: @config.worktrees.fetch("common_dir"), deadline: deadline).observe(cwd)
75
- after, after_cwd = observe_recorded_binding(binding, nil, deadline)
76
- unless before == after && cwd == after_cwd && checkout.fetch("head") == head
77
- raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "ordinary recorded worker or HEAD changed"
78
- end
79
- {runtime_binding: after, checkout: checkout}
80
- end
81
-
82
- private
83
-
84
- def observe_recorded_binding(binding, caller_pid, deadline)
85
- @config.verify_selection!
86
- uid = binding.fetch("process_identity").fetch("uid")
87
- participant = @config.selected_principal!(uid)
88
- unless participant && binding["runtime"] == participant.fetch("runtime") && binding["session"] == participant.fetch("workspace")
89
- raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "ordinary recorded participant is no longer selected"
90
- end
91
- native, runtime = native_runtime(deadline)
92
- actual = runtime.process_binding(pane: binding.fetch("pane"), caller_pid: caller_pid || binding.fetch("process_identity").fetch("pid"))
93
- unless actual == binding
94
- raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "ordinary recorded native owner is unavailable or changed"
95
- end
96
- cwd = native.process_cwd(binding.fetch("process_identity").fetch("pid"))
97
- @config.selected_principal!(uid)
98
- [actual, cwd]
99
- end
100
-
101
- def native_runtime(deadline)
102
- return [@runtime, @runtime] if @runtime
103
- observation = Ace::Herdr::Molecules::BoundedObservation.new(deadline: deadline, binary: @config.runtime_binary)
104
- native = OrdinaryNativeRead.new(config: @config, deadline: deadline, peer: @peer)
105
- native.preflight!
106
- [native, Ace::Herdr::Organisms::RuntimeAdapter.new(executor: native, env: {},
107
- process_identity: Ace::Runtime::Molecules::ProcessIdentity.new(executor: observation))]
108
- end
109
-
110
- def observe_identity(peer:, pane:, deadline:, with_cwd:)
111
- @peer.live!(peer, deadline: deadline)
112
- participant = @config.participant!(peer)
113
- unless pane.is_a?(String) && pane.bytesize.between?(1, 128) && !pane.include?("\0")
114
- raise ArgumentError, "ordinary native pane selector is invalid"
115
- end
116
- native, runtime = native_runtime(deadline)
117
- binding = runtime.process_binding(pane: pane, caller_pid: peer.fetch("pid"))
118
- unless binding && binding["runtime"] == participant.fetch("runtime") &&
119
- binding["session"] == participant.fetch("workspace") && binding["pane"] == pane &&
120
- binding.dig("process_identity", "uid") == peer.fetch("uid")
121
- raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "ordinary native owner is unavailable or differs"
122
- end
123
- @peer.live!(peer, deadline: deadline)
124
- @config.participant!(peer)
125
- # Ordinary native agents may share an OS account. Preserve that actor
126
- # for HITL attribution; privilege follows observed native placement,
127
- # not a claimed role or an invented per-agent account alias.
128
- role = participant.fetch("supervisor_panes").include?(binding.fetch("pane")) ? "coordinator" : "worker"
129
- identity = Ace::Assign::Molecules::ExecutionIdentityResolver::Identity.new(
130
- actor: participant.fetch("actor"), role: role, runtime: participant.fetch("runtime"),
131
- adapter: "ordinary", process_pid: binding.fetch("process_identity").fetch("pid"), runtime_binding: binding)
132
- [identity, with_cwd ? native.process_cwd(identity.process_pid) : nil]
133
- end
134
-
135
- def default_deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 30
136
- end
137
- end
138
- end
139
- end