ace-herdr 0.3.2 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.ace-defaults/herdr/config.yml +0 -4
- data/CHANGELOG.md +30 -0
- data/docs/usage.md +65 -32
- data/lib/ace/herdr/cli/commands/inbox.rb +81 -31
- data/lib/ace/herdr/cli/commands/native_source.rb +56 -0
- data/lib/ace/herdr/cli/commands/support.rb +2 -6
- data/lib/ace/herdr/cli.rb +3 -0
- data/lib/ace/herdr/errors.rb +23 -0
- data/lib/ace/herdr/molecules/bounded_process.rb +266 -51
- data/lib/ace/herdr/molecules/delivery_record_store.rb +81 -5
- data/lib/ace/herdr/molecules/guarded_native_origin.rb +59 -0
- data/lib/ace/herdr/molecules/herdr_executor.rb +6 -3
- data/lib/ace/herdr/molecules/inbox_cli_input.rb +36 -0
- data/lib/ace/herdr/molecules/inbox_context_client.rb +88 -0
- data/lib/ace/herdr/molecules/inbox_context_completion_client.rb +119 -0
- data/lib/ace/herdr/molecules/inbox_context_key.rb +63 -0
- data/lib/ace/herdr/molecules/inbox_context_lifetime.rb +122 -0
- data/lib/ace/herdr/molecules/inbox_context_native_process.rb +92 -0
- data/lib/ace/herdr/molecules/inbox_context_service_configuration.rb +183 -0
- data/lib/ace/herdr/molecules/inbox_context_store.rb +159 -0
- data/lib/ace/herdr/molecules/inbox_context_wire.rb +76 -0
- data/lib/ace/herdr/molecules/inbox_direct_effect_binding.rb +92 -0
- data/lib/ace/herdr/molecules/inbox_direct_result.rb +65 -0
- data/lib/ace/herdr/molecules/native_queue_executor.rb +10 -21
- data/lib/ace/herdr/molecules/native_source.rb +189 -0
- data/lib/ace/herdr/molecules/protected_inbox_selection.rb +126 -0
- data/lib/ace/herdr/molecules/protected_native_control.rb +259 -0
- data/lib/ace/herdr/native_source/HERDR-LICENSE +201 -0
- data/lib/ace/herdr/native_source/guarded-prompt.patch +3493 -0
- data/lib/ace/herdr/native_source/selection.json +24 -0
- data/lib/ace/herdr/organisms/deliverer.rb +2 -3
- data/lib/ace/herdr/organisms/inbox.rb +384 -137
- data/lib/ace/herdr/organisms/inbox_context_direct_effects.rb +285 -0
- data/lib/ace/herdr/organisms/inbox_context_effects.rb +30 -0
- data/lib/ace/herdr/organisms/inbox_context_listener.rb +151 -0
- data/lib/ace/herdr/organisms/inbox_context_owner.rb +550 -0
- data/lib/ace/herdr/organisms/inbox_context_server.rb +70 -0
- data/lib/ace/herdr/organisms/inbox_context_service.rb +107 -0
- data/lib/ace/herdr/organisms/inbox_guarded_wake.rb +117 -0
- data/lib/ace/herdr/organisms/native_source_builder.rb +183 -0
- data/lib/ace/herdr/organisms/runtime_adapter.rb +30 -1
- data/lib/ace/herdr/version.rb +1 -1
- data/lib/ace/herdr.rb +5 -28
- metadata +69 -8
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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.
|