ace-lab 0.7.0 → 0.9.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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  3. data/docs/usage.md +343 -0
  4. data/lib/ace/lab/cli/commands/assignment.rb +92 -0
  5. data/lib/ace/lab/cli/commands/command_execute.rb +28 -0
  6. data/lib/ace/lab/cli/commands/run.rb +65 -0
  7. data/lib/ace/lab/cli/commands/service.rb +12 -28
  8. data/lib/ace/lab/cli.rb +16 -1
  9. data/lib/ace/lab/molecules/command_owner_admission.rb +183 -0
  10. data/lib/ace/lab/molecules/command_policy.rb +158 -0
  11. data/lib/ace/lab/molecules/grant_resolver.rb +15 -0
  12. data/lib/ace/lab/molecules/ordinary_admission_resolver.rb +20 -1
  13. data/lib/ace/lab/molecules/ordinary_listener.rb +41 -17
  14. data/lib/ace/lab/molecules/ordinary_merge_read.rb +45 -0
  15. data/lib/ace/lab/molecules/ordinary_native_read.rb +106 -0
  16. data/lib/ace/lab/molecules/ordinary_owner_admission.rb +152 -0
  17. data/lib/ace/lab/molecules/ordinary_owner_config.rb +232 -0
  18. data/lib/ace/lab/molecules/ordinary_receiver_config.rb +12 -4
  19. data/lib/ace/lab/molecules/ordinary_socket.rb +6 -9
  20. data/lib/ace/lab/molecules/ordinary_worktree_read.rb +216 -0
  21. data/lib/ace/lab/molecules/service_executor.rb +9 -39
  22. data/lib/ace/lab/molecules/service_policy.rb +15 -4
  23. data/lib/ace/lab/organisms/authority_composition.rb +1 -1
  24. data/lib/ace/lab/organisms/command_executor.rb +220 -0
  25. data/lib/ace/lab/organisms/command_run_client.rb +131 -0
  26. data/lib/ace/lab/organisms/ordinary_assignment_client.rb +120 -0
  27. data/lib/ace/lab/organisms/ordinary_authority_composition.rb +153 -0
  28. data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +77 -61
  29. data/lib/ace/lab/organisms/ordinary_merge_request.rb +41 -7
  30. data/lib/ace/lab/version.rb +1 -1
  31. metadata +33 -6
  32. data/lib/ace/lab/molecules/ordinary_ff_merge.rb +0 -86
@@ -12,7 +12,7 @@ module Ace
12
12
  # entrypoint). Binds the configured unix socket and answers the exact
13
13
  # framed protocol the OrdinarySocket client speaks: one bounded JSON
14
14
  # document each way behind the shared handshake line. Authentication is
15
- # MUTUAL through SO_PEERCRED: every connection's peer UID must equal
15
+ # MUTUAL through OS peer credentials: every connection's peer UID must equal
16
16
  # the configured producer principal UID, and the listener itself runs
17
17
  # as the configured receiver UID — the identity the client verifies on
18
18
  # its side. Every identity, framing, deadline or parse failure is
@@ -24,11 +24,15 @@ module Ace
24
24
  LIMIT = OrdinarySocket::LIMIT
25
25
  DEADLINE_SECONDS = 30
26
26
 
27
- def initialize(socket_path:, peer_uid:, handler:, lock_path: nil, logger: nil)
27
+ def initialize(socket_path:, peer_uid:, handler:, socket_gid: nil, lock_path: nil, logger: nil)
28
28
  unless socket_path.is_a?(String) && socket_path.start_with?("/") &&
29
29
  peer_uid.is_a?(Integer) && peer_uid >= 0 && handler.respond_to?(:call)
30
30
  raise ArgumentError, "ordinary listener needs an absolute socket path, a peer uid and a handler"
31
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
32
36
  @socket_path = socket_path
33
37
  @peer_uid = peer_uid
34
38
  @handler = handler
@@ -40,12 +44,15 @@ module Ace
40
44
  # Serve until #stop. Excludes a second listener through one owned
