ace-herdr 0.4.0 → 0.5.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 (43) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/herdr/config.yml +0 -4
  3. data/CHANGELOG.md +12 -0
  4. data/docs/usage.md +57 -35
  5. data/lib/ace/herdr/cli/commands/inbox.rb +83 -21
  6. data/lib/ace/herdr/cli/commands/native_source.rb +56 -0
  7. data/lib/ace/herdr/cli.rb +3 -0
  8. data/lib/ace/herdr/errors.rb +23 -0
  9. data/lib/ace/herdr/molecules/bounded_process.rb +261 -52
  10. data/lib/ace/herdr/molecules/delivery_record_store.rb +81 -6
  11. data/lib/ace/herdr/molecules/guarded_native_origin.rb +59 -0
  12. data/lib/ace/herdr/molecules/herdr_executor.rb +6 -3
  13. data/lib/ace/herdr/molecules/inbox_cli_input.rb +36 -0
  14. data/lib/ace/herdr/molecules/inbox_context_client.rb +88 -0
  15. data/lib/ace/herdr/molecules/inbox_context_completion_client.rb +119 -0
  16. data/lib/ace/herdr/molecules/inbox_context_key.rb +63 -0
  17. data/lib/ace/herdr/molecules/inbox_context_lifetime.rb +122 -0
  18. data/lib/ace/herdr/molecules/inbox_context_native_process.rb +92 -0
  19. data/lib/ace/herdr/molecules/inbox_context_service_configuration.rb +183 -0
  20. data/lib/ace/herdr/molecules/inbox_context_store.rb +159 -0
  21. data/lib/ace/herdr/molecules/inbox_context_wire.rb +76 -0
  22. data/lib/ace/herdr/molecules/inbox_direct_effect_binding.rb +92 -0
  23. data/lib/ace/herdr/molecules/inbox_direct_result.rb +65 -0
  24. data/lib/ace/herdr/molecules/native_queue_executor.rb +10 -21
  25. data/lib/ace/herdr/molecules/native_source.rb +189 -0
  26. data/lib/ace/herdr/molecules/protected_inbox_selection.rb +126 -0
  27. data/lib/ace/herdr/molecules/protected_native_control.rb +129 -4
  28. data/lib/ace/herdr/native_source/HERDR-LICENSE +201 -0
  29. data/lib/ace/herdr/native_source/guarded-prompt.patch +3493 -0
  30. data/lib/ace/herdr/native_source/selection.json +24 -0
  31. data/lib/ace/herdr/organisms/inbox.rb +293 -203
  32. data/lib/ace/herdr/organisms/inbox_context_direct_effects.rb +285 -0
  33. data/lib/ace/herdr/organisms/inbox_context_effects.rb +30 -0
  34. data/lib/ace/herdr/organisms/inbox_context_listener.rb +151 -0
  35. data/lib/ace/herdr/organisms/inbox_context_owner.rb +550 -0
  36. data/lib/ace/herdr/organisms/inbox_context_server.rb +70 -0
  37. data/lib/ace/herdr/organisms/inbox_context_service.rb +107 -0
  38. data/lib/ace/herdr/organisms/inbox_guarded_wake.rb +117 -0
  39. data/lib/ace/herdr/organisms/native_source_builder.rb +183 -0
  40. data/lib/ace/herdr/version.rb +1 -1
  41. data/lib/ace/herdr.rb +5 -28
  42. metadata +64 -5
  43. data/lib/ace/herdr/organisms/protected_inbox.rb +0 -73
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "digest"
5
+ require "time"
6
+
7
+ module Ace
8
+ module Herdr
9
+ module Molecules
10
+ # The accepted native producer source shipped by this exact package.
11
+ # Provenance verifies bytes; deployment authorization stays with its owner.
12
+ class NativeSource
13
+ class Error < StandardError; end
14
+ class ClosedObject < Hash
15
+ def []=(key, value)
16
+ raise FrozenError, "frozen native source object" if frozen?
17
+ raise Error, "duplicate native provenance field" if key?(key)
18
+ super
19
+ end
20
+ end
21
+
22
+ ASSETS = File.expand_path("../native_source", __dir__).freeze
23
+ SOURCE_KEYS = %w[schema repository baseline_commit source_commit source_tree patch_file patch_sha256
24
+ cargo_lock_sha256 rust_toolchain_sha256 rust_version zig_version native_version protocol committer targets].freeze
25
+ BUILD_KEYS = %w[target profile rust_version zig_version tools_sha256].freeze
26
+ RECEIPT_KEYS = %w[schema selection_sha256 source build artifact].freeze
27
+ TARGETS = %w[x86_64-unknown-linux-musl aarch64-unknown-linux-musl].freeze
28
+ SHA256 = /\A[0-9a-f]{64}\z/
29
+ GIT_SHA = /\A[0-9a-f]{40}\z/
30
+ VERSION = /\A\d+\.\d+\.\d+\z/
31
+ MAX_ARTIFACT_BYTES = 256 * 1024 * 1024
32
+
33
+ attr_reader :selection, :selection_sha256, :selection_path, :patch_path
34
+
35
+ def initialize(assets: ASSETS)
36
+ @selection_path = File.join(File.expand_path(assets), "selection.json")
37
+ bytes = read_regular(@selection_path, 65_536)
38
+ @selection = parse(bytes)
39
+ validate_selection!
40
+ @patch_path = File.join(File.dirname(@selection_path), @selection.fetch("patch_file"))
41
+ patch = read_regular(@patch_path, 1_048_576)
42
+ raise Error, "native source patch digest mismatch" unless digest(patch) == @selection.fetch("patch_sha256")
43
+ @selection_sha256 = digest(bytes).freeze
44
+ freeze_data(@selection)
45
+ end
46
+
47
+ def description
48
+ {"selection_path" => @selection_path, "selection_sha256" => @selection_sha256, "source" => @selection}
49
+ end
50
+
51
+ def verify_artifact(directory:)
52
+ directory = File.expand_path(directory)
53
+ info = File.lstat(directory)
54
+ raise Error, "native artifact directory must be regular" unless info.directory? && !info.symlink?
55
+ raise Error, "unexpected native artifact files" unless Dir.children(directory).sort == %w[herdr provenance.json]
56
+ receipt_bytes = read_regular(File.join(directory, "provenance.json"), 65_536)
57
+ receipt = parse(receipt_bytes)
58
+ validate_receipt!(receipt)
59
+ artifact = read_regular(File.join(directory, "herdr"), MAX_ARTIFACT_BYTES)
60
+ validate_elf!(artifact, receipt.fetch("build").fetch("target"))
61
+ expected = receipt.fetch("artifact")
62
+ unless artifact.bytesize == expected.fetch("size") && digest(artifact) == expected.fetch("sha256")
63
+ raise Error, "native artifact bytes do not match provenance"
64
+ end
65
+ freeze_data(receipt)
66
+ {"provenance_sha256" => digest(receipt_bytes), "selection_sha256" => @selection_sha256,
67
+ "source" => @selection, "build" => receipt.fetch("build"), "artifact" => expected}
68
+ rescue JSON::ParserError, SystemCallError, IOError => error
69
+ raise Error, "native artifact unavailable: #{error.class}"
70
+ end
71
+
72
+ def artifact_record(bytes:, target:)
73
+ validate_elf!(bytes, target)
74
+ {"file" => "herdr", "size" => bytes.bytesize, "sha256" => digest(bytes)}
75
+ end
76
+
77
+ def receipt(build:, artifact:)
78
+ data = {"schema" => "ace.herdr.native-build/v1", "selection_sha256" => @selection_sha256,
79
+ "source" => @selection, "build" => build, "artifact" => artifact}
80
+ validate_receipt!(data)
81
+ data
82
+ end
83
+
84
+ def read_regular(path, limit)
85
+ before = File.lstat(path)
86
+ raise Error, "native source input must be a regular file" unless before.file? && !before.symlink?
87
+ File.open(path, File::RDONLY | File::NOFOLLOW) do |input|
88
+ opened = input.stat
89
+ unless opened.file? && [opened.dev, opened.ino] == [before.dev, before.ino]
90
+ raise Error, "native source input changed while opening"
91
+ end
92
+ bytes = input.read(limit + 1)
93
+ raise Error, "native source input exceeds bound" if bytes.bytesize > limit
94
+ after = input.stat
95
+ unless [opened.size, opened.mtime, opened.ctime] == [after.size, after.mtime, after.ctime]
96
+ raise Error, "native source input changed while reading"
97
+ end
98
+ bytes
99
+ end
100
+ rescue SystemCallError, IOError => error
101
+ raise Error, "native source input unavailable: #{error.class}"
102
+ end
103
+
104
+ def digest(bytes)
105
+ Digest::SHA256.hexdigest(bytes)
106
+ end
107
+
108
+ private
109
+
110
+ def parse(bytes)
111
+ text = bytes.dup.force_encoding(Encoding::UTF_8)
112
+ raise Error, "invalid native source UTF-8" unless text.valid_encoding?
113
+ JSON.parse(text, object_class: ClosedObject, create_additions: false,
114
+ max_nesting: 16, allow_comments: false, allow_duplicate_key: false)
115
+ rescue JSON::ParserError, EncodingError
116
+ raise Error, "invalid native source JSON"
117
+ end
118
+
119
+ def fields!(data, expected)
120
+ unless data.is_a?(Hash) && data.keys.sort == expected.sort
121
+ raise Error, "invalid native source fields"
122
+ end
123
+ end
124
+
125
+ def string!(value, pattern)
126
+ raise Error, "invalid native source value" unless value.is_a?(String) && pattern.match?(value)
127
+ end
128
+
129
+ def validate_selection!(selection = @selection)
130
+ fields!(selection, SOURCE_KEYS)
131
+ raise Error, "unsupported native source schema" unless selection["schema"] == "ace.herdr.native-source/v1"
132
+ unless selection["repository"] == "https://github.com/herdrdev/herdr.git" && selection["patch_file"] == "guarded-prompt.patch"
133
+ raise Error, "unsupported native source selection"
134
+ end
135
+ %w[baseline_commit source_commit source_tree].each { |key| string!(selection[key], GIT_SHA) }
136
+ %w[patch_sha256 cargo_lock_sha256 rust_toolchain_sha256].each { |key| string!(selection[key], SHA256) }
137
+ %w[rust_version zig_version native_version].each { |key| string!(selection[key], VERSION) }
138
+ unless selection["protocol"].is_a?(Integer) && selection["protocol"].positive? && selection["targets"] == TARGETS
139
+ raise Error, "unsupported native target/protocol"
140
+ end
141
+ committer = selection["committer"]
142
+ fields!(committer, %w[name email date])
143
+ %w[name email].each { |key| string!(committer[key], /\A[^\x00-\x1f\x7f]{1,255}\z/) }
144
+ string!(committer["date"], /\A\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d[+-]\d\d:\d\d\z/)
145
+ Time.iso8601(committer["date"])
146
+ rescue ArgumentError
147
+ raise Error, "invalid native source committer date"
148
+ end
149
+
150
+ def validate_elf!(bytes, target)
151
+ raise Error, "unsupported native artifact target" unless TARGETS.include?(target)
152
+ machine = target.start_with?("x86_64-") ? 62 : 183
153
+ unless bytes.bytesize >= 64 && bytes.byteslice(0, 7) == "\x7fELF\x02\x01\x01".b &&
154
+ [2, 3].include?(bytes.byteslice(16, 2).unpack1("v")) && bytes.byteslice(18, 2).unpack1("v") == machine
155
+ raise Error, "native artifact is not the selected Linux executable architecture"
156
+ end
157
+ end
158
+
159
+ def validate_receipt!(data)
160
+ fields!(data, RECEIPT_KEYS)
161
+ validate_selection!(data["source"])
162
+ unless data["schema"] == "ace.herdr.native-build/v1" && data["selection_sha256"] == @selection_sha256 && data["source"] == @selection
163
+ raise Error, "native build source selection mismatch"
164
+ end
165
+ build = data["build"]
166
+ fields!(build, BUILD_KEYS)
167
+ unless @selection["targets"].include?(build["target"]) && build["profile"] == "release" &&
168
+ build["rust_version"] == @selection["rust_version"] && build["zig_version"] == @selection["zig_version"]
169
+ raise Error, "native build toolchain/target mismatch"
170
+ end
171
+ fields!(build["tools_sha256"], %w[cargo rustc zig linker])
172
+ build["tools_sha256"].each_value { |value| string!(value, SHA256) }
173
+ artifact = data["artifact"]
174
+ fields!(artifact, %w[file size sha256])
175
+ unless artifact["file"] == "herdr" && artifact["size"].is_a?(Integer) && artifact["size"].positive? && artifact["size"] <= MAX_ARTIFACT_BYTES
176
+ raise Error, "invalid native build artifact"
177
+ end
178
+ string!(artifact["sha256"], SHA256)
179
+ end
180
+
181
+ def freeze_data(value)
182
+ value.each { |key, item| freeze_data(key); freeze_data(item) } if value.is_a?(Hash)
183
+ value.each { |item| freeze_data(item) } if value.is_a?(Array)
184
+ value.freeze
185
+ end
186
+ end
187
+ end
188
+ end
189
+ end
@@ -0,0 +1,126 @@
1
+ # frozen_string_literal: true
2
+ require "tmpdir"
3
+ require "ace/runtime/molecules/protected_task_context_entry"
4
+ require_relative "bounded_process"
5
+ require_relative "inbox_context_store"
6
+
7
+ module Ace
8
+ module Herdr
9
+ module Molecules
10
+ # Existing immutable entry owns discovery; the endpoint still authorizes effects.
11
+ class ProtectedInboxSelection
12
+ TOKEN = /\A[A-Za-z0-9][A-Za-z0-9._-]{0,127}\z/
13
+ def initialize(entry_owner: Ace::Runtime::Molecules::ProtectedTaskContextEntry.new,
14
+ runner: BoundedProcess, uid: Process.uid, env: ENV)
15
+ @owner, @runner, @uid, @env = entry_owner, runner, uid, env
16
+ end
17
+
18
+ def with(options)
19
+ downstream_started = false
20
+ selected = options.values_at(:project, :mapping, :inbox_context, :claim_generation, :assignment).any? { |item| !item.nil? } ||
21
+ %w[ACE_ASSIGN_PROJECT_ID ACE_ASSIGN_LAUNCH_MAPPING ACE_ASSIGN_INBOX_CONTEXT_ID].any? { |key| !@env[key].to_s.empty? }
22
+ @owner.with do |pin|
23
+ unless pin
24
+ raise ValidationError, "protected inbox entry is absent" if selected
25
+ downstream_started = true
26
+ next yield nil
27
+ end
28
+ principal = invoke(pin, ["authority", "inbox-context-principal"], 1024)
29
+ object!(principal, %w[schema uid protected_participant])
30
+ unless principal["schema"] == "ace.assign.inbox-context-principal/v1" && principal["uid"] == @uid &&
31
+ principal["uid"].is_a?(Integer) && [true, false].include?(principal["protected_participant"])
32
+ raise ValidationError, "protected inbox actual caller differs"
33
+ end
34
+ unless principal.fetch("protected_participant") || selected
35
+ downstream_started = true
36
+ next yield nil
37
+ end
38
+ keys = {project: "ACE_ASSIGN_PROJECT_ID", mapping: "ACE_ASSIGN_LAUNCH_MAPPING", inbox_context: "ACE_ASSIGN_INBOX_CONTEXT_ID"}
39
+ selectors = keys.to_h do |key, hint_key|
40
+ value, hint = options[key], @env[hint_key].to_s
41
+ unless value.is_a?(String) && TOKEN.match?(value) && (hint.empty? || hint == value)
42
+ raise ValidationError, "explicit protected inbox selectors differ"
43
+ end
44
+ [key, value]
45
+ end
46
+ args = selectors.flat_map { |key, value| ["--#{key.to_s.tr('_', '-')}", value] }
47
+ result = invoke(pin, ["authority", "inbox-context-selection", *args], 16_384)
48
+ object!(result, %w[schema uid project_id mapping_id inbox_context_id descriptor history context authority])
49
+ unless result["schema"] == "ace.assign.inbox-context-selection/v1" && result["uid"] == @uid &&
50
+ result.values_at("project_id", "mapping_id", "inbox_context_id") == selectors.values
51
+ raise ValidationError, "protected inbox selected association differs"
52
+ end
53
+ %w[descriptor history].each do |field|
54
+ reference = result.fetch(field)
55
+ object!(reference, %w[path bytes sha256])
56
+ @owner.validate_pin!({"manifest" => reference, "wrapper" => reference})
57
+ end
58
+ context = result.fetch("context")
59
+ object!(context, %w[control_socket_path owner_credentials native_mapping_id])
60
+ endpoint!(context.fetch("control_socket_path")); credentials!(context.fetch("owner_credentials"))
61
+ raise ValidationError, "protected inbox native mapping differs" unless context["native_mapping_id"].is_a?(String) && TOKEN.match?(context["native_mapping_id"])
62
+ authority = result.fetch("authority")
63
+ object!(authority, %w[authority_id socket_path uid gid groups])
64
+ endpoint!(authority.fetch("socket_path")); credentials!(authority.slice("uid", "gid", "groups"))
65
+ raise ValidationError, "protected inbox authority ID differs" unless authority["authority_id"].is_a?(String) && TOKEN.match?(authority["authority_id"])
66
+ downstream_started = true
67
+ yield result
68
+ end
69
+ rescue Ace::Runtime::Error, IOError, SystemCallError, Timeout::Error, BoundedProcess::PostLaunchError, KeyError, TypeError, ArgumentError
70
+ raise if downstream_started
71
+ raise ValidationError, "protected inbox selection is unavailable"
72
+ end
73
+
74
+ private
75
+
76
+ def invoke(pin, args, limit)
77
+ @owner.with_entry(pin) do |entry|
78
+ Dir.mktmpdir("ace-inbox-selector-", "/tmp") do |directory|
79
+ path = File.join(directory, "selector.json")
80
+ File.open(path, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |writer|
81
+ writer.write(JSON.generate(entry.pin.fetch("manifest")) + "\n"); writer.flush
82
+ File.open(path, File::RDONLY | File::NOFOLLOW | File::NONBLOCK) do |reader|
83
+ unless reader.stat.file? && reader.stat.uid == Process.uid && (reader.stat.mode & 0o077).zero? &&
84
+ [reader.stat.dev, reader.stat.ino, reader.stat.size] == [writer.stat.dev, writer.stat.ino, writer.stat.size]
85
+ raise ValidationError, "protected inbox selector handle differs"
86
+ end
87
+ File.unlink(path)
88
+ output = @runner.call(["/proc/self/fd/4", "-I", "-S", "-B", "-", *args], stdin_data: entry.body,
89
+ timeout_s: 30, output_limit: limit, stderr_limit: 16_384,
90
+ descriptor_mapping: {4 => entry.interpreter, 5 => reader}, environment: {}, chdir: "/", cleanup_group: true)
91
+ unless output.status&.success? && !output.oversized && output.stdout.is_a?(String) &&
92
+ output.stdout.end_with?("\n") && output.stdout.count("\n") == 1
93
+ raise ValidationError, "protected inbox entry failed or framing differs"
94
+ end
95
+ record = InboxContextStore.decode(output.stdout, limit: limit)
96
+ raise ValidationError, "protected inbox entry is not compact JSON" unless JSON.generate(record) + "\n" == output.stdout
97
+ record
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
103
+
104
+ def object!(value, fields)
105
+ raise ValidationError, "protected inbox fields differ" unless value.is_a?(Hash) && value.keys.sort == fields.sort
106
+ end
107
+
108
+ def endpoint!(value)
109
+ unless value.is_a?(String) && value.encoding == Encoding::UTF_8 && value.valid_encoding? &&
110
+ value.bytesize.between?(2, 4096) && value.start_with?("/") && !value.include?("\0") && File.expand_path(value) == value
111
+ raise ValidationError, "protected inbox endpoint differs"
112
+ end
113
+ end
114
+
115
+ def credentials!(value)
116
+ object!(value, %w[uid gid groups])
117
+ unless value.values_at("uid", "gid").all? { |number| number.is_a?(Integer) && number.positive? } &&
118
+ value["groups"].is_a?(Array) && value["groups"].size <= 64 &&
119
+ value["groups"].all? { |number| number.is_a?(Integer) && number.positive? } && value["groups"] == value["groups"].sort.uniq
120
+ raise ValidationError, "protected inbox principal differs"
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
126
+ end
@@ -2,6 +2,7 @@
2
2
  require "ace/runtime/molecules/protected_linux"
3
3
  require "ace/runtime/molecules/protected_socket"
4
4
  require "securerandom"
5
+ require_relative "guarded_native_origin"
5
6
 
6
7
  module Ace
7
8
  module Herdr
@@ -26,6 +27,129 @@ module Ace
26
27
  end
27
28
 
28
29
  def request(method, params = {})
30
+ result = exchange(method, params)
31
+ unless result["result"].is_a?(Hash) && !result.key?("error")
32
+ raise Ace::Runtime::RuntimeUnavailableError, "native control response is invalid"
33
+ end
34
+ result.fetch("result")
35
+ end
36
+
37
+ # Admission calls this before committing the durable prompt intent.
38
+ # Neither this method nor replay is permission to issue native input.
39
+ def prompt_preflight!(binding)
40
+ raise Ace::Runtime::RuntimeUnavailableError, "canonical original binding is unavailable" unless binding.is_a?(Hash)
41
+ original = binding.reject { |key, _| key == "guarded_origin" }
42
+ fresh = guarded_binding!(original)
43
+ unless fresh.fetch("guarded_origin") == guarded_origin!(binding)
44
+ raise Ace::Runtime::RuntimeUnavailableError, "canonical original guarded actor changed"
45
+ end
46
+ fresh.fetch("guarded_origin")
47
+ end
48
+
49
+ # Explicit N2 capture keeps existing original scope binding bytes
50
+ # unchanged. The launch owner persists this separately in its canonical
51
+ # record data before issuing any steering permission.
52
+ def guarded_binding!(binding)
53
+ raise Ace::Runtime::RuntimeUnavailableError, "canonical original binding is unavailable" unless binding.is_a?(Hash)
54
+ original = binding.reject { |key, _| key == "guarded_origin" }
55
+ observed = observe(original.fetch("native_origin").merge("process_binding" => original))
56
+ info = request("pane.process_info", {"pane_id" => original.fetch("pane")}).fetch("process_info")
57
+ unless info["pane_id"] == original.fetch("pane") && info["shell_pid"] == observed.dig("process_identity", "pid") &&
58
+ info["guarded_prompt"] == true && info["guarded_input_drain"] == true
59
+ raise Ace::Runtime::RuntimeUnavailableError, "native original guarded capability is unavailable"
60
+ end
61
+ observed.merge("guarded_origin" => GuardedNativeOrigin.verify!(info.fetch("guarded_prompt_origin"),
62
+ terminal_id: observed.fetch("terminal_id"), child: observed.fetch("process_identity")))
63
+ rescue KeyError, TypeError
64
+ raise Ace::Runtime::RuntimeUnavailableError, "canonical original guarded actor is unavailable"
65
+ end
66
+
67
+ # Only an already accepted canonical issue permit invokes this method.
68
+ # Unknown/lost transport results remain uncertain even if a local error
69
+ # occurred before some observed write; native phase is the only refusal
70
+ # evidence. Never return native agent/message fields or prompt bytes.
71
+ def prompt(binding:, text:)
72
+ submit_prompt(guarded_origin!(binding), prompt_body!(text, 16_384))
73
+ end
74
+
75
+ # The admitted Inbox keeps its existing bounded payload contract while
76
+ # sharing the original actor guard and native submission transport.
77
+ def inbox_prompt(binding:, text:)
78
+ submit_prompt(guarded_origin!(binding), prompt_body!(text, 65_536))
79
+ end
80
+
81
+ def prompt_body!(text, limit)
82
+ body = text.dup.force_encoding(Encoding::UTF_8) if text.is_a?(String)
83
+ unless body && body.valid_encoding? && body.bytesize.between?(1, limit) && !body.match?(/\A[[:space:]]*\z/)
84
+ raise ArgumentError, "prompt text must be bounded nonblank UTF-8"
85
+ end
86
+ body
87
+ end
88
+ private :prompt_body!
89
+
90
+ # Called only by the admitted Inbox owner after durable queue/wake
91
+ # intent; this domain notification accepts no caller text or target.
92
+ def queue_wake(binding:)
93
+ submit_prompt(guarded_origin!(binding), "Check your native queued messages.")
94
+ end
95
+
96
+ def submit_prompt(origin, body)
97
+ frame = exchange("agent.prompt", {"target" => origin.fetch("terminal_id"), "text" => body, "expected_origin" => origin},
98
+ write_limit: 131_072, read_limit: 16_384)
99
+ result = frame["result"]
100
+ if frame.keys.sort == %w[id result] && result.is_a?(Hash) && result.keys.sort == %w[agent origin submission type] &&
101
+ result["type"] == "agent_prompted" && result["submission"] == "submitted" && result["origin"] == origin && result["agent"].is_a?(Hash)
102
+ GuardedNativeOrigin.verify!(result.fetch("origin"), terminal_id: origin.fetch("terminal_id"), child: origin.fetch("child"))
103
+ return {"outcome" => "submitted", "submission" => "submitted", "origin" => origin}
104
+ end
105
+ error = frame["error"]
106
+ codes = %w[guard_mismatch origin_unavailable origin_exited agent_blocked agent_not_ready target_missing submission_busy]
107
+ if frame.keys.sort == %w[error id] && error.is_a?(Hash) && error.keys.sort == %w[code message phase] &&
108
+ error["phase"] == "not_issued" && codes.include?(error["code"]) && error["message"].is_a?(String)
109
+ return {"outcome" => "not_issued", "phase" => "not_issued", "code" => error.fetch("code"), "origin" => origin}
110
+ end
111
+ {"outcome" => "uncertain", "origin" => origin}
112
+ rescue Ace::Runtime::RuntimeUnavailableError, IOError, SystemCallError
113
+ {"outcome" => "uncertain", "origin" => origin}
114
+ end
115
+
116
+ # Input drain authenticates the captured actor even after its original
117
+ # child exited. Reobserving a live child here would destroy that proof.
118
+ # Actor/server replacement or missing completion remains unconfirmed.
119
+ def inhibit_input(binding:)
120
+ origin = guarded_origin!(binding)
121
+ drain_input(origin)
122
+ end
123
+
124
+ def drain_input(origin)
125
+ frame = exchange("terminal.inhibit_input", {"target" => origin.fetch("terminal_id"), "expected_origin" => origin},
126
+ write_limit: 16_384, read_limit: 16_384)
127
+ result = frame["result"]
128
+ if frame.keys.sort == %w[id result] && result.is_a?(Hash) && result.keys.sort == %w[input_state origin pending_input type] &&
129
+ result["type"] == "terminal_input_drained" && result["origin"] == origin && result["input_state"] == "inhibited" &&
130
+ result["pending_input"].is_a?(Integer) && result["pending_input"].zero?
131
+ GuardedNativeOrigin.verify!(result.fetch("origin"), terminal_id: origin.fetch("terminal_id"), child: origin.fetch("child"))
132
+ return {"outcome" => "inhibited", "origin" => origin, "input_state" => "inhibited", "pending_input" => 0}
133
+ end
134
+ error = frame["error"]
135
+ if frame.keys.sort == %w[error id] && error.is_a?(Hash) && error.keys.sort == %w[code phase] &&
136
+ error["phase"] == "unconfirmed" && %w[invalid_params guard_mismatch origin_unavailable input_drain_unavailable].include?(error["code"])
137
+ return {"outcome" => "unconfirmed", "origin" => origin, "code" => error.fetch("code"), "phase" => "unconfirmed"}
138
+ end
139
+ {"outcome" => "unconfirmed", "origin" => origin}
140
+ rescue Ace::Runtime::RuntimeUnavailableError, IOError, SystemCallError
141
+ {"outcome" => "unconfirmed", "origin" => origin}
142
+ end
143
+
144
+ def guarded_origin!(binding)
145
+ raise Ace::Runtime::RuntimeUnavailableError, "canonical original guarded binding is unavailable" unless binding.is_a?(Hash)
146
+ GuardedNativeOrigin.verify!(binding.fetch("guarded_origin"), terminal_id: binding.fetch("terminal_id"),
147
+ child: binding.fetch("process_identity"))
148
+ rescue KeyError, TypeError
149
+ raise Ace::Runtime::RuntimeUnavailableError, "canonical original guarded binding is unavailable"
150
+ end
151
+
152
+ def exchange(method, params = {}, write_limit: wire::LIMIT, read_limit: wire::LIMIT)
29
153
  verify!
30
154
  native = @mapping.fetch("native")
31
155
  wire.connect(native.fetch("socket_path")) do |socket|
@@ -35,13 +159,13 @@ module Ace
35
159
  end
36
160
  id = SecureRandom.hex(12)
37
161
  deadline = wire.deadline
38
- wire.write(socket, {"id" => id, "method" => method, "params" => params}, deadline: deadline)
39
- result = wire.read(socket, deadline: deadline)
40
- unless result.is_a?(Hash) && result["id"] == id && result["result"].is_a?(Hash) && !result.key?("error")
162
+ wire.write(socket, {"id" => id, "method" => method, "params" => params}, deadline: deadline, limit: write_limit)
163
+ result = wire.read(socket, deadline: deadline, limit: read_limit)
164
+ unless result.is_a?(Hash) && result["id"] == id
41
165
  raise Ace::Runtime::RuntimeUnavailableError, "native control response is invalid"
42
166
  end
43
167
  verify!
44
- result.fetch("result")
168
+ result
45
169
  end
46
170
  rescue SystemCallError, IOError
47
171
  raise Ace::Runtime::RuntimeUnavailableError, "native control outcome is unavailable"
@@ -125,6 +249,7 @@ module Ace
125
249
  end
126
250
 
127
251
  private
252
+ private :exchange, :submit_prompt, :drain_input
128
253
  def wire
129
254
  Ace::Runtime::Molecules::ProtectedSocket
130
255
  end