ace-hitl 0.14.0 → 0.16.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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/hitl/config.yml +0 -0
  3. data/.ace-defaults/nav/protocols/skill-sources/ace-hitl.yml +0 -0
  4. data/.ace-defaults/nav/protocols/wfi-sources/ace-hitl.yml +0 -0
  5. data/CHANGELOG.md +20 -0
  6. data/Rakefile +0 -0
  7. data/docs/demo/ace-hitl-scope-and-answer.tape.yml +0 -0
  8. data/docs/demo/fixtures/demo-context.md +0 -0
  9. data/docs/usage.md +47 -2
  10. data/handbook/README.md +0 -0
  11. data/handbook/skills/as-hitl/SKILL.md +0 -0
  12. data/handbook/workflow-instructions/hitl.wf.md +0 -0
  13. data/lib/ace/hitl/atoms/hitl_effect_validator.rb +0 -0
  14. data/lib/ace/hitl/atoms/hitl_file_pattern.rb +0 -0
  15. data/lib/ace/hitl/atoms/hitl_id_formatter.rb +0 -0
  16. data/lib/ace/hitl/cli/commands/create.rb +0 -0
  17. data/lib/ace/hitl/cli/commands/list.rb +0 -0
  18. data/lib/ace/hitl/cli/commands/serve.rb +38 -2
  19. data/lib/ace/hitl/cli/commands/show.rb +0 -0
  20. data/lib/ace/hitl/lifecycle/client.rb +11 -151
  21. data/lib/ace/hitl/lifecycle/credential_path_policy.rb +183 -0
  22. data/lib/ace/hitl/lifecycle/frame_exchange.rb +78 -0
  23. data/lib/ace/hitl/lifecycle/frame_serving.rb +119 -0
  24. data/lib/ace/hitl/lifecycle/lifecycle_requests.rb +101 -0
  25. data/lib/ace/hitl/lifecycle/policy.rb +34 -3
  26. data/lib/ace/hitl/lifecycle/proposals.rb +12 -0
  27. data/lib/ace/hitl/lifecycle/remote_peer.rb +78 -0
  28. data/lib/ace/hitl/lifecycle/service.rb +33 -102
  29. data/lib/ace/hitl/lifecycle/store.rb +1 -0
  30. data/lib/ace/hitl/lifecycle/tls_access_policy.rb +50 -0
  31. data/lib/ace/hitl/lifecycle/tls_authority.rb +121 -0
  32. data/lib/ace/hitl/lifecycle/tls_credentials.rb +210 -0
  33. data/lib/ace/hitl/lifecycle/tls_material.rb +133 -0
  34. data/lib/ace/hitl/lifecycle/tls_project_scope.rb +68 -0
  35. data/lib/ace/hitl/lifecycle/tls_service.rb +242 -0
  36. data/lib/ace/hitl/lifecycle.rb +11 -0
  37. data/lib/ace/hitl/models/hitl_event.rb +0 -0
  38. data/lib/ace/hitl/molecules/hitl_answer_editor.rb +0 -0
  39. data/lib/ace/hitl/molecules/hitl_config_loader.rb +0 -0
  40. data/lib/ace/hitl/molecules/hitl_creator.rb +0 -0
  41. data/lib/ace/hitl/molecules/hitl_display_formatter.rb +0 -0
  42. data/lib/ace/hitl/molecules/hitl_loader.rb +0 -0
  43. data/lib/ace/hitl/molecules/hitl_resolver.rb +0 -0
  44. data/lib/ace/hitl/molecules/hitl_scanner.rb +0 -0
  45. data/lib/ace/hitl/molecules/resume_dispatcher.rb +0 -0
  46. data/lib/ace/hitl/molecules/worktree_scope_resolver.rb +0 -0
  47. data/lib/ace/hitl/proposals/policy.rb +1 -0
  48. data/lib/ace/hitl/providers/lab/assignment_binding.rb +20 -1
  49. data/lib/ace/hitl/version.rb +1 -1
  50. metadata +31 -6
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Hitl
5
+ module Lifecycle
6
+ # The deadline-bounded frame exchange of a lifecycle client (spec
7
+ # 8wq.t.34i, shared by the Unix and the TLS 1.3/TCP transports):
8
+ # one newline-delimited request frame out, one bounded response
9
+ # frame back, size and deadline rules identical on both transports.
10
+ # The includer supplies `@deadline_seconds` and implements
11
+ # `#request(op, params, deadline:)`.
12
+ module FrameExchange
13
+ private
14
+
15
+ def await!(socket, direction, deadline)
16
+ remaining = deadline && deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
17
+ raise TransportError, "HITL boundary deadline exceeded" if remaining && remaining <= 0
18
+ readable, writable = direction == :read ? [[socket], nil] : [nil, [socket]]
19
+ raise TransportError, "HITL boundary deadline exceeded" unless IO.select(readable, writable, nil, remaining)
20
+ end
21
+
22
+ def write_frame!(socket, frame, deadline)
23
+ offset = 0
24
+ while offset < frame.bytesize
25
+ await!(socket, :write, deadline)
26
+ written = socket.write_nonblock(frame.byteslice(offset..), exception: false)
27
+ next if written == :wait_writable
28
+ raise TransportError, "HITL boundary closed before request" unless written.is_a?(Integer) && written.positive?
29
+ offset += written
30
+ end
31
+ end
32
+
33
+ def read_line(socket, deadline)
34
+ data = +""
35
+ loop do
36
+ remaining = deadline && deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
37
+ raise TransportError, "HITL boundary deadline exceeded" if remaining && remaining <= 0
38
+ ready = IO.select([socket], nil, nil, remaining)
39
+ raise TransportError, "HITL boundary deadline exceeded" unless ready
40
+
41
+ chunk = socket.read_nonblock(4096, exception: false)
42
+ raise TransportError, "HITL boundary closed before responding" if chunk.nil?
43
+ # A spurious not-ready result goes back to select; only the
44
+ # deadline ends the wait (review 8x32r9b4).
45
+ next if chunk == :wait_readable
46
+
47
+ data << chunk
48
+ if data.bytesize > Protocol::MAX_FRAME_BYTES
49
+ raise TransportError, "HITL boundary response is too large"
50
+ end
51
+ return data if data.include?("\n")
52
+ end
53
+ end
54
+
55
+ # A consume without a timeout waits indefinitely at the client
56
+ # boundary too; every bounded call gets a deadline slightly
57
+ # beyond its own wait so the server result wins the race.
58
+ def request_deadline(op, params, selected)
59
+ if !selected.nil? && (!selected.is_a?(Numeric) || !selected.finite?)
60
+ raise TransportError, "HITL boundary deadline is invalid"
61
+ end
62
+ local = deadline_for(op, params)
63
+ [selected, local].compact.min
64
+ end
65
+
66
+ def deadline_for(op, params)
67
+ if op == "consume" && Integer(params["timeout"]) <= 0
68
+ nil
69
+ else
70
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) + @deadline_seconds + Integer(params["timeout"])
71
+ end
72
+ rescue ArgumentError, TypeError
73
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) + @deadline_seconds
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Hitl
5
+ module Lifecycle
6
+ # The connection-serving half of a lifecycle boundary (spec
7
+ # 8wq.t.34i, reused by the TLS/TCP crossing of spec 8xa.t.nh0):
8
+ # one bounded request frame per dispatch, classified errors cross
9
+ # the wire, idleness and frame size stay bounded. The includer
10
+ # supplies `@idle_seconds`, `@logger` and `store_for(connection)`.
11
+ module FrameServing
12
+ def serve_connection(socket)
13
+ peer = authenticate!(socket)
14
+ store = store_for(peer)
15
+ loop do
16
+ line = read_frame(socket)
17
+ break if line.nil?
18
+
19
+ response = dispatch(store, line)
20
+ write_frame(socket, response)
21
+ end
22
+ rescue Lifecycle::Error => e
23
+ write_frame(socket, Protocol.encode_error(e)) rescue nil
24
+ rescue SystemCallError, IOError
25
+ nil
26
+ ensure
27
+ socket.close rescue nil
28
+ end
29
+
30
+ private
31
+
32
+ # One request frame per dispatch; lifecycle failures return
33
+ # classified error frames, transport/frame failures close the
34
+ # connection (serve_connection's rescues).
35
+ def dispatch(store, line)
36
+ request = Protocol.decode_request(line)
37
+ result = execute(store, request[:op], request[:params])
38
+ Protocol.encode_result(result)
39
+ rescue Protocol::FrameError, Lifecycle::Error, ArgumentError, TypeError => e
40
+ Protocol.encode_error(e)
41
+ end
42
+
43
+ def execute(store, op, params)
44
+ case op
45
+ when "ping" then {"pong" => true}
46
+ when "proposal-create" then store.proposal_create(**symbolize(params))
47
+ when "proposal-show" then store.proposal_show(required(params, "id"), project: required(params, "project"), history_after: params.fetch("history_after", 0))
48
+ when "proposal-revise" then store.proposal_revise(required(params, "id"), project: required(params, "project"), expected_revision: required(params, "expected_revision"),
49
+ operation_id: required(params, "operation_id"), document: required(params, "document"))
50
+ when "proposal-ack"
51
+ store.proposal_acknowledge(required(params, "id"), project: required(params, "project"), submitted_at: required(params, "submitted_at"))
52
+ when "proposal-reply"
53
+ store.proposal_reply(required(params, "id"), project: required(params, "project"), answer: required(params, "answer"),
54
+ received_at: required(params, "received_at"), sequence: required(params, "sequence"))
55
+ when "proposal-reconcile"
56
+ store.proposal_reconcile(required(params, "id"), project: required(params, "project"), checkpoint: required(params, "checkpoint"))
57
+ when "proposal-wake" then store.proposal_wake(project: required(params, "project"), after: params["after"])
58
+ when "proposal-due" then store.proposal_due(project: required(params, "project"), after: params["after"])
59
+ when "proposal-history" then store.proposal_history(project: required(params, "project"),
60
+ query: params.fetch("query", ""), after: params["after"])
61
+ when "create" then store.create(**symbolize(params))
62
+ when "read" then store.read(required(params, "id"))
63
+ when "deliver" then store.deliver(required(params, "id"), answer_reader(params))
64
+ when "consume"
65
+ store.consume(required(params, "id"), timeout: Integer(params["timeout"] || 0),
66
+ operation: params["operation"], native_delivery: params.fetch("native_delivery", false))
67
+ when "cancel" then store.cancel(required(params, "id"), reason: params["reason"].to_s)
68
+ when "pending" then store.pending_page(project: params["project"], after: params["after"])
69
+ when "states" then store.states
70
+ end
71
+ end
72
+
73
+ def symbolize(params)
74
+ params.transform_keys { |key| key.to_sym }
75
+ end
76
+
77
+ def required(params, key)
78
+ value = params[key]
79
+ raise Protocol::FrameError, "boundary parameter '#{key}' is required" if value.nil? || value.to_s.empty?
80
+
81
+ value
82
+ end
83
+
84
+ def answer_reader(params)
85
+ answer = params["answer"].to_s
86
+ ->(limit) { answer[0, limit] }
87
+ end
88
+
89
+ def read_frame(socket)
90
+ data = +""
91
+ loop do
92
+ ready = IO.select([socket], nil, nil, @idle_seconds)
93
+ return nil unless ready
94
+
95
+ chunk = socket.read_nonblock(4096, exception: false)
96
+ return nil if chunk.nil?
97
+ next sleep(0.01) if chunk == :wait_readable
98
+
99
+ data << chunk
100
+ if data.bytesize > Protocol::MAX_FRAME_BYTES
101
+ write_frame(socket, Protocol.encode_error(Protocol::FrameError.new("request frame is too large"))) rescue nil
102
+ return nil
103
+ end
104
+ return data if data.include?("\n")
105
+ end
106
+ end
107
+
108
+ def write_frame(socket, frame)
109
+ socket.write(frame)
110
+ socket.flush
111
+ end
112
+
113
+ def log(message)
114
+ @logger&.call("[ace-hitl] #{message}")
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Hitl
5
+ module Lifecycle
6
+ # The client request surface of the scoped store boundary (spec
7
+ # 8wq.t.34i), shared verbatim by the Unix client and the
8
+ # mutual-TLS Hermes client (spec 8xa.t.nh0): the existing bounded
9
+ # lifecycle frames are exchanged unchanged on both transports.
10
+ # The includer implements `#request(op, params, deadline:)`.
11
+ module LifecycleRequests
12
+ def proposal_create(**params)
13
+ request("proposal-create", params)
14
+ end
15
+
16
+ def proposal_show(id, project:, history_after: 0)
17
+ request("proposal-show", {"id" => id, "project" => project, "history_after" => history_after})
18
+ end
19
+
20
+ def proposal_revise(id, project:, expected_revision:, operation_id:, document:)
21
+ request("proposal-revise", {"id" => id, "project" => project, "expected_revision" => expected_revision, "operation_id" => operation_id, "document" => document})
22
+ end
23
+
24
+ def proposal_acknowledge(id, project:, submitted_at:)
25
+ request("proposal-ack", {"id" => id, "project" => project, "submitted_at" => submitted_at})
26
+ end
27
+
28
+ def proposal_reply(id, project:, **params)
29
+ request("proposal-reply", {"id" => id, "project" => project}.merge(params))
30
+ end
31
+
32
+ def proposal_reconcile(id, project:, checkpoint:)
33
+ request("proposal-reconcile", {"id" => id, "project" => project, "checkpoint" => checkpoint})
34
+ end
35
+
36
+ def proposal_wake(project:, after: nil)
37
+ request("proposal-wake", {"project" => project, "after" => after}.compact)
38
+ end
39
+
40
+ def proposal_due(project:, after: nil)
41
+ request("proposal-due", {"project" => project, "after" => after}.compact)
42
+ end
43
+
44
+ def proposal_history(project:, query: "", after: nil)
45
+ request("proposal-history", {"project" => project, "query" => query, "after" => after}.compact)
46
+ end
47
+
48
+ def create(deadline: nil, **params)
49
+ request("create", params.transform_keys(&:to_s), deadline: deadline)
50
+ end
51
+
52
+ def read(id, deadline: nil)
53
+ request("read", {"id" => id}, deadline: deadline)
54
+ end
55
+
56
+ def deliver(id, answer)
57
+ request("deliver", {"id" => id, "answer" => answer.to_s})
58
+ end
59
+
60
+ def consume(id, timeout: 0, operation: nil, native_delivery: false, deadline: nil)
61
+ params = {"id" => id, "timeout" => Integer(timeout)}
62
+ params["operation"] = operation if operation
63
+ params["native_delivery"] = true if native_delivery
64
+ request("consume", params, deadline: deadline)
65
+ end
66
+
67
+ def cancel(id, reason: "", deadline: nil)
68
+ request("cancel", {"id" => id, "reason" => reason.to_s}, deadline: deadline)
69
+ end
70
+
71
+ def pending(project: nil)
72
+ items = []
73
+ after = nil
74
+ loop do
75
+ page = pending_page(project: project, after: after)
76
+ items.concat(page.fetch("items"))
77
+ cursor = page.fetch("next")
78
+ break unless cursor
79
+ unless cursor.is_a?(String) && (after.nil? || cursor > after)
80
+ raise TransportError, "pending cursor did not advance"
81
+ end
82
+ after = cursor
83
+ end
84
+ items
85
+ end
86
+
87
+ def pending_page(project: nil, after: nil)
88
+ request("pending", {"project" => project, "after" => after}.compact)
89
+ end
90
+
91
+ def states
92
+ request("states")
93
+ end
94
+
95
+ def ping
96
+ request("ping")
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -105,6 +105,14 @@ module Ace
105
105
  def proposal?(_peer, project:)
