ace-lab 0.13.0 → 0.14.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.
@@ -4,11 +4,15 @@ require_relative "../errors"
4
4
  require_relative "../molecules/install_manifest_loader"
5
5
  require_relative "../molecules/key_provisioner"
6
6
  require_relative "../molecules/role_verifier"
7
+ require_relative "../molecules/gemset_provisioner"
8
+ require_relative "../molecules/extension_provisioner"
7
9
  require_relative "../molecules/hitl_routes"
8
10
  require_relative "../molecules/caller_authorizer"
9
11
  require_relative "../atoms/topology_schema"
10
12
  require "open3"
11
13
  require "yaml"
14
+ require "json"
15
+ require "fileutils"
12
16
  require "date"
13
17
 
14
18
  module Ace
@@ -77,7 +81,7 @@ module Ace
77
81
  # @return [Hash] {ready:, machine:, manifest:,
78
82
  # authorization_path:, checks: {accounts:, directories:,
79
83
  # keys:, roles:, repositories:, grants:, registry:,
80
- # overseer:, adapters:, tools:}, counts: {pass:, fail:,
84
+ # overseer:, adapters:, tools:, gems:}, counts: {pass:, fail:,
81
85
  # unknown:}}
82
86
  # @raise [Ace::Lab::InvalidConfigurationError] missing,
83
87
  # untrusted or schema-invalid manifest — same classification
@@ -97,7 +101,9 @@ module Ace
97
101
  "registry" => check_registry(manifest),
98
102
  "overseer" => check_overseer(manifest),
99
103
  "adapters" => check_adapters(manifest, executors),
100
- "tools" => check_tools(manifest, executors)
104
+ "tools" => check_tools(manifest, executors),
105
+ "gems" => check_gems(manifest),
106
+ "extensions" => check_extensions(manifest, entries)
101
107
  }
102
108
  flat = checks.values.flatten(1)
103
109
  {
@@ -551,6 +557,198 @@ module Ace
551
557
  end
552
558
  end
553
559
 
560
+ # Declared gem set (gad.2 gap #1, owner spec 8xg.t.sbz.7): every
561
+ # declared gem must be installed at its EXACT declared version in
562
+ # the declared target runtime (GEM_HOME) — read-only, through the
563
+ # SAME probe the provisioner converges from
564
+ # (GemsetProvisioner#runtime_versions), so readiness and
565
+ # convergence can never drift. A missing, symlinked or
566
+ # non-directory runtime fails every declared gem as a dependency
567
+ # (nothing could have converged into it); a gem at a wrong or
568
+ # missing version is a classified failure with an
569
+ # `ace-lab install` remedy; an environment without the `gem`
570
+ # executable degrades to unknown (the offline posture) and does
571
+ # not block by itself. Extra undeclared versions are not judged:
572
+ # the manifest is the single owner of the DECLARED set.
573
+ def check_gems(manifest)
574
+ gemset = manifest["gemset"]
575
+ gems = gemset["gems"]
576
+ return [] if gems.empty?
577
+
578
+ gem_home = gemset["gem_home"]
579
+ runtime_reason = gem_runtime_reason(gem_home)
580
+ provisioner = Molecules::GemsetProvisioner.new(runner: @runner)
581
+ gems.map do |gem|
582
+ if runtime_reason
583
+ next {"gem" => gem["name"], "version" => gem["version"], "gem_home" => gem_home,
584
+ "status" => FAIL, "reason" => runtime_reason}
585
+ end
586
+
587
+ kind, versions_or_message = provisioner.runtime_versions(
588
+ name: gem["name"], gem_home: gem_home
589
+ )
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
+ end
595
+ {"gem" => gem["name"], "version" => gem["version"], "gem_home" => gem_home,
596
+ "status" => status, "reason" => reason}
597
+ end
598
+ end
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
+
730
+ def gem_version_reason(gem, versions, gem_home)
731
+ return nil if versions.include?(gem["version"])
732
+ if versions.empty?
733
+ "gem '#{gem["name"]}' is not installed in gem runtime '#{gem_home}'; " \
734
+ "rerun ace-lab install"
735
+ else
736
+ "gem '#{gem["name"]}' is installed as #{versions.join(", ")} in " \
737
+ "'#{gem_home}' but the manifest declares #{gem["version"]}; rerun ace-lab install"
738
+ end
739
+ end
740
+
741
+ def gem_runtime_reason(gem_home)
742
+ stat = lstat(gem_home)
743
+ if stat.nil?
744
+ "gem runtime '#{gem_home}' is missing; rerun ace-lab install"
745
+ elsif stat.symlink?
746
+ "gem runtime '#{gem_home}' is a symbolic link"
747
+ elsif !stat.directory?
748
+ "'#{gem_home}' exists but is not a directory"
749
+ end
750
+ end
751
+
554
752
  # Resolve an executable without running it and judge it for the
555
753
  # TARGET EXECUTORS (FINAL-LAB3): a name containing a separator
556
754
  # must be a file executable at that path; a bare name must
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Lab
5
- VERSION = "0.13.0"
5
+ VERSION = "0.14.0"
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.0
4
+ version: 0.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-10-20 00:00:00.000000000 Z
10
+ date: 2026-10-30 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/gemset_provisioner.rb
203
216
  - lib/ace/lab/molecules/grant_provisioner.rb
204
217
  - lib/ace/lab/molecules/grant_resolver.rb
205
218
  - lib/ace/lab/molecules/hitl_routes.rb
@@ -208,6 +221,7 @@ files:
208
221
  - lib/ace/lab/molecules/key_provisioner.rb
209
222
  - lib/ace/lab/molecules/lab_state_scanner.rb
210
223
  - lib/ace/lab/molecules/overseer_provisioner.rb
224
+ - lib/ace/lab/molecules/project_locations.rb
211
225
  - lib/ace/lab/molecules/project_registry.rb
212
226
  - lib/ace/lab/molecules/repository_provisioner.rb
213
227
  - lib/ace/lab/molecules/role_verifier.rb