ace-herdr 0.3.2 → 0.4.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c6c7f32e364c4df76447fd6d4356f2117007cf920dc3f2f420bd4dd1e21f8242
4
- data.tar.gz: 783dd62904dd12e952528d80d9956511c052fcf23a53551b390f5817bbe93c8c
3
+ metadata.gz: 4e0747847d9a56e36a452fd156063284154be0c5eb6464b9acca559fba504750
4
+ data.tar.gz: 2dd3be6a92a0217c2ec0d194a83714c0b6cbc22856c4df0ba7bb99f4f0a02810
5
5
  SHA512:
6
- metadata.gz: bd865086e653b9f8bed7434073707fb733f0f9f0f08c6d979046476ee955a65a2938e7efbad966942fe2a55b68b80c7bbc6a53076601bdaac4cb0ff94029e099
7
- data.tar.gz: '08c29e0f0cfb5fa1857959c1fd0ef05376ce3dcc895e6ee6a056e2b3be58c00311e5d0b397e07f063bc94151bb3567441c9e1213bf11e6ea16ea570e8e3fa010'
6
+ metadata.gz: dc0422a8f818acfb8ad055543ef95938bd41bc988622e527dcffccbf0e757456f4b9aef47ab141eda71eb97fd1d55311d836b2f0f6fc2f04d6ac2f50f56a5c80
7
+ data.tar.gz: 98c840541c5d15bc6241f1a7bf0535567b34386fe441fdb14c97be8d1a98413a9777151807d4f142504d725871905b77144d89a2622b64f32e85e384b74daaa1
data/CHANGELOG.md CHANGED
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.4.0] - 2026-10-05
11
+
12
+ ### Fixed
13
+ - Use the shared typed reverse-reference pair at CLI, Inbox and delivery boundaries, refusing malformed persisted targets and noncanonical serialized files before claim or native observation.
14
+ - Pin managed delivery to the accepted original native target under the event lock and reject secret-bearing payloads even without a nested message.
15
+
16
+ ### Changed
17
+
18
+ - Declare the required direct dependencies and minimum producer versions for this coordinated release: `ace-hitl-contract ~> 0.2`, `ace-runtime ~> 0.2`.
19
+ - Bind protected control to the canonical per-attempt server/socket identity while retaining the fixed native executable view selected by installation verification.
20
+ - Validate and preserve the shared managed delivery envelope at Inbox enqueue, rejecting mismatched scope/digest/correlation and changed replay metadata.
21
+
22
+ ### Added
23
+
24
+ - Add protected Herdr control pinned to the installed server/socket and fixed workspace, creating one fresh bootstrap pane with exact child observation and no default shell.
25
+
26
+ - Expose verified native process/session ownership and allow identical signed inbox proof re-verification after settlement without duplicate transitions.
27
+
10
28
  ## [0.3.2] - 2026-10-04
11
29
 
12
30
  ### Changed
data/docs/usage.md CHANGED
@@ -48,13 +48,20 @@ Native `agent_blocked` becomes `SendRejectedError`; `agent_prompt_stalled` becom
48
48
 
49
49
  Use a stable event ID for one message and the assignment attempt that owns it. The reverse-address JSON contains `session` and `pane`, as with `deliver`.
50
50
 
51
+ Reverse references use the shared `ace.hitl.ref/v1` pair contract. Herdr token names
52
+ remain valid; the codec also preserves paired native tmux IDs (`$0`/`%0`) when
53
+ carried by managed metadata. Mixed native/token or cross-field IDs refuse.
54
+ Wire and persisted references must be canonical. Syntax acceptance does not add
55
+ tmux execution to this Herdr controller or replace its exact native ownership,
56
+ target and signed receipt checks.
57
+
51
58
  ```bash
52
59
  ace-herdr inbox enqueue --event inb-12345678 --attempt ATTEMPT --ref ref.json --file prompt.txt
53
60
  ace-herdr inbox status --event inb-12345678 --format json
54
61
  ace-herdr inbox deliver --event inb-12345678
55
62
  ```
56
63
 
57
- `enqueue` checks the live pane, durable terminal, agent kind, and native thread identity. Repeating the same event, attempt, target, and payload returns its existing record; a changed value is an error. `deliver` claims the record once. Idle and busy Codex or Pi agents receive an exact-session native queue submission. After an accepted idle submission, Herdr sends a generic wake prompt without the inbox payload. A pane replacement can receive at most that generic wake, never the payload. The JSON result includes `state`, `claim_generation`, `binding`, `last_error`, and any submission receipt.
64
+ `enqueue` checks the live pane, durable terminal, agent kind, and native thread identity. Managed API callers must supply `expected_target`, projected from the accepted runtime owner with `Inbox#target_from_owner`; the live observation must equal that original session/pane/terminal/agent/thread identity under the event lock. The immutable `origin_target` survives retries and signed replacement of the current target. Managed payloads are checked for secret shapes before persistence, including envelopes without a nested message. Repeating the same event, attempt, target, and payload returns its existing record; a changed value is an error. `deliver` claims the record once. Idle and busy Codex or Pi agents receive an exact-session native queue submission. After an accepted idle submission, Herdr sends a generic wake prompt without the inbox payload. A pane replacement can receive at most that generic wake, never the payload. The JSON result includes `state`, `claim_generation`, `binding`, `last_error`, and any submission receipt.
58
65
 
59
66
  `queued` can be retried after a proven pre-submission rejection. `delivered` means native submission was accepted; it does not mean the agent consumed the message. A crash after claim, changed target identity, or a native result without a verified acceptance receipt is `uncertain`; another `deliver` call does not resend it. A `completed` record has a positively verified consumption receipt. A superseded uncertain record can return to `queued` only after positive nonconsumption proof.