106
106
  false
107
107
  end
108
+
109
+ def proposal_silence?(caller_uid:, requester:, project:, operation:)
110
+ false
111
+ end
112
+
113
+ def silence_operation?(project:, operation:)
114
+ false
115
+ end
108
116
  # @return [Boolean] true when the peer may deliver answers and
109
117
  # observe pending/states for the given project
110
118
  def transport?(_peer, project: nil)
@@ -151,13 +159,36 @@ module Ace
151
159
  # an ordinary requester or transport grant.
152
160
  def proposal?(peer, project:)
153
161
  current = document
162
+ proposal_principal?(peer.uid, project, current)
163
+ end
164
+
165
+ # A proposer grant allows asking. Only this separate, exact project
166
+ # operation list delegates a decision after the acknowledged window.
167
+ def proposal_silence?(caller_uid:, requester:, project:, operation:)
168
+ current = document
169
+ proposal_principal?(caller_uid, project, current) &&
170
+ delegated_operation?(project, operation, current)
171
+ end
172
+
173
+ def silence_operation?(project:, operation:)
174
+ delegated_operation?(project, operation, document)
175
+ end
176
+
177
+ private
178
+
179
+ def proposal_principal?(uid, project, current)
154
180
  uids = hitl_facts(current)["proposal_uids"]
