ace-hitl 0.9.0 → 0.11.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 (46) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +30 -0
  3. data/README.md +40 -1
  4. data/docs/usage.md +153 -13
  5. data/lib/ace/hitl/cli/commands/ask.rb +107 -58
  6. data/lib/ace/hitl/cli/commands/cancel.rb +34 -0
  7. data/lib/ace/hitl/cli/commands/consume.rb +37 -0
  8. data/lib/ace/hitl/cli/commands/deliver.rb +33 -0
  9. data/lib/ace/hitl/cli/commands/duty.rb +29 -0
  10. data/lib/ace/hitl/cli/commands/lifecycle_command.rb +38 -0
  11. data/lib/ace/hitl/cli/commands/overseer_ack.rb +30 -0
  12. data/lib/ace/hitl/cli/commands/overseer_pending.rb +28 -0
  13. data/lib/ace/hitl/cli/commands/overseer_send.rb +35 -0
  14. data/lib/ace/hitl/cli/commands/pending.rb +29 -0
  15. data/lib/ace/hitl/cli/commands/serve.rb +71 -0
  16. data/lib/ace/hitl/cli/commands/states.rb +28 -0
  17. data/lib/ace/hitl/cli/commands/wait.rb +2 -2
  18. data/lib/ace/hitl/cli.rb +30 -1
  19. data/lib/ace/hitl/lifecycle/atomic_json.rb +79 -0
  20. data/lib/ace/hitl/lifecycle/binding.rb +38 -0
  21. data/lib/ace/hitl/lifecycle/client.rb +161 -0
  22. data/lib/ace/hitl/lifecycle/duty.rb +43 -0
  23. data/lib/ace/hitl/lifecycle/effects.rb +279 -0
  24. data/lib/ace/hitl/lifecycle/errors.rb +38 -0
  25. data/lib/ace/hitl/lifecycle/identity.rb +66 -0
  26. data/lib/ace/hitl/lifecycle/kinds.rb +69 -0
  27. data/lib/ace/hitl/lifecycle/otp_vault.rb +131 -0
  28. data/lib/ace/hitl/lifecycle/overseer.rb +111 -0
  29. data/lib/ace/hitl/lifecycle/peer.rb +61 -0
  30. data/lib/ace/hitl/lifecycle/policy.rb +172 -0
  31. data/lib/ace/hitl/lifecycle/protocol.rb +109 -0
  32. data/lib/ace/hitl/lifecycle/service.rb +289 -0
  33. data/lib/ace/hitl/lifecycle/store.rb +890 -0
  34. data/lib/ace/hitl/lifecycle.rb +24 -0
  35. data/lib/ace/hitl/molecules/lab_projection_observer.rb +9 -1
  36. data/lib/ace/hitl/providers/errors.rb +24 -0
  37. data/lib/ace/hitl/providers/lab/assignment_binding.rb +82 -0
  38. data/lib/ace/hitl/providers/lab/composite_binding.rb +47 -0
  39. data/lib/ace/hitl/providers/lab/daemon_binding.rb +210 -0
  40. data/lib/ace/hitl/providers/lab.rb +165 -0
  41. data/lib/ace/hitl/providers/providers.rb +41 -0
  42. data/lib/ace/hitl/providers/ref.rb +62 -0
  43. data/lib/ace/hitl/version.rb +1 -1
  44. data/lib/ace/hitl.rb +3 -0
  45. metadata +50 -3
  46. data/lib/ace/hitl/molecules/lab_request_submitter.rb +0 -82
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+ require_relative "lifecycle_command"
5
+
6
+ module Ace
7
+ module Hitl
8
+ module CLI
9
+ module Commands
10
+ # The standing-duty projection: pending + escalated (spec
11
+ # 8wm.t.y21 §7).
12
+ class Duty < Ace::Support::Cli::Command
13
+ include Ace::Support::Cli::Base
14
+ include LifecycleCommand
15
+
16
+ desc "Project pending and escalated HITL requests (transport operation)"
17
+
18
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
19
+
20
+ def call(**options)
21
+ emit(Lifecycle::Duty.project(pending: lifecycle_client.pending, states: lifecycle_client.states))
22
+ rescue Lifecycle::Error, Providers::ProviderUnavailableError => e
23
+ raise_lifecycle_error(e.message)
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Ace
6
+ module Hitl
7
+ module CLI
8
+ module Commands
9
+ # Shared plumbing for the lifecycle commands (spec 8wm.t.y21 §8,
10
+ # scoped by 8wq.t.34i): every lifecycle operation reaches the
11
+ # store through the AUTHENTICATED BOUNDARY CLIENT — direct
12
+ # shared-store access is not a CLI option — and machine outputs
13
+ # are one JSON line, byte-compatible with the migrated CLI
14
+ # contract.
15
+ module LifecycleCommand
16
+ STDIN_READER = ->(limit) { $stdin.read(limit) }.freeze
17
+
18
+ def lifecycle_client
19
+ Providers::Lab.boundary_client
20
+ end
21
+
22
+ def overseer
23
+ channel_root = ENV.fetch("ACE_HITL_OVERSEER_CHANNEL_ROOT", "/lab/state/overseer-channel")
24
+ Lifecycle::Overseer.new(outbox_dir: File.join(channel_root, "outbox"))
25
+ end
26
+
27
+ def emit(result)
28
+ puts JSON.generate(result)
29
+ end
30
+
31
+ def raise_lifecycle_error(message)
32
+ raise Ace::Support::Cli::Error.new(message)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+ require_relative "lifecycle_command"
5
+
6
+ module Ace
7
+ module Hitl
8
+ module CLI
9
+ module Commands
10
+ # Host-broker acknowledgement of one relayed Overseer response.
11
+ class OverseerAck < Ace::Support::Cli::Command
12
+ include Ace::Support::Cli::Base
13
+ include LifecycleCommand
14
+
15
+ desc "Acknowledge (remove) one relayed Overseer response"
16
+
17
+ argument :id, required: true, desc: "Overseer response message id"
18
+
19
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
20
+
21
+ def call(id:, **options)
22
+ emit(overseer.ack(id))
23
+ rescue Lifecycle::Error => e
24
+ raise_lifecycle_error(e.message)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+ require_relative "lifecycle_command"
5
+
6
+ module Ace
7
+ module Hitl
8
+ module CLI
9
+ module Commands
10
+ # Host-broker drain view of the Overseer response outbox.
11
+ class OverseerPending < Ace::Support::Cli::Command
12
+ include Ace::Support::Cli::Base
13
+ include LifecycleCommand
14
+
15
+ desc "List queued Overseer responses (host-broker operation)"
16
+
17
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
18
+
19
+ def call(**options)
20
+ emit(overseer.pending)
21
+ rescue Lifecycle::Error => e
22
+ raise_lifecycle_error(e.message)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+ require_relative "lifecycle_command"
5
+
6
+ module Ace
7
+ module Hitl
8
+ module CLI
9
+ module Commands
10
+ # The Root Overseer's bounded, type-tagged response through the
11
+ # reverse address (spec 8wm.t.y21 §6). The response is read from
12
+ # stdin.
13
+ class OverseerSend < Ace::Support::Cli::Command
14
+ include Ace::Support::Cli::Base
15
+ include LifecycleCommand
16
+
17
+ desc "Queue a bounded, type-tagged Overseer response (stdin)"
18
+
19
+ option :"reply-to", type: :string, desc: "Source message id this response answers"
20
+
21
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
22
+
23
+ def call(**options)
24
+ emit(overseer.send_response(
25
+ reply_to: options[:"reply-to"] || "",
26
+ reader: LifecycleCommand::STDIN_READER
27
+ ))
28
+ rescue Lifecycle::Error => e
29
+ raise_lifecycle_error(e.message)
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+ require_relative "lifecycle_command"
5
+
6
+ module Ace
7
+ module Hitl
8
+ module CLI
9
+ module Commands
10
+ # Host-broker listing of answerable relay requests; never purges
11
+ # or cancels anything (W651).
12
+ class Pending < Ace::Support::Cli::Command
13
+ include Ace::Support::Cli::Base
14
+ include LifecycleCommand
15
+
16
+ desc "List answerable HITL relay requests (transport operation)"
17
+
18
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
19
+
20
+ def call(**options)
21
+ emit(lifecycle_client.pending)
22
+ rescue Lifecycle::Error, Providers::ProviderUnavailableError => e
23
+ raise_lifecycle_error(e.message)
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+ require_relative "../../lifecycle"
5
+ require_relative "../../providers/providers"
6
+
7
+ module Ace
8
+ module Hitl
9
+ module CLI
10
+ module Commands
11
+ # The installed scoped store boundary (spec 8wq.t.34i): serves
12
+ # the private HITL store over a peer-authenticated UNIX socket.
13
+ # Authorization facts come only from the trusted grants
14
+ # document; the store root and socket paths come from the
15
+ # deployment (options/environment), never from caller payloads.
16
+ class Serve < Ace::Support::Cli::Command
17
+ include Ace::Support::Cli::Base
18
+
19
+ desc "Run the authenticated HITL store boundary service (peer-credential access)"
20
+
21
+ option :socket, type: :string, desc: "Boundary socket path (default: ACE_HITL_SOCKET or /run/lab/hitl.sock)"
22
+ option :store_root, type: :string, desc: "Private store root (default: ACE_HITL_STORE_ROOT or /run/lab/hitl)"
23
+ option :grants_path, type: :string, desc: "Trusted grants document (default: ACE_HITL_GRANTS_PATH or /etc/lab/ace-lab/authorization.yml)"
24
+ option :repo_root, type: :string, desc: "Candidate repository for the managed attempt authority"
25
+
26
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
27
+ option :verbose, type: :boolean, aliases: %w[-v], desc: "Show verbose output"
28
+
29
+ def call(**options)
30
+ policy = Providers::Lab.grants_policy(grants_path: grants_path(options))
31
+ unless policy.service_uid
32
+ raise_cli_error(
33
+ "the HITL service requires trusted grants with hitl.service_uid " \
34
+ "(#{grants_path(options)}); serving without a trusted service identity would " \
35
+ "fail every client endpoint verification"
36
+ )
37
+ end
38
+ binding_policy = Providers::Lab::CompositeBinding.new(
39
+ assignment: Providers::Lab.assignment_binding(repo_root: options[:repo_root]),
40
+ work: Providers::Lab::DaemonBinding.new
41
+ )
42
+ service = Lifecycle::Service.new(
43
+ root: store_root(options),
44
+ binding: binding_policy,
45
+ policy: policy,
46
+ socket_path: socket_path(options),
47
+ logger: options[:verbose] ? ->(message) { warn message } : nil
48
+ )
49
+ service.run
50
+ rescue Lifecycle::Error, Providers::ProviderUnavailableError => e
51
+ raise_cli_error(e.message)
52
+ end
53
+
54
+ private
55
+
56
+ def socket_path(options)
57
+ options[:socket] || ENV[Providers::Lab::SOCKET_PATH_ENV] || Providers::Lab::DEFAULT_SOCKET_PATH
58
+ end
59
+
60
+ def store_root(options)
61
+ options[:store_root] || ENV[Providers::Lab::STORE_ROOT_ENV] || Providers::Lab::DEFAULT_STORE_ROOT
62
+ end
63
+
64
+ def grants_path(options)
65
+ options[:grants_path] || ENV[Providers::Lab::GRANTS_PATH_ENV] || Providers::Lab::DEFAULT_GRANTS_PATH
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/support/cli"
4
+ require_relative "lifecycle_command"
5
+
6
+ module Ace
7
+ module Hitl
8
+ module CLI
9
+ module Commands
10
+ # Host-broker listing of all public lifecycle projections.
11
+ class States < Ace::Support::Cli::Command
12
+ include Ace::Support::Cli::Base
13
+ include LifecycleCommand
14
+
15
+ desc "List public HITL lifecycle projections (transport operation)"
16
+
17
+ option :quiet, type: :boolean, aliases: %w[-q], desc: "Suppress non-essential output"
18
+
19
+ def call(**options)
20
+ emit(lifecycle_client.states)
21
+ rescue Lifecycle::Error, Providers::ProviderUnavailableError => e
22
+ raise_lifecycle_error(e.message)
23
+ end
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -55,11 +55,11 @@ module Ace
55
55
  lab_request_id = event.metadata["lab_request_id"]
