ace-runtime 0.2.0 → 0.3.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.
@@ -1,9 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "protected_worker_entry"
4
+ require_relative "execution_network_selection"
5
+
3
6
  require "json"
4
7
  require "digest"
5
8
  require "etc"
6
9
  require_relative "systemd_scope_manager"
10
+ require_relative "readiness_configuration"
7
11
 
8
12
  module Ace
9
13
  module Runtime
@@ -14,35 +18,55 @@ module Ace
14
18
  class ExecutionUnitInstallation
15
19
  SCHEMA = "ace.execution-unit-manifest/v1"
16
20
  ROLES = %w[slice_fragment service_fragment unit_dropin native_executable native_configuration
17
- readiness_executable bootstrap worker_executable runtime_dependency].freeze
21
+ readiness_executable readiness_configuration boundary_manifest bootstrap worker_executable runtime_dependency].freeze
18
22
  REQUIRED_ROLES = %w[slice_fragment service_fragment native_executable native_configuration
19
- readiness_executable bootstrap worker_executable].freeze
23
+ readiness_executable readiness_configuration boundary_manifest bootstrap worker_executable].freeze
24
+ CONTEXT_REQUIRED_ROLES = %w[slice_fragment service_fragment boundary_manifest bootstrap
25
+ context_executable context_configuration interpreter].freeze
26
+ CONTEXT_ROLES = (CONTEXT_REQUIRED_ROLES + %w[unit_dropin runtime_dependency]).freeze
27
+ CODEX_REQUIRED_ROLES = %w[slice_fragment service_fragment native_executable native_configuration boundary_manifest].freeze
28
+ CODEX_ROLES = (CODEX_REQUIRED_ROLES + %w[unit_dropin runtime_dependency]).freeze
20
29
  SERVICE_REQUIRED = {"Type" => "exec", "Restart" => "no", "RestartForceExitStatus" => [[], []],
21
30
  "KillMode" => "control-group", "SendSIGKILL" => true, "Delegate" => false,
22
31
  "ProtectControlGroups" => true, "NoNewPrivileges" => true, "CapabilityBoundingSet" => 0,
23
32
  "AmbientCapabilities" => 0, "RestrictNamespaces" => 0, "PrivateIPC" => true,
24
- "PrivateDevices" => true, "ProtectSystem" => "strict", "DynamicUser" => false,
33
+ "PrivateDevices" => true, "PrivateTmp" => false, "MountAPIVFS" => true, "ProtectKernelTunables" => true, "BindLogSockets" => false,
34
+ "DevicePolicy" => "closed", "DeviceAllow" => [], "ProtectSystem" => "strict", "DynamicUser" => false, "PAMName" => "",
25
35
  "EnvironmentFiles" => [], "PassEnvironment" => [], "UnsetEnvironment" => [],
26
- "StandardOutput" => "journal", "StandardError" => "journal", "RuntimeDirectoryPreserve" => "no",
36
+ "StandardInput" => "null", "TTYPath" => "", "StandardOutput" => "journal", "StandardError" => "journal", "RuntimeDirectoryPreserve" => "no",
27
37
  "RuntimeDirectoryMode" => 0o700, "UMask" => 0o077, "RootImage" => "", "RootImageOptions" => [], "RootEphemeral" => false, "ExtensionDirectories" => [],
28
38
  "ExtensionImages" => [], "MountImages" => [], "TemporaryFileSystem" => [], "InaccessiblePaths" => []}.freeze
29
39
  EMPTY_ACTIVATION = %w[Requisite BindsTo PartOf Upholds OnFailure OnSuccess OnFailureOf OnSuccessOf
30
40
  TriggeredBy Triggers PropagatesStopTo StopPropagatedFrom JoinsNamespaceOf].freeze
31
41
  NATIVE_ENVIRONMENT = %w[HERDR_CONFIG_PATH HERDR_SOCKET_PATH HOME SHELL PATH LANG LC_ALL TERM
32
42
  TMPDIR TMP TEMP XDG_CONFIG_HOME XDG_DATA_HOME XDG_STATE_HOME XDG_CACHE_HOME XDG_RUNTIME_DIR CODEX_HOME CLAUDE_CONFIG_DIR].freeze
43
+ MANAGER_ENVIRONMENT = (NATIVE_ENVIRONMENT + %w[LANGUAGE LC_CTYPE LC_NUMERIC LC_TIME LC_COLLATE LC_MONETARY LC_MESSAGES
44
+ LC_PAPER LC_NAME LC_ADDRESS LC_TELEPHONE LC_MEASUREMENT LC_IDENTIFICATION]).freeze
33
45
  IMPLICIT_NAMESPACE_ROOTS = %w[/dev /proc /sys /run /tmp /var/tmp].freeze
