ace-lab 0.8.0 → 0.13.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.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +363 -92
  3. data/README.md +66 -100
  4. data/docs/usage.md +529 -404
  5. data/lib/ace/lab/atoms/install_schema.rb +373 -0
  6. data/lib/ace/lab/cli/commands/install.rb +161 -0
  7. data/lib/ace/lab/cli/commands/prepare.rb +118 -0
  8. data/lib/ace/lab/cli/commands/project_add.rb +101 -0
  9. data/lib/ace/lab/cli/commands/status.rb +109 -0
  10. data/lib/ace/lab/cli/commands/verify.rb +115 -0
  11. data/lib/ace/lab/cli.rb +21 -13
  12. data/lib/ace/lab/install_manifest_path.rb +23 -0
  13. data/lib/ace/lab/models/status_report.rb +38 -0
  14. data/lib/ace/lab/molecules/account_provisioner.rb +196 -0
  15. data/lib/ace/lab/molecules/config_state_classifier.rb +145 -0
  16. data/lib/ace/lab/molecules/directory_provisioner.rb +208 -0
  17. data/lib/ace/lab/molecules/grant_provisioner.rb +250 -0
  18. data/lib/ace/lab/molecules/grant_resolver.rb +49 -23
  19. data/lib/ace/lab/molecules/hitl_routes.rb +263 -0
  20. data/lib/ace/lab/molecules/install_manifest_loader.rb +64 -0
  21. data/lib/ace/lab/molecules/key_provisioner.rb +736 -0
  22. data/lib/ace/lab/molecules/lab_state_scanner.rb +55 -0
  23. data/lib/ace/lab/molecules/overseer_provisioner.rb +188 -0
  24. data/lib/ace/lab/molecules/project_registry.rb +251 -0
  25. data/lib/ace/lab/molecules/repository_provisioner.rb +141 -0
  26. data/lib/ace/lab/molecules/role_verifier.rb +223 -0
  27. data/lib/ace/lab/organisms/install.rb +213 -0
  28. data/lib/ace/lab/organisms/prepare.rb +156 -0
  29. data/lib/ace/lab/organisms/project_add.rb +170 -0
  30. data/lib/ace/lab/organisms/status_service.rb +70 -0
  31. data/lib/ace/lab/organisms/verify.rb +768 -0
  32. data/lib/ace/lab/version.rb +1 -1
  33. data/lib/ace/lab.rb +34 -6
  34. metadata +34 -102
  35. data/lib/ace/lab/atoms/protected_publication_input.rb +0 -50
  36. data/lib/ace/lab/atoms/protected_workspace_prune_input.rb +0 -114
  37. data/lib/ace/lab/atoms/protected_workspace_prune_preview.rb +0 -86
  38. data/lib/ace/lab/atoms/service_input.rb +0 -92
  39. data/lib/ace/lab/cli/commands/assignment.rb +0 -45
  40. data/lib/ace/lab/cli/commands/service.rb +0 -240
  41. data/lib/ace/lab/models/service_receipt.rb +0 -18
  42. data/lib/ace/lab/molecules/hitl_authorizer.rb +0 -63
  43. data/lib/ace/lab/molecules/ordinary_admission_resolver.rb +0 -131
  44. data/lib/ace/lab/molecules/ordinary_listener.rb +0 -262
  45. data/lib/ace/lab/molecules/ordinary_merge_read.rb +0 -45
  46. data/lib/ace/lab/molecules/ordinary_native_read.rb +0 -106
  47. data/lib/ace/lab/molecules/ordinary_owner_admission.rb +0 -139
  48. data/lib/ace/lab/molecules/ordinary_owner_config.rb +0 -232
  49. data/lib/ace/lab/molecules/ordinary_receiver_config.rb +0 -94
  50. data/lib/ace/lab/molecules/ordinary_socket.rb +0 -126
  51. data/lib/ace/lab/molecules/ordinary_worktree_read.rb +0 -216
  52. data/lib/ace/lab/molecules/protected_cleanup_owner_admission.rb +0 -163
  53. data/lib/ace/lab/molecules/protected_cleanup_owner_client.rb +0 -165
  54. data/lib/ace/lab/molecules/protected_cleanup_owner_identity.rb +0 -239
  55. data/lib/ace/lab/molecules/protected_publication_publisher.rb +0 -110
  56. data/lib/ace/lab/molecules/protected_service_handler.rb +0 -43
  57. data/lib/ace/lab/molecules/protected_service_policy.rb +0 -156
  58. data/lib/ace/lab/molecules/service_executor.rb +0 -99
  59. data/lib/ace/lab/molecules/service_policy.rb +0 -289
  60. data/lib/ace/lab/organisms/authority_composition.rb +0 -87
  61. data/lib/ace/lab/organisms/ordinary_assignment_client.rb +0 -149
  62. data/lib/ace/lab/organisms/ordinary_authority_composition.rb +0 -123
  63. data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +0 -688
  64. data/lib/ace/lab/organisms/ordinary_merge_request.rb +0 -456
  65. data/lib/ace/lab/organisms/protected_cleanup_owner.rb +0 -361
  66. data/lib/ace/lab/organisms/protected_service_client.rb +0 -129
  67. data/lib/ace/lab/organisms/protected_service_listener.rb +0 -181
  68. data/lib/ace/lab/organisms/protected_service_publication.rb +0 -268
  69. data/lib/ace/lab/organisms/protected_service_receiver.rb +0 -267
  70. data/lib/ace/lab/organisms/protected_service_recovery.rb +0 -185
  71. data/lib/ace/lab/organisms/protected_service_request.rb +0 -128
  72. data/lib/ace/lab/organisms/protected_service_worker.rb +0 -89
  73. data/lib/ace/lab/organisms/service_request_service.rb +0 -249