155
- projects = authorized_projects(peer.uid, current)
156
- uids.is_a?(Array) && uids.include?(peer.uid) && projects &&
181
+ projects = authorized_projects(uid, current)
182
+ uid.is_a?(Integer) && uids.is_a?(Array) && uids.include?(uid) && projects &&
157
183
  (projects.empty? || projects.include?(project.to_s))
158
184
  end
159
185
 
160
- private
186
+ def delegated_operation?(project, operation, current)
187
+ delegations = hitl_facts(current)["silence_operations"]
188
+ operations = delegations.is_a?(Hash) && delegations[project]
189
+ operations.is_a?(Array) && operation.is_a?(String) &&
190
+ operation.match?(/\A[a-z][a-z0-9-]{0,63}\z/) && operations.include?(operation)
191
+ end
161
192
 
162
193
  def document
163
194
  @document || TrustedFile.read_yaml(@grants_path) || {}
@@ -54,6 +54,7 @@ module Ace
54
54
  claims = proposal_journal(project).proposal_claims(record["revision_id"])
55
55
  claim = claims.last
56
56
  outcome = case claim && claim["state"]
57
+ when "pending" then "queued"
57
58
  when "accepted" then "executing"
58
59
  when "succeeded" then "succeeded"
59
60
  when "failed", "failed-settled", "rejected" then "failed"
