ace-lab 0.13.1 → 0.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,6 +3,7 @@
3
3
  require_relative "../errors"
4
4
  require "open3"
5
5
  require "fileutils"
6
+ require_relative "gem_payload_permissions"
6
7
 
7
8
  module Ace
8
9
  module Lab
@@ -49,7 +50,12 @@ module Ace
49
50
  if runtime_failure
50
51
  base(gem, gem_home).merge("status" => "failed", "message" => runtime_failure)
51
52
  else
52
- converge(base(gem, gem_home), gem, gem_home)
53
+ report = converge(base(gem, gem_home), gem, gem_home)
54
+ if %w[exists installed updated].include?(report["status"])
55
+ converge_post_presence(report, gem, gem_home)
56
+ else
57
+ report
58
+ end
53
59
  end
54
60
  end
55
61
  end
@@ -78,6 +84,55 @@ module Ace
78
84
  [:ok, versions]
79
85
  end
80
86
 
87
+ # Public READ-ONLY probes (verify, task 8xs.t.pu0.0): the same
88
+ # primitives convergence runs, exposed so readiness reuses the
89
+ # identical logic instead of reimplementing it.
90
+ #
91
+ # Unsatisfied runtime dependencies of the declared gem — the
92
+ # concrete missing-dependency list readiness reports.
93
+ # @return [Array(Symbol, Object)] [:ok, reasons] (empty reasons
94
+ # = closed) or the failing probe's [:unknown/:failed,
95
+ # message] — the same shape newer_versions returns.
96
+ def closure_failures(name:, version:, gem_home:)
97
+ queue = [[name, version]]
98
+ seen = {}
99
+ reasons = []
100
+ until queue.empty?
101
+ current_name, current_version = queue.shift
102
+ next if seen[[current_name, current_version]]
103
+
104
+ seen[[current_name, current_version]] = true
105
+ deps = runtime_dependencies(name: current_name, version: current_version, gem_home: gem_home)
106
+ return [:failed, deps] if deps.is_a?(String)
107
+
108
+ deps.each do |dep|
109
+ kind, versions_or_message = runtime_versions(name: dep["name"], gem_home: gem_home)
110
+ return [kind, versions_or_message] if kind != :ok
111
+
112
+ satisfying = satisfying_versions(dep, versions_or_message)
113
+ if satisfying.empty?
114
+ reasons << "dependency '#{dep["name"]} (#{dep["requirement"]})' required by gem " \
115
+ "'#{current_name}' is not satisfied in runtime '#{gem_home}'"
116
+ else
117
+ queue << [dep["name"], satisfying.max_by { |v| Gem::Version.new(v) }]
118
+ end
119
+ end
120
+ end
121
+ [:ok, reasons]
122
+ end
123
+
124
+ # Installed versions of `name` NEWER than the declared one —
125
+ # the versions that would win activation and take over the CLI.
126
+ # @return [Array(String, Object)] [:ok, versions] or the probe's
127
+ # [:unknown/:failed, message]
128
+ def newer_versions(name:, declared_version:, gem_home:)
129
+ kind, versions_or_message = runtime_versions(name: name, gem_home: gem_home)
130
+ return [kind, versions_or_message] if kind != :ok
131
+
132
+ declared = Gem::Version.new(declared_version)
133
+ [:ok, versions_or_message.select { |version| Gem::Version.new(version) > declared }]
134
+ end
135
+
81
136
  private
82
137
 
83
138
  def base(gem, gem_home)
@@ -132,6 +187,158 @@ module Ace
132
187
  end
133
188
  end
134
189
 