34
46
  EMPTY_EXEC = %w[ExecConditionEx ExecStartPreEx ExecReloadEx ExecStopEx ExecStopPostEx].freeze
35
47
 
36
48
  class Files
49
+ def authority_socket!(authority)
50
+ path = authority.fetch("socket_path")
51
+ ProtectedSocket.root_path!(File.dirname(path), directory: true, owner: authority.fetch("uid"))
52
+ identity = ProtectedSocket.socket_identity(path)
53
+ raise RuntimeUnavailableError, "selected authority socket owner differs" unless identity.last == authority.fetch("uid")
54
+ identity
55
+ end
37
56
  def read(path, limit:)
38
57
  ProtectedSocket.root_path!(path)
39
- File.open(path, File::RDONLY | File::NOFOLLOW) do |file|
58
+ File.open(path, File::RDONLY | File::NONBLOCK | File::NOFOLLOW) do |file|
40
59
  stat = file.stat
41
- unless stat.file? && stat.uid.zero? && (stat.mode & 0o022).zero?
60
+ unless stat.file? && stat.uid.zero? && (stat.mode & 0o6022).zero? && stat.size <= limit
42
61
  raise RuntimeUnavailableError, "installed artifact descriptor ownership is unsafe"
43
62
  end
44
63
  bytes = file.read(limit + 1)
45
64
  raise RuntimeUnavailableError, "installed artifact is oversized" if bytes.bytesize > limit
65
+ after = file.stat
66
+ unless [stat.dev, stat.ino, stat.uid, stat.gid, stat.mode, stat.size, stat.mtime, stat.ctime] ==
67
+ [after.dev, after.ino, after.uid, after.gid, after.mode, after.size, after.mtime, after.ctime] && bytes.bytesize == stat.size
68
+ raise RuntimeUnavailableError, "installed held artifact changed"
69
+ end
46
70
  bytes
47
71
  end
48
72
  end
@@ -101,18 +125,61 @@ module Ace
101
125
  end
102
126
  end
103
127
 
104
- def initialize(scope:, native:, bootstrap:, worker_executable:, worker_uid:, worker_gid:, files: Files.new)
105
- @scope, @native, @bootstrap, @worker_executable, @files = scope, native, bootstrap, worker_executable, files
128
+ def initialize(scope:, native:, bootstrap:, worker_entry:, worker_uid:, worker_gid:, files: Files.new)
129
+ @scope, @native, @bootstrap, @worker_entry, @files = scope, native, bootstrap, worker_entry, files
130
+ ProtectedWorkerEntry.validate!(@worker_entry)
106
131
  @worker_uid, @worker_gid = worker_uid, worker_gid
107
132
  end
108
133
 
134
+ # Context entries have their own fixed protocol. The same effective
135
+ # installation, activation, mount and isolation checks still apply.
136
+ def self.for_inbox_context(service:, configuration:, load_paths:, authority:, owner_credentials:, files: Files.new)
137
+ unless owner_credentials.is_a?(Hash) && owner_credentials.keys.sort == %w[gid groups uid] &&
138
+ owner_credentials.values_at("uid", "gid").all? { |id| id.is_a?(Integer) && id.positive? } &&
139
+ owner_credentials["groups"].is_a?(Array) && owner_credentials["groups"].size <= 64 &&
140
+ owner_credentials["groups"].all? { |id| id.is_a?(Integer) && id >= 0 } &&
141
+ owner_credentials["groups"] == owner_credentials["groups"].sort.uniq &&
142
+ load_paths.is_a?(Array) && load_paths.size.between?(1, 64) && load_paths.uniq.size == load_paths.size
143
+ raise RuntimeUnavailableError, "context installation selection differs"
144
+ end
145
+ selected = allocate
146
+ selected.send(:initialize_context, service, configuration, load_paths, authority, owner_credentials, files)
147
+ selected
148
+ end
149
+
150
+ # Selection only. The original bootstrap authenticates mapping ownership;
151
+ # verify! still requires the actual fixed manager profile and full topology.
152
+ def verify_codex_runtime_selection!(service:, intent_reference:, manager:)
153
+ unless @codex_service && @codex_service == service && @codex_intent_reference == intent_reference
154
+ raise RuntimeUnavailableError, "Codex dedicated installation selection differs"
155
+ end
156
+ verify!(manager: manager)
157
+ end
158
+
159
+ def self.for_codex_runtime(service:, intent_reference:, original_mapping:, mapping_id:, authority:, files: Files.new)
160
+ require_relative "codex_runtime_installation"
161
+ selected = allocate
162
+ selected.send(:initialize_codex_runtime, service, intent_reference, original_mapping, mapping_id, authority, files)
163
+ selected
164
+ end
165
+
166
+ # Descriptor shape only; no effective unit observation or role grant.
167
+ def self.validate_inbox_context_service!(service)
168
+ selected = allocate
169
+ selected.send(:validate_context_service!, service)
170
+ true
171
+ end
172
+
109
173
  def verify!(manager:)