@@ -88,6 +89,7 @@ module Ace
88
89
  record = proposal_for_request!(request, project: project)
89
90
  require_transport!("proposal reconciliation", {"project" => record["project_id"]})
90
91
  proposal_transition(record) do |current|
92
+ next current unless silence_delegated?(current)
91
93
  Ace::Hitl::Proposals::Policy.resolve(current, checkpoint: checkpoint, now: proposal_now)
92
94
  end
93
95
  end
@@ -136,6 +138,11 @@ module Ace
136
138
 
137
139
  private
138
140
 
141
+ def silence_delegated?(record)
142
+ @policy.proposal_silence?(caller_uid: record["caller_uid"], requester: record["requester"],
143
+ project: record["project_id"], operation: record["operation"])
144
+ end
145
+
139
146
  def require_proposer!(project)
140
147
  unless @policy.respond_to?(:proposal?) && @policy.proposal?(@identity, project: project)
141
148
  raise PermissionError, "proposal requires the installed second-commander decision principal"
@@ -189,6 +196,8 @@ module Ace
189
196
  def proposal_transition(record, &policy)
190
197
  proposal_journal(record.fetch("project_id")).change_proposal(record["proposal_id"], assignment_id: record["assignment_id"],
191
198
  attempt_id: record["attempt_id"]) do |current|