190
+ # Post-presence convergence (task 8xs.t.pu0.0): the declared
191
+ # version being present is necessary but not sufficient.
192
+ # 1. NEWER versions of the declared gem sitting beside the
193
+ # declared one would win RubyGems activation and take over
194
+ # the runtime's CLI — the manifest is the single owner of
195
+ # the declared set, so convergence removes them from the
196
+ # target runtime.
197
+ # 2. The dependency closure must actually resolve in the
198
+ # runtime: a gem whose version already figures in
199
+ # `gem list` can still miss a transitive runtime
200
+ # dependency (the manually-repaired `json` class of gap);
201
+ # missing dependencies are converged by installing them
202
+ # at the parent's requirement, and an unsatisfiable
203
+ # dependency is a classified failure — never readiness.
204
+ # A dry run predicts both without writing (would_uninstall /
205
+ # would_install) and stops recursion at the prediction.
206
+ def converge_post_presence(report, gem, gem_home)
207
+ removal = converge_newer_versions(gem, gem_home)
208
+ return report.merge("status" => "failed", "message" => removal) if removal.is_a?(String)
209
+ report["removed_newer_versions"] = removal
210
+
211
+ closure = converge_closure(gem, gem_home)
212
+ return report.merge("status" => "failed", "message" => closure) if closure.is_a?(String)
213
+ report["closure"] = closure
214
+ permissions = GemPayloadPermissions.new(gem_home: gem_home, dry_run: @dry_run,
215
+ privileged: @privileged.call).call(name: gem["name"], version: gem["version"])
216
+ report["permissions"] = permissions
217
+ report
218
+ rescue GemPayloadPermissions::Error => e
219
+ report.merge("status" => "failed", "message" => e.message)
220
+ end
221
+
222
+ # @return [Array<Hash>, String] per-version removal reports or a
223
+ # classified failure message
224
+ def converge_newer_versions(gem, gem_home)
225
+ kind, versions_or_message = runtime_versions(name: gem["name"], gem_home: gem_home)
226
+ return versions_or_message if kind != :ok
227
+
228
+ declared = Gem::Version.new(gem["version"])
229
+ newer = versions_or_message.select { |version| Gem::Version.new(version) > declared }
230
+ return [] if newer.empty?
231
+
232
+ return newer.map { |version| {"version" => version, "status" => "would_uninstall"} } if @dry_run
233
+
234
+ newer.map do |version|
235
+ result = @runner.call(runtime_argv(gem_home,
236
+ ["gem", "uninstall", gem["name"], "--version", version, "-x", "--force"]))
237
+ if result["status"] != "ok"
238
+ break command_failure("gem uninstall #{gem["name"]} #{version}", result)
239
+ end
240
+ {"version" => version, "status" => "uninstalled"}
241
+ end
242
+ end
243
+
244
+ # Breadth-first walk of the gem's RUNTIME dependency closure in
245
+ # the target runtime. `gem dependency` output is plain text (no
246
+ # marshaled objects), development dependencies are ignored.
247
+ # @return [Array<Hash>, String] per-dependency convergence
248
+ # actions or a classified failure message
249
+ def converge_closure(gem, gem_home)
250
+ queue = [[gem["name"], gem["version"]]]
251
+ seen = {}
252
+ actions = []
253
+ until queue.empty?
254
+ name, version = queue.shift
255
+ next if seen[[name, version]]
256
+
257
+ seen[[name, version]] = true
258
+ deps = runtime_dependencies(name: name, version: version, gem_home: gem_home)
259
+ return deps if deps.is_a?(String)
260
+
261
+ deps.each do |dep|
262
+ kind, versions_or_message = runtime_versions(name: dep["name"], gem_home: gem_home)
263
+ return versions_or_message if kind != :ok
264
+
265
+ satisfying = satisfying_versions(dep, versions_or_message)
266
+ if satisfying.empty?
267
+ if @dry_run
268
+ actions << {"name" => dep["name"], "requirement" => dep["requirement"],
269
+ "status" => "would_install"}
270
+ next # recursion stops at the prediction
271
+ end
272
+
273
+ failure = install_dependency(dep, gem_home)
274
+ return failure if failure
275
+
276
+ kind, versions_or_message = runtime_versions(name: dep["name"], gem_home: gem_home)
277
+ return versions_or_message if kind != :ok
278
+ satisfying = satisfying_versions(dep, versions_or_message)
279
+ if satisfying.empty?
280
+ return "dependency '#{dep["name"]} (#{dep["requirement"]})' required by gem " \
281
+ "'#{name}' is not satisfiable in runtime '#{gem_home}' after install; " \
282
+ "check the gem source or the manifest declaration"
283
+ end
284
+ actions << {"name" => dep["name"], "requirement" => dep["requirement"],
285
+ "status" => "installed"}
286
+ end
287
+
288
+ chosen = satisfying.max_by { |v| Gem::Version.new(v) }
289
+ queue << [dep["name"], chosen]
290
+ end
291
+ end
292
+ actions
293
+ end
294
+
295
+ # Installs a missing dependency at the parent requirement: the
296
+ # requirement string the runtime's own resolver would accept
297
+ # (`gem install name -v "~> 2.7"`).
298
+ # @return [String, nil] classified failure, nil on success
299
+ def install_dependency(dep, gem_home)
300
+ result = @runner.call(runtime_argv(gem_home,
301
+ ["gem", "install", dep["name"], "--version", dep["requirement"], "--no-document"]))
302
+ return nil if result["status"] == "ok"
303
+
304
+ command_failure("gem install #{dep["name"]}", result)
305
+ end
306
+
307
+ # The runtime (non-development) dependencies of an installed
308
+ # gem, from `gem dependency <name> --version <v>` plain-text
309
+ # output: "dep (~> 2.0, runtime)" lines under the gem header.
310
+ # @return [Array<Hash{"name","requirement"}>, String] or a
311
+ # classified failure message
312
+ def runtime_dependencies(name:, version:, gem_home:)
313
+ result = @runner.call(runtime_argv(gem_home,
314
+ ["gem", "dependency", name, "--version", version]))
315
+ if result["status"] == "enoent"
316
+ return "gem not found in PATH; install RubyGems and rerun ace-lab prepare"
317
+ end
318
+ return "gem dependency failed (#{exit_detail(result["status"])}); rerun ace-lab prepare" \
319
+ unless result["status"] == "ok"
320
+
321
+ result["out"].lines.filter_map do |line|
322
+ match = line.match(/\A\s+([A-Za-z0-9._-]+) \(([^)]*)\)\s*\z/)
323
+ next if match.nil?
324
+
325
+ parts = match[2].split(",").map(&:strip)
326
+ type = %w[runtime development].include?(parts.last) ? parts.pop : "runtime"
327
+ next if type == "development"
328
+
329
+ requirement = parts.join(", ")
330
+ requirement = ">= 0" if requirement.empty?
331
+ {"name" => match[1], "requirement" => requirement}
332
+ end
333
+ end
334
+
335
+ def satisfying_versions(dep, versions)
336
+ requirement = dep["requirement"].split(",").map(&:strip)
337
+ versions.select do |version|
338
+ Gem::Dependency.new(dep["name"], *requirement).match?(dep["name"], version)
339
+ end
340
+ end
341
+
135
342
  # Installs the EXACT declared version into the runtime. Sources