110
174
  slot = @scope.fetch("slot_id")
111
175
  unless slot.is_a?(String) && SystemdScopeManager::UNIT.match?(slot)
112
176
  raise RuntimeUnavailableError, "installed slot identity is invalid"
113
177
  end
114
- bytes = @files.read("/etc/ace/execution-slots/#{slot}/unit-manifest.json", limit: 65_536)
115
- unless Digest::SHA256.hexdigest(bytes) == @scope.fetch("unit_manifest_sha256")
178
+ selected_service = @context || @codex_service
179
+ path = selected_service ? selected_service.fetch("unit_manifest").fetch("path") : "/etc/ace/execution-slots/#{slot}/unit-manifest.json"
180
+ bytes = @files.read(path, limit: 65_536)
181
+ unless Digest::SHA256.hexdigest(bytes) == @scope.fetch("unit_manifest_sha256") &&
182
+ (!selected_service || bytes.bytesize == selected_service.fetch("unit_manifest").fetch("bytes"))
116
183
  raise RuntimeUnavailableError, "unit manifest bytes differ from deployment"
117
184
  end
118
185
  manifest = JSON.parse(bytes)
@@ -121,24 +188,154 @@ module Ace
121
188
  raise RuntimeUnavailableError, "unit manifest schema/slot differs"
122
189
  end
123
190
  artifacts = verify_artifacts!(manifest.fetch("artifacts"))
124
- profile = manager.inspect_profile
191
+ profile = @context ? manager.inspect_inbox_context_profile : manager.inspect_profile
125
192
  verify_profile!(profile, manifest.fetch("properties"), artifacts)
126
193
  manifest
127
194
  rescue SystemCallError, IOError, JSON::ParserError, KeyError, TypeError, NoMethodError
128
195
  raise RuntimeUnavailableError, "installed execution units cannot be verified"
129
196
  end
130
197
 
198
+ # Source validity only. The caller owns selected staged bytes; this
199
+ # neither observes a live authority nor grants installed readiness.
200
+ def validate_staged_boundary_topology!(service:, artifacts:, authority:)
201
+ validate_boundary_topology!(service, artifacts, authority: authority)
202
+ true
203
+ end
204
+
205
+ def inbox_context_scope
206
+ raise RuntimeUnavailableError, "installation is not a context service" unless @context
207
+ @scope
208
+ end
209
+
210
+ def inbox_context_configuration_reference
211
+ raise RuntimeUnavailableError, "installation is not a context service" unless @context
212
+ @context_configuration
213
+ end
214
+
215
+ def inbox_context_service
216
+ raise RuntimeUnavailableError, "installation is not a context service" unless @context
217
+ @context
218
+ end
219
+
220
+ # Called from the held context configuration before service admission.
221
+ # Native startup bytes and IPC paths must be actual members of this
222
+ # SAME complete installed profile, not merely readable host selections.
223
+ def verify_inbox_native_projection!(references:, resources:, manager:)
224
+ raise RuntimeUnavailableError, "installation is not a context service" unless @context
225
+ manifest = verify!(manager: manager)
226
+ artifacts = manifest.fetch("artifacts")
227
+ profile = manager.inspect_inbox_context_profile.fetch("service")
228
+ unless references.is_a?(Array) && references.size.between?(3, 512) &&
229
+ resources.is_a?(Array) && resources.size <= 64
230
+ raise RuntimeUnavailableError, "context native projection exceeds bounds"
231
+ end
232
+ references.each do |ref|
233
+ unless ref.is_a?(Hash) && ref.keys.sort == %w[bytes path sha256] &&
234
+ ref["bytes"].is_a?(Integer) && ref["bytes"].between?(1, 268_435_456) &&
235
+ artifacts.any? { |artifact| artifact.values_at("host_path", "view_path", "sha256", "role") ==
236
+ [ref.fetch("path"), ref.fetch("path"), ref.fetch("sha256"), "runtime_dependency"] }
237
+ raise RuntimeUnavailableError, "context native startup reference is not exactly projected"
238
+ end
239
+ bytes = @files.read(ref.fetch("path"), limit: ref.fetch("bytes"))
240
+ unless bytes.bytesize == ref.fetch("bytes") && Digest::SHA256.hexdigest(bytes) == ref.fetch("sha256")
241
+ raise RuntimeUnavailableError, "context native startup reference differs"
242
+ end
243
+ end
244
+ boundary = JSON.parse(@files.read(@context.fetch("boundary_manifest").fetch("path"), limit: 65_536))
245
+ resources.each do |resource|
246
+ unless resource.is_a?(Hash) && resource.keys.sort == %w[access gid kind mode path uid] &&
247
+ %w[read write].include?(resource["access"]) && path?(resource["path"])
248
+ raise RuntimeUnavailableError, "context native resource fields differ"
249
+ end
250
+ path = resource.fetch("path")
251
+ readonly = resource.fetch("access") == "read"
252
+ mounts = profile.fetch(readonly ? "BindReadOnlyPaths" : "BindPaths")
253
+ unless mounts.include?([path, path, false, 0]) &&
254
+ boundary.fetch("resources").any? { |entry| entry.values_at("host_path", "view_path", "stage", "worker_visible", "read_only") ==
255
+ [path, path, "parent", true, readonly] } &&
256
+ (!readonly || profile.fetch("BindPaths").none? { |mount| overlaps?(path, mount[1]) } &&
257
+ profile.fetch("ReadWritePaths").none? { |root| overlaps?(path, root) })
258
+ raise RuntimeUnavailableError, "context native resource is not exactly projected"
259
+ end
260
+ end
261
+ verify!(manager: manager)
262
+ true
263
+ rescue SystemCallError, IOError, JSON::ParserError, KeyError, TypeError, NoMethodError
264
+ raise RuntimeUnavailableError, "context native projection is unavailable"
265
+ end
266
+
131
267
  private
