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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ff20c0d594202a649d5597257411b611d6faf6b66128fd8ff4ad0702989b6e16
4
- data.tar.gz: 1d6575916e526eec678ee39c9be5025f53653fcdfac27c8075692c936d3351bd
3
+ metadata.gz: 3c2b81c8fa6e9c3e9838a696ccb0813d28e2a18f7cd46f49e724cc3122cb7ed5
4
+ data.tar.gz: 1b5338586438ce21609dad591ddd740d1fe4277f7d997f4c84eb7a0a097c8f6a
5
5
  SHA512:
6
- metadata.gz: 32b6ee73258e1534a9ff91f2bc83d30ff4265b124aefcee056652dd0544ef770aba4036e055e282fc35ea1c2a993cfefa67f46480e82ecf4f294fbda5561deeb
7
- data.tar.gz: 786a3ca1af5fad657f722d3ea2c0ba525988c5461ed18a94023650e17f5b7870c15a754d71560ee442e78bd6527a42823d25dc297911666156bf57287072e9c7
6
+ metadata.gz: 803eb4a258b8776e6dc07e98ce77fe58f903236e68bfb486d1bb785b7d888128649a151d637b1b6e5f710a8574c5f1b13238148ae43c9cae83d9501543dbdb19
7
+ data.tar.gz: 327c1a76590d7b5695545c1003c4ad97d6638b73287524f0fc51464837f5e4524f915e1e3828f23b89e4d4fdac776d64531f5270ec375444dca5b8aa7d651a5f
data/CHANGELOG.md CHANGED
@@ -5,7 +5,138 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
- ## [Unreleased]
8
+ ## [0.14.0] - 2026-10-30
9
+
10
+ ### Fixed
11
+
12
+ - Forgejo wire contract (PR #398 re-review R1): issue/revoke use the
13
+ token-authenticated ADMIN endpoints (`/api/v1/admin/users/{account}/tokens`)
14
+ — the user endpoint requires BasicAuth and does not accept a PAT;
15
+ repository scoping uses the server's RepoTargetOption shape
16
+ (`owner/name` → owner+name objects) and an unidentifiable repo is refused
17
+ before any request, never an all-repositories token; the creation
18
+ response's granted scopes are validated against the request.
19
+ - GitHub scope semantics (re-review R8): requested subjects are validated
20
+ against the REAL installation-permission names (`contents`,
21
+ `pull_requests`, `members`, ...) — Forgejo-shaped subjects like
22
+ `write:repository` are refused instead of producing permission keys the
23
+ endpoint does not have; docs use correct examples.
24
+ - Lease durability (re-review R3/R4/R5): every record write is atomic
25
+ (tmp+fsync+rename under a stable sidecar write lock) and an unparseable
26
+ record surfaces as a visible `corrupt` recovery error in reconcile, never
27
+ a silently skipped entry; a record is born `preparing` and becomes
28
+ `issued` only after a complete usable handoff (a killed issuer leaves a
29
+ recoverable record, never a phantom live grant); the deterministic remote
30
+ identity (Forgejo token name) is written at creation, and a retry after
31
+ an unresolved issue settles the predecessor through it (404 =
32
+ already-invalid) or refuses — never a blind second mint.
33
+ - exec environment isolation (re-review R6): the child runs through
34
+ `env -i` with an ALLOWLISTED environment (PATH/HOME &c. plus the leased
35
+ credential reference) — ambient credential overrides (GH_TOKEN, GIT_*
36
+ auth, ...) are never inherited past the profile's scope; verified with a
37
+ real spawned child.
38
+ - Auth lease resilience (task 8xs.t.pu0.f): `LeaseStore#find_live`/`#live?`
39
+ treated a revoke-pending record (an interrupted issue or a lost revoke) as
40
+ a LIVE grant — a retry after an interrupted issue returned the dead grant
41
+ instead of minting a fresh one. Only an ISSUED record inside its deadlines
42
+ is live; the interrupted record stays a visible open item for reconcile.
43
+ - Auth service hardening (PR #398 review round 8xthaf): the provider and its
44
+ expiry capability are resolved BEFORE any lease record exists (no orphaned
45
+ records); an empty repository intersection is refused before issuing (an
46
+ empty `repositories` payload means ALL repos provider-side — the github and
47
+ forgejo plugins refuse it too); TTL must be a positive integer; concurrent
48
+ identical requests serialize through a per-correlation lock (one token, not
49
+ two); `find_live` is deadline-aware (an expired grant is never returned as
50
+ live); exec refuses a live lease without its delivered credential; revoke
51
+ and reconcile degrade cleanly when the profile was removed from the
52
+ authorization document; a revoke-pending lease whose credential is gone can
53
+ be closed by an explicit operator `revoke --force` (forced-terminal, never
54
+ a claimed revocation); exec preserves and EXITS with the child's real exit
55
+ code; status never prints `token_ref`; provider Authorization headers live
56
+ in 0600 ephemeral files (`curl -H @file`), never in argv visible to `ps`;
57
+ github scopes are correctly oriented `level:subject` (`write:repository` →
58
+ `{"repository" => "write"}` installation permission).
59
+ - Installer robustness: `gemset_provisioner` validates every parsed version
60
+ with `Gem::Version` itself (platform-suffixed strings fail the probe closed)
61
+ and normalizes default-gem `default: V` markers from real `gem list` output;
62
+ `ProjectLocations` reads the lab configuration by explicit path (no
63
+ `Dir.chdir`) and returns nil for nil/blank project path encodings instead
64
+ of raising.
65
+ - `ace-lab auth` no longer advertises `--format` (Changed, re-review 8xttao06):
66
+ the reports are always a single structured JSON document — the option
67
+ previously parsed and was silently ignored (no table form exists for auth
68
+ reports), so callers following the lab-wide `--format json` convention now
69
+ get a documented hard error instead of a silently ignored flag.
70
+
71
+ ### Added
72
+
73
+ - `ace-lab auth request|status|exec|revoke|reconcile` (task 8xs.t.pu0.7):
74
+ short-lived role-scoped access. Host-owned profiles in the trusted
75
+ authorization document's `auth:` section; one minimal lease record (token
76
+ REFERENCE, deadlines, cleanup outcome — never secrets); ephemeral
77
+ credential handoff under /run/lab/credentials; idempotent requests per live
78
+ correlation; scope = profile∩request (never widened by task/config edits);
79
+ TTL refusals instead of clipping; exec preserves exit codes with both
80
+ process and cleanup outcomes; revoke idempotent, network outcomes stay
81
+ revoke-pending; reconcile resolves interrupted cycles.
82
+ - Auth plugins (explicit registry): forgejo (per-lease scoped token on the
83
+ role account, exact-id revoke), github (App JWT → installation token with
84
+ real expires_at, DELETE revoke, 401 = already invalid), env (named
85
+ process-scoped values; standalone env cannot declare remote expiry).
86
+ - Canonical owner map (docs/lab-flow.md) and ADR-034 amendment for the
87
+ Captain-approved lease record and handoff channel.
88
+ - `ProjectLocations` internal molecule (task 8xs.t.pu0.a): the lab-level
89
+ start reads the configured projects' canonical locations from the raw
90
+ topology cascade document — never derived from a public id or repo name.
91
+ - Gem-set dependency closure convergence: missing transitive runtime dependencies
92
+ (e.g. `json`) are installed at the parent requirement even when the parent version
93
+ already figures in `gem list`; unsatisfiable dependencies are classified failures
94
+ (task 8xs.t.pu0.0).
95
+ - Newer versions of a declared gem beside the declared one are removed from the
96
+ declared runtime during convergence, and `verify` fails while any newer sibling
97
+ would win CLI activation.
98
+ - `gemset.extensions` manifest entries: harness extension projections from the
99
+ installed gem's exact declared version (`ExtensionProvisioner`), receipt-based
100
+ pruning, `loaded_version`/`restart_required` reporting, and read-only
101
+ re-derivation in `verify`.
102
+
103
+ ## [0.13.1] - 2026-10-26
104
+
105
+ ### Added
106
+
107
+ - Gem-set installation into the target lab runtime (gad.2 acceptance
108
+ gap #1, owner spec 8xg.t.sbz.7, L8 install group): the trusted
109
+ install manifest (still schema v1) gains an optional `gemset` section
110
+ — a sibling of `tools` — declaring the target runtime (`gem_home`,
111
+ an absolute path converged as GEM_HOME and GEM_PATH) and the required
112
+ gem set (`name` + exact `version`; unique names, versions validated
113
+ as real Gem versions, so ranges and "latest" are rejected at load).
114
+ A manifest without the section behaves exactly as before.
115
+ `ace-lab prepare` (and therefore `ace-lab install`) converges the gem
116
+ set into the declared runtime idempotently through a new
117
+ `GemsetProvisioner` molecule: exact version present = no-op
118
+ (`exists`), missing = installed, different version present = updated
119
+ (the declared version installed; RubyGems keeps versions side by
120
+ side and verify judges the declared set). The gem subprocesses run
121
+ with `GEM_HOME`/`GEM_PATH` pinned to the runtime through the same
122
+ argv runner seam as every other prepare step — with the invoking
123
+ process's bundler context stripped (`RUBYOPT`, `RUBYLIB`,
124
+ `BUNDLE_*`/`BUNDLER_*`), so `bundle exec ace-lab prepare` converges
125
+ the LAB runtime instead of its own bundle. The runtime directory is
126
+ created only with administrator privileges (the AccountProvisioner
127
+ seam); a symlink or non-directory runtime is refused (the
128
+ DirectoryProvisioner takeover posture); runtime failures fail every
129
+ gem item as a dependency and gate the install's projects and grants
130
+ like any other machine-level failure. `--dry-run` predicts
131
+ `would_install`/`would_update`/`exists` read-only and predicts the
132
+ wet privilege failure. The install report echoes the declared gemset
133
+ under `gemset`. `ace-lab verify` gained a matching read-only `gems`
134
+ check — every declared gem must be installed at its exact version in
135
+ the declared runtime, judged through the SAME probe the provisioner
136
+ converges from (a missing `gem` executable reports `unknown`, the
137
+ offline posture). `project add` stays project-scoped: gem
138
+ installation is machine-level provisioning and is deliberately not
139
+ added there. (PR #77)
9
140
 
10
141
 
11
142
  ## [0.13.0] - 2026-10-20
data/docs/usage.md CHANGED
@@ -170,14 +170,27 @@ projects: # optional (consumed by ace-lab install)
170
170
  grants: # optional: trusted-tier principal grants
171
171
  ace-overseer: # principal → declared project ids
172
172
  - atlas
173
+ gemset: # optional (gad.2): target runtime + required gem set
174
+ gem_home: /opt/lab/gems # absolute; converged as GEM_HOME and GEM_PATH
175
+ gems: # exact versions, unique names
176
+ - name: ace-lab
177
+ version: 0.13.0 # must be an exact gem version (no ranges)
178
+ extensions: # optional (8xs.t.pu0.0): harness extension projections
179
+ - gem: ace-wake # must be declared in gems above
180
+ path: /Users/overseer/.pi/agent/extensions # absolute target directory
181
+ account: lab-overseer # must be a declared account (the loading executor)
173
182
  ```
174
183
 
175
184
  Statuses: accounts `created|exists|would_create|failed`; directories
176
185
  `created|exists|mode_fixed|would_create|would_fix_mode|failed`; keys
177
186
  `created|exists|would_create|would_repair_public|would_fix_mode|failed`
178
- (with the public `fingerprint`); roles `verified|failed`. `ok` in the
187
+ (with the public `fingerprint`); roles `verified|failed`; gems
188
+ `installed|updated|exists|would_install|would_update|failed` (one item
189
+ per declared gem). `ok` in the
179
190
  JSON report means zero failed items. Key/role items whose account
180
- failed are reported as dependency failures instead of being attempted.
191
+ failed are reported as dependency failures instead of being attempted;
192
+ gem items fail as dependencies when the declared runtime did not
193
+ prepare.
181
194
 
182
195
  `--dry-run` is a faithful prediction of the wet run (`would_*`
183
196
  statuses, zero mutations): keys and roles of an account the account
@@ -299,6 +312,55 @@ Behavior and guarantees:
299
312
  group, or other. A readable file behind an unsearchable directory
300
313
  (e.g. 0644 inside a 0700 directory owned by someone else) is
301
314
  `failed`, not `verified`. ACLs remain out of scope.
315
+ - **Gem-set convergence (gad.2).** When the manifest declares a
316
+ `gemset`, prepare converges the declared gem set into the declared
317
+ target runtime (`gem_home`): a gem whose exact declared version is
318
+ already present is a no-op (`exists`), a missing gem is installed
319
+ (`installed`), a gem present at a different version is updated (the
320
+ declared version is installed; RubyGems keeps versions side by side
321
+ and verify judges the declared set). The `gem` subprocesses run with
322
+ `GEM_HOME`/`GEM_PATH` pinned to the runtime (through the POSIX `env`
323
+ command, argv arrays only) and with the invoking process's bundler
324
+ context stripped (`RUBYOPT`, `RUBYLIB`, `BUNDLE_*`/`BUNDLER_*`), so
325
+ a `bundle exec ace-lab prepare` converges the LAB runtime, never its
326
+ own bundle. The runtime directory is created only with
327
+ administrator privileges; a symlink or non-directory at `gem_home`
328
+ is refused (takeover posture). Installs fetch from the `gem`
329
+ command's configured sources (provisioning network access, like
330
+ cloning a declared remote) with `--no-document`. `--dry-run` is a
331
+ faithful prediction: the read-only probe runs, statuses are
332
+ `would_install`/`would_update`/`exists`, nothing is created or
333
+ installed, and a runtime that would need privileges predicts the wet
334
+ failure.
335
+ - **Dependency closure and CLI activation (8xs.t.pu0.0).** Presence of
336
+ the exact declared version is necessary but not sufficient. Prepare
337
+ also walks each declared gem's runtime dependency closure in the
338
+ target runtime and installs missing transitive dependencies at the
339
+ parent's requirement — a gem whose version already figures in
340
+ `gem list` but misses a transitive dependency (the manually repaired
341
+ `json` class of gap) is repaired by `ace-lab install`, and an
342
+ unsatisfiable dependency is a classified failure, never readiness.
343
+ A NEWER version of a declared gem sitting beside the declared one
344
+ would win RubyGems activation and silently take over the runtime's
345
+ CLI; convergence removes it from the declared runtime (the manifest
346
+ is the single owner of the declared set) and `verify` fails the gem
347
+ while any newer sibling remains. `verify` re-derives both facts with
348
+ the same probes convergence uses — readiness means the closure
349
+ resolves AND the declared version wins activation.
350
+ - **Extension projections (8xs.t.pu0.0).** Each `gemset.extensions`
351
+ entry projects the installed gem's `handbook/extensions` tree — at
352
+ its EXACT declared version, never the checkout and never a newer
353
+ sibling — into the declared target directory. The projection is
354
+ receipt-based: only files recorded in the target's
355
+ `.ace-lab-extension-projection.json` receipt are ever removed, so
356
+ user-authored extensions in the same directory stay untouched. The
357
+ report carries `loaded_version` (the version the running harness
358
+ still holds) and `restart_required` — a changed projection means the
359
+ executor's processes hold the old extension, and the installer
360
+ REPORTS the restart need; it never restarts an executor on its own.
361
+ `verify` re-derives the projection from the installed gem tree and
362
+ the receipt: a missing receipt, a version mismatch, or a differing
363
+ file fails readiness.
302
364
  - **Exit codes.** A wet run with failed items prints the report, then
303
365
  exits non-zero enumerating each `<section>/<item>: <message>`;
304
366
  `--dry-run` exits 0 whenever a report renders (conflicts appear as
@@ -408,8 +470,9 @@ existing molecules in a fixed order:
408
470
  present) must not grant projects the manifest does not declare: a
409
471
  ready or foreign lab is refused with a classified error and zero
410
472
  writes, identically in wet runs and `--dry-run` (prediction parity).
411
- 2. **prepare (L8.1)** — accounts → directories → keys → roles, exactly
412
- as `ace-lab prepare` (same organism, same per-item report).
473
+ 2. **prepare (L8.1)** — accounts → directories → keys → roles → gems,
474
+ exactly as `ace-lab prepare` (same organism, same per-item report;
475
+ the declared gem set converges into the declared target runtime).
413
476
  3. **project add (L8.2) per declared project** — repository, private
414
477
  `_overseer`, project registry, exactly as `ace-lab project add`.
415
478
  Projects are isolated: one failing project does not block the
@@ -486,6 +549,7 @@ never taken over, never cleaned:
486
549
  | --- | --- | --- |
487
550
  | declared directory | symlink, non-directory, or foreign ownership | step fails with a resolve-manually message; projects and grants are gated as dependency failures |
488
551
  | key pair | foreign-owned key files or `.ssh`, non-regular file | step fails with a resolve-manually message |
552
+ | gem set | symlinked or non-directory runtime, missing privileges, or a failed probe/install | every gem item fails with a classified message; projects and grants are gated as dependency failures |
489
553
  | project repository | non-empty non-Git directory, or a Git repository with a different origin | step fails: "refusing to take over a foreign location" / "refusing to overwrite an existing registration"; the other projects still run (isolation) |
490
554
  | grants | trusted document granting projects outside the manifest, or a document that fails verification/parsing | the whole install refuses before any mutation ("refusing to install over a foreign lab"), wet and in `--dry-run` alike |
491
555
 
@@ -533,6 +597,9 @@ a concrete reason (never a bare boolean):
533
597
  | grants | the trusted document is present and verifiable, and every manifest principal's grants resolve through the real read-side mechanics (`CallerAuthorizer`) to the declared projects |
534
598
  | registry | each project is registered in its `.ace/lab/config.yml` (visibility through the configuration cascade) |
535
599
  | overseer | `.ace-tasks/_overseer/` exists and is ignored by Git (`git check-ignore`) |
600
+ | adapters | configuration is a mapping; a declared local command resolves (probed for the target executors); `kind: hitl` routes are installed in every project's `.ace/hitl/config.yml` and satisfy the runtime loader's rules |
601
+ | tools | each required tool resolves to an executable the target executors can run (presence + permission probe, no execution) |
602
+ | gems | each declared gem is installed at its EXACT declared version in the declared target runtime — judged through the same read-only probe prepare converges from; a missing runtime, a missing gem or a version mismatch blocks readiness (a missing `gem` executable reports `unknown`, the offline posture) |
536
603
 
537
604
  The overall verdict is `ready` only when no check failed. A failure
538
605
  never hides healthy data: the full report renders first (table or
@@ -572,3 +639,100 @@ The installed authority/admission/journal/command path is removed in L1: the
572
639
  and `command execute` commands no longer exist. The local file-based flow does
573
640
  not use them. The Forgejo merge adapter and its trusted configuration loader
574
641
  remain as provider building blocks; the local flow does not invoke them.
642
+
643
+ ## Auth (8xs.t.pu0.7)
644
+
645
+ `ace-lab auth request|status|exec|revoke|reconcile` — short-lived, role-scoped
646
+ access for delegated work. The host owns the profile declarations in the SAME
647
+ trusted document (`/etc/lab/ace-lab/authorization.yml`, `auth:` section —
648
+ converged by install); the existing `principals` map stays a visibility map and
649
+ grants no issuance. A project/coworker config only NAMES a profile — it can
650
+ never widen one.
651
+
652
+ ```yaml
653
+ auth:
654
+ leases_dir: /var/lib/lab/ace-lab/auth/leases
655
+ credentials_dir: /run/lab/credentials
656
+ profiles:
657
+ developer-forgejo:
658
+ provider: forgejo # forgejo | github | env
659
+ roles: [developer]
660
+ principals: [lab-overseer] # who may REQUEST (empty = any verified account)
661
+ repos: [cs3b/ace] # owner/name — Forgejo RepoTargetOption targets
662
+ scopes: ["write:repository"]
663
+ ttl_minutes: 30 # default 30; max 60 unless the host sets otherwise
664
+ expiry_mode: host-revoke # provider | host-revoke | process
665
+ issuer: # an ADMIN PAT: the token-authenticated admin API
666
+ url: https://forge.example.com
667
+ account: lab-role-dev
668
+ token_file: /etc/lab/ace-lab/secrets/forgejo.admin.token
669
+ ```
670
+
671
+ ```bash
672
+ ace-lab auth request --profile developer-forgejo --role developer \
673
+ --task 8xs.t.pu0.7 --assignment job-7 --repo cs3b/ace
674
+ ace-lab auth status [LEASE] # read-only readiness view
675
+ ace-lab auth exec LEASE --cmd bin/ace-test --cmd ace-lab
676
+ ace-lab auth revoke LEASE # idempotent
677
+ ace-lab auth reconcile # resolves interrupted cycles
678
+ ```
679
+
680
+ Contract highlights:
681
+
682
+ - The lease record (`/var/lib/lab/ace-lab/auth/leases/<id>.json`) is the ONE
683
+ minimal record: correlation, provider token REFERENCE (the DETERMINISTIC
684
+ remote identity, written at creation), deadlines, cleanup outcome. No
685
+ secrets, no task progress. The ephemeral handoff
686
+ (`/run/lab/credentials/<lease>-<recipient>`, 0400) carries the reference the
687
+ child reads (`ACE_LAB_CREDENTIAL_FILE`); no command ever prints a token.
688
+ A record is born `preparing` and becomes `issued` only after a COMPLETE,
689
+ usable handoff — an issuer killed mid-flight leaves a recoverable record,
690
+ never a phantom live grant; every record write is atomic (tmp+fsync+rename)
691
+ and an unparseable record surfaces as a visible `corrupt` recovery error in
692
+ `reconcile`, never a silently skipped entry.
693
+ - Request is idempotent per the live task/assignment/profile/role correlation
694
+ (concurrent requests mint one token under a per-correlation lock); the
695
+ granted scope is the intersection of the host profile and the request — a
696
+ request may narrow, never widen, and editing the task or project config
697
+ grants nothing. The calling ACCOUNT is verified against the profile's
698
+ principals. A retry after an UNRESOLVED issue outcome never blind-mints a
699
+ second remote token: the predecessor is settled first through its
700
+ deterministic reference (Forgejo: the `ace-lab-<lease id>` token name, where
701
+ a 404 settles as already-invalid), and an unresolvable outcome refuses the
702
+ retry until reconcile or the provider's native expiry.
703
+ - TTL: default 30m, max 60m unless the host sets an explicit profile limit;
704
+ exceeding the limit is a REFUSAL, never a silent clip. Status always shows
705
+ the actual use-until and — separately — the provider-guaranteed expiry.
706
+ - exec preserves the child's exit code and EXITS with it (`ace-lab auth exec`
707
+ returns the child's code after printing the report); on process success a
708
+ cleanup failure is itself an error and the report carries both outcomes;
709
+ one exec at a time per lease; normal end/cancel revokes immediately, a
710
+ SIGKILL'd executor is cleaned up no later than the recorded deadline (host
711
+ scheduler) and `reconcile` resolves interrupted cycles. The child runs with
712
+ an ALLOWLISTED environment (PATH/HOME &c. plus the leased credential
713
+ reference) — ambient credential overrides (GH_TOKEN, GIT_* auth, ...) are
714
+ never inherited past the profile's scope.
715
+ - Refusals are non-zero with a reason and the escalation addressee; there is
716
+ no fallback identity. A network-shaped revoke stays visibly
717
+ `revoke-pending` — a revocation is never claimed before the provider
718
+ confirms it. A revoke-pending lease whose credential file is already gone
719
+ can be closed by an explicit operator decision (`ace-lab auth revoke
720
+ LEASE --force` → forced-terminal with the unconfirmed outcome recorded),
721
+ never silently marked revoked.
722
+ - Plugins (explicit registry, like the ace-git providers): `forgejo` (one
723
+ scoped token per lease through the TOKEN-AUTHENTICATED ADMIN API
724
+ (`/api/v1/admin/users/{account}/tokens`; the user endpoint requires
725
+ BasicAuth, not a PAT) with owner/name RepoTargetOption repository targets —
726
+ a repo without an owner is refused, never an all-repositories token — and
727
+ the response's granted scopes validated against the request; revoke deletes
728
+ that exact token name, 404 = already invalid), `github` (App JWT →
729
+ installation token for chosen repos/permissions with the real `expires_at`;
730
+ DELETE to revoke; 401 = already invalid), `env` (named values of configured
731
+ safe sources, process-scoped only — a standalone env profile cannot declare
732
+ remote expiry/revoke). Scopes are `level:subject` strings, validated per
733
+ provider: forgejo uses its own scope strings (`write:repository`); github
734
+ validates subjects against the REAL installation-permission names
735
+ (`write:contents`, `read:pull_requests`, `members`, ...) and refuses
736
+ Forgejo-shaped subjects like `write:repository`. Provider Authorization
737
+ headers travel in 0600 ephemeral files (`curl -H @file`), never in argv
738
+ visible to `ps`.
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "rubygems/version"
4
+
3
5
  module Ace
4
6
  module Lab
5
7
  module Atoms
@@ -13,7 +15,7 @@ module Ace
13
15
  SCHEMA_VERSION = 1
14
16
  TOP_LEVEL_KEYS =
15
17
  %w[schema_version machine accounts directories keys roles projects grants
16
- adapters tools].freeze
18
+ adapters tools gemset].freeze
17
19
  ENTRY_KEYS = {
18
20
  "accounts" => %w[username role create home],
19
21
  "directories" => %w[path owner mode],
@@ -29,6 +31,10 @@ module Ace
29
31
  DEFAULT_MODE = "0755"
30
32
  DEFAULT_KEY_TYPE = "ed25519"
31
33
  KEY_TYPES = [DEFAULT_KEY_TYPE].freeze
34
+ GEMSET_KEYS = %w[gem_home gems extensions].freeze
35
+ GEM_KEYS = %w[name version].freeze
36
+ GEM_NAME_PATTERN = /\A[A-Za-z0-9._-]+\z/
37
+ EXTENSION_KEYS = %w[gem path account].freeze
32
38
 
33
39
  class << self
34
40
  # @param document [Object] parsed YAML document
@@ -71,7 +77,8 @@ module Ace
71
77
  "projects" => projects,
72
78
  "grants" => normalize_grants(document["grants"], projects.map { |p| p["id"] }),
73
79
  "adapters" => normalize_adapters(document["adapters"]),
74
- "tools" => normalize_tools(document["tools"])
80
+ "tools" => normalize_tools(document["tools"]),
81
+ "gemset" => normalize_gemset(document["gemset"], accounts.map { |a| a["username"] })
75
82
  }
76
83
  end
77
84
 
@@ -317,6 +324,131 @@ module Ace
317
324
  tools.map(&:strip).uniq
318
325
  end
319
326
 
327
+ # Target lab runtime, required gem set and harness extension
328
+ # projections (gad.2 gap #1, owner spec 8xg.t.sbz.7; task
329
+ # 8xs.t.pu0.0): a sibling of `tools` declaring WHERE the
330
+ # lab's gems must live (`gem_home`, converged as GEM_HOME/
331
+ # GEM_PATH), WHICH gems at WHICH exact versions, and WHICH
332
+ # installed gems' handbook/extensions trees are projected
333
+ # into WHICH target extension directories. Absent = no
334
+ # runtime declared, behavior unchanged. Names must be real
335
+ # gem names; versions must parse as exact Gem versions (the
336
+ # same parser the runtime uses) — ranges and bare names are
337
+ # rejected so convergence is always exact-version. An
338
+ # extension entry must reference a declared gem and a
339
+ # declared account (the executor whose harness loads the
340
+ # projection) and carries an absolute target path.
341
+ def normalize_gemset(raw, usernames)
342
+ return {"gem_home" => nil, "gems" => [], "extensions" => []} if raw.nil?
343
+ unless raw.is_a?(Hash)
344
+ raise_invalid "gemset must be a mapping"
345
+ end
346
+
347
+ unknown = raw.keys - GEMSET_KEYS
348
+ unless unknown.empty?
349
+ raise_invalid "gemset has unknown key '#{unknown.sort.first}'"
350
+ end
351
+
352
+ gem_home = raw["gem_home"]
353
+ unless gem_home.is_a?(String) && !gem_home.strip.empty? && absolute_path?(gem_home.strip)
354
+ raise_invalid "gemset gem_home is required and must be an absolute path"
355
+ end
356
+
357
+ {"gem_home" => gem_home.strip, "gems" => normalize_gems(raw["gems"]),
358
+ "extensions" => normalize_extensions(raw["extensions"], raw["gems"], usernames)}
359
+ end
360
+
361
+ # Harness extension projections: each entry names a DECLARED
362
+ # gem (the projection source — the gem's handbook/extensions
363
+ # tree at its exact declared version), an absolute target
364
+ # path (the harness extension directory) and the declared
365
+ # account whose executor loads it. The manifest stays the
366
+ # single owner of the declaration; the projection itself is
367
+ # re-derived from the installed gem on every run.
368
+ def normalize_extensions(raw, gems, usernames)
369
+ entries = raw.nil? ? [] : raw
370
+ unless entries.is_a?(Array)
371
+ raise_invalid "gemset extensions must be a list"
372
+ end
373
+ entries.each_with_index do |entry, index|
374
+ unless entry.is_a?(Hash)
375
+ raise_invalid "gemset extensions entry ##{index} must be a mapping"
376
+ end
377
+ unknown = entry.keys - EXTENSION_KEYS
378
+ unless unknown.empty?
379
+ raise_invalid "gemset extensions entry ##{index} has unknown key '#{unknown.sort.first}'"
380
+ end
381
+ end
382
+
383
+ declared = (gems || []).filter_map do |gem|
384
+ gem.is_a?(Hash) && gem["name"].is_a?(String) ? gem["name"].strip : nil
385
+ end
386
+ entries.map do |entry|
387
+ gem = required_string(entry, "gemset extensions", "gem")
388
+ unless declared.include?(gem)
389
+ raise_invalid_entry("gemset extensions",
390
+ "gem '#{gem}' must be declared in gemset gems before it can project an extension")
391
+ end
392
+
393
+ path = required_string(entry, "gemset extensions", "path")
394
+ unless absolute_path?(path)
395
+ raise_invalid_entry("gemset extensions", "path must be an absolute path")
396
+ end
397
+
398
+ account = required_string(entry, "gemset extensions", "account")
399
+ unless usernames.include?(account)
400
+ raise_invalid_entry("gemset extensions",
401
+ "account must reference a declared account")
402
+ end
403
+
404
+ {"gem" => gem, "path" => path, "account" => account}
405
+ end.tap do |extensions|
406
+ duplicates_of(extensions.map { |extension| extension["path"] }) do |_path|
407
+ raise_invalid_entry("gemset extensions",
408
+ "path is declared more than once — a duplicate projection path would overwrite " \
409
+ "another entry's receipt")
410
+ end
411
+ end
412
+ end
413
+
414
+ def normalize_gems(raw)
415
+ gems = raw.nil? ? [] : raw
416
+ unless gems.is_a?(Array)
417
+ raise_invalid "gemset gems must be a list"
418
+ end
419
+ gems.each_with_index do |entry, index|
420
+ unless entry.is_a?(Hash)
421
+ raise_invalid "gemset gems entry ##{index} must be a mapping"
422
+ end
423
+ unknown = entry.keys - GEM_KEYS
424
+ unless unknown.empty?
425
+ raise_invalid "gemset gems entry ##{index} has unknown key '#{unknown.sort.first}'"
426
+ end
427
+ end
428
+
429
+ gems.map do |entry|
430
+ name = entry["name"]
431
+ unless name.is_a?(String) && name.strip =~ GEM_NAME_PATTERN
432
+ raise_invalid_entry("gemset gems", "name is required and must be a gem name")
433
+ end
434
+ version = entry["version"]
435
+ unless version.is_a?(String) && !version.strip.empty?
436
+ raise_invalid_entry("gemset gems", "version is required and must be an exact version")
437
+ end
438
+ begin
439
+ Gem::Version.new(version.strip)
440
+ rescue ArgumentError
441
+ raise_invalid_entry("gemset gems", "version must be an exact gem version")
442
+ end
443
+
444
+ {"name" => name.strip, "version" => version.strip}
445
+ end.tap do |gems|
446
+ duplicates_of(gems.map { |gem| gem["name"] }) do |_name|
447
+ raise_invalid_entry("gemset gems", "name is declared more than once")
448
+ end
449
+ end
450
+ end
451
+
320
452
  def optional_string(entry, section_name, field)
321
453
  value = entry[field]
322
454
  if !value.nil? && (!value.is_a?(String) || value.strip.empty?)
@@ -330,7 +462,7 @@ module Ace
330
462
  unless value.is_a?(String) && !value.strip.empty?
331
463
  raise_invalid_entry(section_name, "#{field} is required and must be a non-empty string")
332
464
  end
333
- value
465
+ value.strip
334
466
  end
335
467
 
336
468
  def normalize_mode(raw)
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Ace
6
+ module Lab
7
+ module Auth
8
+ # Ephemeral secret handoff (task 8xs.t.pu0.7): the leased secret
9
+ # is written ONCE under the credentials dir (/run/lab/credentials
10
+ # by default), in a per-lease, per-recipient file, mode 0400,
11
+ # verified against symlink takeover; the child receives the file
12
+ # REFERENCE (path), never the value in argv or the serialized
13
+ # report. Removal after the lease is idempotent.
14
+ class CredentialHandoff
15
+ def initialize(credentials_dir, recipient: )
16
+ @credentials_dir = credentials_dir
17
+ @recipient = recipient
18
+ end
19
+
20
+ def path(lease_id)
21
+ File.join(@credentials_dir, "#{lease_id}-#{@recipient}")
22
+ end
23
+
24
+ # @return [String] the credential file path (the reference)
25
+ def deliver(lease_id, secret)
26
+ FileUtils.mkdir_p(@credentials_dir)
27
+ target = path(lease_id)
28
+ raise Error, "credential handoff path is a symlink; refusing" if File.symlink?(target)
29
+
30
+ temporary = "#{target}.#{Process.pid}.tmp"
31
+ File.write(temporary, secret.to_s)
32
+ FileUtils.chmod(0o400, temporary)
33
+ File.rename(temporary, target)
34
+ target
35
+ end
36
+
37
+ def clear(lease_id)
38
+ target = path(lease_id)
39
+ File.delete(target) if File.file?(target) && !File.symlink?(target)
40
+ :cleared
41
+ rescue Errno::ENOENT
42
+ :cleared
43
+ end
44
+ end
45
+ end
46
+ end
47
+ end