56
56
  puts "Lab request delivered: #{event.id} (#{result[:lab_state]})"
57
57
  puts "Lab request: #{lab_request_id}"
58
- puts "Consume the answer when ready: lab-hitl consume #{lab_request_id}"
58
+ puts "Consume the answer when ready from the lab relay."
59
59
  if result[:lab_state] == "callback-ok"
60
60
  puts "Effect callback: ok"
61
61
  elsif result[:lab_state] == "callback-escalated"
62
- puts "Effect callback: escalated; inspect with: lab-hitl duty"
62
+ puts "Effect callback: escalated; inspect the lab duty projection"
63
63
  end
64
64
  when :timeout
65
65
  raise Ace::Support::Cli::Error.new("Timed out waiting for HITL event '#{ref}'")
data/lib/ace/hitl/cli.rb CHANGED
@@ -8,6 +8,16 @@ require_relative "cli/commands/show"
8
8
  require_relative "cli/commands/list"
9
9
  require_relative "cli/commands/update"
10
10
  require_relative "cli/commands/wait"
11
+ require_relative "cli/commands/deliver"
12
+ require_relative "cli/commands/consume"
13
+ require_relative "cli/commands/cancel"
14
+ require_relative "cli/commands/pending"
15
+ require_relative "cli/commands/states"
16
+ require_relative "cli/commands/duty"
17
+ require_relative "cli/commands/serve"
18
+ require_relative "cli/commands/overseer_send"
19
+ require_relative "cli/commands/overseer_pending"
20
+ require_relative "cli/commands/overseer_ack"
11
21
 