41
45
  # lock file, removes only its own stale endpoint, and always removes
42
46
  # the endpoint it created.
43
- def serve
47
+ def serve(on_ready: nil)
44
48
  acquire_lock!
45
49
  remove_stale_endpoint!
46
50
  @listener = UNIXServer.new(@socket_path)
47
- File.chmod(0o600, @socket_path)
48
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!)
49
56
  until @stopping
50
57
  socket = @listener.accept
51
58
  serve_connection(socket)
@@ -59,6 +66,11 @@ module Ace
59
66
  @lock&.close
60
67
  end
61
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
+
62
74
  def stop
63
75
  @stopping = true
64
76
  @listener&.close
@@ -75,26 +87,27 @@ module Ace
75
87
  verify_peer!(socket)
76
88
  submission = read_request!(socket, deadline)
77
89
  response = @handler.call(submission)
78
- write_response!(socket, response, deadline)
90
+ write_response_safely(socket, response, deadline)
79
91
  if response.is_a?(Hash) && response["status"] == "ok" && @handler.respond_to?(:execute)
80
92
  begin
81
- # The durable acknowledgment is already on the wire; the
93
+ # Admission is durable even if the acknowledgment was lost; the
82
94
  # execution lifetime belongs to the canonical record, not
83
95
  # the short connection. A lost lifecycle stays pending or
84
96
  # uncertain and reconciles by the original reference.
85
97
  @handler.execute(response.dig("data", "request_id"))
86
- rescue StandardError
87
- nil
98
+ rescue StandardError => error
99
+ log_failure("effect", error)
88
100
  end
89
101
  end
90
102
  rescue OrdinaryRefused => e
91
- write_response!(socket, e.envelope, deadline)
92
- rescue OrdinaryListenerError
103
+ write_response_safely(socket, e.envelope, deadline)
104
+ rescue OrdinaryListenerError, IOError, SystemCallError => error
93
105
  # Fail closed without a fabricated envelope; the client classifies
94
106
  # a lost/unreadable response as ordinary_merge_unavailable.
95
- nil
96
- rescue StandardError
97
- write_response!(socket, refusal_envelope("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",
98
111
  "receiver failed to handle the request"), deadline)
99
112
  ensure
100
113
  begin
@@ -119,16 +132,27 @@ module Ace
119
132
 
120
133
  private
121
134
 
122
- # Mutual half of SO_PEERCRED: the connecting process must run as the
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
123
148
  # configured producer principal UID. Anything else is refused before
124
149
  # one byte of request is read.
125
150
  def verify_peer!(socket)
126
- credentials = socket.getsockopt(:SOCKET, :PEERCRED)
127
- _peer_pid, peer_uid, = credentials.unpack("L3")
151
+ peer_uid, = socket.getpeereid
128
152
  return if peer_uid == @peer_uid
129
153
  raise OrdinaryRefused, refusal_envelope("ordinary_merge_unauthenticated",
130
154
  "connecting principal is not the configured ordinary overseer")
131
- rescue SystemCallError, IOError
155
+ rescue SystemCallError, IOError, SocketError, NotImplementedError
132
156
  raise OrdinaryListenerError, "peer credentials are unavailable"
133
157
  end
134
158
 