199
+ # Selection may change while waiting for the canonical ref lock.
200
+ proposal_journal(record.fetch("project_id"))
192
201
  # A reply to an older revision cannot modify the new decision.
193
202
  next current unless current["request_id"] == record["request_id"]
194
203
  policy.call(current)
@@ -203,6 +212,7 @@ module Ace
203
212
  replay = nil
204
213
  record = @binding.with_active(assignment: assignment, attempt: attempt, project: project, requester: @identity.username) do
205
214
  proposal_journal(project).change_proposal(id, assignment_id: assignment, attempt_id: attempt) do |previous|
215
+ proposal_journal(project)
206
216
  if operation_id
207
217
  replay = proposal_revision_replay(id, expected_revision, operation_id, content, project: project)
208
218
  next previous if replay
@@ -228,6 +238,8 @@ module Ace
228
238
  "attempt_id" => attempt, "project_id" => project, "requester" => @identity.username,
229
239
  "caller_uid" => @identity.uid, "content_digest" => Digest::SHA256.hexdigest(JSON.generate(content)),
230
240
  "state" => "awaiting-delivery", "created_at" => proposal_now,
241
+ "silence_delegated" => @policy.proposal_silence?(caller_uid: @identity.uid,
242
+ requester: @identity.username, project: project, operation: content.fetch("operation")),
231
243
  "authorization" => request, "lifecycle_request" => projection).tap do |value|