132
268
 
269
+ def initialize_context(service, configuration, load_paths, authority, credentials, files)
270
+ validate_context_service!(service)
271
+ refs = service.slice("unit_manifest", "boundary_manifest", "entry", "interpreter", "bootstrap_manifest").merge("configuration" => configuration)
272
+ unless refs.values.all? { |ref| ref.is_a?(Hash) && ref.keys.sort == %w[bytes path sha256] && path?(ref["path"]) &&
273
+ ref["bytes"].is_a?(Integer) && ref["bytes"].between?(1, 268_435_456) &&
274
+ ref["sha256"].is_a?(String) && ref["sha256"].match?(/\A[0-9a-f]{64}\z/) } &&
275
+ load_paths.all? { |path| path?(path) }
276
+ raise RuntimeUnavailableError, "context installation artifacts differ"
277
+ end
278
+ @scope = immutable_selection(service.fetch("execution_scope"))
279
+ @context = immutable_selection(service)
280
+ @context_configuration, @context_authority = immutable_selection(configuration), immutable_selection(authority)
281
+ @context_load_paths, @context_groups = immutable_selection(load_paths), immutable_selection(credentials.fetch("groups"))
282
+ @worker_uid, @worker_gid, @files = credentials.fetch("uid"), credentials.fetch("gid"), files
283
+ rescue KeyError, TypeError
284
+ raise RuntimeUnavailableError, "context installation selection is incomplete"
285
+ end
286
+
287
+ def validate_context_service!(service)
288
+ unless service.is_a?(Hash) && service.keys.sort == %w[bootstrap_manifest boundary_manifest entry execution_scope interpreter unit_manifest] &&
289
+ service.reject { |key, _| key == "execution_scope" }.values.all? { |ref|
290
+ ref.is_a?(Hash) && ref.keys.sort == %w[bytes path sha256] && path?(ref["path"]) &&
291
+ ref["bytes"].is_a?(Integer) && ref["bytes"].between?(1, 268_435_456) &&
292
+ ref["sha256"].is_a?(String) && ref["sha256"].match?(/\A[0-9a-f]{64}\z/)
293
+ }
294
+ raise RuntimeUnavailableError, "context service descriptor differs"
295
+ end
296
+ scope = service.fetch("execution_scope")
297
+ unless scope.is_a?(Hash) && scope.keys.sort == %w[backend boundary_manifest_sha256 network_namespace_path root_directory runtime_directory service_unit slice_unit slot_id unit_manifest_sha256] &&
298
+ scope["backend"] == "linux_systemd_cgroup_v2" && scope["slot_id"].is_a?(String) && SystemdScopeManager::UNIT.match?(scope["slot_id"]) &&
299
+ %w[root_directory runtime_directory network_namespace_path].all? { |key| path?(scope[key]) } &&
300
+ scope.fetch("runtime_directory").start_with?("/run/") && !overlaps?(scope.fetch("root_directory"), scope.fetch("runtime_directory")) &&
301
+ {"slice_unit" => ".slice", "service_unit" => ".service"}.all? { |key, suffix|
302
+ scope[key].is_a?(String) && SystemdScopeManager::UNIT.match?(scope[key]) && scope[key].end_with?(suffix) && !scope[key].downcase.include?("overseer")
303
+ } &&
304
+ scope["unit_manifest_sha256"] == service.fetch("unit_manifest").fetch("sha256") &&
305
+ scope["boundary_manifest_sha256"] == service.fetch("boundary_manifest").fetch("sha256") &&
306
+ service.fetch("unit_manifest").fetch("bytes") <= 65_536 && service.fetch("boundary_manifest").fetch("bytes") <= 65_536
307
+ raise RuntimeUnavailableError, "context service scope differs"
308
+ end
309
+ rescue KeyError, TypeError, NoMethodError
310
+ raise RuntimeUnavailableError, "context service descriptor is incomplete"
311
+ end
312
+
313
+ def immutable_selection(value)
314
+ case value
315
+ when Hash then value.to_h { |key, item| [key.dup.freeze, immutable_selection(item)] }.freeze
316
+ when Array then value.map { |item| immutable_selection(item) }.freeze
317
+ when String then value.dup.freeze
318
+ else value.freeze
319
+ end
320
+ end
321
+
133
322
  def verify_artifacts!(artifacts)