@@ -0,0 +1,45 @@
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
@@ -0,0 +1,106 @@
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
@@ -0,0 +1,152 @@
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
+ def supervisor_ended!(binding:, deadline: default_deadline)
48
+ @config.verify_selection!
49
+ process = binding.fetch("process_identity")
50
+ @config.selected_principal!(process.fetch("uid"))
51
+ observation = Ace::Herdr::Molecules::BoundedObservation.new(deadline: deadline, binary: @config.runtime_binary)
52
+ result = Ace::Runtime::Molecules::ProcessIdentity.new(executor: observation).generation_status(process)
53
+ unless result.fetch("liveness") == "ended"
54
+ raise Ace::Assign::AttemptErrors::EvidenceUnavailable, "original supervisor process generation is live or unknown"
55
+ end
56
+ @config.verify_selection!
57
+ result
58
+ end
59
+
60
+ # The caller's claimed head is only a comparison. The accepted start
61
+ # uses the observed worker's checkout and process, never daemon cwd.
62
+ def execution!(peer:, pane:, head:, deadline: default_deadline)
63
+ unless head.is_a?(String) && head.match?(/\A[0-9a-f]{40}\z/)
64
+ raise ArgumentError, "ordinary start HEAD is invalid"
65
+ end
66
+ identity, cwd = observe_identity(peer: peer, pane: pane, deadline: deadline, with_cwd: true)
67
+ checkout = OrdinaryWorktreeRead.new(root: @config.worktrees.fetch("root"),
68
+ common_dir: @config.worktrees.fetch("common_dir"), deadline: deadline).observe(cwd)
69
+ after, after_cwd = observe_identity(peer: peer, pane: pane, deadline: deadline, with_cwd: true)
70
+ unless after == identity && after_cwd == cwd && checkout.fetch("head") == head
71
+ raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "ordinary worker process, working directory or HEAD changed"
72
+ end
73
+ {identity: identity, checkout: checkout}
74
+ end
75
+
76
+ # Read the original canonical native owner for a trusted consumer.
77
+ # The binding must come from accepted history, never requester fields.
78
+ # This observes ownership; it does not grant the target's privileges to
79
+ # the caller or manufacture a kernel-authenticated connection identity.
80
+ def recorded_binding!(binding:, caller_pid: nil, deadline: default_deadline)
81
+ observe_recorded_binding(binding, caller_pid, deadline).first
82
+ end
83
+
84
+ def recorded_execution!(binding:, head:, deadline: default_deadline)
85
+ before, cwd = observe_recorded_binding(binding, nil, deadline)
86
+ checkout = OrdinaryWorktreeRead.new(root: @config.worktrees.fetch("root"),
87
+ common_dir: @config.worktrees.fetch("common_dir"), deadline: deadline).observe(cwd)
88
+ after, after_cwd = observe_recorded_binding(binding, nil, deadline)
89
+ unless before == after && cwd == after_cwd && checkout.fetch("head") == head
90
+ raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "ordinary recorded worker or HEAD changed"
91
+ end
92
+ {runtime_binding: after, checkout: checkout}
93
+ end
94
+
95
+ private
96
+
97
+ def observe_recorded_binding(binding, caller_pid, deadline)
98
+ @config.verify_selection!
99
+ uid = binding.fetch("process_identity").fetch("uid")
100
+ participant = @config.selected_principal!(uid)
101
+ unless participant && binding["runtime"] == participant.fetch("runtime") && binding["session"] == participant.fetch("workspace")
102
+ raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "ordinary recorded participant is no longer selected"
103
+ end
104
+ native, runtime = native_runtime(deadline)
105
+ actual = runtime.process_binding(pane: binding.fetch("pane"), caller_pid: caller_pid || binding.fetch("process_identity").fetch("pid"))
106
+ unless actual == binding
107
+ raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "ordinary recorded native owner is unavailable or changed"
108
+ end
109
+ cwd = native.process_cwd(binding.fetch("process_identity").fetch("pid"))
110
+ @config.selected_principal!(uid)
111
+ [actual, cwd]
112
+ end
113
+
114
+ def native_runtime(deadline)
115
+ return [@runtime, @runtime] if @runtime
116
+ observation = Ace::Herdr::Molecules::BoundedObservation.new(deadline: deadline, binary: @config.runtime_binary)
117
+ native = OrdinaryNativeRead.new(config: @config, deadline: deadline, peer: @peer)
118
+ native.preflight!
119
+ [native, Ace::Herdr::Organisms::RuntimeAdapter.new(executor: native, env: {},
120
+ process_identity: Ace::Runtime::Molecules::ProcessIdentity.new(executor: observation))]
121
+ end
122
+
123
+ def observe_identity(peer:, pane:, deadline:, with_cwd:)
124
+ @peer.live!(peer, deadline: deadline)
125
+ participant = @config.participant!(peer)
126
+ unless pane.is_a?(String) && pane.bytesize.between?(1, 128) && !pane.include?("\0")
127
+ raise ArgumentError, "ordinary native pane selector is invalid"
128
+ end
129
+ native, runtime = native_runtime(deadline)
130
+ binding = runtime.process_binding(pane: pane, caller_pid: peer.fetch("pid"))
131
+ unless binding && binding["runtime"] == participant.fetch("runtime") &&
132
+ binding["session"] == participant.fetch("workspace") && binding["pane"] == pane &&
133
+ binding.dig("process_identity", "uid") == peer.fetch("uid")
134
+ raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "ordinary native owner is unavailable or differs"
135
+ end
136
+ @peer.live!(peer, deadline: deadline)
137
+ @config.participant!(peer)
138
+ # Ordinary native agents may share an OS account. Preserve that actor
139
+ # for HITL attribution; privilege follows observed native placement,
140
+ # not a claimed role or an invented per-agent account alias.
141
+ role = participant.fetch("supervisor_panes").include?(binding.fetch("pane")) ? "coordinator" : "worker"
142
+ identity = Ace::Assign::Molecules::ExecutionIdentityResolver::Identity.new(
143
+ actor: participant.fetch("actor"), role: role, runtime: participant.fetch("runtime"),
144
+ adapter: "ordinary", process_pid: binding.fetch("process_identity").fetch("pid"), runtime_binding: binding)
145
+ [identity, with_cwd ? native.process_cwd(identity.process_pid) : nil]
146
+ end
147
+
148
+ def default_deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + 30
149
+ end
150
+ end
151
+ end
152
+ end
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+ require "json"
5
+ require "digest"
6
+ require "etc"
7
+ require "ace/assign/authority/private_directory"
8
+ require "ace/assign/authority/protected_assignment_context"
9
+ require "ace/assign/molecules/installed_journal_read"
10
+ require "ace/runtime/molecules/protected_artifact_set"
11
+ require "ace/hitl/lifecycle/store"
12
+ require_relative "../errors"
13
+
14
+ module Ace
15
+ module Lab
16
+ module Molecules
17
+ # Placement and principals only. Source selects lifecycle operations.
18
+ # Root installs this independently of requester worktrees/config cascades.
19
+ class OrdinaryOwnerConfig
20
+ PATH = "/etc/lab/ace-lab/assign-owners.yml"
21
+ ID = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/
22
+ PROJECT_KEYS = %w[service journal assignment_root lifecycle_root runtime_binary native worktrees participants hitl].freeze
23
+ SERVICE_KEYS = %w[uid gid socket_path state_root].freeze
24
+ JOURNAL_KEYS = %w[repository ref checkout_root].freeze
25
+ PARTICIPANT_KEYS = %w[gid actor runtime workspace supervisor_panes].freeze
26
+ PANE = /\A[a-zA-Z0-9][a-zA-Z0-9_.:-]{0,127}\z/
27
+ NATIVE_KEYS = %w[socket_path socket_identity server_identity version protocol].freeze
28
+ HITL_KEYS = %w[root socket_path group grants_path].freeze
29
+ HITL_OPTIONAL_KEYS = %w[tls_authority].freeze
30
+
31
+ def self.load(project:, path: PATH)
32
+ reader = Ace::Runtime::Molecules::ProtectedArtifactSet.new(file_limit: 65_536, total_limit: 65_536)
33
+ bytes = reader.with do |held|
34
+ content, = held.read_path!(path)
35
+ held.verify_unchanged!
36
+ content
37
+ end
38
+ new(YAML.safe_load(bytes, aliases: false), project: project, source: path)
39
+ rescue Psych::Exception, Ace::Runtime::Error
40
+ raise InvalidConfigurationError, "ordinary assignment owner configuration is unavailable: #{path}"
41
+ end
42
+
43
+ attr_reader :project, :service, :journal, :assignment_root, :lifecycle_root,
44
+ :runtime_binary, :native, :worktrees, :participants, :hitl, :source
45
+
46
+ def initialize(document, project:, source:)
47
+ unless document.is_a?(Hash) && document.keys.sort == %w[projects version] && document["version"] == 1 &&
48
+ document["projects"].is_a?(Hash) && document["projects"].keys.all? { |id| id.is_a?(String) && ID.match?(id) } &&
49
+ project.is_a?(String) && ID.match?(project)
50
+ invalid!
51
+ end
52
+ selected = document.fetch("projects")[project]
53
+ validate_principal_separation!(document.fetch("projects"))
54
+ invalid! unless selected.is_a?(Hash) && selected.keys.sort == PROJECT_KEYS.sort
55
+ invalid! unless absolute?(source)
56
+ @project, @source = project.dup.freeze, source.dup.freeze
57
+ # Validate a detached immutable copy; callers cannot mutate trusted selection.
58
+ selected = JSON.parse(JSON.generate(selected))
59
+ @service, @journal, @participants = selected.values_at("service", "journal", "participants")
60
+ invalid! unless @service.is_a?(Hash) && @service.keys.sort == SERVICE_KEYS.sort &&
61
+ %w[uid gid].all? { |key| uid?(@service[key]) } &&
62
+ %w[socket_path state_root].all? { |key| absolute?(@service[key]) }
63
+ invalid! unless @journal.is_a?(Hash) && @journal.keys.sort == JOURNAL_KEYS.sort &&
64
+ %w[repository checkout_root].all? { |key| absolute?(@journal[key]) } &&
65
+ @journal["ref"].is_a?(String) && @journal["ref"].match?(%r{\Arefs/[a-zA-Z0-9_-]+(?:/[a-zA-Z0-9_-]+)+\z})
66
+ @assignment_root, @lifecycle_root, @runtime_binary = selected.values_at("assignment_root", "lifecycle_root", "runtime_binary")
67
+ invalid! unless [@assignment_root, @lifecycle_root, @runtime_binary].all? { |path| absolute?(path) }
68
+ @native = selected.fetch("native")
69
+ invalid! unless @native.is_a?(Hash) && @native.keys.sort == NATIVE_KEYS.sort && absolute?(@native["socket_path"]) &&
70
+ @native["protocol"] == 22 && @native["protocol"].is_a?(Integer) && @native["version"].is_a?(String) &&
71
+ @native["version"].match?(/\A\d+\.\d+\.\d+\z/)
72
+ server = @native["server_identity"]
73
+ invalid! unless server.is_a?(Hash) && server.keys.sort == %w[gid host pid started_at uid] &&
74
+ %w[pid uid gid].all? { |key| uid?(server[key]) } &&
75
+ %w[host started_at].all? { |key| server[key].is_a?(String) && server[key].bytesize.between?(1, 512) && !server[key].include?("\0") }
76
+ endpoint = @native["socket_identity"]
77
+ invalid! unless endpoint.is_a?(Array) && endpoint.size == 3 && endpoint.all? { |value| value.is_a?(Integer) && value >= 0 } && endpoint.last.zero?
78
+ @worktrees = selected.fetch("worktrees")
79
+ invalid! unless @worktrees.is_a?(Hash) && @worktrees.keys.sort == %w[common_dir root] && @worktrees.values.all? { |path| absolute?(path) }
80
+ @hitl = selected.fetch("hitl")
81
+ invalid! unless @hitl.is_a?(Hash) && (HITL_KEYS - @hitl.keys).empty? &&
82
+ (@hitl.keys - HITL_KEYS - HITL_OPTIONAL_KEYS).empty? &&
83
+ %w[root socket_path grants_path].all? { |key| absolute?(@hitl[key]) } &&
84
+ (!@hitl.key?("tls_authority") || absolute?(@hitl["tls_authority"])) &&
85
+ @hitl["group"].is_a?(String) && ID.match?(@hitl["group"]) &&
86
+ [@service.fetch("socket_path"), @native.fetch("socket_path"), @hitl.fetch("socket_path")].uniq.size == 3
87
+ private_paths = [@assignment_root, @lifecycle_root, @journal.fetch("checkout_root"), @hitl.fetch("root")]
88
+ invalid! unless private_paths.all? { |path| path.start_with?(@service.fetch("state_root") + "/") } &&
89
+ private_paths.uniq.size == private_paths.size && private_paths.combination(2).none? { |a, b| a.start_with?(b + "/") || b.start_with?(a + "/") }
90
+ invalid! if @journal.fetch("repository").start_with?(@service.fetch("state_root") + "/") ||
91
+ @service.fetch("state_root").start_with?(@journal.fetch("repository") + "/") ||
92
+ @service.fetch("state_root") == @journal.fetch("repository")
93
+ invalid! unless @participants.is_a?(Hash) && !@participants.empty? && @participants.size <= 64
94
+ @participants.each do |uid, participant|
95
+ invalid! unless uid.is_a?(String) && uid.match?(/\A[1-9][0-9]{0,9}\z/) && uid?(uid.to_i) && uid.to_i != @service.fetch("uid") &&
96
+ participant.is_a?(Hash) && participant.keys.sort == PARTICIPANT_KEYS.sort && uid?(participant["gid"]) &&
97
+ participant["actor"].is_a?(String) && ID.match?(participant["actor"]) &&
98
+ participant["runtime"] == "herdr" && participant["workspace"].is_a?(String) && ID.match?(participant["workspace"]) &&
99
+ participant["supervisor_panes"].is_a?(Array) && participant["supervisor_panes"].size <= 16 &&
100
+ participant["supervisor_panes"].uniq == participant["supervisor_panes"] &&
101
+ participant["supervisor_panes"].all? { |pane| pane.is_a?(String) && PANE.match?(pane) }
102
+ end
103
+ invalid! unless @participants.values.map { |value| value.fetch("actor") }.uniq.size == @participants.size
104
+ @fingerprint = Digest::SHA256.hexdigest(Ace::Assign::Atoms::EvidenceDigest.canonical_json(selected))
105
+ freeze_value(selected)
106
+ freeze
107
+ end
108
+
109
+ def verify_selection!
110
+ current = self.class.load(project: @project, path: @source)
111
+ unless current.fingerprint == @fingerprint
112
+ raise InvalidConfigurationError, "ordinary assignment owner selection changed; restart with installed configuration"
113
+ end
114
+ true
115
+ end
116
+
117
+ def verify_listener!
118
+ verify_owner!
119
+ unless [Process.uid, Process.euid, Process.gid, Process.egid] ==
120
+ [@service.fetch("uid"), @service.fetch("uid"), @service.fetch("gid"), @service.fetch("gid")]
121
+ raise InvalidConfigurationError, "ordinary assignment owner process differs"
122
+ end
123
+ ([service.fetch("uid")] + participants.keys.map(&:to_i)).each { |uid| exclude_protected!(uid) }
124
+ [@service.fetch("state_root"), @assignment_root, @lifecycle_root, @journal.fetch("checkout_root")].each do |path|
125
+ Ace::Assign::Authority::PrivateDirectory.verify!(path)
126
+ end
127
+ verify_hitl_root!
128
+ wire = Ace::Runtime::Molecules::ProtectedSocket
129
+ wire.root_path!(@runtime_binary)
130
+ invalid! unless File.file?(@runtime_binary) && File.executable?(@runtime_binary)
131
+ invalid! unless File.lstat(@journal.fetch("repository")).uid == @service.fetch("uid")
132
+ reader = Ace::Assign::Molecules::InstalledJournalRead.new(repo_root: @journal.fetch("repository"),
133
+ ref: @journal.fetch("ref"), owner_uid: @service.fetch("uid"))
134
+ reader.with(deadline: wire.deadline(30)) { |_snapshot| true }
135
+ true
136
+ end
137
+
138
+ def verify_owner!
139
+ verify_selection!
140
+ exclude_protected!(@service.fetch("uid"))
141
+ true
142
+ end
143
+
144
+ def participant!(peer)
145
+ participant = selected_principal!(peer.fetch("uid"))
146
+ unless participant.fetch("gid") == peer.fetch("gid")
147
+ raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "ordinary participant group differs"
148
+ end
149
+ participant
150
+ end
151
+
152
+ # Selection only: consumers of canonical native history have an
153
+ # observed process UID, not a new authenticated socket from that owner.
154
+ def selected_principal!(uid)
155
+ verify_owner!
156
+ exclude_protected!(uid)
157
+ participant = @participants[uid.to_s]
158
+ unless participant
159
+ raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "ordinary participant is not installed for this project"
160
+ end
161
+ # HITL and the canonical coordinator use the authenticated passwd
162
+ # principal. An unrelated display label cannot replace that identity.
163
+ unless Etc.getpwuid(uid).name == participant.fetch("actor")
164
+ raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "ordinary actor differs from installed OS principal"
165
+ end
166
+ participant
167
+ rescue ArgumentError
168
+ raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "ordinary OS principal is unavailable"
169
+ end
170
+
171
+ protected
172
+
173
+ attr_reader :fingerprint
174
+
175
+ private
176
+
177
+ # HITL maintains its own layout (root 0711, private request/secret
178
+ # directories). The already-verified private state_root encloses it.
179
+ # Requiring a 0700 HITL leaf would reject every normal service restart.
180
+ def verify_hitl_root!
181
+ root = @hitl.fetch("root")
182
+ Ace::Runtime::Molecules::ProtectedSocket.root_path!(root, directory: true, owner: @service.fetch("uid"))
183
+ unless (File.stat(root).mode & 0o7777) == Ace::Hitl::Lifecycle::Store::ROOT_MODE
184
+ raise InvalidConfigurationError, "ordinary HITL store root must use the maintained store mode"
185
+ end
186
+ true
187
+ end
188
+
189
+ def validate_principal_separation!(projects)
190
+ invalid! unless projects.size.between?(1, 64)
191
+ owners, callers = [], []
192
+ projects.each_value do |entry|
193
+ invalid! unless entry.is_a?(Hash) && entry["service"].is_a?(Hash) &&
194
+ uid?(entry["service"]["uid"]) && entry["participants"].is_a?(Hash)
195
+ owners << entry.fetch("service").fetch("uid")
196
+ entry.fetch("participants").each_key do |uid|
197
+ invalid! unless uid.is_a?(String) && uid.match?(/\A[1-9][0-9]{0,9}\z/) && uid?(uid.to_i)
198
+ callers << uid.to_i
199
+ end
200
+ end
201
+ invalid! unless (owners & callers).empty?
202
+ end
203
+
204
+ def exclude_protected!(uid)
205
+ context = Ace::Assign::Authority::ProtectedAssignmentContext.load(uid: uid)
206
+ if context.protected_participant?
207
+ raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "protected principal cannot use ordinary assignment authority"
208
+ end
209
+ end
210
+
211
+ def absolute?(value)
212
+ value.is_a?(String) && value.bytesize.between?(1, 4096) && !value.include?("\0") &&
213
+ value.start_with?("/") && File.expand_path(value) == value
214
+ end
215
+
216
+ def uid?(value) = value.is_a?(Integer) && value.between?(1, 2**31 - 1)
217
+
218
+ def freeze_value(value)
219
+ case value
220
+ when Hash then value.each { |key, item| key.freeze; freeze_value(item) }
221
+ when Array then value.each { |item| freeze_value(item) }
222
+ end
223
+ value.freeze
224
+ end
225
+
226
+ def invalid!
227
+ raise InvalidConfigurationError, "ordinary assignment owner configuration is invalid"
228
+ end
229
+ end
230
+ end
231
+ end
232
+ end