60
67
 
@@ -364,3 +371,7 @@ Commands raise a CLI error (non-zero exit) carrying herdr's machine code where o
364
371
  - Invalid send shapes: usage error **before any transport call** (nothing is sent)
365
372
 
366
373
  Exit `0` means success (including an explicit empty `list` result or a satisfied wait).
374
+
375
+ An identical signed reconciliation receipt may be verified again after a consumed/superseded settlement, without another transition. This lets an assignment consumer recover a crash before journaling its verified observation reference. Re-verification preserves the original pinned key, complete binding and generation checks; a later claim's generation rejects the old proof. It never resubmits the payload. Runtime recovery additionally exposes the read-only `process_binding(pane:, caller_pid:)` adapter operation, which requires OS ancestry under the native shell, a matching foreground owner and immutable agent-session identity.
376
+
377
+ Recovery consumers with a separately accepted journal registration pass `expected_registration:` to `Inbox#reconcile`. The exact `event_id`, `attempt_id`, `payload_sha256` and `receipt_key_sha256` map is checked inside the event lock before signature verification or settlement. A prior `status` call alone does not establish this precondition. A mismatch raises `ValidationError` without settling the event.
@@ -32,10 +32,9 @@ module Ace
32
32
  translate_errors do
33
33
  cli_error("--event is required") if options[:event].to_s.empty?
34
34
  cli_error("only --format json is supported") if options[:format] && options[:format] != "json"
35
- inbox = Organisms::Inbox.new(executor: executor,
35
+ inbox = Organisms::Inbox.from_config(config: config, executor: executor,
36
36
  native: @native || Molecules::NativeQueueExecutor.new,
37
- deliveries_dir: deliveries_dir,
38
- receipt_public_key: %w[enqueue reconcile].include?(operation) ? receipt_public_key : nil)
37
+ root: Dir.pwd)
39
38
  result = case operation
40
39
  when "enqueue"
41
40
  %i[attempt ref file].each { |key| cli_error("--#{key} is required") if options[key].to_s.empty? }
@@ -70,17 +69,6 @@ module Ace
70
69
  raise Ace::Support::Cli::Error, e.message
71
70
  end
72
71
 
73
- private
74
-
75
- def receipt_public_key
76
- path = config["inbox_receipt_public_key"]
77
- return nil unless path.is_a?(String) && path.start_with?("/")
78
-
79
- key = OpenSSL::PKey.read(File.read(path))
80
- key if key.is_a?(OpenSSL::PKey::RSA) && !key.private?
81
- rescue SystemCallError, OpenSSL::PKey::PKeyError
82
- nil
83
- end
84
72
  end
85
73
  end
86
74
  end
@@ -30,12 +30,8 @@ module Ace
30
30
  # fail closed (spec 8wm.t.vrz §3)
31
31
  def resolve_ref(session, pane)
32
32
  Ace::Hitl::Providers::Ref.new(
33
- session: Ace::Hitl::Providers::Ref.validate!(
34
- session || ENV["HERDR_SESSION"], "HERDR_SESSION"
35
- ),
36
- pane: Ace::Hitl::Providers::Ref.validate!(
37
- pane || ENV["HERDR_PANE"], "HERDR_PANE"
38
- )
33
+ session: session || ENV["HERDR_SESSION"], pane: pane || ENV["HERDR_PANE"],
34
+ session_source: "HERDR_SESSION", pane_source: "HERDR_PANE"
39
35
  )
40
36
  end
41
37
 
@@ -33,8 +33,14 @@ module Ace
33
33
  # @raise [Timeout::Error] when the child outlives the deadline (killed)
34
34
  # @raise [SystemCallError] spawn failures only (child never launched)
35
35
  # @raise [PostLaunchError] SystemCallError while the child was live
36
- def call(argv, stdin_data: "", timeout_s:, output_limit: 65_536)
37
- Open3.popen3(*argv, pgroup: true) do |stdin, stdout, stderr, waiter|
36
+ def call(argv, stdin_data: "", timeout_s:, output_limit: 65_536, environment: nil)
37
+ if environment && (!environment.is_a?(Hash) || !environment.all? { |key, value| key.is_a?(String) && value.is_a?(String) })
38
+ raise ArgumentError, "bounded process environment must be explicit strings"
39
+ end
40
+ command = environment ? [environment, *argv] : argv
41
+ options = {pgroup: true}
42
+ options[:unsetenv_others] = true if environment
43
+ Open3.popen3(*command, **options) do |stdin, stdout, stderr, waiter|
38
44
  begin
39
45
  run_loop(stdin, stdout, stderr, waiter,
40
46
  stdin_data: stdin_data, timeout_s: timeout_s, output_limit: output_limit)
@@ -56,9 +56,10 @@ module Ace
56
56
 
57
57
  # Hold the exclusive per-event lock while delivering; concurrent
58
58
  # callers block until the winner finishes, then observe its record.
59
- def with_lock(deliveries_dir, event_id)
60
- FileUtils.mkdir_p(deliveries_dir)
61
- File.open(lock_path(deliveries_dir, event_id), "a") do |lock|
59
+ def with_lock(deliveries_dir, event_id, create: true)
60
+ FileUtils.mkdir_p(deliveries_dir) if create
61
+ flags = create ? "a" : File::RDWR | File::NOFOLLOW
62
+ File.open(lock_path(deliveries_dir, event_id), flags) do |lock|
62
63
  lock.flock(File::LOCK_EX)
63
64
  begin