136
343
  # are the gem command's own defaults — fetching from the
137
344
  # configured gem source is provisioning network access, the same
@@ -169,8 +376,16 @@ module Ace
169
376
 
170
377
  # `gem list <name> --exact --local` prints one `name (v1, v2)`
171
378
  # line when the name is present in the runtime and nothing when
172
- # it is absent. A present-but-unparseable line is a failed probe
173
- # (nil, fail closed), never a guessed empty list.
379
+ # it is absent. A DEFAULT gem's bundled version appears as a
380
+ # `default: V` part (`abbrev (default: 0.1.2)`,
381
+ # `json (2.9.4, default: 2.9.1)`): the marked V is the default
382
+ # gem's own version, every other part is an additionally
383
+ # installed version — both are present in the runtime, so the
384
+ # normalized list carries the marked version in place of the
385
+ # marker. Every candidate is validated by Gem::Version itself —
386
+ # platform-suffixed or otherwise malformed strings fail the
387
+ # probe (nil, fail closed) instead of crashing a later
388
+ # Gem::Version.new caller; never a guessed empty list.
174
389
  def parse_versions(out, name)
175
390
  line = out.lines.find { |candidate| candidate.start_with?("#{name} ") }
176
391
  return [] if line.nil?
@@ -178,7 +393,17 @@ module Ace
178
393
  match = line.chomp.match(/\A#{Regexp.escape(name)} \((.*)\)\z/)
179
394
  return nil if match.nil?
180
395
 
181
- match[1].split(",").map(&:strip)
396
+ versions = match[1].split(",").map(&:strip).map do |part|
397
+ default = part.match(/\Adefault: (\S+)\z/)
398
+ default ? default[1] : part
399
+ end
400
+ begin
401
+ versions.each { |version| Gem::Version.new(version) }
402
+ rescue ArgumentError
403
+ return nil
404
+ end
405
+
406
+ versions
182
407
  end
183
408
 
184
409
  # Classified one-line command failure: status plus the first
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Ace
6
+ module Lab
7
+ module Molecules
8
+ # INTERNAL project-location read for the lab-level overseer start
9
+ # (task 8xs.t.pu0.a): the project list and their CANONICAL
10
+ # locations come from the raw topology configuration
11
+ # (`topology.projects`: id/path) read at the lab root — the schema
12
+ # normalization and the public projection deliberately strip paths,
13
+ # so this reader goes to the raw cascade document and takes only
14
+ # what the start needs. Locations are never derived from a public
15
+ # id/label or a repo name. The public CLI keeps its allowlisted
16
+ # projection; only the start operation consumes locations,
17
+ # in-process.
18
+ class ProjectLocations
19
+ # @param lab_root [String] lab coordination directory whose
20
+ # cascade lists the configured projects
21
+ # @return [Array<Hash>] {"id" =>, "path" =>} sorted by id; a
22
+ # project entry without a usable path is returned with a nil
23
+ # path so its OWN start reports the concrete error
24
+ # @raise [Ace::Lab::InvalidConfigurationError] the topology
25
+ # configuration could not be read or has no project list
26
+ def self.read(lab_root)
27
+ config_path = File.join(lab_root, ".ace/lab/config.yml")
28
+ document = YAML.safe_load_file(config_path,
29
+ permitted_classes: [Date], aliases: true)
30
+ unless document.is_a?(Hash)
31
+ raise Ace::Lab::InvalidConfigurationError, ".ace/lab/config.yml must be a mapping"
32
+ end
33
+
34
+ raw = document.dig("topology", "projects")
35
+ unless raw.is_a?(Array)
36
+ raise Ace::Lab::InvalidConfigurationError,
37
+ "topology.projects must be a list in .ace/lab/config.yml"
38
+ end
39
+
40
+ raw.map do |entry|
41
+ next {"id" => nil, "path" => nil} unless entry.is_a?(Hash)
42
+
43
+ # A nil/blank path encoding is returned as a nil path (the
44
+ # project's OWN start reports the concrete error) — never a
45
+ # bogus empty-string path, never an exception. The config is
46
+ # read by explicit path: no global cwd mutation.
47
+ path = entry["path"]
48
+ path = nil unless path.is_a?(String) && !path.strip.empty?
49
+ {"id" => entry["id"].to_s, "path" => path}
50
+ end.sort_by { |project| project["id"].to_s }
51
+ rescue Errno::ENOENT
52
+ raise Ace::Lab::InvalidConfigurationError,
53
+ "no lab configuration at #{lab_root}/.ace/lab/config.yml; run " \
54
+ "ace-lab project add or fix the lab root"
55
+ rescue Psych::SyntaxError => e
56
+ raise Ace::Lab::InvalidConfigurationError,
57
+ "lab configuration could not be parsed: #{e.message}"
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -7,6 +7,7 @@ require_relative "../molecules/directory_provisioner"
7
7
  require_relative "../molecules/key_provisioner"
8
8
  require_relative "../molecules/role_verifier"
9
9
  require_relative "../molecules/gemset_provisioner"
10
+ require_relative "../molecules/extension_provisioner"
10
11
 
11
12
  module Ace
12
13
  module Lab
@@ -51,9 +52,11 @@ module Ace
51
52
  unresolved, effective_dry_run)