134
- unless artifacts.is_a?(Array) && artifacts.size.between?(7, 512) &&
323
+ minimum = @context ? CONTEXT_REQUIRED_ROLES.size : @codex_service ? CODEX_REQUIRED_ROLES.size : 9
324
+ unless artifacts.is_a?(Array) && artifacts.size.between?(minimum, 512) &&
135
325
  artifacts.all? { |a| a.is_a?(Hash) && a.keys.sort == %w[host_path role sha256 view_path] } &&
136
326
  artifacts.map { |a| a["host_path"] }.uniq.size == artifacts.size
137
327
  raise RuntimeUnavailableError, "installed unit artifact declarations differ"
138
328
  end
139
329
  roles = artifacts.group_by { |a| a.fetch("role") }
140
- unless REQUIRED_ROLES.all? { |role| roles.key?(role) } && (roles.keys - ROLES).empty? &&
141
- %w[slice_fragment service_fragment native_executable native_configuration readiness_executable bootstrap worker_executable].all? { |role| roles[role].size == 1 }
330
+ required, allowed = if @context
331
+ [CONTEXT_REQUIRED_ROLES, CONTEXT_ROLES]
332
+ elsif @codex_service
333
+ [CODEX_REQUIRED_ROLES, CODEX_ROLES]
334
+ else
335
+ [REQUIRED_ROLES, ROLES]
336
+ end
337
+ unless required.all? { |role| roles.key?(role) } && (roles.keys - allowed).empty? &&
338
+ required.all? { |role| roles[role].size == 1 }
142
339
  raise RuntimeUnavailableError, "installed unit artifacts are incomplete or ambiguous"
143
340
  end
144
341
  artifacts.each do |artifact|
@@ -147,12 +344,44 @@ module Ace
147
344
  raise RuntimeUnavailableError, "installed unit artifact identity changed"
148
345
  end
149
346
  end
347
+ return verify_context_artifacts!(roles) if @context
348
+ return verify_codex_artifacts!(roles) if @codex_service
150
349
  expected = {"native_executable" => @native.fetch("executable"), "bootstrap" => @bootstrap,
151
- "worker_executable" => @worker_executable}
350
+ "worker_executable" => @worker_entry.fetch("wrapper").fetch("path"),
351
+ "readiness_configuration" => "/etc/ace/execution-slots/#{@scope.fetch('slot_id')}/readiness.json",
352
+ "boundary_manifest" => "/etc/ace/execution-slots/#{@scope.fetch('slot_id')}/boundary-manifest.json"}
152
353
  unless expected.all? { |role, path| roles.fetch(role).first.fetch("view_path") == path } &&
153
- roles.fetch("native_executable").first.fetch("sha256") == @native.fetch("executable_sha256")
354
+ roles.fetch("native_executable").first.fetch("sha256") == @native.fetch("executable_sha256") &&
355
+ roles.fetch("boundary_manifest").first.fetch("sha256") == @scope.fetch("boundary_manifest_sha256")
154
356
  raise RuntimeUnavailableError, "installed executable declarations differ from deployment"
155
357
  end
358
+ {"wrapper" => "worker_executable", "interpreter" => "runtime_dependency"}.each do |key, role|
359
+ reference = @worker_entry.fetch(key)
360
+ matches = roles.fetch(role).select { |artifact| artifact.fetch("view_path") == reference.fetch("path") }
361
+ unless matches.size == 1 && matches.first.fetch("sha256") == reference.fetch("sha256")
362
+ raise RuntimeUnavailableError, "installed worker entry role differs"
363
+ end
364
+ bytes = @files.read(matches.first.fetch("host_path"), limit: reference.fetch("bytes"))
365
+ unless bytes.bytesize == reference.fetch("bytes") && Digest::SHA256.hexdigest(bytes) == reference.fetch("sha256")
366
+ raise RuntimeUnavailableError, "installed worker entry bytes differ"
367
+ end
368
+ end
369
+ roles
370
+ end
371
+
372
+ def verify_context_artifacts!(roles)
373
+ refs = {"context_executable" => @context.fetch("entry"), "interpreter" => @context.fetch("interpreter"),
374
+ "bootstrap" => @context.fetch("bootstrap_manifest"), "boundary_manifest" => @context.fetch("boundary_manifest"),
375
+ "context_configuration" => @context_configuration}
376
+ refs.each do |role, ref|
377
+ artifact = roles.fetch(role).first
378
+ bytes = @files.read(artifact.fetch("host_path"), limit: ref.fetch("bytes"))
379
+ unless artifact.fetch("host_path") == ref.fetch("path") && artifact.fetch("sha256") == ref.fetch("sha256") &&
380
+ (!%w[bootstrap boundary_manifest context_configuration].include?(role) || artifact.fetch("view_path") == ref.fetch("path")) &&
381
+ bytes.bytesize == ref.fetch("bytes") && Digest::SHA256.hexdigest(bytes) == ref.fetch("sha256")
382
+ raise RuntimeUnavailableError, "context host artifact differs from selected immutable reference"
383
+ end
384
+ end
156
385
  roles