64
65
  return yield
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+ require "ace/runtime/molecules/protected_linux"
3
+ require "ace/runtime/molecules/protected_socket"
4
+ require "securerandom"
5
+
6
+ module Ace
7
+ module Herdr
8
+ module Molecules
9
+ # Uses only the canonical attempt's pinned server and original fresh reply.
10
+ class ProtectedNativeControl
11
+ def initialize(mapping:, kernel: Ace::Runtime::Molecules::ProtectedLinux.new)
12
+ @mapping, @kernel = mapping, kernel
13
+ @origin = nil
14
+ end
15
+
16
+ def verify!
17
+ @kernel.supported!
18
+ native = @mapping.fetch("native")
19
+ wire.root_path!(File.dirname(native.fetch("socket_path")), directory: true, owner: @mapping.fetch("worker_uid"))
20
+ identity = wire.socket_identity(native.fetch("socket_path"))
21
+ unless identity == native.fetch("socket_identity") && native.fetch("version") == "0.9.3"
22
+ raise Ace::Runtime::RuntimeUnavailableError, "native endpoint generation or version changed"
23
+ end
24
+ @kernel.live!(native.fetch("server_identity"))
25
+ true
26
+ end
27
+
28
+ def request(method, params = {})
29
+ verify!
30
+ native = @mapping.fetch("native")
31
+ wire.connect(native.fetch("socket_path")) do |socket|
32
+ unless @kernel.same?(@kernel.peer(socket), native.fetch("server_identity")) &&
33
+ wire.socket_identity(native.fetch("socket_path")) == native.fetch("socket_identity")
34
+ raise Ace::Runtime::RuntimeUnavailableError, "native peer differs from installed server"
35
+ end
36
+ id = SecureRandom.hex(12)
37
+ 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")
41
+ raise Ace::Runtime::RuntimeUnavailableError, "native control response is invalid"
42
+ end
43
+ verify!
44
+ result.fetch("result")
45
+ end
46
+ rescue SystemCallError, IOError
47
+ raise Ace::Runtime::RuntimeUnavailableError, "native control outcome is unavailable"
48
+ end
49
+
50
+ def preflight!
51
+ ping = request("ping")
52
+ unless ping["version"] == "0.9.3" && ping["protocol"] == 22 &&
53
+ ping.dig("capabilities", "endpoint_protocol_generation") == 1
54
+ raise Ace::Runtime::RuntimeUnavailableError, "installed native protocol is unsupported"
55
+ end
56
+ workspace = request("workspace.get", {"workspace_id" => @mapping.fetch("native").fetch("workspace_id")}).fetch("workspace")
57
+ unless workspace["workspace_id"] == @mapping.fetch("native").fetch("workspace_id")
58
+ raise Ace::Runtime::RuntimeUnavailableError, "installed native workspace differs"
59
+ end
60
+ true
61
+ rescue KeyError, TypeError
62
+ raise Ace::Runtime::RuntimeUnavailableError, "installed native workspace is unavailable"
63
+ end
64
+
65
+ def create(mapping_id:, ticket:)
66
+ preflight!
67
+ raise Ace::Runtime::RuntimeUnavailableError, "native creation is never repeated" if @origin
68
+ # Mark attempted BEFORE any remote write: a lost reply cannot respawn.
69
+ @origin = {"state" => "uncertain"}
70
+ workspace = @mapping.fetch("native").fetch("workspace_id")
71
+ command = [@mapping.fetch("bootstrap"), mapping_id, ticket]
72
+ layout = request("layout.apply", {"workspace_id" => workspace, "focus" => false,
73
+ "root" => {"type" => "pane", "cwd" => @mapping.fetch("worker_cwd"),
74
+ "command" => command}})
75
+ layout = layout.fetch("layout")
76
+ root = layout.fetch("root")
77
+ unless layout["workspace_id"] == workspace && root.is_a?(Hash) && root["type"] == "pane" &&
78
+ !root.key?("children") && !root.key?("first") && !root.key?("second") &&
79
+ root["command"] == command && root["cwd"] == @mapping.fetch("worker_cwd")
80
+ raise Ace::Runtime::RuntimeUnavailableError, "native creation is not a fresh single-pane layout"
81
+ end
82
+ pane = root.fetch("pane_id")
83
+ original = {"workspace" => workspace, "tab" => layout.fetch("tab_id"), "pane" => pane,
84
+ "server_identity" => @mapping.fetch("native").fetch("server_identity"),
85
+ "socket_identity" => @mapping.fetch("native").fetch("socket_identity"), "command" => command, "cwd" => @mapping.fetch("worker_cwd")}
86
+ @origin = original
87
+ observe(original)
88
+ rescue KeyError, TypeError
89
+ raise Ace::Runtime::RuntimeUnavailableError, "native creation provenance is unavailable"
90
+ end
91
+
92
+ def observe(origin)
93
+ unless origin["workspace"] == @mapping.fetch("native").fetch("workspace_id") &&
94
+ origin["server_identity"] == @mapping.fetch("native").fetch("server_identity") &&
95
+ origin["socket_identity"] == @mapping.fetch("native").fetch("socket_identity")
96
+ raise Ace::Runtime::RuntimeUnavailableError, "native creation server changed"
97
+ end
98
+ pane = request("pane.get", {"pane_id" => origin.fetch("pane")}).fetch("pane")
99
+ info = request("pane.process_info", {"pane_id" => origin.fetch("pane")}).fetch("process_info")
100
+ unless pane["pane_id"] == origin["pane"] && pane["workspace_id"] == origin["workspace"] &&
101
+ pane["tab_id"] == origin["tab"] && info["pane_id"] == origin["pane"] && [String, Integer].any? { |type| pane["terminal_id"].is_a?(type) }
102
+ raise Ace::Runtime::RuntimeUnavailableError, "original native pane or terminal changed"
103
+ end
104
+ child = @kernel.capture(info.fetch("shell_pid"))
105
+ unless child["uid"] == @mapping.fetch("worker_uid") && child["gid"] == @mapping.fetch("worker_gid") &&
106
+ child["groups"] == @mapping.fetch("worker_groups") &&
107
+ child["parent_pid"] == origin.fetch("server_identity").fetch("pid")
108
+ raise Ace::Runtime::RuntimeUnavailableError, "original native child lineage or credentials changed"
109
+ end
110
+ binding = {"runtime" => "herdr", "session" => origin.fetch("workspace"), "pane" => origin.fetch("pane"),
111
+ "terminal_id" => pane.fetch("terminal_id").to_s, "process_identity" => child,
112
+ "shell_identity" => child, "native_origin" => origin.reject { |key, _| key == "process_binding" }}
113
+ if origin.key?("process_binding") && origin["process_binding"] != binding
114
+ raise Ace::Runtime::RuntimeUnavailableError, "original native child changed"
115
+ end
116
+ binding
117
+ rescue KeyError, TypeError
118
+ raise Ace::Runtime::RuntimeUnavailableError, "original native child cannot be observed"
119
+ end
120
+
121
+ def terminate(binding, handle:)
122
+ observe(binding.fetch("native_origin").merge("process_binding" => binding))
123
+ request("pane.close", {"pane_id" => binding.fetch("pane")})
124
+ @kernel.exited?(handle, timeout: 5)
125
+ end
126
+
127
+ private
128
+ def wire
129
+ Ace::Runtime::Molecules::ProtectedSocket
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -105,7 +105,7 @@ module Ace
105
105
  "no recoverable answer stored for event #{event_id.inspect}"
