ace-lab 0.8.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.
@@ -0,0 +1,158 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "yaml"
5
+ require "digest"
6
+ require "ace/assign/atoms/evidence_digest"
7
+ require_relative "grant_resolver"
8
+
9
+ module Ace
10
+ module Lab
11
+ module Molecules
12
+ # One deployment-owned policy, independent of repository preferences.
13
+ # The selected grant covers the WHOLE service combination and exact argv.
14
+ class CommandPolicy
15
+ PATH = "/etc/lab/ace-lab/project-commands.yml"
16
+ ID = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}\z/
17
+ ENV_KEY = /\A[A-Z][A-Z0-9_]{0,63}\z/
18
+ RESERVED = %w[HOME USER LOGNAME SHELL PATH RUBYOPT RUBYLIB BUNDLE_GEMFILE GEM_HOME GEM_PATH BASH_ENV ENV PYTHONPATH PYTHONHOME PERL5LIB LD_PRELOAD LD_LIBRARY_PATH].freeze
19
+ attr_reader :project, :launcher
20
+
21
+ def self.load(project:)
22
+ bytes = GrantResolver.read_verified(PATH)
23
+ raise InvalidConfigurationError, "project commands are not installed" unless bytes && bytes.bytesize <= 65_536
24
+ new(YAML.safe_load(bytes, aliases: false), project: project)
25
+ rescue Psych::Exception
26
+ raise InvalidConfigurationError, "invalid project command policy"
27
+ end
28
+
29
+ def initialize(document, project:)
30
+ strict!(document, %w[version projects])
31
+ invalid! unless document["version"] == 1 && document["projects"].is_a?(Hash) &&
32
+ document["projects"].keys.all? { |id| id.is_a?(String) && ID.match?(id) } && project.is_a?(String) && ID.match?(project)
33
+ @project = project
34
+ @document = JSON.parse(JSON.generate(document))
35
+ @entry = @document.fetch("projects").fetch(project) { invalid! }
36
+ strict!(@entry, %w[launcher default_executor default_services executors services commands grants])
37
+ @launcher = @entry.fetch("launcher")
38
+ strict!(@launcher, %w[socket_path identity_path])
39
+ invalid! unless @launcher.values.all? { |value| absolute?(value) }
40
+ %w[executors services commands].each do |key|
41
+ value = @entry.fetch(key)
42
+ invalid! unless value.is_a?(Hash) && value.size <= 64 && value.keys.all? { |id| id.is_a?(String) && ID.match?(id) }
43
+ end
44
+ @entry.fetch("executors").each_value do |value|
45
+ strict!(value, %w[uid gid groups home cwd path environment])
46
+ invalid! unless %w[uid gid].all? { |key| uid?(value[key]) } &&
47
+ value["groups"].is_a?(Array) && value["groups"].all? { |gid| uid?(gid) } &&
48
+ value["groups"].uniq == value["groups"] &&
49
+ %w[home cwd].all? { |key| absolute?(value[key]) } &&
50
+ value["path"].is_a?(Array) && !value["path"].empty? && value["path"].all? { |item| absolute?(item) }
51
+ environment!(value.fetch("environment"))
52
+ end
53
+ @entry.fetch("services").each_value do |value|
54
+ strict!(value, %w[environment secret_references])
55
+ environment!(value.fetch("environment"))
56
+ references = value.fetch("secret_references")
57
+ invalid! unless references.is_a?(Hash) && references.all? { |key, path| env_key?(key) && absolute?(path) }
58
+ end
59
+ @entry.fetch("commands").each_value do |value|
60
+ strict!(value, %w[argv executable_sha256 source purpose])
61
+ invalid! unless %w[command test].include?(value["purpose"])
62
+ argv!(value.fetch("argv"))
63
+ invalid! unless sha?(value.fetch("executable_sha256"))
64
+ source = value.fetch("source")
65
+ strict!(source, %w[head tree])
66
+ invalid! unless source.values.all? { |sha| sha.nil? || (sha.is_a?(String) && sha.match?(/\A[0-9a-f]{40}\z/)) } &&
67
+ source.values.all?(&:nil?) == source.values.any?(&:nil?)
68
+ invalid! if value["purpose"] == "test" && source.values.any?(&:nil?)
69
+ end
70
+ invalid! unless @entry.fetch("executors").key?(@entry["default_executor"])
71
+ services!(@entry.fetch("default_services"))
72
+ invalid! unless @entry["grants"].is_a?(Array) && @entry["grants"].size <= 256
73
+ @entry["grants"].each do |grant|
74
+ strict!(grant, %w[kind uid command executor services])
75
+ invalid! unless %w[operator agent].include?(grant["kind"]) && uid?(grant["uid"]) &&
76
+ @entry.fetch("commands").key?(grant["command"]) && @entry.fetch("executors").key?(grant["executor"])
77
+ services!(grant.fetch("services"))
78
+ end
79
+ @fingerprint = Ace::Assign::Atoms::EvidenceDigest.digest(@entry)
80
+ freeze_value(@document)
81
+ end
82
+
83
+ def reader!(uid)
84
+ deny! unless @entry.fetch("grants").any? { |grant| grant.fetch("uid") == uid }
85
+ true
86
+ end
87
+
88
+ def select(selection:, peer:)
89
+ strict!(selection, %w[argv executor services pane])
90
+ argv!(selection.fetch("argv"))
91
+ executor_id = selection["executor"] || @entry.fetch("default_executor")
92
+ executor = @entry.fetch("executors")[executor_id]
93
+ deny! unless executor
94
+ requested = selection["services"].nil? ? @entry.fetch("default_services") : selection["services"]
95
+ services!(requested)
96
+ selected = requested.uniq.sort
97
+ matches = @entry.fetch("commands").select { |_name, value| value.fetch("argv") == selection["argv"] }
98
+ deny! unless matches.size == 1
99
+ command_id, command = matches.first
100
+ kind = selection["pane"].nil? ? "operator" : "agent"
101
+ deny! unless @entry.fetch("grants").any? { |grant|
102
+ grant.values_at("kind", "uid", "command", "executor") == [kind, peer.fetch("uid"), command_id, executor_id] &&
103
+ grant.fetch("services").sort == selected }
104
+ environment = executor.fetch("environment").dup
105
+ references = {}
106
+ selected.each do |id|
107
+ service = @entry.fetch("services").fetch(id)
108
+ additions = service.fetch("environment")
109
+ secrets = service.fetch("secret_references")
110
+ deny! unless (environment.keys & (additions.keys + secrets.keys)).empty? &&
111
+ (references.keys & (additions.keys + secrets.keys)).empty? && (additions.keys & secrets.keys).empty?
112
+ environment.merge!(additions)
113
+ references.merge!(secrets)
114
+ end
115
+ definition = {"policy_sha256" => @fingerprint, "project_id" => @project, "command_id" => command_id,
116
+ "argv" => command.fetch("argv"), "executable_sha256" => command.fetch("executable_sha256"),
117
+ "source" => command.fetch("source"), "purpose" => command.fetch("purpose"), "executor_id" => executor_id,
118
+ "executor" => executor.merge("environment" => environment, "groups" => executor.fetch("groups").sort), "services" => selected,
119
+ "secret_references" => references, "requester" => peer, "requester_kind" => kind,
120
+ "selection" => selection}
121
+ invalid! if JSON.generate(definition).bytesize > 8192
122
+ definition
123
+ end
124
+
125
+ private
126
+
127
+ def strict!(value, keys)
128
+ invalid! unless value.is_a?(Hash) && value.keys.sort == keys.sort
129
+ end
130
+ def argv!(value)
131
+ invalid! unless value.is_a?(Array) && value.size.between?(1, 128) &&
132
+ value.all? { |item| item.is_a?(String) && item.bytesize <= 4096 && !item.include?("\0") } &&
133
+ !value.first.empty? && value.sum(&:bytesize) <= 8192
134
+ end
135
+ def services!(value)
136
+ invalid! unless value.is_a?(Array) && value.size <= 64 && value.all? { |id| @entry.fetch("services").key?(id) }
137
+ end
138
+ def environment!(value)
139
+ invalid! unless value.is_a?(Hash) && value.size <= 64 && value.all? { |key, item|
140
+ env_key?(key) && item.is_a?(String) && item.bytesize <= 4096 && !item.include?("\0") }
141
+ end
142
+ def env_key?(key) = key.is_a?(String) && ENV_KEY.match?(key) && !RESERVED.include?(key) && !key.start_with?("GIT_CONFIG_", "BUNDLE_", "LD_", "DYLD_")
143
+ def absolute?(value) = value.is_a?(String) && value.start_with?("/") && !value.include?("\0") && File.expand_path(value) == value
144
+ def uid?(value) = value.is_a?(Integer) && value.between?(1, 2**31 - 1)
145
+ def sha?(value) = value.is_a?(String) && value.match?(/\A[0-9a-f]{64}\z/)
146
+ def invalid! = raise(InvalidConfigurationError, "invalid project command policy")
147
+ def deny! = raise(SecurityError, "project command selection is not authorized")
148
+ def freeze_value(value)
149
+ case value
150
+ when Hash then value.each { |key, child| key.freeze; freeze_value(child) }
151
+ when Array then value.each { |child| freeze_value(child) }
152
+ end
153
+ value.freeze
154
+ end
155
+ end
156
+ end
157
+ end
158
+ end
@@ -43,6 +43,21 @@ module Ace
43
43
  "invalid lab configuration: trusted authorization file could not be parsed"