@@ -0,0 +1,768 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../errors"
4
+ require_relative "../molecules/install_manifest_loader"
5
+ require_relative "../molecules/key_provisioner"
6
+ require_relative "../molecules/role_verifier"
7
+ require_relative "../molecules/hitl_routes"
8
+ require_relative "../molecules/caller_authorizer"
9
+ require_relative "../atoms/topology_schema"
10
+ require "open3"
11
+ require "yaml"
12
+ require "date"
13
+
14
+ module Ace
15
+ module Lab
16
+ module Organisms
17
+ # Read-only end-of-install readiness verification (L8.5, task
18
+ # 8xg.t.sbz.7.4): checks that the installed lab is ready UNDER
19
+ # THE TARGET ACCOUNTS — not merely for the installer's own
20
+ # account. For every account, directory, key, role, project and
21
+ # grant declared in the trusted install manifest it recomputes
22
+ # the facts from the SAME artifacts install/resume derive truth
23
+ # from (the manifest, the filesystem's permission bits and
24
+ # ownership, the repositories, the per-project registry and the
25
+ # trusted authorization document): no journal, no second state
26
+ # owner, nothing written anywhere.
27
+ #
28
+ # Every check reports pass / fail / unknown with a concrete
29
+ # reason; a failure beside healthy data never hides the healthy
30
+ # data and never prevents the remaining checks from running. The
31
+ # overall verdict (`ready`) is false when ANY check failed — a
32
+ # missing file, wrong permission, unreadable role instructions,
33
+ # an unreachable origin, a principal without grants or a
34
+ # committable `_overseer` folder each block lab readiness.
35
+ # `unknown` (a network remote the read-only verifier does not
36
+ # dial, an unset PATH, a missing resolver) is reported and
37
+ # counted but does not block: it is not a failure, and pretending
38
+ # it were one would make `verify` unusable offline. An unknown is
39
+ # never a verified pass either — the check reports `unknown`, not
40
+ # `pass`, so unverified availability never counts as proven.
41
+ #
42
+ # Because verification reads only converged artifacts, a fresh
43
+ # install and a resumed one verify identically — there is no
44
+ # hidden gap after resume.
45
+ class Verify
46
+ PASS = "pass"
47
+ FAIL = "fail"
48
+ UNKNOWN = "unknown"
49
+
50
+ attr_reader :manifest_path, :platform
51
+
52
+ # @param manifest_path [String, nil] trusted manifest location
53
+ # @param runner [Proc, nil] argv runner seam (git / ssh-keygen)
54
+ # @param account_lookup [Proc, nil] passwd lookup seam
55
+ # @param group_source [Proc, nil] supplementary-group seam
56
+ # (RoleVerifier)
57
+ # @param trusted_read [Proc, nil] trusted-document read seam
58
+ # (defaults to GrantResolver.read_verified)
59
+ # @param authorization_path [String, nil] trusted grants
60
+ # location (defaults to the fixed deployment path)
61
+ # @param nav_runner [Proc, nil] (argv, cwd) runner seam for
62
+ # resolving protocol role instructions through ace-nav
63
+ # @param platform [String, nil] "darwin" / "linux"
64
+ def initialize(manifest_path: nil, runner: nil, account_lookup: nil,
65
+ group_source: nil, trusted_read: nil, authorization_path: nil, nav_runner: nil,
66
+ platform: nil)
67
+ @manifest_path = manifest_path || Ace::Lab.install_manifest_path
68
+ @runner = runner || method(:run_argv)
69
+ @account_lookup = account_lookup || method(:lookup_passwd)
70
+ @group_source = group_source
71
+ @trusted_read = trusted_read || ->(path) { Molecules::GrantResolver.read_verified(path) }
72
+ @authorization_path = authorization_path || Ace::Lab.authorization_path
73
+ @nav_runner = nav_runner || method(:run_nav)
74
+ @platform = platform
75
+ end
76
+
77
+ # @return [Hash] {ready:, machine:, manifest:,
78
+ # authorization_path:, checks: {accounts:, directories:,
79
+ # keys:, roles:, repositories:, grants:, registry:,
80
+ # overseer:, adapters:, tools:}, counts: {pass:, fail:,
81
+ # unknown:}}
82
+ # @raise [Ace::Lab::InvalidConfigurationError] missing,
83
+ # untrusted or schema-invalid manifest — same classification
84
+ # as install/prepare
85
+ def call
86
+ manifest = Molecules::InstallManifestLoader.load(path: manifest_path, platform: platform)
87
+ entries = manifest["accounts"].to_h { |a| [a["username"], @account_lookup.call(a["username"])] }
88
+ executors = target_executors(manifest, entries)
89
+
90
+ checks = {
91
+ "accounts" => check_accounts(manifest, entries),
92
+ "directories" => check_directories(manifest, entries),
93
+ "keys" => check_keys(manifest, entries),
94
+ "roles" => check_roles(manifest),
95
+ "repositories" => check_repositories(manifest, executors),
96
+ "grants" => check_grants(manifest),
97
+ "registry" => check_registry(manifest),
98
+ "overseer" => check_overseer(manifest),
99
+ "adapters" => check_adapters(manifest, executors),
100
+ "tools" => check_tools(manifest, executors)
101
+ }
102
+ flat = checks.values.flatten(1)
103
+ {
104
+ "ready" => flat.none? { |check| check["status"] == FAIL },
105
+ "machine" => manifest["machine"],
106
+ "manifest" => manifest_path,
107
+ "authorization_path" => @authorization_path,
108
+ "checks" => checks,
109
+ "counts" => {
110
+ PASS => flat.count { |check| check["status"] == PASS },
111
+ FAIL => flat.count { |check| check["status"] == FAIL },
112
+ UNKNOWN => flat.count { |check| check["status"] == UNKNOWN }
113
+ }
114
+ }
115
+ end
116
+
117
+ private
118
+
119
+ def check_accounts(manifest, entries)
120
+ manifest["accounts"].map do |account|
121
+ username = account["username"]
122
+ entry = entries[username]
123
+ if entry.nil?
124
+ next {"account" => username, "status" => FAIL,
125
+ "reason" => "account '#{username}' does not exist on this machine; " \
126
+ "fix the account phase (ace-lab prepare) first",
127
+ "uid" => nil, "shell" => nil}
128
+ end
129
+
130
+ reason = if entry.dir != account["home"]
131
+ "account '#{username}' home is '#{entry.dir}' but the manifest declares " \
132
+ "'#{account["home"]}' (the manifest home is authoritative for key paths)"
133
+ elsif non_login_shell?(entry)
134
+ "account '#{username}' has non-login shell '#{shell_of(entry)}'; " \
135
+ "the target executor cannot log in"
136
+ end
137
+ {"account" => username, "status" => reason ? FAIL : PASS, "reason" => reason,
138
+ "uid" => entry.uid, "shell" => shell_of(entry)}
139
+ end
140
+ end
141
+
142
+ def check_directories(manifest, entries)
143
+ manifest["directories"].map do |directory|
144
+ path = directory["path"]
145
+ stat = lstat(path)
146
+ reason = if stat.nil?
147
+ "directory '#{path}' is missing"
148
+ elsif stat.symlink?
149
+ "directory '#{path}' is a symbolic link"
150
+ elsif !stat.directory?
151
+ "'#{path}' exists but is not a directory"
152
+ elsif (stat.mode & 0o7777) != directory["mode"].to_i(8)
153
+ "directory '#{path}' mode is #{format('%04o', stat.mode & 0o7777)} " \
154
+ "but the manifest declares #{directory["mode"]}"
155
+ else
156
+ owner_reason(directory, stat, entries)
157
+ end
158
+ {"directory" => path, "status" => reason ? FAIL : PASS, "reason" => reason}
159
+ end
160
+ end
161
+
162
+ def check_keys(manifest, entries)
163
+ provisioner = Molecules::KeyProvisioner.new(runner: @runner)
164
+ by_username = manifest["accounts"].to_h { |a| [a["username"], a] }
165
+ manifest["keys"].map do |key|
166
+ username = key["account"]
167
+ path = key["path"]
168
+ entry = entries[username]
169
+ reason = if entry.nil?
170
+ "account '#{username}' does not exist; fix the account phase (ace-lab prepare) first"
171
+ else
172
+ key_pair_reason(path, entry)
173
+ end
174
+ fingerprint = if reason
175
+ nil
176
+ else
177
+ provisioner.pair_fingerprint(path, "#{path}.pub")
178
+ end
179
+ if reason.nil? && fingerprint.nil?
180
+ reason = "key pair '#{path}' is not usable (unreadable or mismatched key files); " \
181
+ "resolve manually and rerun ace-lab prepare"
182
+ end
183
+ {"account" => username, "path" => path,
184
+ "status" => reason ? FAIL : PASS, "reason" => reason, "fingerprint" => fingerprint}
185
+ end
186
+ end
187
+
188
+ # Final verification resolves role instructions through the
189
+ # ACTUAL resolver for the execution context (LAB-4): prepare's
190
+ # protocol check is deliberately syntax-only, so reusing it as
191
+ # readiness would promote a syntactically valid but
192
+ # nonexistent role (`wfi://does-not-exist/not-a-role`) to
193
+ # verified readiness. Protocol instructions are resolved with
194
+ # ace-nav from every declared project root (the execution
195
+ # contexts the executor works in); a role that fails resolution
196
+ # is a failed check, an environment without the resolver
197
+ # reports unknown (offline verifier posture, like network
198
+ # remotes) and does not block.
199
+ def check_roles(manifest)
200
+ verifier = Molecules::RoleVerifier.new(
201
+ account_lookup: @account_lookup, group_source: @group_source
202
+ )
203
+ manifest["roles"].map do |role|
204
+ report = verifier.call(role: role)
205
+ if report["status"] == "verified"
206
+ status, reason = protocol_resolution(role["instructions"], manifest)
207
+ else
208
+ status, reason = FAIL, report["message"]
209
+ end
210
+ {"id" => role["id"], "account" => role["account"],
211
+ "instructions" => role["instructions"],
212
+ "status" => status, "reason" => reason}
213
+ end
214
+ end
215
+
216
+ # @return [Array<(String, String, nil)>] pass/fail/unknown with
217
+ # reason for protocol instructions; file-path instructions
218
+ # were already resolved (readability) by the RoleVerifier.
219
+ def protocol_resolution(instructions, manifest)
220
+ unless Molecules::RoleVerifier::PROTOCOL_PREFIXES.any? { |p| instructions.start_with?(p) }
221
+ return [PASS, nil]
222
+ end
223
+
224
+ contexts = manifest["projects"].map { |project| project["path"] }
225
+ if contexts.empty?
226
+ return [PASS, "no project declared as an execution context; syntax verified only"]
227
+ end
228
+
229
+ failures = []
230
+ unresolved_resolver = false
231
+ contexts.each do |root|
232
+ result = @nav_runner.call([nav_binary(root), "resolve", instructions], root)
233
+ if result["status"] == "ok" && !result["out"].to_s.lines.first.to_s.strip.empty?
234
+ next
235
+ end
236
+
237
+ if %w[enoent exec_failed].include?(result["status"])
238
+ unresolved_resolver = true
239
+ else
240
+ failures << "project '#{File.basename(root)}': #{resolution_error(result)}"
241
+ end
242
+ end
243
+ return [FAIL, "role instructions '#{instructions}' do not resolve through ace-nav " \
244
+ "for the execution context (#{failures.join('; ')})"] unless failures.empty?
245
+ return [UNKNOWN, "ace-nav is not available to resolve role instructions " \
246
+ "'#{instructions}'; not probed by the read-only verifier"] if unresolved_resolver
247
+
248
+ [PASS, nil]
249
+ end
250
+
251
+ def resolution_error(result)
252
+ detail = result["err"].to_s.lines.map(&:strip).reject(&:empty?).first
253
+ detail ? detail : "ace-nav exited with status #{result['status']}"
254
+ end
255
+
256
+ # Same binary choice the executor's resolver makes
257
+ # (Ace::Coworker::Molecules::InstructionResolver): the
258
+ # project's binstub when present, otherwise the PATH lookup.
259
+ # A privileged installer never executes repository-controlled
260
+ # binstubs (same trust posture as the LAB-1 symlink refusals):
261
+ # under root only the PATH-installed resolver is used.
262
+ def nav_binary(project_root)
263
+ binstub = File.join(project_root, "bin", "ace-nav")
264
+ use_binstub = !Process.uid.zero? && File.executable?(binstub)
265
+ use_binstub ? binstub : "ace-nav"
266
+ end
267
+
268
+ def run_nav(argv, cwd)
269
+ out, err, status = Open3.capture3(*argv, chdir: cwd)
270
+ {"status" => status.success? ? "ok" : (status.exitstatus || "signal #{status.termsig}"),
271
+ "out" => out, "err" => err}
272
+ rescue Errno::ENOENT
273
+ {"status" => "enoent", "out" => "", "err" => ""}
274
+ rescue SystemCallError, IOError => e
275
+ {"status" => "exec_failed", "out" => "", "err" => e.class.name}
276
+ end
277
+
278
+ # The declared target executors: the distinct accounts the
279
+ # manifest binds roles to (nil entries — accounts that failed
280
+ # lookup — are dropped; the accounts check already fails those).
281
+ def target_executors(manifest, entries)
282
+ manifest["roles"].filter_map { |role| entries[role["account"]] }
283
+ .uniq { |entry| entry.uid }
284
+ end
285
+
286
+ def check_repositories(manifest, executors)
287
+ verifier = Molecules::RoleVerifier.new(
288
+ account_lookup: @account_lookup, group_source: @group_source
289
+ )
290
+ manifest["projects"].map do |project|
291
+ path = project["path"]
292
+ remote = project["remote"]
293
+ origin = origin_url(path)
294
+
295
+ reason = repo_structure_reason(path)
296
+ if reason.nil? && remote && origin != remote
297
+ reason = if origin.nil?
298
+ "'#{path}' has no origin remote configured but the manifest declares '#{remote}'"
299
+ else
300
+ "'#{path}' origin is '#{origin}' but the manifest declares '#{remote}'"
301
+ end
302
+ end
303
+ # Readiness is judged for the target EXECUTOR, not the
304
+ # invoking installer (LAB-2): a repository behind an
305
+ # installer-owned 0700 ancestor with a different target UID
306
+ # is NOT ready even though the installer's git succeeded.
307
+ if reason.nil?
308
+ reason = executors.filter_map do |entry|
309
+ access = verifier.directory_access_reason(path, entry)
310
+ access ? "executor '#{entry.name}': #{access}" : nil
311
+ end.join("; ")
312
+ reason = nil if reason.empty?
313
+ end
314
+
315
+ status, reach = if reason
316
+ [FAIL, nil]
317
+ elsif remote.nil?
318
+ [PASS, "no remote declared (offline repository)"]
319
+ elsif (local = local_remote_path(remote))
320
+ ls_remote(local) ? [PASS, "origin reachable at '#{remote}'"] : [FAIL,
321
+ "origin '#{remote}' is not reachable (git ls-remote failed)"]
322
+ else
323
+ [UNKNOWN, "origin '#{remote}' is not a local-path remote; not probed by the " \
324
+ "read-only verifier"]
325
+ end
326
+
327
+ {"project" => project["id"], "path" => path, "remote" => remote,
328
+ "origin" => origin, "status" => status,
329
+ "reason" => reason || (status == PASS ? nil : reach)}
330
+ end
331
+ end
332
+
333
+ # Project visibility through grants: read the SAME trusted
334
+ # document every grant reader verifies, and evaluate it with
335
+ # the real read-side mechanics (CallerAuthorizer) as the
336
+ # manifest principals' identities.
337
+ def check_grants(manifest)
338
+ document_check = installed_principles
339
+ if document_check["missing"] && manifest["grants"].empty?
340
+ # No grants declared: the trusted document is not part of
341
+ # this lab's contract, and none was written — requiring one
342
+ # would make a grants-less manifest permanently unready.
343
+ return [{"principal" => "(document)", "status" => PASS,
344
+ "reason" => "no grants declared; no trusted authorization document required",
345
+ "projects" => nil}]
346
+ end
347
+ document_check.delete("missing") # internal only — keep JSON schema uniform
348
+ return [document_check] if document_check["status"] == FAIL
349
+
350
+ principals = document_check.delete("principals") # internal only — keep JSON schema uniform
351
+ [document_check] + manifest["grants"].map do |principal, projects|
352
+ granted = Molecules::CallerAuthorizer.new(
353
+ principals: principals, identity: [principal]
354
+ ).authorized_project_ids
355
+ missing = projects - granted
356
+ if missing.empty?
357
+ {"principal" => principal, "status" => PASS,
358
+ "reason" => nil, "projects" => projects}
359
+ else
360
+ {"principal" => principal, "status" => FAIL,
361
+ "reason" => "principal '#{principal}' is granted #{granted.sort.inspect} but the " \
362
+ "manifest declares #{projects.sort.inspect} (missing: #{missing.sort.join(', ')}); " \
363
+ "rerun ace-lab install",
364
+ "projects" => granted.sort}
365
+ end
366
+ end
367
+ end
368
+
369
+ # On failure returns a synthetic failed check carrying the
370
+ # classified reason (document-level failure). An UNTRUSTED but
371
+ # present document fails the same synthetic check (a failed
372
+ # check never hides the healthy data around it) instead of
373
+ # aborting the whole report.
374
+ def installed_principles
375
+ path = @authorization_path
376
+ begin
377
+ content = @trusted_read.call(path)
378
+ rescue Ace::Lab::InvalidConfigurationError => e
379
+ return {"principal" => "(document)", "status" => FAIL,
380
+ "reason" => "trusted authorization file #{path} failed verification: #{e.message}",
381
+ "projects" => nil}
382
+ end
383
+ if content.nil?
384
+ return {"principal" => "(document)", "status" => FAIL, "missing" => true,
385
+ "reason" => "trusted authorization file #{path} is missing; no principal can see " \
386
+ "the installed projects — rerun ace-lab install", "projects" => nil}
387
+ end
388
+
389
+ begin
390
+ document = YAML.safe_load(content, permitted_classes: [Date], aliases: true)
391
+ unless document.is_a?(Hash)
392
+ raise Ace::Lab::InvalidConfigurationError, "#{path} must contain a YAML mapping"
393
+ end
394
+ principals = Molecules::GrantResolver.validate_principals(document["principals"] || {}, path)
395
+ {"principal" => "(document)", "status" => PASS, "reason" => nil,
396
+ "principals" => principals, "projects" => nil}
397
+ rescue Psych::Exception
398
+ {"principal" => "(document)", "status" => FAIL,
399
+ "reason" => "#{path} could not be parsed as YAML; failing closed", "projects" => nil}
400
+ rescue Ace::Lab::InvalidConfigurationError => e
401
+ {"principal" => "(document)", "status" => FAIL, "reason" => e.message, "projects" => nil}
402
+ end
403
+ end
404
+
405
+ def check_registry(manifest)
406
+ manifest["projects"].map do |project|
407
+ path = File.join(project["path"], ".ace", "lab", "config.yml")
408
+ reason = registry_reason(path, project)
409
+ {"project" => project["id"], "registry" => path,
410
+ "status" => reason ? FAIL : PASS, "reason" => reason}
411
+ end
412
+ end
413
+
414
+ def registry_reason(path, project)
415
+ return "'#{path}' is missing; the project is not visible through the configuration " \
416
+ "cascade (ace-lab projects / ace-overseer projects)" unless File.file?(path)
417
+
418
+ begin
419
+ document = YAML.safe_load_file(path, permitted_classes: [Date], aliases: true)
420
+ rescue Psych::Exception
421
+ return "'#{path}' could not be parsed as YAML"
422
+ end
423
+ return "'#{path}' must contain a YAML mapping" unless document.is_a?(Hash)
424
+
425
+ # Shape validation through the same schema every registry
426
+ # consumer applies (LAB-6): a syntactically valid but
427
+ # structurally malformed document (`topology: invalid`) must
428
+ # become a classified per-project failure, never a TypeError
429
+ # that kills the whole readiness report.
430
+ begin
431
+ Atoms::TopologySchema.normalize!(document)
432
+ rescue Ace::Lab::InvalidConfigurationError => e
433
+ return "'#{path}' is not a valid lab configuration: #{e.message}"
434
+ end
435
+
436
+ projects = document.dig("topology", "projects")
437
+ entry = projects.is_a?(Array) ? projects.find { |c| c.is_a?(Hash) && c["id"] == project["id"] } : nil
438
+ return "'#{path}' does not register project '#{project["id"]}'" if entry.nil?
439
+ if entry["path"] && entry["path"] != project["path"]
440
+ return "project '#{project["id"]}' is registered at '#{entry["path"]}' but the " \
441
+ "manifest declares '#{project["path"]}'"
442
+ end
443
+ nil
444
+ end
445
+
446
+ def check_overseer(manifest)
447
+ manifest["projects"].map do |project|
448
+ dir = File.join(project["path"], ".ace-tasks", "_overseer")
449
+ reason = if !File.directory?(dir)
450
+ "private folder '#{dir}' is missing"
451
+ elsif (ignore_reason = gitignore_reason(project["path"], ".ace-tasks/_overseer"))
452
+ ignore_reason
453
+ end
454
+ {"project" => project["id"], "dir" => dir,
455
+ "status" => reason ? FAIL : PASS, "reason" => reason}
456
+ end
457
+ end
458
+
459
+ # --- shared probes ------------------------------------------------
460
+
461
+ # Declared communication/HITL adapters (task 8xg.t.sbz.7.2/.7.4,
462
+ # LAB-3): configuration validity is re-derived from the trusted
463
+ # manifest (the single state owner for the declaration), an
464
+ # adapter that names a local command is probed for availability
465
+ # FOR THE TARGET EXECUTORS (FINAL-LAB3), and a `kind: hitl`
466
+ # adapter must be projected into the runtime route configuration
467
+ # (`hitl.adapters.routes` in every declared project's
468
+ # `.ace/hitl/config.yml`, FINAL-LAB2) — an adapter that is
469
+ # schema-checked but not usable by the runtime blocks readiness
470
+ # with a classified failure, never a crash. Network reachability
471
+ # stays out of scope (L9).
472
+ def check_adapters(manifest, executors = [])
473
+ manifest["adapters"].map do |adapter|
474
+ config_reason = ("adapter '#{adapter['id']}' configuration is not a mapping" \
475
+ unless adapter["config"].is_a?(Hash))
476
+ probe = if config_reason.nil? && adapter["command"]
477
+ availability_probe(adapter["command"], executors).first
478
+ else
479
+ :pass
480
+ end
481
+ route_reason = if config_reason.nil? && adapter["kind"] == "hitl"
482
+ hitl_route_reason(adapter, manifest)
483
+ end
484
+ # Route failures are decidable regardless of PATH — they
485
+ # take precedence over the PATH-unset probe message so a
486
+ # missing/invalid HITL route is never masked as UNKNOWN.
487
+ reason = route_reason ||
488
+ config_reason ||
489
+ (probe != :pass ? adapter_command_reason(adapter, executors) : nil)
490
+ note = if reason.nil? && adapter["command"].nil?
491
+ if adapter["kind"] == "hitl"
492
+ "configured (route installed in every project; no local command declared; " \
493
+ "network reachability is out of scope)"
494
+ else
495
+ "configured (no local command declared; network reachability is out of scope)"
496
+ end
497
+ end
498
+ # A config-shape or route failure is decidable regardless
499
+ # of PATH: it stays FAIL; only the command probe degrades
500
+ # to unknown under an unset PATH.
501
+ status = if config_reason || route_reason
502
+ FAIL
503
+ elsif probe == :unknown
504
+ UNKNOWN
505
+ elsif reason
506
+ FAIL
507
+ else
508
+ PASS
509
+ end
510
+ {"adapter" => adapter["id"], "kind" => adapter["kind"], "command" => adapter["command"],
511
+ "status" => status, "reason" => reason || note}
512
+ end
513
+ end
514
+
515
+ # A `kind: hitl` adapter's route must be installed in EVERY
516
+ # declared execution context and the resulting route list must
517
+ # satisfy the runtime loader's contract (HitlRoutes validates
518
+ # the same rules the ace-hitl route loader enforces).
519
+ def hitl_route_reason(adapter, manifest)
520
+ return nil if manifest["projects"].empty?
521
+
522
+ expected = Molecules::HitlRoutes.expected_routes([adapter]).first
523
+ failures = manifest["projects"].filter_map do |project|
524
+ reason = Molecules::HitlRoutes.config_reason(root: project["path"], expected: expected)
525
+ reason ? "project '#{project['id']}': #{reason}" : nil
526
+ end
527
+ failures.empty? ? nil : failures.join("; ")
528
+ end
529
+
530
+ # Required tools (task 8xg.t.sbz.7.4, LAB-3, FINAL-LAB3): the
531
+ # tool must resolve to an executable the TARGET EXECUTORS can
532
+ # run — a missing tool, or one only the installer can execute,
533
+ # blocks readiness. No execution, no network.
534
+ def check_tools(manifest, executors = [])
535
+ manifest["tools"].map do |tool|
536
+ status, detail = availability_probe(tool, executors)
537
+ case status
538
+ when :pass
539
+ {"tool" => tool, "status" => PASS, "reason" => nil}
540
+ when :fail
541
+ reason = if detail
542
+ "required tool '#{tool}' is not available for the target executor (#{detail})"
543
+ else
544
+ "required tool '#{tool}' is not available (not found on PATH)"
545
+ end
546
+ {"tool" => tool, "status" => FAIL, "reason" => reason}
547
+ else
548
+ {"tool" => tool, "status" => UNKNOWN,
549
+ "reason" => "PATH is unset; cannot probe tool '#{tool}' availability"}
550
+ end
551
+ end
552
+ end
553
+
554
+ # Resolve an executable without running it and judge it for the
555
+ # TARGET EXECUTORS (FINAL-LAB3): a name containing a separator
556
+ # must be a file executable at that path; a bare name must
557
+ # resolve through PATH (the installer's PATH is the best
558
+ # available locator — candidate files are then permission-checked
559
+ # per target executor through the same governing-class logic as
560
+ # the repository access checks). Without declared executors the
561
+ # installer's own executable bit decides (presence check only).
562
+ # An unset or empty PATH leaves the probe undecidable and yields
563
+ # unknown (never a false failure) — reported as UNKNOWN, never
564
+ # counted as a verified pass, and like every other unknown it
565
+ # does not block readiness by itself.
566
+ # @return [Array<(Symbol, String, nil)>] status (:pass/:fail/:
567
+ # unknown) and the executor-denial detail for failures
568
+ def availability_probe(name, executors)
569
+ if name.include?(File::SEPARATOR)
570
+ candidates = File.file?(name) ? [name] : []
571
+ else
572
+ directories = (ENV["PATH"].to_s.split(File::PATH_SEPARATOR) - [""])
573
+ return [:unknown, nil] if directories.empty?
574
+
575
+ candidates = directories.filter_map do |directory|
576
+ candidate = File.join(directory, name)
577
+ File.file?(candidate) ? candidate : nil
578
+ end
579
+ end
580
+ return [:fail, nil] if candidates.empty?
581
+ if executors.empty?
582
+ return [candidates.any? { |candidate| File.executable?(candidate) } ? :pass : :fail, nil]
583
+ end
584
+
585
+ verifier = Molecules::RoleVerifier.new(
586
+ account_lookup: @account_lookup, group_source: @group_source
587
+ )
588
+ denials = executors.filter_map do |entry|
589
+ reasons = candidates.map { |candidate| verifier.executable_access_reason(candidate, entry) }
590
+ reasons.compact.empty? ? nil : "executor '#{entry.name}': #{reasons.compact.first}"
591
+ end
592
+ denials.empty? ? [:pass, nil] : [:fail, denials.join("; ")]
593
+ end
594
+
595
+ def adapter_command_reason(adapter, executors = [])
596
+ status, detail = availability_probe(adapter["command"], executors)
597
+ case status
598
+ when :fail
599
+ if detail
600
+ "adapter '#{adapter['id']}' command '#{adapter['command']}' is not available " \
601
+ "for the target executor (#{detail})"
602
+ else
603
+ "adapter '#{adapter['id']}' command '#{adapter['command']}' is not available " \
604
+ "(not found on PATH); fix the adapter installation or the manifest declaration"
605
+ end
606
+ when :unknown
607
+ "PATH is unset; cannot probe adapter '#{adapter['id']}' command availability"
608
+ end
609
+ end
610
+
611
+ def key_pair_reason(path, entry)
612
+ stat = lstat(path)
613
+ public_path = "#{path}.pub"
614
+ pub_stat = lstat(public_path)
615
+ return "private key '#{path}' is missing" if stat.nil?
616
+ return "private key '#{path}' is not a regular file" unless stat.file?
617
+ return "public key '#{public_path}' is missing" if pub_stat.nil?
618
+ return "public key '#{public_path}' is not a regular file" unless pub_stat.file?
619
+
620
+ if (stat.mode & 0o7777) != Molecules::KeyProvisioner::PRIVATE_MODE
621
+ return "private key '#{path}' mode is #{format('%04o', stat.mode & 0o7777)} " \
622
+ "but #{format('%04o', Molecules::KeyProvisioner::PRIVATE_MODE)} is required"
623
+ end
624
+ if (pub_stat.mode & 0o7777) != Molecules::KeyProvisioner::PUBLIC_MODE
625
+ return "public key '#{public_path}' mode is #{format('%04o', pub_stat.mode & 0o7777)} " \
626
+ "but #{format('%04o', Molecules::KeyProvisioner::PUBLIC_MODE)} is required"
627
+ end
628
+ if stat.uid != entry.uid
629
+ return "private key '#{path}' is owned by uid #{stat.uid} but the account " \
630
+ "'#{entry.name}' is uid #{entry.uid}"
631
+ end
632
+ if pub_stat.uid != entry.uid
633
+ return "public key '#{public_path}' is owned by uid #{pub_stat.uid} but the " \
634
+ "account '#{entry.name}' is uid #{entry.uid}"
635
+ end
636
+
637
+ ssh_dir = File.dirname(path)
638
+ ssh_stat = lstat(ssh_dir)
639
+ if ssh_stat.nil?
640
+ "key directory '#{ssh_dir}' is missing"
641
+ elsif !ssh_stat.directory? || ssh_stat.symlink?
642
+ "key directory '#{ssh_dir}' is not a directory"
643
+ elsif (ssh_stat.mode & 0o7777) != Molecules::KeyProvisioner::SSH_DIR_MODE
644
+ "key directory '#{ssh_dir}' mode is #{format('%04o', ssh_stat.mode & 0o7777)} " \
645
+ "but #{format('%04o', Molecules::KeyProvisioner::SSH_DIR_MODE)} is required"
646
+ elsif ssh_stat.uid != entry.uid
647
+ "key directory '#{ssh_dir}' is owned by uid #{ssh_stat.uid} but the account " \
648
+ "'#{entry.name}' is uid #{entry.uid}"
649
+ end
650
+ end
651
+
652
+ def repo_structure_reason(path)
653
+ stat = lstat(path)
654
+ return "'#{path}' is missing" if stat.nil?
655
+ return "'#{path}' is not a directory" unless stat.directory?
656
+
657
+ result = @runner.call(["git", "-C", path, "rev-parse", "--show-toplevel"])
658
+ if dubious_ownership?(result)
659
+ return "git refuses to operate on '#{path}' because of dubious ownership — run verify " \
660
+ "as the account that owns the repository, or configure git safe.directory"
661
+ end
662
+ return "'#{path}' is not a Git repository" unless result["status"] == "ok"
663
+
664
+ reported = result["out"].to_s.lines.first.to_s.strip
665
+ return "'#{path}' is a subdirectory of the repository at '#{reported}'" unless same_location?(reported, path)
666
+
667
+ nil
668
+ end
669
+
670
+ # Raw stored URL: `git remote get-url` applies url.<base>.insteadOf
671
+ # rewrites from the invoking user's git config, which would
672
+ # false-fail an origin that matches the manifest canonically.
673
+ def origin_url(path)
674
+ result = @runner.call(["git", "-C", path, "config", "--get", "remote.origin.url"])
675
+ return nil unless result["status"] == "ok"
676
+
677
+ url = result["out"].to_s.lines.first.to_s.strip
678
+ url.empty? ? nil : url
679
+ end
680
+
681
+ # A remote the verifier may dial without leaving the machine:
682
+ # an existing local path (bare or not), optionally via file://.
683
+ def local_remote_path(remote)
684
+ candidate = remote.start_with?("file://") ? remote.delete_prefix("file://") : remote
685
+ candidate if candidate.start_with?("/") && File.directory?(candidate)
686
+ end
687
+
688
+ def ls_remote(remote)
689
+ @runner.call(["git", "ls-remote", remote])["status"] == "ok"
690
+ end
691
+
692
+ # nil when Git ignores the path; otherwise the concrete reason
693
+ # (including the dubious-ownership refusal, which must not be
694
+ # misdiagnosed as "not ignored").
695
+ def gitignore_reason(root, relative)
696
+ result = @runner.call(["git", "-C", root, "check-ignore", "--", relative])
697
+ if dubious_ownership?(result)
698
+ return "git refuses to operate on '#{root}' because of dubious ownership — run " \
699
+ "verify as the owning account, or configure git safe.directory"
700
+ end
701
+ return nil if result["status"] == "ok"
702
+
703
+ "'#{File.join(root, relative)}' is not ignored by Git; private overseer tasks " \
704
+ "would be committed (add '.ace-tasks/_overseer/' to the project .gitignore " \
705
+ "and rerun ace-lab install)"
706
+ end
707
+
708
+ # Git's ownership-safety refusal (verify often runs as a different
709
+ # account than the one that converged the repository — exactly the
710
+ # under-target-accounts posture). Detected so the reported reason
711
+ # is actionable instead of "not a Git repository".
712
+ def dubious_ownership?(result)
713
+ result["status"] != "ok" && result["err"].to_s.include?("dubious ownership")
714
+ end
715
+
716
+ def same_location?(reported, path)
717
+ File.realpath(reported) == File.realpath(path)
718
+ rescue Errno::ENOENT, Errno::ENOTDIR, Errno::EACCES
719
+ File.expand_path(reported) == File.expand_path(path)
720
+ end
721
+
722
+ def owner_reason(directory, stat, entries)
723
+ owner = directory["owner"]
724
+ return nil if owner.nil?
725
+
726
+ entry = entries[owner]
727
+ return nil if entry.nil?
728
+
729
+ if stat.uid != entry.uid
730
+ "directory '#{directory["path"]}' is owned by uid #{stat.uid} but the manifest " \
731
+ "declares owner '#{owner}' (uid #{entry.uid})"
732
+ end
733
+ end
734
+
735
+ def non_login_shell?(entry)
736
+ shell = shell_of(entry).to_s
737
+ shell.end_with?("/nologin") || shell == "/bin/false" || shell == "/usr/bin/false"
738
+ end
739
+
740
+ def shell_of(entry)
741
+ entry.respond_to?(:shell) ? entry.shell : nil
742
+ end
743
+
744
+ def lstat(path)
745
+ File.lstat(path)
746
+ rescue Errno::ENOENT, Errno::ENOTDIR
747
+ nil
748
+ end
749
+
750
+ def run_argv(argv)
751
+ out, err, status = Open3.capture3(*argv)
752
+ {"status" => status.success? ? "ok" : (status.exitstatus || "signal #{status.termsig}"),
753
+ "out" => out, "err" => err}
754
+ rescue Errno::ENOENT
755
+ {"status" => "enoent", "out" => "", "err" => ""}
756
+ rescue SystemCallError => e
757
+ {"status" => "exec_failed", "out" => "", "err" => e.class.name}
758
+ end
759
+
760
+ def lookup_passwd(username)
761
+ Etc.getpwnam(username)
762
+ rescue ArgumentError
763
+ nil
764
+ end
765
+ end
766
+ end
767
+ end
768
+ end