106
106
  end
107
107
 
108
- ref = Ace::Hitl::Providers::Ref.new(session: record.session, pane: record.pane)
108
+ ref = Ace::Hitl::Providers::Ref.new(session: record.session, pane: record.pane, canonical: true)
109
109
  deliver_locked(ref, record.answer, record, kind, label)
110
110
  end
111
111
  end
@@ -302,8 +302,7 @@ module Ace
302
302
  end
303
303
 
304
304
  Ace::Hitl::Providers::Ref.new(
305
- session: Ace::Hitl::Providers::Ref.validate!(session, "ref session"),
306
- pane: Ace::Hitl::Providers::Ref.validate!(pane, "ref pane")
305
+ session: session, pane: pane, session_source: "ref session", pane_source: "ref pane"
307
306
  )
308
307
  end
309
308
 
@@ -16,6 +16,7 @@ module Ace
16
16
  class IdentityDriftError < ValidationError; end
17
17
 
18
18
  EVENT = /\A[A-Za-z0-9][A-Za-z0-9._-]{0,127}\z/
19
+ TARGET_IDENTITY = %w[session pane terminal_id agent thread thread_kind].freeze
19
20
  THREAD_ID = /\A[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}\z/
20
21
  PI_PATH = /_([0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12})\.jsonl\z/
21
22
  # Single validator for Pi queue event ids, shared by enqueue and
@@ -28,6 +29,55 @@ module Ace
28
29
  AGENT_STATUSES = %w[idle busy working blocked done].freeze
29
30
  WAKE_STATUSES = %w[idle done].freeze
30
31
 
32
+ # One configured verifier construction for the Herdr CLI and signed
33
+ # receipt consumers. Configuration remains supervisor-owned.
34
+ def self.from_config(config: Ace::Herdr.config, root: Dir.pwd,
35
+ executor: Molecules::HerdrExecutor.new, native: Molecules::NativeQueueExecutor.new)
36
+ path = config["inbox_receipt_public_key"]
37
+ key = begin
38
+ candidate = OpenSSL::PKey.read(File.read(path)) if path.is_a?(String) && path.start_with?("/")
39
+ candidate if candidate.is_a?(OpenSSL::PKey::RSA) && !candidate.private?
40
+ rescue SystemCallError, OpenSSL::PKey::PKeyError
41
+ nil
42
+ end
43
+ new(executor: executor, native: native,
44
+ deliveries_dir: File.expand_path(config["deliveries_dir"] || ".ace-local/herdr/deliveries", root),
45
+ receipt_public_key: key)
46
+ end
47
+
48
+ # Enumerate retained event identities through the same store/lock owner.
49
+ # Unreadable unattributable records cannot be treated as another attempt.
50
+ def self.retained_events(deliveries_dir:, attempt:)
51
+ raise ValidationError, "invalid attempt id" unless attempt.is_a?(String) && EVENT.match?(attempt)
52
+ ids = [deliveries_dir, Molecules::DeliveryRecordStore.archive_dir(deliveries_dir)].flat_map do |dir|
53
+ next [] unless Dir.exist?(dir)
54
+ Dir.children(dir).filter_map { |name| name.delete_suffix(".json") if name.end_with?(".json") }
55
+ end.uniq
56
+ ids.select do |event|
57
+ raise ValidationError, "retained inbox event id is invalid" unless EVENT.match?(event)
58
+ Molecules::DeliveryRecordStore.with_lock(deliveries_dir, event, create: false) do
59
+ paths = [Molecules::DeliveryRecordStore.path_for(deliveries_dir, event),
60
+ Molecules::DeliveryRecordStore.path_for(Molecules::DeliveryRecordStore.archive_dir(deliveries_dir), event)]
61
+ copies = paths.filter_map do |path|
62
+ next unless File.exist?(path)
63
+ record = Models::DeliveryRecord.from_json(File.read(path))
64
+ unless record.event_id == event && record.inbox && record.inbox.fetch("attempt_id").is_a?(String)
65
+ raise ValidationError, "retained inbox event is unavailable"
66
+ end
67
+ record.to_h
68
+ end
69
+ # Identical retained copies describe one event. Never let live
70
+ # precedence hide a corrupt or conflicting archived copy.
71
+ if copies.empty? || copies.uniq.length != 1
72
+ raise ValidationError, "retained inbox copies conflict"
73
+ end
74
+ copies.first.fetch("inbox").fetch("attempt_id") == attempt
75
+ end
76
+ end
77
+ rescue SystemCallError, JSON::ParserError, ArgumentError, KeyError
78
+ raise ValidationError, "retained inbox inventory is unavailable"
79
+ end
80
+
31
81
  def initialize(executor:, native:, deliveries_dir:, receipt_public_key: nil)