157
386
  end
158
387
 
@@ -160,8 +389,13 @@ module Ace
160
389
  unless expected.is_a?(Hash) && expected.keys.sort == %w[service slice]
161
390
  raise RuntimeUnavailableError, "unit profile declarations differ"
162
391
  end
392
+ environment = profile.fetch("manager_environment")
393
+ unless environment.is_a?(Array) && environment.all? { |entry| entry.is_a?(String) &&
394
+ MANAGER_ENVIRONMENT.include?(entry.split("=", 2).first) }
395
+ raise RuntimeUnavailableError, "effective manager environment changes the fixed startup surface"
396
+ end
163
397
  {"slice" => SystemdScopeManager::UNIT_GRAPH_SIGNATURES.keys,
164
- "service" => (SystemdScopeManager::UNIT_GRAPH_SIGNATURES.keys + SystemdScopeManager::SERVICE_EXEC_SIGNATURES.keys).uniq}.each do |kind, keys|
398
+ "service" => (SystemdScopeManager::UNIT_GRAPH_SIGNATURES.keys + SystemdScopeManager::SERVICE_EXEC_SIGNATURES.keys + (@context ? ["TimeoutStopUSec"] : [])).uniq}.each do |kind, keys|
165
399
  wanted, actual = expected.fetch(kind), profile.fetch(kind)
166
400
  projected = actual.dup
167
401
  %w[ExecStartEx ExecStartPostEx].each do |key|
@@ -193,15 +427,35 @@ module Ace
193
427
  credential_id(service.fetch("User"), :uid) == @worker_uid &&
194
428
  credential_id(service.fetch("Group"), :gid) == @worker_gid &&
195
429
  SERVICE_REQUIRED.all? { |key, value| service[key] == value } &&
196
- service["SupplementaryGroups"] == [] &&
430
+ service["SupplementaryGroups"] == (@context ? @context_groups.map(&:to_s) : @codex_service ? @codex_groups.map(&:to_s) : []) &&
197
431
  service["RestrictAddressFamilies"] == [true, %w[AF_INET AF_INET6 AF_UNIX]] &&
198
432
  EMPTY_EXEC.all? { |key| service[key] == [] }
199
433
  raise RuntimeUnavailableError, "execution unit lacks the required retained isolation profile"
200
434
  end
435
+ if @context
436
+ verify_context_protocol!(service, artifacts)
437
+ verify_artifact_projection!(service, artifacts)
438
+ verify_boundary_topology!(service, artifacts, authority: @context_authority)
439
+ return
440
+ end
441
+ if @codex_service
442
+ verify_codex_protocol!(service, artifacts)
443
+ verify_artifact_projection!(service, artifacts)
444
+ verify_boundary_topology!(service, artifacts, authority: @codex_authority)
445
+ return
446
+ end
201
447
  verify_command!(service.fetch("ExecStartEx"), artifacts.fetch("native_executable").first.fetch("view_path"))
202
- verify_command!(service.fetch("ExecStartPostEx"), artifacts.fetch("readiness_executable").first.fetch("view_path"))
448
+ config_artifact = artifacts.fetch("readiness_configuration").first
449
+ config = ReadinessConfiguration.decode(@files.read(config_artifact.fetch("host_path"), limit: 65_536),
450
+ slot: @scope.fetch("slot_id"))
451
+ interpreter = config.data.dig("runtime", "interpreter_path")
452
+ unless artifacts.fetch("runtime_dependency", []).any? { |artifact| artifact.fetch("view_path") == interpreter }
453
+ raise RuntimeUnavailableError, "readiness interpreter is not installed"
454
+ end
455
+ verify_command!(service.fetch("ExecStartPostEx"), interpreter)
203
456
  unless service.fetch("ExecStartEx").first[1] == [@native.fetch("executable"), "server"] &&
