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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +363 -92
- data/README.md +66 -100
- data/docs/usage.md +529 -404
- data/lib/ace/lab/atoms/install_schema.rb +373 -0
- data/lib/ace/lab/cli/commands/install.rb +161 -0
- data/lib/ace/lab/cli/commands/prepare.rb +118 -0
- data/lib/ace/lab/cli/commands/project_add.rb +101 -0
- data/lib/ace/lab/cli/commands/status.rb +109 -0
- data/lib/ace/lab/cli/commands/verify.rb +115 -0
- data/lib/ace/lab/cli.rb +21 -13
- data/lib/ace/lab/install_manifest_path.rb +23 -0
- data/lib/ace/lab/models/status_report.rb +38 -0
- data/lib/ace/lab/molecules/account_provisioner.rb +196 -0
- data/lib/ace/lab/molecules/config_state_classifier.rb +145 -0
- data/lib/ace/lab/molecules/directory_provisioner.rb +208 -0
- data/lib/ace/lab/molecules/grant_provisioner.rb +250 -0
- data/lib/ace/lab/molecules/grant_resolver.rb +49 -23
- data/lib/ace/lab/molecules/hitl_routes.rb +263 -0
- data/lib/ace/lab/molecules/install_manifest_loader.rb +64 -0
- data/lib/ace/lab/molecules/key_provisioner.rb +736 -0
- data/lib/ace/lab/molecules/lab_state_scanner.rb +55 -0
- data/lib/ace/lab/molecules/overseer_provisioner.rb +188 -0
- data/lib/ace/lab/molecules/project_registry.rb +251 -0
- data/lib/ace/lab/molecules/repository_provisioner.rb +141 -0
- data/lib/ace/lab/molecules/role_verifier.rb +223 -0
- data/lib/ace/lab/organisms/install.rb +213 -0
- data/lib/ace/lab/organisms/prepare.rb +156 -0
- data/lib/ace/lab/organisms/project_add.rb +170 -0
- data/lib/ace/lab/organisms/status_service.rb +70 -0
- data/lib/ace/lab/organisms/verify.rb +768 -0
- data/lib/ace/lab/version.rb +1 -1
- data/lib/ace/lab.rb +34 -6
- metadata +34 -102
- data/lib/ace/lab/atoms/protected_publication_input.rb +0 -50
- data/lib/ace/lab/atoms/protected_workspace_prune_input.rb +0 -114
- data/lib/ace/lab/atoms/protected_workspace_prune_preview.rb +0 -86
- data/lib/ace/lab/atoms/service_input.rb +0 -92
- data/lib/ace/lab/cli/commands/assignment.rb +0 -45
- data/lib/ace/lab/cli/commands/service.rb +0 -240
- data/lib/ace/lab/models/service_receipt.rb +0 -18
- data/lib/ace/lab/molecules/hitl_authorizer.rb +0 -63
- data/lib/ace/lab/molecules/ordinary_admission_resolver.rb +0 -131
- data/lib/ace/lab/molecules/ordinary_listener.rb +0 -262
- data/lib/ace/lab/molecules/ordinary_merge_read.rb +0 -45
- data/lib/ace/lab/molecules/ordinary_native_read.rb +0 -106
- data/lib/ace/lab/molecules/ordinary_owner_admission.rb +0 -139
- data/lib/ace/lab/molecules/ordinary_owner_config.rb +0 -232
- data/lib/ace/lab/molecules/ordinary_receiver_config.rb +0 -94
- data/lib/ace/lab/molecules/ordinary_socket.rb +0 -126
- data/lib/ace/lab/molecules/ordinary_worktree_read.rb +0 -216
- data/lib/ace/lab/molecules/protected_cleanup_owner_admission.rb +0 -163
- data/lib/ace/lab/molecules/protected_cleanup_owner_client.rb +0 -165
- data/lib/ace/lab/molecules/protected_cleanup_owner_identity.rb +0 -239
- data/lib/ace/lab/molecules/protected_publication_publisher.rb +0 -110
- data/lib/ace/lab/molecules/protected_service_handler.rb +0 -43
- data/lib/ace/lab/molecules/protected_service_policy.rb +0 -156
- data/lib/ace/lab/molecules/service_executor.rb +0 -99
- data/lib/ace/lab/molecules/service_policy.rb +0 -289
- data/lib/ace/lab/organisms/authority_composition.rb +0 -87
- data/lib/ace/lab/organisms/ordinary_assignment_client.rb +0 -149
- data/lib/ace/lab/organisms/ordinary_authority_composition.rb +0 -123
- data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +0 -688
- data/lib/ace/lab/organisms/ordinary_merge_request.rb +0 -456
- data/lib/ace/lab/organisms/protected_cleanup_owner.rb +0 -361
- data/lib/ace/lab/organisms/protected_service_client.rb +0 -129
- data/lib/ace/lab/organisms/protected_service_listener.rb +0 -181
- data/lib/ace/lab/organisms/protected_service_publication.rb +0 -268
- data/lib/ace/lab/organisms/protected_service_receiver.rb +0 -267
- data/lib/ace/lab/organisms/protected_service_recovery.rb +0 -185
- data/lib/ace/lab/organisms/protected_service_request.rb +0 -128
- data/lib/ace/lab/organisms/protected_service_worker.rb +0 -89
- 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
|