32
82
  @executor = executor
33
83
  @native = native
@@ -35,7 +85,20 @@ module Ace
35
85
  @receipt_public_key = receipt_public_key
36
86
  end
37
87
 
38
- def enqueue(event:, attempt:, ref:, payload:)
88
+ # Project the accepted runtime owner before consuming an answer. Native
89
+ # observation may verify this identity, but cannot choose its replacement.
90
+ def target_from_owner(owner)
91
+ unless owner.is_a?(Hash) && owner["runtime"] == "herdr" &&
92
+ owner["agent_session"].is_a?(Hash) && owner.dig("agent_session", "agent") == owner["agent"]
93
+ raise ValidationError, "accepted native owner is unavailable"
94
+ end
95
+ target = owner.slice("session", "pane", "terminal_id", "agent").merge(
96
+ "thread" => owner.dig("agent_session", "value"), "thread_kind" => owner.dig("agent_session", "kind"))
97
+ validate_expected_target!(target)
98
+ target.transform_values { |value| value.dup.freeze }.freeze
99
+ end
100
+
101
+ def enqueue(event:, attempt:, ref:, payload:, managed_envelope: nil, expected_target: nil)
39
102
  validate_id!(event, "event")
40
103
  validate_id!(attempt, "attempt")
41
104
  raise ValidationError, "trusted receipt public key is unavailable" unless @receipt_public_key
@@ -51,13 +114,41 @@ module Ace
51
114
 
52
115
  address = address_for(ref)
53
116
  digest = Digest::SHA256.hexdigest(payload)
117
+ envelope = if managed_envelope
118
+ Ace::Hitl::Contract::ManagedEnvelope.load(managed_envelope, expected: {
119
+ attempt_id: attempt, payload_sha256: digest,
120
+ reverse: {"schema" => Ace::Hitl::Providers::Ref::SCHEMA, "session" => address.session, "pane" => address.pane}
121
+ })
122
+ end
123
+ if envelope && Ace::Hitl::Contract::SecretGate::PATTERN.match?(payload)
124
+ raise ValidationError, "secret-bearing managed native answers are prohibited"
125
+ end
126
+ if envelope && expected_target.nil?
127
+ raise ValidationError, "managed native delivery requires the accepted original target"
128
+ end
129
+ unless expected_target.nil?
130
+ validate_expected_target!(expected_target)
131
+ expected_target = expected_target.slice(*TARGET_IDENTITY)
132
+ unless expected_target.values_at("session", "pane") == [address.session, address.pane]
133
+ raise ValidationError, "accepted original target differs from reverse address"
134
+ end
135
+ end
54
136
  with_event(event) do |record|
55
137
  if record
56
138
  validate_match!(record, attempt, address, digest)
139
+ unless record.inbox["managed_envelope"] == envelope
140
+ raise ValidationError, "managed envelope conflicts with immutable inbox event"
141
+ end
142
+ if expected_target && record.inbox["origin_target"] != expected_target
143
+ raise IdentityDriftError, "original native target conflicts with immutable inbox event"
144
+ end
57
145
  next public_record(record)
58
146
  end
59
147
 
60
- target = observe_target(address.session, address.pane)
148
+ target = observe_target(address.session, address.pane, target: expected_target)
149
+ if expected_target && !TARGET_IDENTITY.all? { |key| target[key] == expected_target[key] }
150
+ raise IdentityDriftError, "native target differs from accepted original owner"
151
+ end
61
152
  if target["agent"] == "pi" && !PI_EVENT_ID.match?(event)
62
153
  raise ValidationError,
63
154
  "Pi queue requires an inbox (inb-) or wake (wnk-) event id with at " \
@@ -76,11 +167,15 @@ module Ace
76
167
  answer_digest: digest, answer: payload, state: "queued",
77
168
  inbox: {"attempt_id" => attempt, "claim_generation" => 0,
78
169
  "receipt_key_sha256" => key_fingerprint,
170
+ "managed_envelope" => envelope,
171
+ "origin_target" => target.slice(*TARGET_IDENTITY),
79
172
  "target" => target, "binding" => target.merge("payload_sha256" => digest)}
80
173
  )
81
174
  save(record)
82
175
  public_record(record)
83
176
  end
177
+ rescue Ace::Hitl::Contract::InvalidEnvelope => e
178
+ raise ValidationError, e.message
84
179
  end
85
180
 
86
181
  def status(event:)
@@ -92,6 +187,18 @@ module Ace
92
187
  end
93
188
  end
94
189
 