44
44
  end
45
45
 
46
+ # Validate an installed directory through the same original-path
47
+ # traversal as files, including root-owned system symlink aliases.
48
+ def trusted_directory!(path)
49
+ candidate = verified_resolve(path)
50
+ raise Ace::Lab::InvalidConfigurationError, "trusted directory is unavailable" unless candidate
51
+ File.open(candidate, File::RDONLY | File::NOFOLLOW) do |handle|
52
+ unless handle.stat.directory? && secure_file_stat?(handle.stat)
53
+ raise Ace::Lab::InvalidConfigurationError, "trusted directory ownership or mode differs"
54
+ end
55
+ end
56
+ true
57
+ rescue SystemCallError
58
+ raise Ace::Lab::InvalidConfigurationError, "trusted directory is unavailable"
59
+ end
60
+
46
61
  # @param documents [Array<Hash>] {path:, document:, defaults:}
47
62
  # from Ace::Lab.cascade_documents
48
63
  # @param topology [Hash] normalized topology (project IDs)
@@ -44,6 +44,19 @@ module Ace
44
44
  observe_identity(peer: peer, pane: pane, deadline: deadline, with_cwd: false).first
45
45
  end
46
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
+
47
60
  # The caller's claimed head is only a comparison. The accepted start
48
61
  # uses the observed worker's checkout and process, never daemon cwd.