232
244
  if operation_id
233
245
  value["revision_operation_id"] = operation_id
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "etc"
4
+
5
+ module Ace
6
+ module Hitl
7
+ module Lifecycle
8
+ # The authenticated caller of one mutually authenticated TLS
9
+ # connection (spec 8xa.t.nh0): resolved from the VERIFIED
10
+ # project-scoped client certificate — single URI SAN, exact
11
+ # fingerprint, issuing CA and EKU checked before any lifecycle
12
+ # frame is decoded — never from payload fields or claim strings.
13
+ #
14
+ # A remote peer has no local uid: every check that would use one
15
+ # fails closed. The username is the exact verified URI SAN token,
16
+ # so requester-only store paths never match a foreign remote peer,
17
+ # and effect callbacks that would need a local privilege drop
18
+ # escalate exactly like the existing non-root transport case. The
19
+ # peer's OWN token materializes to the service identity that
20
+ # terminates the crossing (public projections and attribution of
21
+ # the requests it created); foreign names still resolve through
22
+ # the passwd database and unknown ones fail closed.
23
+ class RemotePeer
24
+ attr_reader :project, :identity
25
+
26
+ def initialize(project:, identity:)
27
+ @project = project.to_s
28
+ @identity = identity
29
+ end
30
+
31
+ def username
32
+ @identity
33
+ end
34
+
35
+ def root?
36
+ false
37
+ end
38
+
39
+ # No local identity exists across the crossing; used only for
40
+ # fail-closed comparisons (see Effects.run).
41
+ def euid
42
+ nil
43
+ end
44
+
45
+ def uid
46
+ nil
47
+ end
48
+
49
+ def gid
50
+ nil
51
+ end
52
+
53
+ def pid
54
+ nil
55
+ end
56
+
57
+ def ids
58
+ [nil, nil]
59
+ end
60
+
61
+ def user_ids(name)
62
+ return [Process.uid, Process.gid] if name.to_s == @identity
63
+
64
+ entry = Etc.getpwnam(name.to_s)
65
+ [entry.uid, entry.gid]
66
+ rescue ArgumentError
67
+ raise Lifecycle::Error, "unknown requester identity: #{name}"
68
+ end
69
+
70
+ def group_id(name)
71
+ Etc.getgrnam(name.to_s).gid
72
+ rescue ArgumentError
73
+ raise Lifecycle::Error, "unknown group identity: #{name}"
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
@@ -7,6 +7,7 @@ require_relative "protocol"
7
7
  require_relative "peer"
8
8
  require_relative "store"
9
9
  require_relative "otp_vault"
10
+ require_relative "frame_serving"
10
11
 
11
12
  module Ace
12
13
  module Hitl
@@ -22,7 +23,12 @@ module Ace
22
23
  # nowhere, transfer exactly once, and die with the challenge or
23
24
  # the service.
24
25
  class Service
26
+ include FrameServing
27
+
25
28
  DEFAULT_SOCKET_MODE = 0o660