190
+ # Protected canonical reads only use an already registered event's lock.
191
+ # Missing retention is unavailable evidence, never query-time repair.
192
+ def retained_status(event:)
193
+ validate_id!(event, "event")
194
+ with_event(event, create_lock: false) do |record|
195
+ raise ValidationError, "unknown inbox event: #{event}" unless record&.inbox
196
+ public_record(record)
197
+ end
198
+ rescue SystemCallError
199
+ raise ValidationError, "retained inbox lock is unavailable"
200
+ end
201
+
95
202
  def deliver(event:)
96
203
  validate_id!(event, "event")
97
204
  with_event(event) do |record|
@@ -176,14 +283,46 @@ module Ace
176
283
  end
177
284
  end
178
285
 
179
- def reconcile(event:, receipt:, signed_bytes: nil, signature: nil)
286
+ def reconcile(event:, receipt:, signed_bytes: nil, signature: nil, expected_registration: nil)
287
+ reconcile_record(event: event, receipt: receipt, signed_bytes: signed_bytes,
288
+ signature: signature, expected_registration: expected_registration, settle: true)
289
+ end
290
+
291
+ # Canonical consumers reverify retained signed settlement without creating
292
+ # a new local transition. The same event lock and proof owner are used.
293
+ def verify_reconciliation(event:, receipt:, signed_bytes:, signature:, expected_registration:)
294
+ unless expected_registration.is_a?(Hash) && expected_registration.keys.sort ==
295
+ %w[event_id attempt_id payload_sha256 receipt_key_sha256].sort &&
296
+ %w[event_id attempt_id].all? { |key| expected_registration[key].is_a?(String) && EVENT.match?(expected_registration[key]) } &&
297
+ %w[payload_sha256 receipt_key_sha256].all? { |key| expected_registration[key].is_a?(String) && expected_registration[key].match?(/\A[0-9a-f]{64}\z/) }
298
+ raise ValidationError, "canonical reconciliation requires exact registration"
299
+ end
300
+ reconcile_record(event: event, receipt: receipt, signed_bytes: signed_bytes,
301
+ signature: signature, expected_registration: expected_registration, settle: false)
302
+ rescue SystemCallError
303
+ raise ValidationError, "retained inbox lock is unavailable"
304
+ end
305
+
306
+ private
307
+
308
+ def reconcile_record(event:, receipt:, signed_bytes:, signature:, expected_registration:, settle:)
180
309
  validate_id!(event, "event")
181
- with_event(event) do |record|
310
+ with_event(event, create_lock: settle) do |record|
182
311
  raise ValidationError, "unknown inbox event: #{event}" unless record&.inbox
312
+ # The consumer's accepted registration must match under the same
313
+ # event lock that verifies and settles the signed observation.
314
+ unless expected_registration.nil?
315
+ registration = public_record(record).slice("event_id", "attempt_id", "payload_sha256", "receipt_key_sha256")
316
+ unless expected_registration == registration
317
+ raise ValidationError, "inbox event differs from expected registration"
318
+ end
319
+ end
183
320
  # `delivered` only proves native queue acceptance: the message may
184
321
  # still be consumed or evicted afterwards, so a signed observation
185
322
  # can reconcile it exactly like an uncertain outcome.
186
- unless %w[uncertain delivered].include?(record.state)
323
+ replay = %w[completed queued].include?(record.state) && receipt.is_a?(Hash) &&
324
+ receipt == record.inbox["reconciliation"]
325
+ unless replay || (settle && %w[uncertain delivered].include?(record.state))
187
326
  raise ValidationError, "event is not reconcilable from state #{record.state}"
188
327
  end
189
328
  binding = record.inbox["binding"]
@@ -211,7 +350,7 @@ module Ace
211
350
  refusal = "replacement target cannot be verified: #{e.message}"
212
351
  nil
213
352
  end
214
- stable = %w[session pane terminal_id agent thread thread_kind]
353
+ stable = TARGET_IDENTITY
215
354
  unless replacement.is_a?(Hash) && observed &&
216
355
  stable.all? { |key| replacement[key] == observed[key] }
217
356
  refusal ||= "replacement target does not match the live native session"
@@ -231,6 +370,11 @@ module Ace
231
370
  end
232
371
  end
233
372
  next public_record(record).merge("reconciliation_refusal" => refusal) if refusal
373
+ # The consumer may crash after Herdr settles but before recording
374
+ # its observation reference. Re-verify the identical signed proof
375
+ # without another transition. A later claim has a new generation
376
+ # and fails the binding checks above.
377
+ next public_record(record) if replay
234
378
 
235
379
  outcome = receipt["outcome"]
236
380
  state = outcome == "consumed" ? "completed" : "queued"
@@ -289,9 +433,13 @@ module Ace
289
433
  nil
290
434
  end
291
435
 
292
- def with_event(event)
293
- Molecules::DeliveryRecordStore.with_lock(@deliveries_dir, event) do
294
- yield Molecules::DeliveryRecordStore.load(@deliveries_dir, event)
436
+ def with_event(event, create_lock: true)
437
+ Molecules::DeliveryRecordStore.with_lock(@deliveries_dir, event, create: create_lock) do
438
+ record = Molecules::DeliveryRecordStore.load(@deliveries_dir, event)
439
+ if record
440
+ Ace::Hitl::Providers::Ref.new(session: record.session, pane: record.pane, canonical: true)
441
+ end
442
+ yield record
295
443
  end
296
444
  end
297
445
 
@@ -313,13 +461,20 @@ module Ace
313
461
  hash = ref.is_a?(Hash) ? ref : JSON.parse(File.read(ref))
314
462
  raise ValidationError, "invalid ref: expected a JSON object" unless hash.is_a?(Hash)