52
53
  roles = prepare_roles(manifest["roles"], available, unresolved, effective_dry_run)
53
54
  gems = prepare_gems(manifest["gemset"], effective_dry_run)
55
+ extensions = prepare_extensions(manifest["gemset"], effective_dry_run, gems)
54
56
 
55
57
  {
56
- "ok" => [accounts, directories, keys, roles, gems].flatten.none? { |item| item["status"] == "failed" },
58
+ "ok" => [accounts, directories, keys, roles, gems, extensions].flatten
59
+ .none? { |item| item["status"] == "failed" },
57
60
  "dry_run" => effective_dry_run,
58
61
  "machine" => manifest["machine"],
59
62
  "manifest" => manifest_path,
@@ -61,7 +64,8 @@ module Ace
61
64
  "directories" => directories,
62
65
  "keys" => keys,
63
66
  "roles" => roles,
64
- "gems" => gems
67
+ "gems" => gems,
68
+ "extensions" => extensions
65
69
  }
66
70
  end
67
71
 
@@ -152,6 +156,28 @@ module Ace
152
156
  provisioner.call(runtime: runtime)
153
157
  end
154
158
 
159
+ # Harness extension projections (task 8xs.t.pu0.0): converged
160
+ # from the installed gem's exact declared version after the
161
+ # gemset phase; a gem phase failure fails every extension as a
162
+ # dependency (the projection source would not exist).
163
+ def prepare_extensions(runtime, dry_run, gems)
164
+ entries = runtime["extensions"]
165
+ return [] if entries.empty?
166
+
167
+ if gems.any? { |gem| gem["status"] == "failed" }
168
+ return entries.map do |entry|
169
+ {"extension" => entry["gem"], "gem" => entry["gem"], "version" => nil,
170
+ "path" => entry["path"], "account" => entry["account"], "status" => "failed",
171
+ "loaded_version" => nil, "restart_required" => false, "files" => [],
172
+ "message" => "gemset convergence failed; fix the gem phase first"}
173
+ end
174
+ end
175
+
176
+ Molecules::ExtensionProvisioner.new(
177
+ gem_home: runtime["gem_home"], dry_run: dry_run, account_lookup: @account_lookup
178
+ ).call(extensions: entries, gems: runtime["gems"])
179
+ end
180
+
155
181
  def available_accounts(account_reports)