29
+ BROAD_CONTROL_GROUPS = [Store::DEFAULT_GROUP].freeze
30
+ LEGACY_SHARED_SOCKET_DIRECTORIES = ["/run/lab"].freeze
31
+ LEGACY_LABD_SOCKET = "labd.sock"
26
32
  # An idle connection holds a thread; bounded idleness keeps a
27
33
  # wedged peer from pinning the service (review 8x327buo).
28
34
  DEFAULT_IDLE_SECONDS = 120.0
@@ -118,28 +124,14 @@ module Ace
118
124
  # multi-UID acceptance fixture uses to drive real peers through
119
125
  # the real dispatch path.
120
126
  def serve_connection(socket)
121
- peer = authenticate!(socket)
122
- store = store_for(peer)
123
- loop do
124
- line = read_frame(socket)
125
- break if line.nil?
126
-
127
- response = dispatch(store, line)
128
- write_frame(socket, response)
129
- end
130
- rescue Lifecycle::Error => e
131
- write_frame(socket, Protocol.encode_error(e)) rescue nil
132
- rescue SystemCallError, IOError
133
- nil
134
- ensure
135
- socket.close rescue nil
127
+ super
136
128
  end
137
129
 
138
130
  private
139
131
 
140
132
  def prepare!
141
133
  begin
142
- group_id = Etc.getgrnam(@group).gid
134
+ group_id = verify_control_group!
143
135
  verify_socket_directory!
144
136
  acquire_endpoint_lock!
145
137
  recover_stale_endpoint!
@@ -193,16 +185,39 @@ module Ace
193
185
  # must exist, must not be world-writable, and must be owned by
194
186
  # the service identity or root — otherwise anyone who can replace
195
187
  # the socket can impersonate the service.
188
+ def verify_control_group!
189
+ # Named broad groups are forbidden even on a host where that
190
+ # deployment group has not been created yet.
191
+ group = Etc.getgrnam(@group) unless BROAD_CONTROL_GROUPS.include?(@group)
192
+ if group.nil? || group.gid.zero? || BROAD_CONTROL_GROUPS.include?(group.name)
193
+ raise TransportError,
194
+ "HITL boundary control group is too broad: #{@group} " \
195
+ "(configure a dedicated lifecycle group; root and #{Store::DEFAULT_GROUP} are refused)"
196
+ end
197
+
198
+ group.gid
199
+ end
200
+
196
201
  def verify_socket_directory!
197
202
  dir = File.dirname(@socket_path)
203
+ expanded_dir = File.expand_path(dir)
204
+ if LEGACY_SHARED_SOCKET_DIRECTORIES.include?(expanded_dir)
205
+ raise TransportError,
206
+ "HITL boundary socket directory is shared with legacy labd: #{dir} " \
207
+ "(configure a dedicated lifecycle directory)"
208
+ end
209
+
198
210
  FileUtils.mkdir_p(dir)
199
211
  stat = File.lstat(dir)
200
- return if (stat.uid.zero? || stat.uid == Process.uid) && (stat.mode & 0o002).zero?
212
+ protected_directory = (stat.uid.zero? || stat.uid == Process.uid) && (stat.mode & 0o002).zero?
213
+ legacy_socket = File.join(dir, LEGACY_LABD_SOCKET)
214
+ legacy_socket_exists = File.exist?(legacy_socket) || File.symlink?(legacy_socket)
215
+ return if protected_directory && !legacy_socket_exists
201
216
 
202
217
  raise TransportError,
203
218
  "HITL boundary socket directory is not protected: #{dir} " \
204
219
  "(use a dedicated service/root-owned, non-world-writable directory; " \
205
- "do not share a directory containing unrelated service sockets)"
220
+ "do not share a directory containing unrelated service sockets or legacy labd)"
206
221
  end
207
222
 
208
223
  def authenticate!(socket)
@@ -238,63 +253,6 @@ module Ace
238
253
  )
239
254
  end
240
255
 