315
463
  Ace::Hitl::Providers::Ref.new(
316
- session: Ace::Hitl::Providers::Ref.validate!(hash.fetch("session"), "session"),
317
- pane: Ace::Hitl::Providers::Ref.validate!(hash.fetch("pane"), "pane")
464
+ session: hash.fetch("session"), pane: hash.fetch("pane"), canonical: !ref.is_a?(Hash)
318
465
  )
319
466
  rescue Errno::ENOENT, JSON::ParserError, KeyError, TypeError => e
320
467
  raise ValidationError, "invalid ref: #{e.message}"
321
468
  end
322
469
 
470
+ def validate_expected_target!(target)
471
+ unless target.is_a?(Hash) && TARGET_IDENTITY.all? { |key| target[key].is_a?(String) && !target[key].empty? } &&
472
+ %w[codex pi].include?(target["agent"]) && target["thread_kind"] == "id" &&
473
+ THREAD_ID.match?(target["thread"]) && target["terminal_id"].bytesize <= 64
474
+ raise ValidationError, "accepted original native target is incomplete or invalid"
475
+ end
476
+ end
477
+
323
478
  def validate_match!(record, attempt, address, digest)
324
479
  unless record.inbox && record.inbox["attempt_id"] == attempt &&
325
480
  record.session == address.session && record.pane == address.pane &&
@@ -335,7 +490,7 @@ module Ace
335
490
  # Target drift is classified BEFORE agent-specific event id rules:
336
491
  # a bound event whose pane changed agent (e.g. codex -> pi) must
337
492
  # reconcile, not loop on a pre-send validation error.
338
- stable = %w[session pane terminal_id agent thread thread_kind]
493
+ stable = TARGET_IDENTITY
339
494
  if target && !stable.all? { |key| target[key] == binding[key] }
340
495
  raise IdentityDriftError, "target identity changed since enqueue; reconciliation is required"
341
496
  end
@@ -468,10 +623,12 @@ module Ace
468
623
  {"event_id" => record.event_id, "attempt_id" => record.inbox["attempt_id"],
469
624
  "session" => record.session, "pane" => record.pane,
470
625
  "payload_sha256" => record.answer_digest, "state" => record.state,
626
+ "managed_envelope" => record.inbox["managed_envelope"],
471
627
  "receipt_key_sha256" => record.inbox["receipt_key_sha256"],
472
628
  "claim_generation" => record.inbox["claim_generation"],
473
629
  "claim_owner" => record.inbox["claim_owner"],
474
630
  "submission_intent" => !!record.inbox["submission_intent"],
631
+ "origin_target" => record.inbox["origin_target"],
475
632
  "target" => record.inbox["target"],
476
633
  "binding" => record.inbox["binding"], "receipt" => record.inbox["receipt"],
477
634
  "reconciliation" => record.inbox["reconciliation"],
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "inbox"
4
+ require_relative "../molecules/protected_native_control"
5
+ require_relative "../molecules/herdr_executor"
6
+ require_relative "../molecules/native_queue_executor"
7
+ require_relative "../molecules/bounded_process"
8
+
9
+ module Ace
10
+ module Herdr
11
+ module Organisms
12
+ # Fixed construction for the canonical authority consumer. Callers of the
13
+ # public protocol cannot inject this factory or its collaborators.
14
+ module ProtectedInbox
15
+ class PaneReader
16
+ def initialize(control)
17
+ @control = control
18
+ end
19
+
20
+ def pane_get_bounded(pane)
21
+ result = @control.request("pane.get", {"pane_id" => pane})
22
+ Molecules::ExecutionResult.new(stdout: JSON.generate({"result" => result}), stderr: "",
23
+ success: true, exit_code: 0)
24
+ rescue Ace::Runtime::RuntimeUnavailableError
25
+ raise ExecutorUnavailableError, "original protected native endpoint is unavailable"
26
+ end
27
+ end
28
+
29
+ class PiIdentity
30
+ def initialize(client)
31
+ runner = lambda do |argv, stdin_data:, timeout_s:|
32
+ unless argv == [client, "--identity"] && stdin_data.empty?
33
+ raise ExecutorUnavailableError, "protected inbox permits identity inspection only"
34
+ end
35
+ result = Molecules::BoundedProcess.call(argv, stdin_data: stdin_data,
36
+ timeout_s: timeout_s, environment: {})
37
+ raise ExecutorError, "Pi identity output is oversized" if result.oversized
38
+ [result.stdout, result.stderr, result.status]
39
+ end
40
+ @native = Molecules::NativeQueueExecutor.new(pi_client: client, runner: runner)
41
+ end
42
+
43
+ def pi_identity
44
+ @native.pi_identity
45
+ end
46
+ end
47
+
48
+ module_function
49
+
50
+ # The Assign resolver has verified context paths/key/client. native is
51
+ # the immutable canonical stage, not current process discovery. Endpoint
52
+ # liveness is checked lazily only if Inbox actually observes replacement.
53
+ def build(context:, mapping:, native:, kernel: Ace::Runtime::Molecules::ProtectedLinux.new)
54
+ native_fields = %w[server_identity socket_identity workspace_id]
55
+ unless native.is_a?(Hash) && native_fields.all? { |key| native.key?(key) }
56
+ raise ValidationError, "canonical native stage is unavailable"
57
+ end
58
+ fixed = JSON.parse(JSON.generate(mapping))
59
+ fixed["native"].merge!(native.slice(*native_fields))
60
+ control = Molecules::ProtectedNativeControl.new(mapping: fixed, kernel: kernel)
61
+ key = OpenSSL::PKey.read(File.binread(context.fetch("receipt_public_key")))
62
+ unless key.is_a?(OpenSSL::PKey::RSA) && !key.private?
63
+ raise ValidationError, "protected inbox requires a public RSA key"
64
+ end
65
+ Inbox.new(executor: PaneReader.new(control), native: PiIdentity.new(context.fetch("pi_queue_client")),
66
+ deliveries_dir: context.fetch("deliveries_dir"), receipt_public_key: key)
67
+ rescue KeyError, SystemCallError, OpenSSL::PKey::PKeyError
68
+ raise ValidationError, "protected inbox fixed context is unavailable"
69
+ end
70
+ end
71
+ end
72
+ end
73
+ end
@@ -14,7 +14,7 @@ module Ace
14
14
  POLL_INTERVAL = 0.02
15
15
 
16
16
  def initialize(executor: Molecules::HerdrExecutor.new, env: ENV, clock: Process, sleeper: Kernel, surface: nil,
17
- identity_dir: nil, poll_interval: POLL_INTERVAL)
17
+ identity_dir: nil, poll_interval: POLL_INTERVAL, process_identity: Runtime::Molecules::ProcessIdentity.new)
18
18
  @executor = executor
