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 +4 -4
- data/CHANGELOG.md +18 -0
- data/docs/usage.md +12 -1
- data/lib/ace/herdr/cli/commands/inbox.rb +2 -14
- data/lib/ace/herdr/cli/commands/support.rb +2 -6
- data/lib/ace/herdr/molecules/bounded_process.rb +8 -2
- data/lib/ace/herdr/molecules/delivery_record_store.rb +4 -3
- data/lib/ace/herdr/molecules/protected_native_control.rb +134 -0
- data/lib/ace/herdr/organisms/deliverer.rb +2 -3
- data/lib/ace/herdr/organisms/inbox.rb +169 -12
- data/lib/ace/herdr/organisms/protected_inbox.rb +73 -0
- data/lib/ace/herdr/organisms/runtime_adapter.rb +30 -1
- data/lib/ace/herdr/version.rb +1 -1
- metadata +8 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4e0747847d9a56e36a452fd156063284154be0c5eb6464b9acca559fba504750
|
|
4
|
+
data.tar.gz: 2dd3be6a92a0217c2ec0d194a83714c0b6cbc22856c4df0ba7bb99f4f0a02810
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
35
|
+
inbox = Organisms::Inbox.from_config(config: config, executor: executor,
|
|
36
36
|
native: @native || Molecules::NativeQueueExecutor.new,
|
|
37
|
-
|
|
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:
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
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 =
|
|
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)
|
data/lib/ace/herdr/version.rb
CHANGED
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.
|
|
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-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|