12
22
  module Ace
13
23
  module Hitl
@@ -22,7 +32,16 @@ module Ace
22
32
  ["show", "Show HITL event details"],
23
33
  ["list", "List HITL events"],
24
34
  ["update", "Update HITL event metadata or answer"],
25
- ["wait", "Wait for an answer on a specific HITL event"]
35
+ ["wait", "Wait for an answer on a specific HITL event"],
36
+ ["deliver", "Deliver an answer (stdin) to a pending HITL relay request"],
37
+ ["consume", "Wait for and consume the answer of one own HITL relay request"],
38
+ ["cancel", "Cancel one HITL relay request with an audited reason"],
39
+ ["pending", "List answerable HITL relay requests (transport)"],
40
+ ["states", "List public HITL lifecycle projections (transport)"],
41
+ ["duty", "Project pending and escalated HITL requests (transport)"],
42
+ ["overseer-send", "Queue a bounded, type-tagged Overseer response (stdin)"],
43
+ ["overseer-pending", "List queued Overseer responses (host-broker)"],
44
+ ["overseer-ack", "Acknowledge one relayed Overseer response"]
26
45
  ].freeze
27
46
 
28
47
  HELP_EXAMPLES = [
@@ -40,6 +59,16 @@ module Ace
40
59
  register "list", CLI::Commands::List
41
60
  register "update", CLI::Commands::Update
42
61
  register "wait", CLI::Commands::Wait
62
+ register "deliver", CLI::Commands::Deliver
63
+ register "consume", CLI::Commands::Consume
64
+ register "cancel", CLI::Commands::Cancel
65
+ register "pending", CLI::Commands::Pending
66
+ register "states", CLI::Commands::States
67
+ register "duty", CLI::Commands::Duty
68
+ register "serve", CLI::Commands::Serve
69
+ register "overseer-send", CLI::Commands::OverseerSend
70
+ register "overseer-pending", CLI::Commands::OverseerPending
71
+ register "overseer-ack", CLI::Commands::OverseerAck
43
72
 
44
73
  version_cmd = Ace::Support::Cli::VersionCommand.build(
45
74
  gem_name: "ace-hitl",
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "securerandom"
6
+
7
+ module Ace
8
+ module Hitl
9
+ module Lifecycle
10
+ # Durable, permission-preserving JSON record writes (port of
11
+ # atomic_json): O_EXCL|O_NOFOLLOW tempfile in the target directory,
12
+ # fsync, chmod, chown, rename. Ownership transitions go through the
13
+ # injectable ownership strategy; production performs real chown(2),
14
+ # tests map names onto the current identity (spec 8wm.t.y21 §2).
15
+ module AtomicJson
16
+ Ownership = Struct.new(:uid, :gid)
17
+
18
+ DEFAULT_OWNERSHIP = Object.new.tap do |strategy|
19
+ strategy.define_singleton_method(:chown) do |path, ownership|
20
+ File.chown(ownership.uid, ownership.gid, path) if ownership
21
+ end
22
+ end.freeze
23
+
24
+ module_function
25
+
26
+ # mode is an Integer permission bits value; ownership nil keeps
27
+ # the current identity. exclusive commits with link(2) instead of
28
+ # rename(2), so an existing destination raises Errno::EEXIST
29
+ # instead of being silently replaced (create-once writes).
30
+ def call(path, value, mode:, ownership: nil, ownership_strategy: DEFAULT_OWNERSHIP,
31
+ exclusive: false)
32
+ path = Pathname.new(path)
33
+ path.parent.mkpath
34
+ # The random suffix makes the temporary unique per invocation:
35
+ # a fixed pid-based name lets a competing writer's cleanup
36
+ # delete our in-flight temporary and fail both writes (review
37
+ # 8wq2zttt on PR#336). Cleanup only ever touches this call's
38
+ # own file.
39
+ temporary = path.parent.join(".#{path.basename}.tmp.#{$PROCESS_ID}.#{SecureRandom.hex(4)}")
40
+ flags = File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW
41
+ fd = IO.sysopen(temporary, flags, mode)
42
+ begin
43
+ io = IO.for_fd(fd, mode: "w")
44
+ begin
45
+ io.write(JSON.generate(value))
46
+ io.write("\n")
47
+ io.flush
48
+ io.fsync
49
+ ensure
50
+ io.close
51
+ end
52
+ rescue
53
+ begin
54
+ temporary.unlink
55
+ rescue
56
+ nil
57
+ end
58
+ raise
59
+ end
60
+ File.chmod(mode, temporary)
61
+ ownership_strategy.chown(temporary, ownership)
62
+ if exclusive
63
+ File.link(temporary, path)
64
+ else
65
+ File.rename(temporary, path)
66
+ end
67
+ ensure
68
+ temporary.unlink if temporary&.exist?
69
+ end
70
+
71
+ def read(path)
72
+ JSON.parse(File.read(path))
73
+ rescue SystemCallError, JSON::ParserError
74
+ nil
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Hitl
5
+ module Lifecycle
6
+ # The binding seam between the generic request lifecycle and the
7
+ # domain that owns Work/Attempt liveness and attribution (spec
8
+ # 8wm.t.y21 §1). The store REQUIRES a binding; the generic core
9
+ # never carries Work/Attempt semantics itself.
10
+ #
11
+ # Implementations must fail closed: any doubt raises BindingError.
12
+ class Binding
13
+ # Validate a request creation against the requester's exact live
14
+ # attempt. Raises BindingError on any violation or unavailability.
15
+ def validate_request(work: nil, assignment: nil, attempt:, project:, requester:)
16
+ raise NotImplementedError
17
+ end
18
+
19
+ # Re-verify that the attempt is still live before an answer is
20
+ # delivered or consumed. Raises BindingError when terminal.
21
+ def require_active(work:, attempt:)
22
+ raise NotImplementedError
23
+ end
24
+
25
+ # Hold verified live authority across ONE whole transition: the
26
+ # check and the caller's locked commit happen inside the same
27
+ # exclusion, so a concurrent attempt termination cannot land
28
+ # between them (spec 8wq.t.34i). The yielded value is opaque.
29
+ # Implementations without a holdable exclusion verify and yield
30
+ # (the legacy gap); the assignment authority holds its lock.
31
+ def with_active(work: nil, assignment: nil, attempt:, project: nil, requester: nil)
32
+ require_active(work: work, attempt: attempt)
33
+ yield
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,161 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+ require_relative "protocol"
5
+
6
+ module Ace
7
+ module Hitl
8
+ module Lifecycle
9
+ # The authenticated client of the scoped store boundary (spec
10
+ # 8wq.t.34i). The CLI lifecycle operations run through this client;
11
+ # direct shared-store access is not a client-side option.
12
+ #
13
+ # Both endpoint directions are authenticated:
14
+ # - the client authenticates the SERVICE: the socket must be a
15
+ # socket owned by the configured service uid, not group/world
16
+ # writable, and the connected peer must BE the service uid;
17
+ # - the service authenticates the client from kernel peer
18
+ # credentials (server side).
19
+ #
20
+ # Transport failures raise classified Lifecycle::TransportError;
21
+ # classified lifecycle errors arrive from the service and are
22
+ # re-raised under their own classes.
23
+ class Client
24
+ attr_reader :socket_path, :service_uid
25
+
26
+ def initialize(socket_path:, service_uid:, deadline_seconds: Protocol::DEFAULT_DEADLINE_SECONDS)
27
+ @socket_path = socket_path.to_s
28
+ @service_uid = Integer(service_uid)
29
+ @deadline_seconds = deadline_seconds
30
+ end
31
+
32
+ # @return [Hash] the result frame
33
+ def request(op, params = {})
34
+ verify_endpoint!
35
+ socket = UNIXSocket.open(@socket_path)
36
+ begin
37
+ verify_service_peer!(socket)
38
+ socket.write(Protocol.encode_request(op, params))
39
+ socket.flush
40
+ line = read_line(socket, deadline_for(op, params))
41
+ Protocol.decode_response(line)
42
+ ensure
43
+ socket.close
44
+ end
45
+ rescue SystemCallError, IOError, EOFError => e
46
+ raise TransportError, "HITL boundary is unavailable (#{e.class}: #{e.message})"
47
+ rescue JSON::GeneratorError => e
48
+ raise TransportError, "HITL boundary request is not serializable (#{e.message})"
49
+ end
50
+
51
+ def create(**params)
52
+ request("create", params.transform_keys(&:to_s))
53
+ end
54
+
55
+ def read(id)
56
+ request("read", {"id" => id})
57
+ end
58
+
59
+ def deliver(id, answer)
60
+ request("deliver", {"id" => id, "answer" => answer.to_s})
61
+ end
62
+
63
+ def consume(id, timeout: 0, operation: nil)
64
+ params = {"id" => id, "timeout" => Integer(timeout)}
65
+ params["operation"] = operation if operation
66
+ request("consume", params)
67
+ end
68
+
69
+ def cancel(id, reason: "")
70
+ request("cancel", {"id" => id, "reason" => reason.to_s})
71
+ end
72
+
73
+ def pending
74
+ request("pending")
75
+ end
76
+
77
+ def states
78
+ request("states")
79
+ end
80
+
81
+ def ping
82
+ request("ping")
83
+ end
84
+
85
+ private
86
+
87
+ # The endpoint must be owned by the trusted service identity and
88
+ # safe to write: an attacker-owned or world-writable socket is a
89
+ # credential-disclosure channel (answers, OTP) and is refused
90
+ # before any byte is sent.
91
+ def verify_endpoint!
92
+ stat = File.lstat(@socket_path)
93
+ unless stat.socket?
94
+ raise TransportError, "HITL boundary endpoint is not a socket: #{@socket_path}"
95
+ end
96
+ if stat.uid != @service_uid
97
+ raise TransportError,
98
+ "HITL boundary endpoint is owned by uid #{stat.uid}, not the trusted service uid #{@service_uid}"
99
+ end
100
+ # The designed endpoint mode is 0660 service:control-group —
101
+ # group-write is the legitimate access path; WORLD-write is
102
+ # never acceptable (no chmod-to-world workaround, spec 8wq.t.34i).
103
+ if (stat.mode & 0o002) != 0
104
+ raise TransportError, "HITL boundary endpoint is world-writable"
105
+ end
106
+ rescue Errno::ENOENT
107
+ raise TransportError, "HITL boundary endpoint does not exist: #{@socket_path}"
108
+ rescue SystemCallError
109
+ raise TransportError, "HITL boundary endpoint is not verifiable: #{@socket_path}"
110
+ end
111
+
112
+ def verify_service_peer!(socket)
113
+ peer_uid, = peer_credentials(socket)
114
+ return if peer_uid == @service_uid
115
+
116
+ raise TransportError,
117
+ "HITL boundary peer is uid #{peer_uid}, not the trusted service uid #{@service_uid}"
118
+ end
119
+
120
+ def peer_credentials(socket)
121
+ socket.getpeereid
122
+ end
123
+
124
+ # A consume without a timeout waits indefinitely at the client
125
+ # boundary too; every bounded call gets a deadline slightly
126
+ # beyond its own wait so the server result wins the race.
127
+ def deadline_for(op, params)
128
+ if op == "consume" && Integer(params["timeout"]) <= 0
129
+ nil
130
+ else
131
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) + @deadline_seconds + Integer(params["timeout"])
132
+ end
133
+ rescue ArgumentError, TypeError
134
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) + @deadline_seconds
135
+ end
136
+
137
+ def read_line(socket, deadline)
138
+ data = +""
139
+ loop do
140
+ remaining = deadline && deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
141
+ raise TransportError, "HITL boundary deadline exceeded" if remaining && remaining <= 0
142
+ ready = IO.select([socket], nil, nil, remaining)
143
+ raise TransportError, "HITL boundary deadline exceeded" unless ready
144
+
145
+ chunk = socket.read_nonblock(4096, exception: false)
146
+ raise TransportError, "HITL boundary closed before responding" if chunk.nil?
147
+ # A spurious not-ready result goes back to select; only the
148
+ # deadline ends the wait (review 8x32r9b4).
149
+ next if chunk == :wait_readable
150
+
151
+ data << chunk
152
+ if data.bytesize > Protocol::MAX_FRAME_BYTES
153
+ raise TransportError, "HITL boundary response is too large"
154
+ end
155
+ return data if data.include?("\n")
156
+ end
157
+ end
158
+ end
159
+ end
160
+ end
161
+ end