204
- service.fetch("ExecStartPostEx").first[1] == [artifacts.fetch("readiness_executable").first.fetch("view_path"), @scope.fetch("slot_id")]
457
+ service.fetch("ExecStartPostEx").first[1] == [interpreter, "--disable=gems,rubyopt",
458
+ artifacts.fetch("readiness_executable").first.fetch("view_path"), @scope.fetch("slot_id")]
205
459
  raise RuntimeUnavailableError, "unit commands differ from the fixed native/readiness protocol"
206
460
  end
207
461
  environment = service.fetch("Environment")
@@ -212,6 +466,19 @@ module Ace
212
466
  raise RuntimeUnavailableError, "native configuration is not the exact immutable input"
213
467
  end
214
468
  verify_artifact_projection!(service, artifacts)
469
+ verify_boundary_topology!(service, artifacts, authority: config.data.fetch("authority"))
470
+ end
471
+
472
+ def verify_context_protocol!(service, artifacts)
473
+ interpreter = artifacts.fetch("interpreter").first.fetch("view_path")
474
+ entry = artifacts.fetch("context_executable").first.fetch("view_path")
475
+ verify_command!(service.fetch("ExecStartEx"), interpreter)
476
+ arguments = [interpreter, "--disable=gems,rubyopt", *@context_load_paths.flat_map { |path| ["-I", path] }, entry]
477
+ unless service.fetch("ExecStartEx").first[1] == arguments && service.fetch("ExecStartPostEx") == [] &&
478
+ service.fetch("Environment") == [] && service.fetch("TimeoutStopUSec") == 35_000_000 &&
479
+ @context_load_paths.all? { |path| artifacts.fetch("runtime_dependency", []).any? { |artifact| covers?(path, artifact.fetch("view_path")) } }
480
+ raise RuntimeUnavailableError, "context commands differ from the fixed service protocol"
481
+ end
215
482
  end
216
483
 
217
484
  def verify_graph!(slice, service, ancestors, prerequisites)
@@ -243,7 +510,7 @@ module Ace
243
510
  service["WantsMountsFor"] == [@scope.fetch("root_directory")] &&
244
511
  service.fetch("RuntimeDirectory") == [@scope.fetch("runtime_directory").delete_prefix("/run/")] &&
245
512
  service["RequiresMountsFor"].sort == service.fetch("RuntimeDirectory").map { |path| "/run/#{path}" }.sort &&
246
- service["WorkingDirectory"] == "" && service["DefaultDependencies"] == false &&
513
+ service["WorkingDirectory"] == (@codex_service ? @codex_intent.fetch("cwd") : "") && service["DefaultDependencies"] == false &&
247
514
  %w[RequiredBy RequisiteOf WantedBy BoundBy UpheldBy ConsistsOf].all? { |key| service[key] == [] } &&
248
515
  %w[disabled static].include?(service["UnitFileState"]) &&
249
516
  EMPTY_ACTIVATION.all? { |key| service[key] == [] }
@@ -280,6 +547,93 @@ module Ace
280
547
  end
281
548
  end
282
549
 
