ace-lab 0.6.0 → 0.7.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.
@@ -0,0 +1,238 @@
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 SO_PEERCRED: 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:, 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
+ @socket_path = socket_path
33
+ @peer_uid = peer_uid
34
+ @handler = handler
35
+ @lock_path = lock_path || "#{@socket_path}.lock"
36
+ @logger = logger
37
+ @stopping = false
38
+ end
39
+
40
+ # Serve until #stop. Excludes a second listener through one owned
41
+ # lock file, removes only its own stale endpoint, and always removes
42
+ # the endpoint it created.
43
+ def serve
44
+ acquire_lock!
45
+ remove_stale_endpoint!
46
+ @listener = UNIXServer.new(@socket_path)
47
+ File.chmod(0o600, @socket_path)
48
+ @endpoint_identity = File.stat(@socket_path)
49
+ until @stopping
50
+ socket = @listener.accept
51
+ serve_connection(socket)
52
+ end
53
+ rescue IOError, Errno::EBADF
54
+ raise unless @stopping
55
+ ensure
56
+ @listener&.close
57
+ remove_owned_endpoint!
58
+ @lock&.flock(File::LOCK_UN)
59
+ @lock&.close
60
+ end
61
+
62
+ def stop
63
+ @stopping = true
64
+ @listener&.close
65
+ rescue IOError
66
+ nil
67
+ end
68
+
69
+ # One bounded connection: verify the peer identity, read one framed
70
+ # request, answer one framed envelope, close. Fail-closed on every
71
+ # violation; a dispatch never happens from an unverified frame.
72
+ def serve_connection(socket)
73
+ deadline = Time.now.utc + DEADLINE_SECONDS
74
+ begin
75
+ verify_peer!(socket)
76
+ submission = read_request!(socket, deadline)
77
+ response = @handler.call(submission)
78
+ write_response!(socket, response, deadline)
79
+ if response.is_a?(Hash) && response["status"] == "ok" && @handler.respond_to?(:execute)
80
+ begin
81
+ # The durable acknowledgment is already on the wire; the
82
+ # execution lifetime belongs to the canonical record, not
83
+ # the short connection. A lost lifecycle stays pending or
84
+ # uncertain and reconciles by the original reference.
85
+ @handler.execute(response.dig("data", "request_id"))
86
+ rescue StandardError
87
+ nil
88
+ end
89
+ end
90
+ rescue OrdinaryRefused => e
91
+ write_response!(socket, e.envelope, deadline)
92
+ rescue OrdinaryListenerError
93
+ # Fail closed without a fabricated envelope; the client classifies
94
+ # a lost/unreadable response as ordinary_merge_unavailable.
95
+ nil
96
+ rescue StandardError
97
+ write_response!(socket, refusal_envelope("ordinary_merge_unavailable",
98
+ "receiver failed to handle the request"), deadline)
99
+ ensure
100
+ begin
101
+ socket.close
102
+ rescue IOError
103
+ nil
104
+ end
105
+ end
106
+ end
107
+
108
+ class OrdinaryListenerError < ::StandardError; end
109
+
110
+ # A verified-connection refusal that still carries a closed envelope.
111
+ class OrdinaryRefused < OrdinaryListenerError
112
+ attr_reader :envelope
113
+
114
+ def initialize(envelope)
115
+ @envelope = envelope
116
+ super(envelope.dig("error", "message").to_s)
117
+ end
118
+ end
119
+
120
+ private
121
+
122
+ # Mutual half of SO_PEERCRED: the connecting process must run as the
123
+ # configured producer principal UID. Anything else is refused before
124
+ # one byte of request is read.
125
+ def verify_peer!(socket)
126
+ credentials = socket.getsockopt(:SOCKET, :PEERCRED)
127
+ _peer_pid, peer_uid, = credentials.unpack("L3")
128
+ return if peer_uid == @peer_uid
129
+ raise OrdinaryRefused, refusal_envelope("ordinary_merge_unauthenticated",
130
+ "connecting principal is not the configured ordinary overseer")
131
+ rescue SystemCallError, IOError
132
+ raise OrdinaryListenerError, "peer credentials are unavailable"
133
+ end
134
+
135
+ def read_request!(socket, deadline)
136
+ header = read_line!(socket, deadline)
137
+ match = /\A#{Regexp.escape(HANDSHAKE)} (\d{1,5})\n\z/o.match(header)
138
+ raise OrdinaryListenerError, "request header is invalid" unless match
139
+ length = Integer(match[1])
140
+ raise OrdinaryListenerError, "request exceeds the wire bound" if length > LIMIT
141
+ payload = read_exact!(socket, length, deadline)
142
+ JSON.parse(payload, allow_duplicate_key: false, create_additions: false, max_nesting: 16)
143
+ rescue JSON::ParserError
144
+ raise OrdinaryRefused, refusal_envelope("ordinary_merge_invalid_request",
145
+ "request document is not valid JSON")
146
+ end
147
+
148
+ def write_response!(socket, response, deadline)
149
+ payload = JSON.generate(response)
150
+ raise OrdinaryListenerError, "response exceeds the wire bound" if payload.bytesize > LIMIT
151
+ frame = "#{HANDSHAKE} #{payload.bytesize}\n#{payload}"
152
+ written = 0
153
+ while written < frame.bytesize
154
+ raise OrdinaryListenerError, "response write deadline expired" if Time.now.utc >= deadline
155
+ begin
156
+ written += socket.write_nonblock(frame.byteslice(written..))
157
+ rescue IO::WaitWritable
158
+ socket.wait_writable(remaining!(deadline)) or
159
+ raise OrdinaryListenerError, "response write deadline expired"
160
+ end
161
+ end
162
+ rescue JSON::GeneratorError
163
+ raise OrdinaryListenerError, "response is not serializable"
164
+ end
165
+
166
+ def refusal_envelope(code, message)
167
+ {"status" => "error", "error" => {
168
+ "schema" => "ace.lab.service.ordinary-merge-refusal/v1",
169
+ "code" => code,
170
+ "message" => message.to_s.scrub.gsub(/\s+/, " ").strip[0, 512],
171
+ "request_id" => "unknown",
172
+ "input_digest" => nil
173
+ }}
174
+ end
175
+
176
+ def read_line!(socket, deadline)
177
+ line = +""
178
+ until line.end_with?("\n")
179
+ raise OrdinaryListenerError, "request header is unbounded" if line.bytesize > 128
180
+ line << read_exact!(socket, 1, deadline)
181
+ end
182
+ line
183
+ end
184
+
185
+ def read_exact!(socket, length, deadline)
186
+ buffer = +""
187
+ while buffer.bytesize < length
188
+ raise OrdinaryListenerError, "request read deadline expired" if Time.now.utc >= deadline
189
+ begin
190
+ chunk = socket.read_nonblock(length - buffer.bytesize)
191
+ raise OrdinaryListenerError, "peer closed the connection" if chunk.nil?
192
+ buffer << chunk
193
+ rescue IO::WaitReadable
194
+ socket.wait_readable(remaining!(deadline)) or
195
+ raise OrdinaryListenerError, "request read deadline expired"
196
+ end
197
+ end
198
+ buffer
199
+ end
200
+
201
+ def remaining!(deadline)
202
+ left = deadline - Time.now.utc
203
+ raise OrdinaryListenerError, "connection deadline expired" if left <= 0
204
+ left
205
+ end
206
+
207
+ def acquire_lock!
208
+ @lock = File.open(@lock_path, File::RDWR | File::CREAT | File::NOFOLLOW, 0o600)
209
+ stat = @lock.stat
210
+ unless stat.file? && stat.uid == Process.uid && stat.nlink == 1 && (stat.mode & 0o7777) == 0o600
211
+ raise Ace::Lab::InvalidConfigurationError, "receiver listener lock differs"
212
+ end
213
+ unless @lock.flock(File::LOCK_EX | File::LOCK_NB)
214
+ raise Ace::Lab::InvalidConfigurationError, "receiver listener already active"
215
+ end
216
+ end
217
+
218
+ def remove_stale_endpoint!
219
+ stat = File.lstat(@socket_path)
220
+ unless stat.socket? && stat.uid == Process.uid
221
+ raise Ace::Lab::InvalidConfigurationError, "receiver endpoint differs"
222
+ end
223
+ File.unlink(@socket_path)
224
+ rescue Errno::ENOENT
225
+ nil
226
+ end
227
+
228
+ def remove_owned_endpoint!
229
+ return unless @endpoint_identity
230
+ stat = File.stat(@socket_path)
231
+ File.unlink(@socket_path) if stat.socket? && stat.ino == @endpoint_identity.ino
232
+ rescue Errno::ENOENT
233
+ nil
234
+ end
235
+ end
236
+ end
237
+ end
238
+ end
@@ -0,0 +1,86 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require_relative "../errors"
5
+ require_relative "service_policy"
6
+
7
+ module Ace
8
+ module Lab
9
+ module Molecules
10
+ # Project-scoped, explicitly filed receiver configuration (spec
11
+ # 8x3.t.xz9.4 execution clauses). The host owns the file, its
12
+ # placement, its credentials and its deployment; this loader only
13
+ # validates the exact closed shape and fails closed on anything else.
14
+ # It invents no credential and accepts no caller-selected endpoint:
15
+ # every value comes from one explicit deployment-owned YAML file.
16
+ class OrdinaryReceiverConfig
17
+ KEYS = %w[project receiver_id socket_path receiver_uid principal principal_uid
18
+ repo_path policy_path execution_identity].freeze
19
+ IDENTITY_KEYS = %w[checkout_id worktree_id branch].freeze
20
+ ID = ServicePolicy::ID
21
+
22
+ def self.load(path)
23
+ raise Ace::Lab::InvalidConfigurationError, "receiver config path is required" unless path.is_a?(String) && !path.empty?
24
+ document = YAML.safe_load_file(path, permitted_classes: [Date], aliases: false)
25
+ new(document, source: path)
26
+ rescue Errno::ENOENT, Errno::EACCES, Psych::Exception
27
+ raise Ace::Lab::InvalidConfigurationError, "receiver configuration is unreadable: #{path}"
28
+ end
29
+
30
+ attr_reader :project, :receiver_id, :socket_path, :receiver_uid, :principal,
31
+ :principal_uid, :repo_path, :policy_path, :execution_identity, :source
32
+
33
+ def initialize(document, source: nil)
34
+ unless document.is_a?(Hash) && (document.keys - KEYS).empty? &&
35
+ (KEYS - document.keys).empty?
36
+ raise Ace::Lab::InvalidConfigurationError,
37
+ "receiver configuration must contain exactly: #{KEYS.join(', ')}"
38
+ end
39
+ @source = source
40
+ @project = string!(document.fetch("project"), "project")
41
+ @receiver_id = string!(document.fetch("receiver_id"), "receiver_id")
42
+ @principal = string!(document.fetch("principal"), "principal")
43
+ @socket_path = absolute!(document.fetch("socket_path"), "socket_path")
44
+ @repo_path = absolute!(document.fetch("repo_path"), "repo_path")
45
+ @policy_path = absolute!(document.fetch("policy_path"), "policy_path")
46
+ @receiver_uid = uid!(document.fetch("receiver_uid"), "receiver_uid")
47
+ @principal_uid = uid!(document.fetch("principal_uid"), "principal_uid")
48
+ identity = document.fetch("execution_identity")
49
+ unless identity.is_a?(Hash) && (identity.keys - IDENTITY_KEYS).empty? &&
50
+ IDENTITY_KEYS.all? { |key| identity[key].is_a?(String) && !identity[key].empty? }
51
+ raise Ace::Lab::InvalidConfigurationError,
52
+ "receiver execution_identity is incomplete"
53
+ end
54
+ @execution_identity = IDENTITY_KEYS.to_h { |key| [key, identity.fetch(key)] }.freeze
55
+ unless @repo_path.start_with?("/") && File.directory?(@repo_path)
56
+ raise Ace::Lab::InvalidConfigurationError, "receiver repo_path is not a directory"
57
+ end
58
+ freeze
59
+ end
60
+
61
+ private
62
+
63
+ def string!(value, name)
64
+ unless value.is_a?(String) && !value.empty? && value.length <= 256 && value.match?(ID)
65
+ raise Ace::Lab::InvalidConfigurationError, "receiver #{name} is invalid"
66
+ end
67
+ value
68
+ end
69
+
70
+ def absolute!(value, name)
71
+ unless value.is_a?(String) && value.start_with?("/") && value.length <= 1024
72
+ raise Ace::Lab::InvalidConfigurationError, "receiver #{name} must be an absolute path"
73
+ end
74
+ value
75
+ end
76
+
77
+ def uid!(value, name)
78
+ unless value.is_a?(Integer) && value >= 0
79
+ raise Ace::Lab::InvalidConfigurationError, "receiver #{name} must be a non-negative integer"
80
+ end
81
+ value
82
+ end
83
+ end
84
+ end
85
+ end
86
+ end