156
182
  account_reports.reject { |report| report["status"] == "failed" }
157
183
  .map { |report| report["username"] }
@@ -5,11 +5,14 @@ require_relative "../molecules/install_manifest_loader"
5
5
  require_relative "../molecules/key_provisioner"
6
6
  require_relative "../molecules/role_verifier"
7
7
  require_relative "../molecules/gemset_provisioner"
8
+ require_relative "../molecules/extension_provisioner"
8
9
  require_relative "../molecules/hitl_routes"
9
10
  require_relative "../molecules/caller_authorizer"
10
11
  require_relative "../atoms/topology_schema"
11
12
  require "open3"
12
13
  require "yaml"
14
+ require "json"
15
+ require "fileutils"
13
16
  require "date"
14
17
 
15
18
  module Ace
@@ -99,7 +102,8 @@ module Ace
99
102
  "overseer" => check_overseer(manifest),
100
103
  "adapters" => check_adapters(manifest, executors),
101
104
  "tools" => check_tools(manifest, executors),
102
- "gems" => check_gems(manifest)
105
+ "gems" => check_gems(manifest),
106
+ "extensions" => check_extensions(manifest, entries)
103
107
  }
104
108
  flat = checks.values.flatten(1)
105
109
  {
@@ -583,20 +587,146 @@ module Ace
583
587
  kind, versions_or_message = provisioner.runtime_versions(
584
588
  name: gem["name"], gem_home: gem_home
585
589
  )
586
- reason = case kind
587
- when :unknown, :failed then versions_or_message
588
- when :ok then gem_version_reason(gem, versions_or_message, gem_home)
589
- end
590
- status = case kind
591
- when :ok then reason ? FAIL : PASS
592
- when :unknown then UNKNOWN
593
- else FAIL
590
+ status, reason = if kind != :ok
591
+ [kind == :unknown ? UNKNOWN : FAIL, versions_or_message]
592
+ else
593
+ verify_present_gem(provisioner, gem, versions_or_message, gem_home)
594
594
  end
595
595
  {"gem" => gem["name"], "version" => gem["version"], "gem_home" => gem_home,
596
596
  "status" => status, "reason" => reason}
597
597
  end
598
598
  end
599
599
 
600
+ # A gem whose exact declared version is present must ALSO prove
601
+ # (1) its runtime dependency closure resolves in the target
602
+ # runtime — a gem listed in `gem list` with a missing
603
+ # transitive dependency is NOT ready (task 8xs.t.pu0.0) — and
604
+ # (2) no NEWER version sits beside the declared one: RubyGems
605
+ # activates the highest installed version, so a newer sibling
606
+ # would silently take over the runtime's CLI.
607
+ def verify_present_gem(provisioner, gem, versions, gem_home)
608
+ reason = gem_version_reason(gem, versions, gem_home)
609
+ return [FAIL, reason] if reason
610
+
611
+ newer_kind, newer_or_message = provisioner.newer_versions(
612
+ name: gem["name"], declared_version: gem["version"], gem_home: gem_home
613
+ )
614
+ if newer_kind != :ok
615
+ return [newer_kind == :unknown ? UNKNOWN : FAIL, newer_or_message]
616
+ end
617
+ unless newer_or_message.empty?
618
+ return [FAIL,
619
+ "gem '#{gem["name"]}' has newer version(s) #{newer_or_message.join(", ")} beside " \
620
+ "the declared #{gem["version"]} in '#{gem_home}'; the newer version would take " \
621
+ "over CLI activation; rerun ace-lab install"]
622
+ end
623
+
624
+ closure_kind, closure_or_message = provisioner.closure_failures(
625
+ name: gem["name"], version: gem["version"], gem_home: gem_home
626
+ )
627
+ return [closure_kind == :unknown ? UNKNOWN : FAIL, closure_or_message] if closure_kind != :ok
628
+ return [FAIL, closure_or_message.join("; ")] unless closure_or_message.empty?
629
+
630
+ [PASS, nil]
631
+ end
632
+
633
+ # Declared harness extension projections (task 8xs.t.pu0.0):
634
+ # re-derived from the same receipts and installed gem trees the
635
+ # provisioner converges (shared ExtensionProjection helpers) —
636
+ # the receipt must name the declared gem at its exact declared
637
+ # version, every projected file must match the installed gem's
638
+ # handbook/extensions content, the destination must not escape
639
+ # the extensions directory through a symlinked component, and
640
+ # the projected files must grant the declared account read.
641
+ def check_extensions(manifest, entries)
642
+ gemset = manifest["gemset"]
643
+ gemset["extensions"].map do |entry|
644
+ declared = gemset["gems"].find { |gem| gem["name"] == entry["gem"] }
645
+ reason = if declared.nil?
646
+ "extension source gem '#{entry["gem"]}' is not declared in the gemset"
647
+ else
648
+ extension_reason(entry, declared, gemset["gem_home"], entries[entry["account"]])
649
+ end
650
+ {"extension" => entry["gem"], "gem" => entry["gem"], "path" => entry["path"],
651
+ "account" => entry["account"], "status" => reason ? FAIL : PASS, "reason" => reason}
652
+ end
653
+ end
654
+
655
+ def extension_reason(entry, declared, gem_home, account_entry)
656
+ source_root = Molecules::ExtensionProjection.gem_source_root(
657
+ gem_home, entry["gem"], declared["version"]
658
+ )
659
+ return "gem '#{entry["gem"]}' #{declared["version"]} is not installed with a " \
660
+ "handbook/extensions tree in '#{gem_home}'; rerun ace-lab install" unless File.directory?(source_root)
661
+
662
+ receipt_path = File.join(entry["path"], Molecules::ExtensionProvisioner::RECEIPT_NAME)
663
+ unless File.file?(receipt_path)
664
+ return "extension projection receipt '#{receipt_path}' is missing; rerun ace-lab install"
665
+ end
666
+
667
+ begin
668
+ receipt = JSON.parse(File.read(receipt_path))
669
+ rescue JSON::ParserError
670
+ return "extension projection receipt '#{receipt_path}' is not valid JSON; rerun ace-lab install"
671
+ rescue Errno::EACCES, Errno::ENOENT
672
+ return "extension projection receipt '#{receipt_path}' is not readable; " \
673
+ "rerun ace-lab install"
674
+ end
675
+ if receipt["gem"] != entry["gem"] || receipt["version"] != declared["version"]
676
+ return "extension projection at '#{entry["path"]}' was projected from " \
677
+ "'#{receipt["gem"]} #{receipt["version"]}' but the manifest declares " \
678
+ "'#{entry["gem"]} #{declared["version"]}'; rerun ace-lab install"
679
+ end
680
+
681
+ expected = Molecules::ExtensionProjection.source_files(source_root)
682
+ listed = receipt.fetch("files", [])
683
+ if listed.sort != expected.keys.sort
684
+ return "extension projection at '#{entry["path"]}' lists different files than " \
685
+ "gem '#{entry["gem"]}' #{declared["version"]} carries; rerun ace-lab install"
686
+ end
687
+
688
+ # A destination that escapes the extensions directory through
689
+ # a symlinked component is refused before any comparison (the
690
+ # same validate_destination posture convergence applies).
691
+ begin
692
+ expected.each_key do |relative|
693
+ Molecules::ExtensionProjection.validate_destination!(entry["path"], relative)
694
+ end
695
+ rescue Ace::Lab::Error => e
696
+ return "#{e.message}; rerun ace-lab install"
697
+ end
698
+
699
+ mismatch = expected.find do |relative, source|
700
+ target = File.join(entry["path"], relative)
701
+ !File.file?(target) || !FileUtils.compare_file(source, target)
702
+ end
703
+ return "extension projection file '#{mismatch.first}' differs from gem " \
704
+ "'#{entry["gem"]}' #{declared["version"]}; rerun ace-lab install" if mismatch
705
+
706
+ # Declared-account readability (stat-based — the verifier
707
+ # does not run as the declared account): a projected file is
708
+ # readable when it is world-readable, or owner-readable AND
709
+ # owned by the declared account's uid.
710
+ unreadable = expected.keys.find do |relative|
711
+ unreadable_reason(File.join(entry["path"], relative), account_entry)
712
+ end
713
+ return "extension projection file '#{unreadable}' is not readable by the declared " \
714
+ "account '#{entry["account"]}'; rerun ace-lab install" if unreadable
715
+
716
+ nil
717
+ end
718
+
719
+ def unreadable_reason(path, account_entry)
720
+ stat = lstat(path)
721
+ return true if stat.nil? || !stat.file? || stat.symlink?
722
+
723
+ mode = stat.mode & 0o777
724
+ return false if (mode & 0o004) != 0 # world-readable
725
+ return false if (mode & 0o400) != 0 && account_entry && stat.uid == account_entry.uid
726
+
727
+ true
728
+ end
729
+
600
730
  def gem_version_reason(gem, versions, gem_home)
601
731
  return nil if versions.include?(gem["version"])
602
732
  if versions.empty?
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Lab
5
- VERSION = "0.13.1"
5
+ VERSION = "0.14.1"
6
6
  end
7
7
  end
data/lib/ace/lab.rb CHANGED
@@ -18,6 +18,7 @@ require_relative "lab/models/topology_entry"
18
18
  require_relative "lab/models/query_result"
19
19
  require_relative "lab/models/status_report"
20
20
  require_relative "lab/molecules/topology_loader"
21
+ require_relative "lab/molecules/project_locations"
21
22
  require_relative "lab/molecules/config_state_classifier"
22
23
  require_relative "lab/molecules/lab_state_scanner"
23
24
  require_relative "lab/molecules/caller_authorizer"
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-lab
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.13.1
4
+ version: 0.14.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-10-26 00:00:00.000000000 Z
10
+ date: 2026-11-01 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-support-cli
@@ -173,9 +173,19 @@ files:
173
173
  - lib/ace/lab/atoms/install_schema.rb
174
174
  - lib/ace/lab/atoms/public_projection.rb
175
175
  - lib/ace/lab/atoms/topology_schema.rb
176
+ - lib/ace/lab/auth.rb
177
+ - lib/ace/lab/auth/credential_handoff.rb
178
+ - lib/ace/lab/auth/lease_store.rb
179
+ - lib/ace/lab/auth/plugins/env_provider.rb
180
+ - lib/ace/lab/auth/plugins/forgejo_provider.rb
181
+ - lib/ace/lab/auth/plugins/github_provider.rb
182
+ - lib/ace/lab/auth/profile_schema.rb
183
+ - lib/ace/lab/auth/providers.rb
184
+ - lib/ace/lab/auth/service.rb
176
185
  - lib/ace/lab/authorization_path.rb
177
186
  - lib/ace/lab/cli.rb
178
187
  - lib/ace/lab/cli/commands/agents.rb
188
+ - lib/ace/lab/cli/commands/auth.rb
179
189
  - lib/ace/lab/cli/commands/install.rb
180
190
  - lib/ace/lab/cli/commands/prepare.rb
181
191
  - lib/ace/lab/cli/commands/project_add.rb
@@ -198,8 +208,11 @@ files:
198
208
  - lib/ace/lab/molecules/config_state_classifier.rb
199
209
  - lib/ace/lab/molecules/directory_provisioner.rb
200
210
  - lib/ace/lab/molecules/exact_resolver.rb
211
+ - lib/ace/lab/molecules/extension_projection.rb
212
+ - lib/ace/lab/molecules/extension_provisioner.rb
201
213
  - lib/ace/lab/molecules/forge_merge_config.rb
202
214
  - lib/ace/lab/molecules/forgejo_merge_adapter.rb
215
+ - lib/ace/lab/molecules/gem_payload_permissions.rb
203
216
  - lib/ace/lab/molecules/gemset_provisioner.rb
204
217
  - lib/ace/lab/molecules/grant_provisioner.rb
205
218
  - lib/ace/lab/molecules/grant_resolver.rb
@@ -209,6 +222,7 @@ files:
209
222
  - lib/ace/lab/molecules/key_provisioner.rb
210
223
  - lib/ace/lab/molecules/lab_state_scanner.rb
211
224
  - lib/ace/lab/molecules/overseer_provisioner.rb
225
+ - lib/ace/lab/molecules/project_locations.rb
212
226
  - lib/ace/lab/molecules/project_registry.rb
213
227
  - lib/ace/lab/molecules/repository_provisioner.rb
214
228
  - lib/ace/lab/molecules/role_verifier.rb