550
+ def verify_boundary_topology!(service, artifacts, authority:)
551
+ validate_staged_boundary_topology!(service: service, artifacts: artifacts, authority: authority)
552
+ @files.authority_socket!(authority)
553
+ end
554
+
555
+ def validate_boundary_topology!(service, artifacts, authority:)
556
+ artifact = artifacts.fetch("boundary_manifest").first
557
+ bytes = @files.read(artifact.fetch("host_path"), limit: 65_536).dup.force_encoding(Encoding::UTF_8)
558
+ raise RuntimeUnavailableError, "boundary content is not UTF-8" unless bytes.valid_encoding?
559
+ boundary = JSON.parse(bytes, create_additions: false, max_nesting: 8, allow_duplicate_key: false, allow_comments: false)
560
+ unless boundary.is_a?(Hash) && boundary.keys.sort == %w[network_installation resources schema slot_id] &&
561
+ boundary["schema"] == "ace.execution-boundary-manifest/v1" && boundary["slot_id"] == @scope.fetch("slot_id") &&
562
+ boundary["resources"].is_a?(Array) && boundary["resources"].size.between?(1, 64)
563
+ raise RuntimeUnavailableError, "boundary lifecycle topology differs"
564
+ end
565
+ entries = boundary.fetch("resources")
566
+ ExecutionNetworkSelection.validate_static!(boundary.fetch("network_installation"), slot_id: @scope.fetch("slot_id"))
567
+ entries.each do |entry|
568
+ unless entry.is_a?(Hash) && entry.keys.sort == %w[host_path read_only stage view_path worker_visible] &&
569
+ path?(entry["host_path"]) && path?(entry["view_path"]) && %w[parent native].include?(entry["stage"]) &&
570
+ [true, false].include?(entry["worker_visible"]) && [true, false].include?(entry["read_only"]) &&
571
+ (entry["worker_visible"] || entry["stage"] == "parent" && entry["read_only"])
572
+ raise RuntimeUnavailableError, "boundary resource declaration differs"
573
+ end
574
+ end
575
+ unless entries.map { |entry| entry.values_at("host_path", "view_path") }.uniq.size == entries.size
576
+ raise RuntimeUnavailableError, "boundary resource projections repeat"
577
+ end
578
+ projections = service.fetch("BindPaths").map { |mount| [mount[0], mount[1], false] } +
579
+ service.fetch("BindReadOnlyPaths").map { |mount| [mount[0], mount[1], true] }
580
+ socket = authority.fetch("socket_path")
581
+ socket_projection = [socket, socket, true]
582
+ socket_mounts = projections.select { |host, view, _| overlaps?(host, socket) || overlaps?(view, socket) }
583
+ unless socket_mounts == [socket_projection]
584
+ raise RuntimeUnavailableError, "authority socket has no unique exact readonly projection"
585
+ end
586
+ devpts = "/run/ace/execution-slots/#{@scope.fetch('slot_id')}/devpts"
587
+ device_projections = @context ? [] : [[devpts, "/dev/pts", false], [devpts + "/ptmx", "/dev/pts/ptmx", true]]
588
+ unless projections.select { |_host, view, _| overlaps?(view, "/dev/pts") } == device_projections
589
+ raise RuntimeUnavailableError, "selected devpts has no exact root and ptmx projection"
590
+ end
591
+ api_roots = %w[/dev /proc /sys]
592
+ if (projections - device_projections).any? { |_host, view, _| api_roots.any? { |root| overlaps?(root, view) } }
593
+ raise RuntimeUnavailableError, "API namespace has an unsupported storage alias"
594
+ end
595
+ readonly_paths = service.fetch("ReadOnlyPaths")
596
+ unless readonly_paths.all? { |path| path?(path) } &&
597
+ %w[/dev /dev/shm].all? { |view| readonly_paths.any? { |path| covers?(path, view) } } &&
598
+ !service.fetch("ReadWritePaths").any? { |path| !path?(path) || overlaps?(path, "/dev/shm") }
599
+ raise RuntimeUnavailableError, "file-backed IPC is not positively read-only"
600
+ end
601
+ runtime = @scope.fetch("runtime_directory")
602
+ projections << [runtime, runtime, false]
603
+ service.fetch("ReadWritePaths").each do |view|
604
+ unless path?(view) && api_roots.none? { |root| overlaps?(root, view) }
605
+ raise RuntimeUnavailableError, "writable path directive is not exact"
606
+ end
607
+ projection = projections.select { |mount| covers?(mount[1], view) }.max_by { |mount| mount[1].length }
608
+ host = projection ? File.join(projection[0], view.delete_prefix(projection[1]).delete_prefix("/")) :
609
+ @scope.fetch("root_directory") + view
610
+ projections << [host, view, false]
611
+ end
612
+ unless projections.select { |host, view, _| overlaps?(host, socket) || overlaps?(view, socket) } == [socket_projection]
613
+ raise RuntimeUnavailableError, "authority socket is shadowed by an effective writable or parent projection"
614
+ end
615
+ projections.each do |host, view, readonly|
616
+ next if [host, view, readonly] == socket_projection
617
+ next if device_projections.include?([host, view, readonly])
618
+ next if readonly && artifacts.values.flatten.any? { |entry| entry["host_path"] == host && entry["view_path"] == view }
619
+ unless entries.any? { |entry| entry.values_at("host_path", "view_path", "worker_visible", "read_only") == [host, view, true, readonly] }
620
+ raise RuntimeUnavailableError, "effective projection is omitted from boundary inventory"
621
+ end
622
+ end
623
+ entries.each do |entry|
624
+ view = entry.fetch("view_path")
625
+ accessible = projections.any? { |host, target, _readonly| covers?(target, view) &&
626
+ File.expand_path(File.join(host, view.delete_prefix(target).delete_prefix("/"))) == entry.fetch("host_path") } ||
627
+ entry.fetch("host_path") == @scope.fetch("root_directory") + view
628
+ if entry.fetch("worker_visible") != accessible
629
+ raise RuntimeUnavailableError, "boundary visibility is not proven by installed topology"
630
+ end
631
+ if entry.fetch("stage") == "native" && !covers?(runtime, entry.fetch("host_path"))
632
+ raise RuntimeUnavailableError, "service-created resource has no fixed creation directive"
633
+ end
634
+ end
635
+ end
636
+
283
637
  def covers?(root, path) = root == path || root == "/" || path.start_with?(root + "/")
284
638
  def overlaps?(left, right) = covers?(left, right) || covers?(right, left)
285
639