241
- # One request frame per dispatch; lifecycle failures return
242
- # classified error frames, transport/frame failures close the
243
- # connection (serve_connection's rescues).
244
- def dispatch(store, line)
245
- request = Protocol.decode_request(line)
246
- result = execute(store, request[:op], request[:params])
247
- Protocol.encode_result(result)
248
- rescue Protocol::FrameError, Lifecycle::Error, ArgumentError, TypeError => e
249
- Protocol.encode_error(e)
250
- end
251
-
252
- def execute(store, op, params)
253
- case op
254
- when "ping" then {"pong" => true}
255
- when "proposal-create" then store.proposal_create(**symbolize(params))
256
- when "proposal-show" then store.proposal_show(required(params, "id"), project: required(params, "project"), history_after: params.fetch("history_after", 0))
257
- when "proposal-revise" then store.proposal_revise(required(params, "id"), project: required(params, "project"), expected_revision: required(params, "expected_revision"),
258
- operation_id: required(params, "operation_id"), document: required(params, "document"))
259
- when "proposal-ack"
260
- store.proposal_acknowledge(required(params, "id"), project: required(params, "project"), submitted_at: required(params, "submitted_at"))
261
- when "proposal-reply"
262
- store.proposal_reply(required(params, "id"), project: required(params, "project"), answer: required(params, "answer"),
263
- received_at: required(params, "received_at"), sequence: required(params, "sequence"))
264
- when "proposal-reconcile"
265
- store.proposal_reconcile(required(params, "id"), project: required(params, "project"), checkpoint: required(params, "checkpoint"))
266
- when "proposal-wake" then store.proposal_wake(project: required(params, "project"), after: params["after"])
267
- when "proposal-due" then store.proposal_due(project: required(params, "project"), after: params["after"])
268
- when "proposal-history" then store.proposal_history(project: required(params, "project"),
269
- query: params.fetch("query", ""), after: params["after"])
270
- when "create" then store.create(**symbolize(params))
271
- when "read" then store.read(required(params, "id"))
272
- when "deliver" then store.deliver(required(params, "id"), answer_reader(params))
273
- when "consume"
274
- store.consume(required(params, "id"), timeout: Integer(params["timeout"] || 0),
275
- operation: params["operation"], native_delivery: params.fetch("native_delivery", false))
276
- when "cancel" then store.cancel(required(params, "id"), reason: params["reason"].to_s)
277
- when "pending" then store.pending_page(project: params["project"], after: params["after"])
278
- when "states" then store.states
279
- end
280
- end
281
-
282
- def symbolize(params)
283
- params.transform_keys { |key| key.to_sym }
284
- end
285
-
286
- def required(params, key)
287
- value = params[key]
288
- raise Protocol::FrameError, "boundary parameter '#{key}' is required" if value.nil? || value.to_s.empty?
289
-
290
- value
291
- end
292
-
293
- def answer_reader(params)
294
- answer = params["answer"].to_s
295
- ->(limit) { answer[0, limit] }
296
- end
297
-
298
256
  # Keep one stable lock inode for the endpoint. Holding the lock
299
257
  # through shutdown prevents simultaneous starters from recovering
300
258
  # or replacing a socket another invocation is still acquiring.
@@ -344,37 +302,10 @@ module Ace
344
302
  nil
345
303
  end
346
304
 
347
- def read_frame(socket)
348
- data = +""
349
- loop do
350
- ready = IO.select([socket], nil, nil, @idle_seconds)
351
- return nil unless ready
352
-
353
- chunk = socket.read_nonblock(4096, exception: false)
354
- return nil if chunk.nil?
355
- next sleep(0.01) if chunk == :wait_readable
356
-
357
- data << chunk
358
- if data.bytesize > Protocol::MAX_FRAME_BYTES
359
- write_frame(socket, Protocol.encode_error(Protocol::FrameError.new("request frame is too large"))) rescue nil
360
- return nil
361
- end
362
- return data if data.include?("\n")
363
- end
364
- end
365
-
366
- def write_frame(socket, frame)
367
- socket.write(frame)
368
- socket.flush
369
- end
370
-
371
305
  def root_store
372
306
  Store.new(root: @root, binding: @binding, policy: @policy, group: @group)
373
307
  end
374
308
 
375
- def log(message)
376
- @logger&.call("[ace-hitl] #{message}")
377
- end
378
309
  end
379
310
  end
380
311
  end