19
19
  @env = env
20
20
  @clock = clock
@@ -23,6 +23,7 @@ module Ace
23
23
  @prepared = {}
24
24
  @identity_dir = identity_dir
25
25
  @poll_interval = poll_interval
26
+ @process_identity = process_identity
26
27
  end
27
28
 
28
29
  def context
@@ -109,6 +110,34 @@ module Ace
109
110
  end
110
111
  end
111
112
 
113
+ def process_binding(pane:, caller_pid:)
114
+ return nil unless caller_pid.is_a?(Integer) && caller_pid.positive?
115
+
116
+ with_errors do
117
+ native = find_hash(@executor.pane_get(pane).parsed_json, %w[result pane])
118
+ info = find_hash(@executor.pane_process_info(pane).parsed_json, %w[result process_info])
119
+ next nil unless native && info && native["pane_id"] == pane
120
+
121
+ owner = @process_identity.owner(shell_pid: info["shell_pid"], caller_pid: caller_pid)
122
+ foreground = info["foreground_processes"]
123
+ next nil unless owner && foreground.is_a?(Array) && foreground.any? do |process|
124
+ process.is_a?(Hash) && process["pid"] == owner.dig("process_identity", "pid")
125
+ end
126
+
127
+ session = native["agent_session"]
128
+ workspace = native["workspace_id"] || native["session_id"]
129
+ terminal = native["terminal_id"]
130
+ next nil unless session.is_a?(Hash) && session["kind"] == "id" &&
131
+ session["value"].is_a?(String) && session["value"].match?(Inbox::THREAD_ID) &&
132
+ session["agent"] == native["agent"] && native["agent"].is_a?(String) &&
133
+ workspace.is_a?(String) && !workspace.empty? &&
134
+ (terminal.is_a?(String) || terminal.is_a?(Integer)) && !terminal.to_s.empty?
135
+
136
+ {"runtime" => "herdr", "session" => workspace, "pane" => pane,
137
+ "terminal_id" => terminal.to_s, "agent" => native["agent"], "agent_session" => session}.merge(owner)
138
+ end
139
+ end
140
+
112
141
  def prepare_pane(window:)
113
142
  with_errors do
114
143
  tab = resolve_tab!(window)
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Herdr
5
- VERSION = "0.3.2"
5
+ VERSION = "0.4.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-herdr
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.2
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-10-04 00:00:00.000000000 Z
10
+ date: 2026-10-05 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-hitl-contract
@@ -15,28 +15,28 @@ dependencies:
15
15
  requirements:
16
16
  - - "~>"
17
17
  - !ruby/object:Gem::Version
18
- version: '0.1'
18
+ version: '0.2'
19
19
  type: :runtime
20
20
  prerelease: false
21
21
  version_requirements: !ruby/object:Gem::Requirement
22
22
  requirements:
23
23
  - - "~>"
24
24
  - !ruby/object:Gem::Version
25
- version: '0.1'
25
+ version: '0.2'
26
26
  - !ruby/object:Gem::Dependency
27
27
  name: ace-runtime
28
28
  requirement: !ruby/object:Gem::Requirement
29
29
  requirements:
30
30
  - - "~>"
31
31
  - !ruby/object:Gem::Version
32
- version: '0.1'
32
+ version: '0.2'
33
33
  type: :runtime
34
34
  prerelease: false
35
35
  version_requirements: !ruby/object:Gem::Requirement
36
36
  requirements:
37
37
  - - "~>"
38
38
  - !ruby/object:Gem::Version
39
- version: '0.1'
39
+ version: '0.2'
40
40
  - !ruby/object:Gem::Dependency
41
41
  name: ace-support-cli
42
42
  requirement: !ruby/object:Gem::Requirement
@@ -209,10 +209,12 @@ files:
209
209
  - lib/ace/herdr/molecules/pane_tidy_probe.rb
210
210
  - lib/ace/herdr/molecules/preset_loader.rb
211
211
  - lib/ace/herdr/molecules/preset_resolver.rb
212
+ - lib/ace/herdr/molecules/protected_native_control.rb
212
213
  - lib/ace/herdr/organisms/control_surface.rb
213
214
  - lib/ace/herdr/organisms/deliverer.rb
214
215
  - lib/ace/herdr/organisms/dispatcher.rb
215
216
  - lib/ace/herdr/organisms/inbox.rb
217
+ - lib/ace/herdr/organisms/protected_inbox.rb
216
218
  - lib/ace/herdr/organisms/runtime_adapter.rb
217
219
  - lib/ace/herdr/organisms/tidy.rb
218
220
  - lib/ace/herdr/version.rb