ace-lab 0.9.0 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +363 -104
- data/README.md +66 -100
- data/docs/usage.md +529 -592
- data/lib/ace/lab/atoms/install_schema.rb +373 -0
- data/lib/ace/lab/cli/commands/install.rb +161 -0
- data/lib/ace/lab/cli/commands/prepare.rb +118 -0
- data/lib/ace/lab/cli/commands/project_add.rb +101 -0
- data/lib/ace/lab/cli/commands/status.rb +109 -0
- data/lib/ace/lab/cli/commands/verify.rb +115 -0
- data/lib/ace/lab/cli.rb +21 -23
- data/lib/ace/lab/install_manifest_path.rb +23 -0
- data/lib/ace/lab/models/status_report.rb +38 -0
- data/lib/ace/lab/molecules/account_provisioner.rb +196 -0
- data/lib/ace/lab/molecules/config_state_classifier.rb +145 -0
- data/lib/ace/lab/molecules/directory_provisioner.rb +208 -0
- data/lib/ace/lab/molecules/grant_provisioner.rb +250 -0
- data/lib/ace/lab/molecules/grant_resolver.rb +34 -23
- data/lib/ace/lab/molecules/hitl_routes.rb +263 -0
- data/lib/ace/lab/molecules/install_manifest_loader.rb +64 -0
- data/lib/ace/lab/molecules/key_provisioner.rb +736 -0
- data/lib/ace/lab/molecules/lab_state_scanner.rb +55 -0
- data/lib/ace/lab/molecules/overseer_provisioner.rb +188 -0
- data/lib/ace/lab/molecules/project_registry.rb +251 -0
- data/lib/ace/lab/molecules/repository_provisioner.rb +141 -0
- data/lib/ace/lab/molecules/role_verifier.rb +223 -0
- data/lib/ace/lab/organisms/install.rb +213 -0
- data/lib/ace/lab/organisms/prepare.rb +156 -0
- data/lib/ace/lab/organisms/project_add.rb +170 -0
- data/lib/ace/lab/organisms/status_service.rb +70 -0
- data/lib/ace/lab/organisms/verify.rb +768 -0
- data/lib/ace/lab/version.rb +1 -1
- data/lib/ace/lab.rb +34 -6
- metadata +34 -108
- data/lib/ace/lab/atoms/protected_publication_input.rb +0 -50
- data/lib/ace/lab/atoms/protected_workspace_prune_input.rb +0 -114
- data/lib/ace/lab/atoms/protected_workspace_prune_preview.rb +0 -86
- data/lib/ace/lab/atoms/service_input.rb +0 -92
- data/lib/ace/lab/cli/commands/assignment.rb +0 -92
- data/lib/ace/lab/cli/commands/command_execute.rb +0 -28
- data/lib/ace/lab/cli/commands/run.rb +0 -65
- data/lib/ace/lab/cli/commands/service.rb +0 -240
- data/lib/ace/lab/models/service_receipt.rb +0 -18
- data/lib/ace/lab/molecules/command_owner_admission.rb +0 -183
- data/lib/ace/lab/molecules/command_policy.rb +0 -158
- data/lib/ace/lab/molecules/hitl_authorizer.rb +0 -63
- data/lib/ace/lab/molecules/ordinary_admission_resolver.rb +0 -131
- data/lib/ace/lab/molecules/ordinary_listener.rb +0 -262
- data/lib/ace/lab/molecules/ordinary_merge_read.rb +0 -45
- data/lib/ace/lab/molecules/ordinary_native_read.rb +0 -106
- data/lib/ace/lab/molecules/ordinary_owner_admission.rb +0 -152
- data/lib/ace/lab/molecules/ordinary_owner_config.rb +0 -232
- data/lib/ace/lab/molecules/ordinary_receiver_config.rb +0 -94
- data/lib/ace/lab/molecules/ordinary_socket.rb +0 -126
- data/lib/ace/lab/molecules/ordinary_worktree_read.rb +0 -216
- data/lib/ace/lab/molecules/protected_cleanup_owner_admission.rb +0 -163
- data/lib/ace/lab/molecules/protected_cleanup_owner_client.rb +0 -165
- data/lib/ace/lab/molecules/protected_cleanup_owner_identity.rb +0 -239
- data/lib/ace/lab/molecules/protected_publication_publisher.rb +0 -110
- data/lib/ace/lab/molecules/protected_service_handler.rb +0 -43
- data/lib/ace/lab/molecules/protected_service_policy.rb +0 -156
- data/lib/ace/lab/molecules/service_executor.rb +0 -99
- data/lib/ace/lab/molecules/service_policy.rb +0 -289
- data/lib/ace/lab/organisms/authority_composition.rb +0 -87
- data/lib/ace/lab/organisms/command_executor.rb +0 -220
- data/lib/ace/lab/organisms/command_run_client.rb +0 -131
- data/lib/ace/lab/organisms/ordinary_assignment_client.rb +0 -120
- data/lib/ace/lab/organisms/ordinary_authority_composition.rb +0 -153
- data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +0 -679
- data/lib/ace/lab/organisms/ordinary_merge_request.rb +0 -456
- data/lib/ace/lab/organisms/protected_cleanup_owner.rb +0 -361
- data/lib/ace/lab/organisms/protected_service_client.rb +0 -129
- data/lib/ace/lab/organisms/protected_service_listener.rb +0 -181
- data/lib/ace/lab/organisms/protected_service_publication.rb +0 -268
- data/lib/ace/lab/organisms/protected_service_receiver.rb +0 -267
- data/lib/ace/lab/organisms/protected_service_recovery.rb +0 -185
- data/lib/ace/lab/organisms/protected_service_request.rb +0 -128
- data/lib/ace/lab/organisms/protected_service_worker.rb +0 -89
- data/lib/ace/lab/organisms/service_request_service.rb +0 -249
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "securerandom"
|
|
4
|
-
require "ace/runtime/molecules/protected_socket"
|
|
5
|
-
require "ace/herdr/molecules/herdr_executor"
|
|
6
|
-
require "ace/assign/authority/ordinary_peer"
|
|
7
|
-
require_relative "ordinary_owner_config"
|
|
8
|
-
|
|
9
|
-
module Ace
|
|
10
|
-
module Lab
|
|
11
|
-
module Molecules
|
|
12
|
-
# Read-only use of Herdr's existing native protocol. Root selection pins
|
|
13
|
-
# the endpoint inode and live server; no ambient CLI environment chooses
|
|
14
|
-
# the daemon, and no protected runtime guarantees are borrowed here.
|
|
15
|
-
class OrdinaryNativeRead
|
|
16
|
-
def initialize(config:, deadline:, peer: Ace::Assign::Authority::OrdinaryPeer.new)
|
|
17
|
-
raise ArgumentError, "native reader needs installed selection" unless config.is_a?(OrdinaryOwnerConfig)
|
|
18
|
-
@config, @deadline, @peer = config, deadline, peer
|
|
19
|
-
@native = config.native
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def preflight!
|
|
23
|
-
result = request("ping", {})
|
|
24
|
-
unless result["type"] == "pong" && result.values_at("version", "protocol") == @native.values_at("version", "protocol")
|
|
25
|
-
unavailable!("native protocol differs from installed selection")
|
|
26
|
-
end
|
|
27
|
-
true
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def pane_get(pane)
|
|
31
|
-
result = request("pane.get", {"pane_id" => pane})
|
|
32
|
-
unless result["type"] == "pane_info" && result["pane"].is_a?(Hash) && result["pane"]["pane_id"] == pane
|
|
33
|
-
unavailable!("native pane metadata differs from requested pane")
|
|
34
|
-
end
|
|
35
|
-
response(result)
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def pane_process_info(pane)
|
|
39
|
-
@process_info = nil
|
|
40
|
-
result = request("pane.process_info", {"pane_id" => pane})
|
|
41
|
-
unless result["type"] == "pane_process_info" && result["process_info"].is_a?(Hash) && result["process_info"]["pane_id"] == pane
|
|
42
|
-
unavailable!("native process inventory differs from requested pane")
|
|
43
|
-
end
|
|
44
|
-
@process_info = result.fetch("process_info")
|
|
45
|
-
response(result)
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
# This per-PID native field comes from process_cwd, not the terminal's
|
|
49
|
-
# OSC/UI working directory. The caller separately verifies PID birth.
|
|
50
|
-
def process_cwd(pid)
|
|
51
|
-
processes = @process_info.is_a?(Hash) && @process_info["foreground_processes"]
|
|
52
|
-
unavailable!("native process inventory is unavailable") unless processes.is_a?(Array)
|
|
53
|
-
matches = processes.select { |entry| entry.is_a?(Hash) && entry["pid"] == pid }
|
|
54
|
-
path = matches.one? && matches.first["cwd"]
|
|
55
|
-
unless path.is_a?(String) && path.bytesize.between?(1, 4096) && !path.include?("\0") &&
|
|
56
|
-
path.start_with?("/") && File.expand_path(path) == path
|
|
57
|
-
unavailable!("exact native process working directory is unavailable")
|
|
58
|
-
end
|
|
59
|
-
path
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
private
|
|
63
|
-
|
|
64
|
-
def request(method, params)
|
|
65
|
-
verify_endpoint!
|
|
66
|
-
wire.connect(@native.fetch("socket_path"), deadline: @deadline) do |socket|
|
|
67
|
-
unless @peer.capture(socket, deadline: @deadline) == @native.fetch("server_identity")
|
|
68
|
-
unavailable!("native peer differs from installed server")
|
|
69
|
-
end
|
|
70
|
-
verify_endpoint!
|
|
71
|
-
id = SecureRandom.hex(12)
|
|
72
|
-
wire.write(socket, {"id" => id, "method" => method, "params" => params}, deadline: @deadline, limit: 16_384)
|
|
73
|
-
value = wire.read(wire::Ingress.new(socket), deadline: @deadline, limit: 65_536)
|
|
74
|
-
unless value.is_a?(Hash) && value.keys.sort == %w[id result] && value["id"] == id && value["result"].is_a?(Hash)
|
|
75
|
-
unavailable!("native read response is invalid")
|
|
76
|
-
end
|
|
77
|
-
verify_endpoint!
|
|
78
|
-
@peer.live!(@native.fetch("server_identity"), deadline: @deadline)
|
|
79
|
-
value.fetch("result")
|
|
80
|
-
end
|
|
81
|
-
rescue SystemCallError, IOError, KeyError, Ace::Assign::AttemptErrors::UnauthorizedIdentity
|
|
82
|
-
unavailable!("native read is unavailable")
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
def verify_endpoint!
|
|
86
|
-
@config.verify_selection!
|
|
87
|
-
path = @native.fetch("socket_path")
|
|
88
|
-
wire.root_path!(File.dirname(path), directory: true)
|
|
89
|
-
unless wire.socket_identity(path) == @native.fetch("socket_identity") && (File.lstat(path).mode & 0o007).zero?
|
|
90
|
-
unavailable!("native endpoint generation or access differs")
|
|
91
|
-
end
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
def response(result)
|
|
95
|
-
Ace::Herdr::Molecules::ExecutionResult.new(stdout: JSON.generate({"result" => result}), stderr: "", success: true, exit_code: 0)
|
|
96
|
-
end
|
|
97
|
-
|
|
98
|
-
def wire = Ace::Runtime::Molecules::ProtectedSocket
|
|
99
|
-
|
|
100
|
-
def unavailable!(reason)
|
|
101
|
-
raise Ace::Runtime::RuntimeUnavailableError, reason
|
|
102
|
-
end
|
|
103
|
-
end
|
|
104
|
-
end
|
|
105
|
-
end
|
|
106
|
-
end
|
|
@@ -1,152 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "ace/assign/authority/ordinary_peer"
|
|
4
|
-
require "ace/assign/molecules/execution_identity_resolver"
|
|
5
|
-
require "ace/herdr"
|
|
6
|
-
require_relative "ordinary_owner_config"
|
|
7
|
-
require_relative "ordinary_native_read"
|
|
8
|
-
require_relative "ordinary_worktree_read"
|
|
9
|
-
|
|
10
|
-
module Ace
|
|
11
|
-
module Lab
|
|
12
|
-
module Molecules
|
|
13
|
-
# The source-selected ordinary peer policy. Native selectors are hints;
|
|
14
|
-
# only actual ancestry in the installed project's Herdr grants attribution.
|
|
15
|
-
class OrdinaryOwnerAdmission
|
|
16
|
-
def initialize(config:, peer: Ace::Assign::Authority::OrdinaryPeer.new, runtime: nil)
|
|
17
|
-
raise ArgumentError, "ordinary owner requires validated installed selection" unless config.is_a?(OrdinaryOwnerConfig)
|
|
18
|
-
@config, @peer = config, peer
|
|
19
|
-
@runtime = runtime
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def service = @config.service
|
|
23
|
-
def verify_listener!
|
|
24
|
-
@config.verify_listener!
|
|
25
|
-
OrdinaryNativeRead.new(config: @config, deadline: default_deadline, peer: @peer).preflight!
|
|
26
|
-
end
|
|
27
|
-
def peer(socket, deadline:) = @peer.capture(socket, deadline: deadline)
|
|
28
|
-
|
|
29
|
-
def authorize!(request:, peer:, deadline: default_deadline)
|
|
30
|
-
@peer.live!(peer, deadline: deadline)
|
|
31
|
-
unless request.fetch("project_id") == @config.project
|
|
32
|
-
raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "ordinary request belongs to another project"
|
|
33
|
-
end
|
|
34
|
-
@config.participant!(peer)
|
|
35
|
-
pane = request.fetch("params", {})["pane"]
|
|
36
|
-
# Reads need project visibility only. Every mutating lifecycle route
|
|
37
|
-
# validates a pane; supervisor authority additionally requires actual
|
|
38
|
-
# caller ancestry in one of the root-installed native placements.
|
|
39
|
-
return :worker unless pane
|
|
40
|
-
identity!(peer: peer, pane: pane, deadline: deadline).role == "coordinator" ? :supervisor : :worker
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
def identity!(peer:, pane:, deadline: default_deadline)
|
|
44
|
-
observe_identity(peer: peer, pane: pane, deadline: deadline, with_cwd: false).first
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
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
|
|
@@ -1,232 +0,0 @@
|
|
|
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
|
|
@@ -1,94 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "yaml"
|
|
4
|
-
require_relative "../errors"
|
|
5
|
-
require_relative "service_policy"
|
|
6
|
-
require_relative "grant_resolver"
|
|
7
|
-
|
|
8
|
-
module Ace
|
|
9
|
-
module Lab
|
|
10
|
-
module Molecules
|
|
11
|
-
# Project-scoped, explicitly filed receiver configuration (spec
|
|
12
|
-
# 8x3.t.xz9.4 execution clauses). The host owns the file, its
|
|
13
|
-
# placement, its credentials and its deployment; this loader only
|
|
14
|
-
# validates the exact closed shape and fails closed on anything else.
|
|
15
|
-
# It invents no credential and accepts no caller-selected endpoint:
|
|
16
|
-
# every value comes from one explicit deployment-owned YAML file.
|
|
17
|
-
class OrdinaryReceiverConfig
|
|
18
|
-
KEYS = %w[project receiver_id socket_path receiver_uid principal principal_uid
|
|
19
|
-
repo_path policy_path execution_identity].freeze
|
|
20
|
-
OPTIONAL_KEYS = %w[socket_gid forge_config_path].freeze
|
|
21
|
-
IDENTITY_KEYS = %w[checkout_id worktree_id branch].freeze
|
|
22
|
-
ID = ServicePolicy::ID
|
|
23
|
-
|
|
24
|
-
def self.load(path)
|
|
25
|
-
raise Ace::Lab::InvalidConfigurationError, "receiver config path is required" unless path.is_a?(String) && !path.empty?
|
|
26
|
-
bytes = GrantResolver.read_verified(path)
|
|
27
|
-
raise Ace::Lab::InvalidConfigurationError, "receiver configuration is missing" unless bytes
|
|
28
|
-
document = YAML.safe_load(bytes, permitted_classes: [Date], aliases: false)
|
|
29
|
-
new(document, source: path)
|
|
30
|
-
rescue Errno::ENOENT, Errno::EACCES, Psych::Exception
|
|
31
|
-
raise Ace::Lab::InvalidConfigurationError, "receiver configuration is unreadable: #{path}"
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
attr_reader :project, :receiver_id, :socket_path, :receiver_uid, :principal,
|
|
35
|
-
:principal_uid, :repo_path, :policy_path, :execution_identity, :source, :socket_gid, :forge_config_path
|
|
36
|
-
|
|
37
|
-
def initialize(document, source: nil)
|
|
38
|
-
unless document.is_a?(Hash) && (document.keys - KEYS - OPTIONAL_KEYS).empty? &&
|
|
39
|
-
(KEYS - document.keys).empty?
|
|
40
|
-
raise Ace::Lab::InvalidConfigurationError,
|
|
41
|
-
"receiver configuration requires #{KEYS.join(', ')}; optional: #{OPTIONAL_KEYS.join(', ')}"
|
|
42
|
-
end
|
|
43
|
-
@socket_gid = uid!(document.fetch("socket_gid"), "socket_gid") if document.key?("socket_gid")
|
|
44
|
-
@forge_config_path = absolute!(document.fetch("forge_config_path"), "forge_config_path") if
|
|
45
|
-
document.key?("forge_config_path")
|
|
46
|
-
@source = source
|
|
47
|
-
@project = string!(document.fetch("project"), "project")
|
|
48
|
-
@receiver_id = string!(document.fetch("receiver_id"), "receiver_id")
|
|
49
|
-
@principal = string!(document.fetch("principal"), "principal")
|
|
50
|
-
@socket_path = absolute!(document.fetch("socket_path"), "socket_path")
|
|
51
|
-
@repo_path = absolute!(document.fetch("repo_path"), "repo_path")
|
|
52
|
-
@policy_path = absolute!(document.fetch("policy_path"), "policy_path")
|
|
53
|
-
@receiver_uid = uid!(document.fetch("receiver_uid"), "receiver_uid")
|
|
54
|
-
raise Ace::Lab::InvalidConfigurationError, "ordinary receiver must have a non-root owner" if @receiver_uid.zero?
|
|
55
|
-
@principal_uid = uid!(document.fetch("principal_uid"), "principal_uid")
|
|
56
|
-
identity = document.fetch("execution_identity")
|
|
57
|
-
unless identity.is_a?(Hash) && (identity.keys - IDENTITY_KEYS).empty? &&
|
|
58
|
-
IDENTITY_KEYS.all? { |key| identity[key].is_a?(String) && !identity[key].empty? }
|
|
59
|
-
raise Ace::Lab::InvalidConfigurationError,
|
|
60
|
-
"receiver execution_identity is incomplete"
|
|
61
|
-
end
|
|
62
|
-
@execution_identity = IDENTITY_KEYS.to_h { |key| [key, identity.fetch(key)] }.freeze
|
|
63
|
-
unless @repo_path.start_with?("/") && File.directory?(@repo_path)
|
|
64
|
-
raise Ace::Lab::InvalidConfigurationError, "receiver repo_path is not a directory"
|
|
65
|
-
end
|
|
66
|
-
freeze
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
private
|
|
70
|
-
|
|
71
|
-
def string!(value, name)
|
|
72
|
-
unless value.is_a?(String) && !value.empty? && value.length <= 256 && value.match?(ID)
|
|
73
|
-
raise Ace::Lab::InvalidConfigurationError, "receiver #{name} is invalid"
|
|
74
|
-
end
|
|
75
|
-
value
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
def absolute!(value, name)
|
|
79
|
-
unless value.is_a?(String) && value.start_with?("/") && value.length <= 1024
|
|
80
|
-
raise Ace::Lab::InvalidConfigurationError, "receiver #{name} must be an absolute path"
|
|
81
|
-
end
|
|
82
|
-
value
|
|
83
|
-
end
|
|
84
|
-
|
|
85
|
-
def uid!(value, name)
|
|
86
|
-
unless value.is_a?(Integer) && value >= 0
|
|
87
|
-
raise Ace::Lab::InvalidConfigurationError, "receiver #{name} must be a non-negative integer"
|
|
88
|
-
end
|
|
89
|
-
value
|
|
90
|
-
end
|
|
91
|
-
end
|
|
92
|
-
end
|
|
93
|
-
end
|
|
94
|
-
end
|