49
62
  def execution!(peer:, pane:, head:, deadline: default_deadline)
@@ -0,0 +1,220 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "pty"
5
+ require "etc"
6
+ require "fiddle"
7
+ require "base64"
8
+ require "io/console"
9
+ require "ace/runtime/molecules/owned_child_scope"
10
+ require_relative "command_run_client"
11
+
12
+ module Ace
13
+ module Lab
14
+ module Organisms
15
+ # Short-lived execution child of the installed root labd, not a listener
16
+ # or another journal owner. A single Ruby thread forks the gated executor.
17
+ class CommandExecutor
18
+ def initialize(project:, owner: nil)
19
+ @project = project
20
+ @owner = owner || CommandRunClient.new(project: project).owner
21
+ end
22
+
23
+ def run(socket:, request:)
24
+ raise SecurityError, "command executor requires Linux root" unless RUBY_PLATFORM.include?("linux") && Process.uid.zero? && Process.euid.zero?
25
+ strict!(request, %w[execution_id caller tty rows cols])
26
+ id = request.fetch("execution_id")
27
+ raise ArgumentError, "invalid command reference" unless id.is_a?(String) && Molecules::CommandPolicy::ID.match?(id)
28
+ interactive = request.fetch("tty")
29
+ raise ArgumentError, "invalid terminal mode" unless [true, false].include?(interactive)
30
+ rows, cols = geometry(request)
31
+ params = {"execution_id" => id}
32
+ initial = @owner.call("command_status", params).data
33
+ unless initial["phase"] == "admitted" && initial.dig("definition", "requester") == request.fetch("caller")
34
+ raise SecurityError, "original command already claimed or requester differs"
35
+ end
36
+ definition = initial.fetch("definition")
37
+ unless definition.fetch("secret_references").empty?
38
+ raise SecurityError, "secret command execution capability is not installed"
39
+ end
40
+ claimed = @owner.call("command_claim", params.merge("caller" => request.fetch("caller")), mutation_id: "command-claim.#{id}")
41
+ raise SecurityError, "claim replay cannot execute" if claimed.replayed
42
+ launch(socket, id, definition, interactive, rows, cols)
43
+ end
44
+
45
+ private
46
+
47
+ def launch(socket, id, definition, interactive, rows, cols)
48
+ raise SecurityError, "command fork requires a single-thread helper" unless Thread.list.count(&:alive?) == 1
49
+ scope = Ace::Runtime::Molecules::OwnedChildScope.new
50
+ executor = definition.fetch("executor")
51
+ user = Etc.getpwuid(executor.fetch("uid"))
52
+ environment = {"HOME" => executor.fetch("home"), "USER" => user.name, "LOGNAME" => user.name,
53
+ "PATH" => executor.fetch("path").join(":")}.merge(executor.fetch("environment"))
54
+ if interactive
55
+ parent_input, child_output = PTY.open
56
+ parent_output, child_input = parent_input, child_output
57
+ child_output.winsize = [rows, cols]
58
+ else
59
+ child_input, parent_input = IO.pipe
60
+ parent_output, child_output = IO.pipe
61
+ parent_error, child_error = IO.pipe
62
+ end
63
+ gate_read, gate_write = IO.pipe
64
+ ready_read, ready_write = IO.pipe
65
+ child = fork do
66
+ begin
67
+ socket.close
68
+ [parent_input, parent_output, parent_error, gate_write, ready_read].compact.uniq.each(&:close)
69
+ Process.setsid
70
+ child_input.ioctl(0x540E, 0) if interactive # Linux TIOCSCTTY
71
+ $stdin.reopen(child_input)
72
+ $stdout.reopen(child_output)
73
+ $stderr.reopen(child_error || child_output)
74
+ [child_input, child_output, child_error].compact.uniq.each(&:close)
75
+ Process.groups = executor.fetch("groups")
76
+ Process::GID.change_privilege(executor.fetch("gid"))
77
+ Process::UID.change_privilege(executor.fetch("uid"))
78
+ File.umask(0o077)
79
+ prctl = Fiddle::Function.new(Fiddle::Handle::DEFAULT["prctl"],
80
+ [Fiddle::TYPE_INT, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG], Fiddle::TYPE_INT)
81
+ raise SecurityError, "cannot inhibit privilege gain" unless prctl.call(38, 1, 0, 0, 0).zero?
82
+ Dir.chdir(executor.fetch("cwd"))
83
+ ready_write.write("R")
84
+ ready_write.close
85
+ exit! 125 unless IO.select([gate_read], nil, nil, 35) && gate_read.read(1) == "E"
86
+ gate_read.close
87
+ argv = definition.fetch("argv")
88
+ exec(environment, [File.expand_path(argv.first), argv.first], *argv.drop(1), unsetenv_others: true)
89
+ rescue Exception
90
+ exit! 126
91
+ end
92
+ end
93
+ [child_input, child_output, child_error, gate_read, ready_write].compact.uniq.each(&:close)
94
+ unless IO.select([ready_read], nil, nil, 10) && ready_read.read(1) == "R"
95
+ raise SecurityError, "child did not reach admission gate"
96
+ end
97
+ ready_read.close
98
+ witness = child_witness(child)
99
+ bound = @owner.call("command_bind", {"execution_id" => id, "child" => witness}, mutation_id: "command-bind.#{id}")
100
+ raise SecurityError, "binding replay cannot execute" if bound.replayed
101
+ gate_write.write("E")
102
+ gate_write.close
103
+ send_frame(socket, {"type" => "started", "execution_id" => id})
104
+ outputs = {parent_output => interactive ? "terminal" : "stdout"}
105
+ outputs[parent_error] = "stderr" if parent_error
106
+ pump(socket, parent_input, outputs, child, interactive)
107
+ scope.cleanup!(leader: child)
108
+ _, result = Process.waitpid2(child)
109
+ reaped = true
110
+ termination = {"exit_status" => result.exitstatus, "signal" => result.termsig}
111
+ @owner.call("command_complete", {"execution_id" => id, "result" => termination}, mutation_id: "command-complete.#{id}")
112
+ send_frame(socket, termination.merge("type" => "result", "execution_id" => id))
113
+ result.exitstatus || 128 + result.termsig
114
+ ensure
115
+ [gate_write, ready_read, parent_input, parent_output, parent_error].compact.uniq.each { |io| io.close unless io.closed? }
116
+ if child && !reaped
117
+ scope.cleanup!(leader: child)
118
+ deadline = monotonic + 5
119
+ loop do
120
+ break if Process.waitpid(child, Process::WNOHANG)
121
+ break if monotonic >= deadline
122
+ IO.select(nil, nil, nil, 0.01)
123
+ end
124
+ end
125
+ end
126
+
127
+ def pump(socket, input, outputs, child, interactive)
128
+ pending = "".b
129
+ pending_input = "".b
130
+ input_open = true
131
+ input_finished = false
132
+ terminal_at = nil
133
+ loop do
134
+ terminal_at ||= monotonic if zombie?(child)
135
+ break if terminal_at && (outputs.empty? || monotonic - terminal_at >= 1)
136
+ readers = (pending_input.bytesize < 131_072 ? [socket] : []) + outputs.keys
137
+ ready = IO.select(readers, pending_input.empty? || input.closed? ? [] : [input], nil, 0.1)
138
+ next unless ready
139
+ if ready.first.include?(socket)
140
+ bytes = socket.read_nonblock(65_536, exception: false)
141
+ next if bytes == :wait_readable
142
+ raise IOError, "command client disconnected" unless bytes
143
+ pending << bytes
144
+ raise ArgumentError, "command frame exceeds bound" if pending.bytesize > 131_072
145
+ while (newline = pending.index("\n"))
146
+ frame = JSON.parse(pending.slice!(0, newline + 1))
147
+ case frame.fetch("type")
148
+ when "input"
149
+ strict!(frame, %w[type data])
150
+ raise ArgumentError, "command input after EOF" unless input_open
151
+ pending_input << Base64.strict_decode64(frame.fetch("data"))
152
+ raise ArgumentError, "command buffered input exceeds bound" if pending_input.bytesize > 131_072
153
+ when "resize"
154
+ strict!(frame, %w[type rows cols])
155
+ raise ArgumentError, "resize requires a terminal" unless interactive
156
+ input.winsize = geometry(frame)
157
+ when "eof"
158
+ strict!(frame, %w[type])
159
+ raise ArgumentError, "duplicate input EOF" unless input_open
160
+ pending_input << "\x04" if interactive
161
+ input_open = false
162
+ else raise ArgumentError, "invalid command input"
163
+ end
164
+ end
165
+ end
166
+ if ready[1].include?(input)
167
+ written = input.write_nonblock(pending_input, exception: false)
168
+ pending_input.slice!(0, written) if written.is_a?(Integer)
169
+ end
170
+ if !input_open && pending_input.empty? && !input_finished
171
+ input.close unless interactive
172
+ input_finished = true
173
+ end
174
+ outputs.keys.each do |output|
175
+ next unless ready.first.include?(output)
176
+ begin
177
+ bytes = output.read_nonblock(65_536, exception: false)
178
+ rescue Errno::EIO
179
+ bytes = nil
180
+ end
181
+ next if bytes == :wait_readable
182
+ if bytes
183
+ send_frame(socket, {"type" => "output", "stream" => outputs.fetch(output), "data" => Base64.strict_encode64(bytes)})
184
+ else
185
+ outputs.delete(output)
186
+ end
187
+ end
188
+ end
189
+ end
190
+
191
+ def child_witness(pid)
192
+ observer = Ace::Runtime::Molecules::ProcessIdentity.new(executor:
193
+ Ace::Herdr::Molecules::BoundedObservation.new(deadline: monotonic + 5))
194
+ identity = observer.capture(pid)
195
+ raise SecurityError, "child generation unavailable" unless identity
196
+ status = File.readlines("/proc/#{pid}/status").filter_map { |line| line.include?(":") && line.split(":", 2) }.to_h
197
+ identity.merge("gid" => status.fetch("Gid").split.first.to_i, "groups" => status.fetch("Groups").split.map(&:to_i).sort,
198
+ "cwd" => File.readlink("/proc/#{pid}/cwd"), "process_group" => Process.getpgid(pid),
199
+ "no_new_privs" => Integer(status.fetch("NoNewPrivs").strip, 10),
200
+ "capabilities" => %w[CapInh CapPrm CapEff CapAmb].to_h { |key| [key, status.fetch(key).strip] })
201
+ end
202
+
203
+ def zombie?(pid) = File.read("/proc/#{pid}/stat").split(")").last.split.first == "Z"
204
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
205
+ def geometry(value)
206
+ rows, cols = value.values_at("rows", "cols")
207
+ raise ArgumentError, "invalid command geometry" unless [rows, cols].all? { |v| v.is_a?(Integer) && v.between?(1, 65_535) }
208
+ [rows, cols]
209
+ end
210
+ def strict!(value, keys)
211
+ raise ArgumentError, "invalid command fields" unless value.is_a?(Hash) && value.keys.sort == keys.sort
212
+ end
213
+ def send_frame(socket, frame)
214
+ Ace::Runtime::Molecules::ProtectedSocket.write(socket, frame,
215
+ deadline: Ace::Runtime::Molecules::ProtectedSocket.deadline(5), limit: 131_072)
216
+ end
217
+ end
218
+ end
219
+ end
220
+ end
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "securerandom"
5
+ require "fileutils"
6
+ require "base64"
7
+ require "io/console"
8
+ require "ace/assign/authority/ordinary_client"
9
+ require_relative "../molecules/ordinary_owner_config"
10
+ require_relative "../molecules/command_policy"
11
+
12
+ module Ace
13
+ module Lab
14
+ module Organisms
15
+ class CommandRunClient
16
+ def initialize(project:)
17
+ @project = project
18
+ @config = Molecules::OrdinaryOwnerConfig.load(project: project)
19
+ end
20
+
21
+ def owner
22
+ Ace::Assign::Authority::OrdinaryClient.new(project_id: @project, service: @config.service,
23
+ verify_selection: @config.method(:verify_owner!))
24
+ end
25
+
26
+ def status(reference)
27
+ owner.call("command_status", {"execution_id" => reference}).data
28
+ end
29
+
30
+ def run(argv:, executor:, services:, pane: nil)
31
+ id = SecureRandom.hex(16)
32
+ selection = {"argv" => argv, "executor" => executor, "services" => services, "pane" => pane}
33
+ request = {"project" => @project, "execution_id" => id, "selection" => selection,
34
+ "mutation_id" => "command-admit.#{id}"}
35
+ parent = File.expand_path(".ace-local/lab-commands")
36
+ FileUtils.mkdir_p(parent, mode: 0o700)
37
+ directory = File.join(parent, id)
38
+ Dir.mkdir(directory, 0o700)
39
+ File.open(File.join(directory, "request.json"), File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600) do |file|
40
+ file.write(JSON.generate(request) + "\n")
41
+ file.flush
42
+ file.fsync
43
+ end
44
+ File.open(directory, File::RDONLY) { |parent_io| parent_io.fsync }
45
+ $stderr.puts "Execution #{id}; inspect with ace-lab run-status --project #{@project} --reference #{id}"
46
+ reply = owner.call("command_admit", {"execution_id" => id, "selection" => selection}, mutation_id: request.fetch("mutation_id"))
47
+ raise Ace::Assign::AttemptErrors::Conflict, "original command admission must be reconciled" if reply.replayed
48
+ stream(id)
49
+ end
50
+
51
+ private
52
+
53
+ def stream(id)
54
+ wire = Ace::Runtime::Molecules::ProtectedSocket
55
+ policy = Molecules::CommandPolicy.load(project: @project)
56
+ path = policy.launcher.fetch("socket_path")
57
+ wire.root_path!(File.dirname(path), directory: true)
58
+ endpoint = wire.socket_identity(path, mode: 0o660)
59
+ raise SecurityError, "command launcher endpoint owner differs" unless endpoint.last.zero?
60
+ expected = JSON.parse(Molecules::GrantResolver.read_verified(policy.launcher.fetch("identity_path")) || "null")
61
+ wire.connect(path, deadline: wire.deadline(5)) do |socket|
62
+ peer = Ace::Assign::Authority::OrdinaryPeer.new.capture(socket)
63
+ unless expected.is_a?(Hash) && expected["schema"] == "labd-command-launcher/v1" && expected["identity"] == peer
64
+ raise SecurityError, "command launcher generation differs"
65
+ end
66
+ interactive = $stdin.tty? && $stdout.tty?
67
+ rows, cols = interactive ? $stdin.winsize : [24, 80]
68
+ wire.write(socket, {"op" => "command_run", "project" => @project, "execution_id" => id,
69
+ "tty" => interactive, "rows" => rows, "cols" => cols}, deadline: wire.deadline(5), limit: 16_384)
70
+ if interactive
71
+ $stdin.raw { pump(socket, id, interactive: true) }
72
+ else
73
+ pump(socket, id, interactive: false)
74
+ end
75
+ end
76
+ end
77
+
78
+ def pump(socket, id, interactive:)
79
+ wire = Ace::Runtime::Molecules::ProtectedSocket
80
+ ingress = wire::Ingress.new(socket)
81
+ inputs = [socket]
82
+ resize = false
83
+ old_winch = Signal.trap("WINCH") { resize = true } if interactive
84
+ loop do
85
+ if resize
86
+ resize = false
87
+ rows, cols = $stdin.winsize
88
+ wire.write(socket, {"type" => "resize", "rows" => rows, "cols" => cols}, deadline: wire.deadline(5), limit: 131_072)
89
+ end
90
+ ready = IO.select(inputs, nil, nil, 0.25)
91
+ next unless ready
92
+ if ready.first.include?(socket)
93
+ frame = wire.read(ingress, deadline: wire.deadline(5), limit: 131_072)
94
+ case frame["type"]
95
+ when "started"
96
+ raise SecurityError, "invalid command start frame" unless frame.keys.sort == %w[execution_id type]
97
+ raise SecurityError, "command reference differs" unless frame["execution_id"] == id
98
+ inputs << $stdin unless inputs.include?($stdin)
99
+ when "output"
100
+ raise SecurityError, "invalid command output frame" unless frame.keys.sort == %w[data stream type] && %w[stdout stderr terminal].include?(frame["stream"])
101
+ destination = frame["stream"] == "stderr" ? $stderr : $stdout
102
+ destination.write(Base64.strict_decode64(frame.fetch("data")))
103
+ destination.flush
104
+ when "result"
105
+ raise SecurityError, "invalid command result frame" unless frame.keys.sort == %w[execution_id exit_status signal type]
106
+ raise SecurityError, "command reference differs" unless frame["execution_id"] == id
107
+ exited, signal = frame.values_at("exit_status", "signal")
108
+ if exited.is_a?(Integer) && exited.between?(0, 255) && signal.nil?
109
+ return exited
110
+ elsif exited.nil? && signal.is_a?(Integer) && signal.between?(1, 127)
111
+ return 128 + signal
112
+ end
113
+ raise SecurityError, "command termination is invalid"
114
+ else
115
+ raise SecurityError, "command stream is incomplete"
116
+ end
117
+ end
118
+ next unless ready.first.include?($stdin)
119
+ bytes = $stdin.read_nonblock(8192, exception: false)
120
+ next if bytes == :wait_readable
121
+ frame = bytes ? {"type" => "input", "data" => Base64.strict_encode64(bytes)} : {"type" => "eof"}
122
+ wire.write(socket, frame, deadline: wire.deadline(5), limit: 131_072)
123
+ inputs.delete($stdin) unless bytes
124
+ end
125
+ ensure
126
+ Signal.trap("WINCH", old_winch) if old_winch
127
+ end
128
+ end
129
+ end
130
+ end
131
+ end