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.
- checksums.yaml +4 -4
- data/.ace-defaults/hitl/config.yml +0 -0
- data/.ace-defaults/nav/protocols/skill-sources/ace-hitl.yml +0 -0
- data/.ace-defaults/nav/protocols/wfi-sources/ace-hitl.yml +0 -0
- data/CHANGELOG.md +20 -0
- data/Rakefile +0 -0
- data/docs/demo/ace-hitl-scope-and-answer.tape.yml +0 -0
- data/docs/demo/fixtures/demo-context.md +0 -0
- data/docs/usage.md +47 -2
- data/handbook/README.md +0 -0
- data/handbook/skills/as-hitl/SKILL.md +0 -0
- data/handbook/workflow-instructions/hitl.wf.md +0 -0
- data/lib/ace/hitl/atoms/hitl_effect_validator.rb +0 -0
- data/lib/ace/hitl/atoms/hitl_file_pattern.rb +0 -0
- data/lib/ace/hitl/atoms/hitl_id_formatter.rb +0 -0
- data/lib/ace/hitl/cli/commands/create.rb +0 -0
- data/lib/ace/hitl/cli/commands/list.rb +0 -0
- data/lib/ace/hitl/cli/commands/serve.rb +38 -2
- data/lib/ace/hitl/cli/commands/show.rb +0 -0
- data/lib/ace/hitl/lifecycle/client.rb +11 -151
- data/lib/ace/hitl/lifecycle/credential_path_policy.rb +183 -0
- data/lib/ace/hitl/lifecycle/frame_exchange.rb +78 -0
- data/lib/ace/hitl/lifecycle/frame_serving.rb +119 -0
- data/lib/ace/hitl/lifecycle/lifecycle_requests.rb +101 -0
- data/lib/ace/hitl/lifecycle/policy.rb +34 -3
- data/lib/ace/hitl/lifecycle/proposals.rb +12 -0
- data/lib/ace/hitl/lifecycle/remote_peer.rb +78 -0
- data/lib/ace/hitl/lifecycle/service.rb +33 -102
- data/lib/ace/hitl/lifecycle/store.rb +1 -0
- data/lib/ace/hitl/lifecycle/tls_access_policy.rb +50 -0
- data/lib/ace/hitl/lifecycle/tls_authority.rb +121 -0
- data/lib/ace/hitl/lifecycle/tls_credentials.rb +210 -0
- data/lib/ace/hitl/lifecycle/tls_material.rb +133 -0
- data/lib/ace/hitl/lifecycle/tls_project_scope.rb +68 -0
- data/lib/ace/hitl/lifecycle/tls_service.rb +242 -0
- data/lib/ace/hitl/lifecycle.rb +11 -0
- data/lib/ace/hitl/models/hitl_event.rb +0 -0
- data/lib/ace/hitl/molecules/hitl_answer_editor.rb +0 -0
- data/lib/ace/hitl/molecules/hitl_config_loader.rb +0 -0
- data/lib/ace/hitl/molecules/hitl_creator.rb +0 -0
- data/lib/ace/hitl/molecules/hitl_display_formatter.rb +0 -0
- data/lib/ace/hitl/molecules/hitl_loader.rb +0 -0
- data/lib/ace/hitl/molecules/hitl_resolver.rb +0 -0
- data/lib/ace/hitl/molecules/hitl_scanner.rb +0 -0
- data/lib/ace/hitl/molecules/resume_dispatcher.rb +0 -0
- data/lib/ace/hitl/molecules/worktree_scope_resolver.rb +0 -0
- data/lib/ace/hitl/proposals/policy.rb +1 -0
- data/lib/ace/hitl/providers/lab/assignment_binding.rb +20 -1
- data/lib/ace/hitl/version.rb +1 -1
- 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(
|
|
156
|
-
uids.is_a?(Array) && uids.include?(
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|