ace-herdr 0.4.0 → 0.5.1

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 +18 -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 +68 -9
  43. data/lib/ace/herdr/organisms/protected_inbox.rb +0 -73
@@ -0,0 +1,183 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/runtime/molecules/protected_artifact_set"
4
+ require "ace/runtime/molecules/execution_unit_installation"
5
+ require_relative "inbox_context_store"
6
+
7
+ module Ace
8
+ module Herdr
9
+ module Molecules
10
+ # The generated entry's accepted literal stage precedes the final owner.
11
+ # Fresh key contents remain owned by InboxContextKey, not this snapshot.
12
+ class InboxContextServiceConfiguration
13
+ LIMIT = 65_536
14
+ TOKEN = /\A[A-Za-z0-9][A-Za-z0-9._-]{0,127}\z/
15
+ MAX_ID = (1 << 32) - 2
16
+ STAGE_FIELDS = %w[configuration inbox_context_id project_id schema].freeze
17
+ FIELDS = %w[authority control_socket_path deliveries_dir grants inbox_context_id key native_clients native_mapping_id owner_credentials project_id schema socket_gid state_root].freeze
18
+ PURPOSES = %w[deliver enqueue maintenance_inventory].freeze
19
+ ROLES = %w[authority maintenance signer supervisor].freeze
20
+ attr_reader :data, :reference
21
+
22
+ def self.load(stage:, artifacts: Ace::Runtime::Molecules::ProtectedArtifactSet.new)
23
+ strict!(stage, STAGE_FIELDS)
24
+ unless stage["schema"] == "ace.herdr.inbox-context-stage/v1" &&
25
+ %w[project_id inbox_context_id].all? { |key| token?(stage[key]) }
26
+ raise ValidationError, "context service stage differs"
27
+ end
28
+ reference = stage.fetch("configuration")
29
+ strict!(reference, %w[bytes path sha256])
30
+ unless path?(reference["path"]) && reference["bytes"].is_a?(Integer) && reference["bytes"].between?(1, LIMIT) &&
31
+ reference["sha256"].is_a?(String) && reference["sha256"].match?(/\A[0-9a-f]{64}\z/)
32
+ raise ValidationError, "context service configuration reference differs"
33
+ end
34
+ artifacts.with do |reader|
35
+ data = InboxContextStore.decode(reader.read!(reference), limit: LIMIT)
36
+ validate!(data)
37
+ unless data.values_at("project_id", "inbox_context_id") == stage.values_at("project_id", "inbox_context_id")
38
+ raise ValidationError, "context service configuration association differs"
39
+ end
40
+ reader.verify_unchanged!
41
+ new(data, reference)
42
+ end
43
+ rescue KeyError, TypeError, Ace::Runtime::RuntimeUnavailableError
44
+ raise ValidationError, "context service configuration unavailable"
45
+ end
46
+
47
+ def self.validate!(data)
48
+ strict!(data, FIELDS)
49
+ unless data["schema"] == "ace.herdr.inbox-context-service/v1" &&
50
+ %w[project_id inbox_context_id native_mapping_id].all? { |key| token?(data[key]) } &&
51
+ %w[control_socket_path state_root deliveries_dir].all? { |key| path?(data[key]) }
52
+ raise ValidationError, "context service configuration fields differ"
53
+ end
54
+ credentials!(data.fetch("owner_credentials"), root: false)
55
+ native_clients!(data.fetch("native_clients"))
56
+ authority = data.fetch("authority")
57
+ strict!(authority, %w[gid groups socket_path uid])
58
+ credentials!(authority.slice("uid", "gid", "groups"), root: false)
59
+ raise ValidationError, "context service authority path differs" unless path?(authority["socket_path"])
60
+ key = data.fetch("key")
61
+ strict!(key, %w[configuration_path public_key_path])
62
+ unless key.values.all? { |path| path?(path) } && key.values.uniq.size == 2
63
+ raise ValidationError, "context service key paths differ"
64
+ end
65
+ grants = data.fetch("grants")
66
+ unless grants.is_a?(Array) && grants.size.between?(1, 64) && grants.all? { |grant| grant.is_a?(Hash) } &&
67
+ grants.map { |grant| grant["uid"] }.uniq.size == grants.size
68
+ raise ValidationError, "context service grants overlap or exceed bounds"
69
+ end
70
+ grants.each do |grant|
71
+ strict!(grant, %w[gid groups purposes role uid])
72
+ credentials!(grant.slice("uid", "gid", "groups"), root: true)
73
+ unless ROLES.include?(grant["role"]) && grant["purposes"].is_a?(Array) &&
74
+ grant["purposes"] == grant["purposes"].sort.uniq && (grant["purposes"] - PURPOSES).empty? &&
75
+ (grant["role"] != "maintenance" || grant["purposes"] == ["maintenance_inventory"])
76
+ raise ValidationError, "context service grant differs"
77
+ end
78
+ end
79
+ uid = data.fetch("owner_credentials").fetch("uid")
80
+ if uid == authority.fetch("uid") || grants.any? { |grant| grant.fetch("uid") == uid }
81
+ raise ValidationError, "context service owner overlaps a caller principal"
82
+ end
83
+ gid = data["socket_gid"]
84
+ unless gid.is_a?(Integer) && gid.between?(1, MAX_ID) &&
85
+ (data.fetch("owner_credentials").fetch("groups") + [data.fetch("owner_credentials").fetch("gid")]).include?(gid) &&
86
+ grants.all? { |grant| grant.fetch("uid").zero? || (grant.fetch("groups") + [grant.fetch("gid")]).include?(gid) }
87
+ raise ValidationError, "context service socket group is unavailable"
88
+ end
89
+ private_roots = data.values_at("state_root", "deliveries_dir")
90
+ protected = key.values + [authority.fetch("socket_path"), data.fetch("control_socket_path")]
91
+ if overlap?(*private_roots) || private_roots.any? { |root| protected.any? { |path| overlap?(root, path) } } ||
92
+ data.fetch("control_socket_path") == authority.fetch("socket_path")
93
+ raise ValidationError, "context service private roots overlap"
94
+ end
95
+ true
96
+ rescue KeyError, TypeError, NoMethodError
97
+ raise ValidationError, "context service configuration malformed"
98
+ end
99
+
100
+ def self.credentials!(value, root:)
101
+ strict!(value, %w[gid groups uid])
102
+ minimum = root ? 0 : 1
103
+ unless value.values_at("uid", "gid").all? { |id| id.is_a?(Integer) && id.between?(minimum, MAX_ID) } &&
104
+ value["groups"].is_a?(Array) && value["groups"].size <= 64 &&
105
+ value["groups"].all? { |id| id.is_a?(Integer) && id.between?(0, MAX_ID) } &&
106
+ value["groups"] == value["groups"].sort.uniq
107
+ raise ValidationError, "context service credentials differ"
108
+ end
109
+ end
110
+
111
+ # The accepted release owns executable startup bytes and IPC placement;
112
+ # no PATH discovery, inherited HOME or ambient queue-client fallback.
113
+ def self.native_clients!(value)
114
+ strict!(value, %w[codex cwd dependencies environment herdr pi resources])
115
+ dependencies = value.fetch("dependencies")
116
+ unless dependencies.is_a?(Array) && dependencies.size <= 509
117
+ raise ValidationError, "context native dependency closure exceeds bounds"
118
+ end
119
+ refs = value.values_at("codex", "pi", "herdr") + dependencies
120
+ refs.each do |ref|
121
+ strict!(ref, %w[bytes path sha256])
122
+ unless path?(ref["path"]) && ref["bytes"].is_a?(Integer) && ref["bytes"].between?(1, 268_435_456) &&
123
+ ref["sha256"].is_a?(String) && ref["sha256"].match?(/\A[0-9a-f]{64}\z/)
124
+ raise ValidationError, "context native executable reference differs"
125
+ end
126
+ end
127
+ unless refs.map { |ref| ref.fetch("path") }.uniq.size == refs.size &&
128
+ refs.sum { |ref| ref.fetch("bytes") } <= 268_435_456 && path?(value["cwd"])
129
+ raise ValidationError, "context native closure overlaps or exceeds bounds"
130
+ end
131
+ environment = value.fetch("environment")
132
+ unless environment.is_a?(Hash) && environment.size <= 32 && environment.all? { |name, item|
133
+ name.is_a?(String) && Ace::Runtime::Molecules::ExecutionUnitInstallation::NATIVE_ENVIRONMENT.include?(name) && item.is_a?(String) &&
134
+ item.bytesize <= 4096 && item.encoding == Encoding::UTF_8 && item.valid_encoding? && !item.include?("\0")
135
+ }
136
+ raise ValidationError, "context native environment differs"
137
+ end
138
+ resources = value.fetch("resources")
139
+ unless resources.is_a?(Array) && resources.size <= 64 && resources.all? { |row|
140
+ row.is_a?(Hash) && row.keys.sort == %w[access gid kind mode path uid] && path?(row["path"]) &&
141
+ %w[directory socket].include?(row["kind"]) && %w[read write].include?(row["access"]) &&
142
+ row.values_at("uid", "gid").all? { |id| id.is_a?(Integer) && id.between?(0, MAX_ID) } &&
143
+ row["mode"].is_a?(Integer) && row["mode"].between?(0, 0o777)
144
+ } && resources.map { |row| row.fetch("path") }.uniq.size == resources.size &&
145
+ resources.any? { |row| row.fetch("path") == value.fetch("cwd") && row.fetch("kind") == "directory" }
146
+ raise ValidationError, "context native resource selection differs"
147
+ end
148
+ true
149
+ rescue KeyError, TypeError, NoMethodError
150
+ raise ValidationError, "context native selection is malformed"
151
+ end
152
+
153
+ def self.strict!(value, fields)
154
+ raise ValidationError, "context service fields differ" unless value.is_a?(Hash) && value.keys.sort == fields
155
+ end
156
+
157
+ def self.token?(value) = value.is_a?(String) && TOKEN.match?(value)
158
+ def self.path?(value)
159
+ value.is_a?(String) && value.bytesize.between?(2, 4096) && value.start_with?("/") &&
160
+ !value.match?(/[\s\0;]/) && File.expand_path(value) == value
161
+ end
162
+ def self.overlap?(left, right) = left == right || left.start_with?(right + "/") || right.start_with?(left + "/")
163
+
164
+ private
165
+
166
+ def initialize(data, reference)
167
+ @data, @reference = [data, reference].map { |value| immutable(value) }
168
+ freeze
169
+ end
170
+
171
+ def immutable(value)
172
+ case value
173
+ when Hash then value.to_h { |key, item| [key.dup.freeze, immutable(item)] }.freeze
174
+ when Array then value.map { |item| immutable(item) }.freeze
175
+ when String then value.dup.freeze
176
+ else value.freeze
177
+ end
178
+ end
179
+ private_class_method :new
180
+ end
181
+ end
182
+ end
183
+ end
@@ -0,0 +1,159 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require "json"
5
+ require "securerandom"
6
+ require "ace/runtime/molecules/protected_socket"
7
+
8
+ module Ace
9
+ module Herdr
10
+ module Molecules
11
+ # Operational admission metadata only; DeliveryRecord remains the event ledger.
12
+ class InboxContextStore
13
+ LIMIT = 1_048_576
14
+ class AlreadyProvisioned < ValidationError; end
15
+ class ClosedObject < Hash
16
+ def []=(key, value)
17
+ raise ValidationError, "duplicate context field" if key?(key)
18
+ super
19
+ end
20
+ end
21
+
22
+ def self.decode(bytes, limit: LIMIT)
23
+ raise ValidationError, "context bytes exceed bounds" unless bytes.is_a?(String) && bytes.bytesize.between?(1, limit)
24
+ text = bytes.dup.force_encoding(Encoding::UTF_8)
25
+ raise ValidationError, "context bytes are not UTF-8" unless text.valid_encoding?
26
+ decoded = JSON.parse(text, object_class: ClosedObject, create_additions: false, max_nesting: 16,
27
+ allow_nan: false, allow_comments: false, allow_duplicate_key: false)
28
+ # ClosedObject detects duplicates while decoding, not subsequent CAS updates.
29
+ JSON.parse(JSON.generate(decoded), create_additions: false)
30
+ rescue JSON::ParserError, JSON::NestingError
31
+ raise ValidationError, "context metadata is malformed"
32
+ end
33
+
34
+ def initialize(root:, uid:, protection: Ace::Runtime::Molecules::ProtectedSocket)
35
+ @root, @uid, @protection = root, uid, protection
36
+ @mutex = Mutex.new
37
+ verify_root!
38
+ @lease = open_private(".context-owner.lock", create: true)
39
+ unless @lease.flock(File::LOCK_EX | File::LOCK_NB)
40
+ @lease.close
41
+ raise ValidationError, "context owner is already active"
42
+ end
43
+ end
44
+
45
+ # Explicit installer/bootstrap operation. Restart never initializes a missing file.
46
+ def provision!(data)
47
+ @mutex.synchronize do
48
+ ensure_open!
49
+ bytes = encode(data)
50
+ File.open(path, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600) do |file|
51
+ file.write(bytes)
52
+ file.flush
53
+ file.fsync
54
+ end
55
+ fsync_root!
56
+ end
57
+ rescue Errno::EEXIST
58
+ raise AlreadyProvisioned, "context metadata already exists"
59
+ rescue SystemCallError
60
+ raise ValidationError, "context metadata provisioning refused"
61
+ end
62
+
63
+ def transaction
64
+ @mutex.synchronize do
65
+ ensure_open!
66
+ verify_root!
67
+ state = read!
68
+ before = JSON.generate(state)
69
+ result = yield state
70
+ persist!(state) unless JSON.generate(state) == before
71
+ result
72
+ end
73
+ rescue SystemCallError, IOError
74
+ raise ValidationError, "context metadata is unavailable"
75
+ end
76
+
77
+ def close
78
+ @mutex.synchronize do
79
+ @lease&.close
80
+ @lease = nil
81
+ end
82
+ end
83
+
84
+ private
85
+
86
+ def ensure_open!
87
+ raise ValidationError, "context owner is closed" unless @lease && !@lease.closed?
88
+ unless identity(@lease.stat) == identity(File.lstat(File.join(@root, ".context-owner.lock")))
89
+ raise ValidationError, "context owner lock changed"
90
+ end
91
+ end
92
+
93
+ def verify_root!
94
+ @protection.root_path!(@root, directory: true, owner: @uid)
95
+ stat = File.lstat(@root)
96
+ unless stat.directory? && !stat.symlink? && stat.uid == @uid && (stat.mode & 0o077).zero?
97
+ raise ValidationError, "context state root is not private"
98
+ end
99
+ current = [stat.dev, stat.ino, stat.uid, stat.mode]
100
+ raise ValidationError, "context state root changed" if @root_identity && current != @root_identity
101
+ @root_identity ||= current
102
+ end
103
+
104
+ def path = File.join(@root, ".context-control.json")
105
+
106
+ def open_private(name, create: false)
107
+ flags = File::RDWR | File::NOFOLLOW | File::NONBLOCK | (create ? File::CREAT : 0)
108
+ file = File.open(File.join(@root, name), flags, 0o600)
109
+ begin
110
+ file.close_on_exec = true
111
+ stat = file.stat
112
+ unless stat.file? && stat.uid == @uid && (stat.mode & 0o077).zero? &&
113
+ identity(stat) == identity(File.lstat(File.join(@root, name)))
114
+ raise ValidationError, "context metadata file is unsafe"
115
+ end
116
+ file
117
+ rescue Exception
118
+ file.close
119
+ raise
120
+ end
121
+ end
122
+
123
+ def read!
124
+ file = open_private(".context-control.json")
125
+ self.class.decode(file.read(LIMIT + 1))
126
+ ensure
127
+ file&.close
128
+ end
129
+
130
+ def encode(data)
131
+ bytes = JSON.generate(data)
132
+ raise ValidationError, "context metadata exceeds bounds" unless bytes.bytesize.between?(1, LIMIT)
133
+ bytes
134
+ end
135
+
136
+ def persist!(data)
137
+ bytes = encode(data)
138
+ temporary = File.join(@root, ".context-control.#{SecureRandom.hex(16)}.tmp")
139
+ File.open(temporary, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0o600) do |file|
140
+ file.write(bytes)
141
+ file.flush
142
+ file.fsync
143
+ end
144
+ verify_root!
145
+ File.rename(temporary, path)
146
+ fsync_root!
147
+ ensure
148
+ File.unlink(temporary) if temporary && File.exist?(temporary)
149
+ end
150
+
151
+ def fsync_root!
152
+ File.open(@root, File::RDONLY | File::NOFOLLOW) { |directory| directory.fsync }
153
+ end
154
+
155
+ def identity(stat) = [stat.dev, stat.ino, stat.uid, stat.mode]
156
+ end
157
+ end
158
+ end
159
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "inbox_context_store"
4
+ require "ace/runtime/molecules/protected_socket"
5
+
6
+ module Ace
7
+ module Herdr
8
+ module Molecules
9
+ module InboxContextWire
10
+ LIMIT = 16_384
11
+ DEADLINE = 30
12
+ module_function
13
+
14
+ def read(socket, deadline:)
15
+ bytes = +""
16
+ loop do
17
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
18
+ unless remaining.positive? && IO.select([socket], nil, nil, remaining)
19
+ raise ValidationError, "context deadline reports blocked"
20
+ end
21
+ chunk = socket.read_nonblock(1, exception: false)
22
+ next if chunk == :wait_readable
23
+ raise ValidationError, "context frame was not completed" unless chunk
24
+ bytes << chunk
25
+ raise ValidationError, "context frame exceeds bounds" if bytes.bytesize > LIMIT
26
+ break if chunk == "\n"
27
+ end
28
+ InboxContextStore.decode(bytes, limit: LIMIT)
29
+ end
30
+
31
+ def write(socket, value, deadline:)
32
+ Ace::Runtime::Molecules::ProtectedSocket.write(socket, value, deadline: deadline, limit: LIMIT)
33
+ end
34
+
35
+ def read_payload(socket, size:, deadline:)
36
+ unless size.is_a?(Integer) && size.between?(1, 65_536)
37
+ raise ValidationError, "context payload byte count differs"
38
+ end
39
+ bytes = +"".b
40
+ while bytes.bytesize < size
41
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
42
+ unless remaining.positive? && IO.select([socket], nil, nil, remaining)
43
+ raise ValidationError, "context payload upload deadline expired"
44
+ end
45
+ part = socket.read_nonblock([size - bytes.bytesize, 4096].min, exception: false)
46
+ next if part == :wait_readable
47
+ raise ValidationError, "context payload upload was not completed" unless part
48
+ bytes << part
49
+ end
50
+ text = bytes.force_encoding(Encoding::UTF_8)
51
+ raise ValidationError, "context payload encoding differs" unless text.valid_encoding? && !text.include?("\0")
52
+ text.freeze
53
+ end
54
+
55
+
56
+
57
+
58
+
59
+ def require_eof!(socket, deadline:)
60
+ loop do
61
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
62
+ unless remaining.positive? && IO.select([socket], nil, nil, remaining)
63
+ raise ValidationError, "context request upload was not completed"
64
+ end
65
+ byte = socket.read_nonblock(1, exception: false)
66
+ next if byte == :wait_readable
67
+ raise ValidationError, "context request has extra bytes" if byte
68
+ return
69
+ end
70
+ end
71
+
72
+ def deadline = Ace::Runtime::Molecules::ProtectedSocket.deadline(DEADLINE)
73
+ end
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+ require "digest"
3
+ require "ace/hitl/contract"
4
+ require_relative "inbox_context_store"
5
+
6
+ module Ace
7
+ module Herdr
8
+ module Molecules
9
+ # Original selectors only; DeliveryRecord is the sole effect ledger.
10
+ module InboxDirectEffectBinding
11
+ SCHEMA = "ace.herdr.inbox-direct-effect/v2"
12
+ ORIGINAL_FIELDS = %w[project_id assignment_id mapping_id inbox_context_id].freeze
13
+ TOKEN = /\A[A-Za-z0-9][A-Za-z0-9._-]{0,127}\z/
14
+ SHA = /\A[0-9a-f]{64}\z/
15
+ module_function
16
+
17
+ def build(purpose:, event_id:, attempt_id:, key_generation:, selection:, original:)
18
+ value = {"schema" => SCHEMA, "purpose" => purpose, "event_id" => event_id,
19
+ "attempt_id" => attempt_id, "selection" => selection}
20
+ original!(original)
21
+ value.merge!(original)
22
+ value["input_sha256"] = digest_input(value, key_generation)
23
+ verify!(value, key_generation: key_generation)
24
+ end
25
+
26
+ def verify!(value, key_generation:)
27
+ object!(value, %w[schema purpose event_id attempt_id selection input_sha256] + ORIGINAL_FIELDS)
28
+ original!(value.slice(*ORIGINAL_FIELDS))
29
+ unless value["schema"] == SCHEMA && %w[enqueue deliver].include?(value["purpose"]) &&
30
+ %w[event_id attempt_id].all? { |key| value[key].is_a?(String) && TOKEN.match?(value[key]) } &&
31
+ key_generation.is_a?(Integer) && key_generation.between?(1, (1 << 63) - 1)
32
+ raise ValidationError, "direct context binding differs"
33
+ end
34
+ selection = value.fetch("selection")
35
+ if value.fetch("purpose") == "enqueue"
36
+ object!(selection, %w[reverse payload_bytes payload_sha256])
37
+ reverse!(selection.fetch("reverse"))
38
+ unless selection["payload_bytes"].is_a?(Integer) && selection["payload_bytes"].between?(1, 65_536) &&
39
+ selection["payload_sha256"].is_a?(String) && SHA.match?(selection["payload_sha256"])
40
+ raise ValidationError, "direct payload selection differs"
41
+ end
42
+ else
43
+ object!(selection, %w[expected_claim_generation])
44
+ unless selection["expected_claim_generation"].is_a?(Integer) && selection["expected_claim_generation"] >= 0
45
+ raise ValidationError, "direct claim generation differs"
46
+ end
47
+ end
48
+ unless value["input_sha256"].is_a?(String) && SHA.match?(value["input_sha256"]) &&
49
+ value["input_sha256"] == digest_input(value, key_generation)
50
+ raise ValidationError, "direct context input digest differs"
51
+ end
52
+ JSON.parse(JSON.generate(value))
53
+ end
54
+
55
+ def original!(value)
56
+ object!(value, ORIGINAL_FIELDS)
57
+ unless ORIGINAL_FIELDS.all? { |key| value[key].is_a?(String) && TOKEN.match?(value[key]) }
58
+ raise ValidationError, "direct original context selection differs"
59
+ end
60
+ value
61
+ end
62
+
63
+ def reverse!(value)
64
+ object!(value, %w[schema session pane])
65
+ raise ValidationError, "direct reverse schema differs" unless value["schema"] == Ace::Hitl::Providers::Ref::SCHEMA
66
+ Ace::Hitl::Providers::Ref.new(session: value["session"], pane: value["pane"], canonical: true)
67
+ value
68
+ rescue Ace::Hitl::Providers::InvalidRefError
69
+ raise ValidationError, "direct reverse address differs"
70
+ end
71
+
72
+ def digest_input(value, generation)
73
+ selection = value.fetch("selection")
74
+ normalized = if value.fetch("purpose") == "enqueue"
75
+ {"reverse" => selection.fetch("reverse").slice("schema", "session", "pane"),
76
+ "payload_bytes" => selection.fetch("payload_bytes"), "payload_sha256" => selection.fetch("payload_sha256")}
77
+ else
78
+ {"expected_claim_generation" => selection.fetch("expected_claim_generation")}
79
+ end
80
+ Digest::SHA256.hexdigest(JSON.generate(value.slice("purpose", "event_id", "attempt_id", *ORIGINAL_FIELDS)
81
+ .merge("key_generation" => generation, "selection" => normalized)))
82
+ rescue KeyError, TypeError, NoMethodError
83
+ raise ValidationError, "direct context selectors differ"
84
+ end
85
+
86
+ def object!(value, fields)
87
+ raise ValidationError, "direct context fields differ" unless value.is_a?(Hash) && value.keys.sort == fields.sort
88
+ end
89
+ end
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+ require_relative "inbox_direct_effect_binding"
3
+
4
+ module Ace
5
+ module Herdr
6
+ module Molecules
7
+ module InboxDirectResult
8
+ SCHEMA = "ace.herdr.inbox-direct-result/v1"
9
+ RECORD_FIELDS = %w[event_id attempt_id payload_sha256 claim_generation state original_context original_binding_digest origin_target target binding session pane submission_intent wake].freeze
10
+ module_function
11
+
12
+ def build(operation:, record:, admission_state: nil)
13
+ projected = record.slice(*RECORD_FIELDS)
14
+ projected["wake"] = record["wake"].is_a?(Hash) ? record.fetch("wake").slice("status") : nil
15
+ result = {"schema" => SCHEMA, "operation" => operation, "record" => projected}
16
+ result["admission_state"] = admission_state unless operation == "status_context"
17
+ verify!(result, operation: operation, event_id: record.fetch("event_id"), attempt_id: record.fetch("attempt_id"), original: record.fetch("original_context"))
18
+ end
19
+
20
+ def verify!(value, operation:, event_id:, attempt_id:, original:)
21
+ fields = %w[schema operation record]
22
+ fields << "admission_state" unless operation == "status_context"
23
+ InboxDirectEffectBinding.object!(value, fields)
24
+ unless value["schema"] == SCHEMA && value["operation"] == operation &&
25
+ %w[enqueue_context deliver_context status_context].include?(operation) &&
26
+ (operation == "status_context" || %w[idle unknown].include?(value["admission_state"])) &&
27
+ (operation != "enqueue_context" || value["admission_state"] == "idle")
28
+ raise ValidationError, "direct context result differs"
29
+ end
30
+ record = value.fetch("record")
31
+ InboxDirectEffectBinding.object!(record, RECORD_FIELDS)
32
+ InboxDirectEffectBinding.original!(record.fetch("original_context"))
33
+ raise ValidationError, "direct result original selection differs" unless record.fetch("original_context") == original
34
+ raise ValidationError, "direct result original record differs" unless record["original_binding_digest"].is_a?(String) && InboxDirectEffectBinding::SHA.match?(record["original_binding_digest"])
35
+ unless record.values_at("event_id", "attempt_id") == [event_id, attempt_id] &&
36
+ %w[payload_sha256].all? { |key| record[key].is_a?(String) && InboxDirectEffectBinding::SHA.match?(record[key]) } &&
37
+ record["claim_generation"].is_a?(Integer) && record["claim_generation"] >= 0 &&
38
+ %w[queued claimed uncertain delivered completed].include?(record["state"]) &&
39
+ [true, false].include?(record["submission_intent"])
40
+ raise ValidationError, "direct context retained result association differs"
41
+ end
42
+ Ace::Hitl::Providers::Ref.new(session: record["session"], pane: record["pane"], canonical: true)
43
+ %w[origin_target target binding].each do |key|
44
+ target = record[key]
45
+ base = %w[session pane terminal_id agent thread thread_kind]
46
+ allowed = base + (key == "binding" ? %w[agent_status payload_sha256] : key == "target" ? %w[agent_status] : [])
47
+ unless target.is_a?(Hash) && (target.keys - allowed).empty? && (base - target.keys).empty? &&
48
+ target.values.all? { |item| item.is_a?(String) && item.valid_encoding? && item.bytesize.between?(1, 1024) && !item.include?("\0") } &&
49
+ %w[codex pi].include?(target["agent"]) && target["thread_kind"] == "id" &&
50
+ (key != "binding" || target["payload_sha256"] == record["payload_sha256"])
51
+ raise ValidationError, "direct context result target differs"
52
+ end
53
+ end
54
+ if record["wake"]
55
+ InboxDirectEffectBinding.object!(record["wake"], %w[status])
56
+ raise ValidationError, "direct context wake observation differs" unless %w[none sent pending issuing uncertain not_issued].include?(record.dig("wake", "status"))
57
+ end
58
+ value
59
+ rescue KeyError, TypeError, Ace::Hitl::Providers::InvalidRefError
60
+ raise ValidationError, "direct context result is unavailable"
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../errors"
3
4
  require "json"
4
5
  require "timeout"
6
+ require_relative "bounded_process"
5
7
 
6
8
  module Ace
7
9
  module Herdr
@@ -17,21 +19,17 @@ module Ace
17
19
  class NativeQueueExecutor
18
20
  DEFAULT_SUBMIT_TIMEOUT_S = 60
19
21
  DEFAULT_IDENTITY_TIMEOUT_S = 20
20
- # Codex payloads ride in argv; a single argument beyond the OS
21
- # per-argument limit fails at exec with E2BIG — before any client
22
- # code ran — so the payload is bounded pre-launch and any E2BIG
23
- # escape is classified as a proven pre-submission rejection.
24
- MAX_ARG_PAYLOAD_BYTES = 65_536
22
+ CODEX_PAYLOAD_LIMIT_BYTES = 65_536
25
23
  PI_PAYLOAD_LIMIT_BYTES = 65_536
26
24
 
27
25
  # runner: test seam. Callable (argv, stdin_data:, timeout_s:) ->
28
26
  # [stdout, stderr, status]. Defaults to the bounded runner.
29
- def initialize(codex: "codex", pi_client: nil, runner: nil,
27
+ def initialize(pi_client: nil, runner: nil, process: BoundedProcess,
30
28
  submit_timeout_s: DEFAULT_SUBMIT_TIMEOUT_S,
31
29
  identity_timeout_s: DEFAULT_IDENTITY_TIMEOUT_S)
32
- @codex = codex
33
30
  @pi_client = pi_client || ENV["ACE_HERDR_PI_QUEUE_CLIENT"] || "pi-overseer-queue-client"
34
31
  @runner = runner
32
+ @process = process
35
33
  @submit_timeout_s = submit_timeout_s
36
34
  @identity_timeout_s = identity_timeout_s
37
35
  end
@@ -54,21 +52,12 @@ module Ace
54
52
  end
55
53
 
56
54
  def submit(agent:, thread:, event_id:, digest:, payload:)
57
- if agent == "pi" && (payload.empty? || payload.bytesize > PI_PAYLOAD_LIMIT_BYTES)
55
+ raise ValidationError, "unsupported native queue agent: #{agent}" unless agent == "pi"
56
+ if payload.empty? || payload.bytesize > PI_PAYLOAD_LIMIT_BYTES
58
57
  return {"accepted" => false, "pre_submit" => true, "error" => "Pi payload must be 1..65536 bytes"}
59
58
  end
60
- if agent == "codex" && payload.bytesize > MAX_ARG_PAYLOAD_BYTES
61
- return {"accepted" => false, "pre_submit" => true,
62
- "error" => "Codex payload must be at most #{MAX_ARG_PAYLOAD_BYTES} bytes for argv delivery"}
63
- end
64
- argv = if agent == "codex"
65
- [@codex, "queue", "--thread", thread, "--message", payload]
66
- elsif agent == "pi"
67
- [@pi_client, "--delivery", event_id, "--session-id", thread,
68
- "--payload-sha256", digest, "--payload-bytes", payload.bytesize.to_s]
69
- else
70
- raise ValidationError, "unsupported native queue agent: #{agent}"
71
- end
59
+ argv = [@pi_client, "--delivery", event_id, "--session-id", thread,
60
+ "--payload-sha256", digest, "--payload-bytes", payload.bytesize.to_s]
72
61
  result = execute(argv, stdin_data: agent == "pi" ? payload : "",
73
62
  timeout_s: @submit_timeout_s)
74
63
  raise ExecutorError, "native queue output exceeded the retained limit" if result.oversized
@@ -103,7 +92,7 @@ module Ace
103
92
  return BoundedProcess::Result.new(stdout, stderr, status, false)
104
93
  end
105
94
 
106
- BoundedProcess.call(argv, stdin_data: stdin_data, timeout_s: timeout_s)
95
+ @process.call(argv, stdin_data: stdin_data, timeout_s: timeout_s)
107
96
  rescue SystemCallError => e
108
97
  # popen3 raises spawn failures before the child exists, so no
109
98
  # submission can have begun: proven pre-launch, retryable.