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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +104 -0
- data/docs/usage.md +130 -0
- data/lib/ace/lab/atoms/install_schema.rb +72 -11
- data/lib/ace/lab/auth/credential_handoff.rb +47 -0
- data/lib/ace/lab/auth/lease_store.rb +212 -0
- data/lib/ace/lab/auth/plugins/env_provider.rb +60 -0
- data/lib/ace/lab/auth/plugins/forgejo_provider.rb +189 -0
- data/lib/ace/lab/auth/plugins/github_provider.rb +207 -0
- data/lib/ace/lab/auth/profile_schema.rb +152 -0
- data/lib/ace/lab/auth/providers.rb +66 -0
- data/lib/ace/lab/auth/service.rb +457 -0
- data/lib/ace/lab/auth.rb +25 -0
- data/lib/ace/lab/cli/commands/auth.rb +99 -0
- data/lib/ace/lab/cli.rb +4 -1
- data/lib/ace/lab/molecules/extension_projection.rb +91 -0
- data/lib/ace/lab/molecules/extension_provisioner.rb +226 -0
- data/lib/ace/lab/molecules/gem_payload_permissions.rb +174 -0
- data/lib/ace/lab/molecules/gemset_provisioner.rb +229 -4
- data/lib/ace/lab/molecules/project_locations.rb +62 -0
- data/lib/ace/lab/organisms/prepare.rb +28 -2
- data/lib/ace/lab/organisms/verify.rb +139 -9
- data/lib/ace/lab/version.rb +1 -1
- data/lib/ace/lab.rb +1 -0
- metadata +16 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a3291bf80fb998a68f1e58c0427dfcaa77e8392761b329db1a637700037f3dfb
|
|
4
|
+
data.tar.gz: 657770601dfa66713d0f0b0de8fb6b9434348a236c486a111f46b8e8df75784c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 3e3cb3595c7042859c5bd9b7dbe721657a614fffbeeac2a6c422d7607ca0ad0510460f68085a802eaacb71a60c723ba98301d3e44a13e7090f36d4e88c766069
|
|
7
|
+
data.tar.gz: e6dd9400e2424f955b178c394c042ce9b1a4aa42f7a854c6bdbd6965d9d43038fccc6d2a732a2e92896d5ec4f3737672578dc478e9996ea503e5606290bfcc92
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,110 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.14.1] - 2026-10-31
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- Converge public installed gem payload permissions for shared operational
|
|
15
|
+
runtimes, including resolved dependencies and native extension outputs.
|
|
16
|
+
Validate cached archive identity and archive-owned payload paths before changes; preserve unknown files
|
|
17
|
+
and reject symlink escapes, with identical dry-run inventory validation.
|
|
18
|
+
|
|
19
|
+
## [0.14.0] - 2026-10-30
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- Forgejo wire contract (PR #398 re-review R1): issue/revoke use the
|
|
24
|
+
token-authenticated ADMIN endpoints (`/api/v1/admin/users/{account}/tokens`)
|
|
25
|
+
— the user endpoint requires BasicAuth and does not accept a PAT;
|
|
26
|
+
repository scoping uses the server's RepoTargetOption shape
|
|
27
|
+
(`owner/name` → owner+name objects) and an unidentifiable repo is refused
|
|
28
|
+
before any request, never an all-repositories token; the creation
|
|
29
|
+
response's granted scopes are validated against the request.
|
|
30
|
+
- GitHub scope semantics (re-review R8): requested subjects are validated
|
|
31
|
+
against the REAL installation-permission names (`contents`,
|
|
32
|
+
`pull_requests`, `members`, ...) — Forgejo-shaped subjects like
|
|
33
|
+
`write:repository` are refused instead of producing permission keys the
|
|
34
|
+
endpoint does not have; docs use correct examples.
|
|
35
|
+
- Lease durability (re-review R3/R4/R5): every record write is atomic
|
|
36
|
+
(tmp+fsync+rename under a stable sidecar write lock) and an unparseable
|
|
37
|
+
record surfaces as a visible `corrupt` recovery error in reconcile, never
|
|
38
|
+
a silently skipped entry; a record is born `preparing` and becomes
|
|
39
|
+
`issued` only after a complete usable handoff (a killed issuer leaves a
|
|
40
|
+
recoverable record, never a phantom live grant); the deterministic remote
|
|
41
|
+
identity (Forgejo token name) is written at creation, and a retry after
|
|
42
|
+
an unresolved issue settles the predecessor through it (404 =
|
|
43
|
+
already-invalid) or refuses — never a blind second mint.
|
|
44
|
+
- exec environment isolation (re-review R6): the child runs through
|
|
45
|
+
`env -i` with an ALLOWLISTED environment (PATH/HOME &c. plus the leased
|
|
46
|
+
credential reference) — ambient credential overrides (GH_TOKEN, GIT_*
|
|
47
|
+
auth, ...) are never inherited past the profile's scope; verified with a
|
|
48
|
+
real spawned child.
|
|
49
|
+
- Auth lease resilience (task 8xs.t.pu0.f): `LeaseStore#find_live`/`#live?`
|
|
50
|
+
treated a revoke-pending record (an interrupted issue or a lost revoke) as
|
|
51
|
+
a LIVE grant — a retry after an interrupted issue returned the dead grant
|
|
52
|
+
instead of minting a fresh one. Only an ISSUED record inside its deadlines
|
|
53
|
+
is live; the interrupted record stays a visible open item for reconcile.
|
|
54
|
+
- Auth service hardening (PR #398 review round 8xthaf): the provider and its
|
|
55
|
+
expiry capability are resolved BEFORE any lease record exists (no orphaned
|
|
56
|
+
records); an empty repository intersection is refused before issuing (an
|
|
57
|
+
empty `repositories` payload means ALL repos provider-side — the github and
|
|
58
|
+
forgejo plugins refuse it too); TTL must be a positive integer; concurrent
|
|
59
|
+
identical requests serialize through a per-correlation lock (one token, not
|
|
60
|
+
two); `find_live` is deadline-aware (an expired grant is never returned as
|
|
61
|
+
live); exec refuses a live lease without its delivered credential; revoke
|
|
62
|
+
and reconcile degrade cleanly when the profile was removed from the
|
|
63
|
+
authorization document; a revoke-pending lease whose credential is gone can
|
|
64
|
+
be closed by an explicit operator `revoke --force` (forced-terminal, never
|
|
65
|
+
a claimed revocation); exec preserves and EXITS with the child's real exit
|
|
66
|
+
code; status never prints `token_ref`; provider Authorization headers live
|
|
67
|
+
in 0600 ephemeral files (`curl -H @file`), never in argv visible to `ps`;
|
|
68
|
+
github scopes are correctly oriented `level:subject` (`write:repository` →
|
|
69
|
+
`{"repository" => "write"}` installation permission).
|
|
70
|
+
- Installer robustness: `gemset_provisioner` validates every parsed version
|
|
71
|
+
with `Gem::Version` itself (platform-suffixed strings fail the probe closed)
|
|
72
|
+
and normalizes default-gem `default: V` markers from real `gem list` output;
|
|
73
|
+
`ProjectLocations` reads the lab configuration by explicit path (no
|
|
74
|
+
`Dir.chdir`) and returns nil for nil/blank project path encodings instead
|
|
75
|
+
of raising.
|
|
76
|
+
- `ace-lab auth` no longer advertises `--format` (Changed, re-review 8xttao06):
|
|
77
|
+
the reports are always a single structured JSON document — the option
|
|
78
|
+
previously parsed and was silently ignored (no table form exists for auth
|
|
79
|
+
reports), so callers following the lab-wide `--format json` convention now
|
|
80
|
+
get a documented hard error instead of a silently ignored flag.
|
|
81
|
+
|
|
82
|
+
### Added
|
|
83
|
+
|
|
84
|
+
- `ace-lab auth request|status|exec|revoke|reconcile` (task 8xs.t.pu0.7):
|
|
85
|
+
short-lived role-scoped access. Host-owned profiles in the trusted
|
|
86
|
+
authorization document's `auth:` section; one minimal lease record (token
|
|
87
|
+
REFERENCE, deadlines, cleanup outcome — never secrets); ephemeral
|
|
88
|
+
credential handoff under /run/lab/credentials; idempotent requests per live
|
|
89
|
+
correlation; scope = profile∩request (never widened by task/config edits);
|
|
90
|
+
TTL refusals instead of clipping; exec preserves exit codes with both
|
|
91
|
+
process and cleanup outcomes; revoke idempotent, network outcomes stay
|
|
92
|
+
revoke-pending; reconcile resolves interrupted cycles.
|
|
93
|
+
- Auth plugins (explicit registry): forgejo (per-lease scoped token on the
|
|
94
|
+
role account, exact-id revoke), github (App JWT → installation token with
|
|
95
|
+
real expires_at, DELETE revoke, 401 = already invalid), env (named
|
|
96
|
+
process-scoped values; standalone env cannot declare remote expiry).
|
|
97
|
+
- Canonical owner map (docs/lab-flow.md) and ADR-034 amendment for the
|
|
98
|
+
Captain-approved lease record and handoff channel.
|
|
99
|
+
- `ProjectLocations` internal molecule (task 8xs.t.pu0.a): the lab-level
|
|
100
|
+
start reads the configured projects' canonical locations from the raw
|
|
101
|
+
topology cascade document — never derived from a public id or repo name.
|
|
102
|
+
- Gem-set dependency closure convergence: missing transitive runtime dependencies
|
|
103
|
+
(e.g. `json`) are installed at the parent requirement even when the parent version
|
|
104
|
+
already figures in `gem list`; unsatisfiable dependencies are classified failures
|
|
105
|
+
(task 8xs.t.pu0.0).
|
|
106
|
+
- Newer versions of a declared gem beside the declared one are removed from the
|
|
107
|
+
declared runtime during convergence, and `verify` fails while any newer sibling
|
|
108
|
+
would win CLI activation.
|
|
109
|
+
- `gemset.extensions` manifest entries: harness extension projections from the
|
|
110
|
+
installed gem's exact declared version (`ExtensionProvisioner`), receipt-based
|
|
111
|
+
pruning, `loaded_version`/`restart_required` reporting, and read-only
|
|
112
|
+
re-derivation in `verify`.
|
|
113
|
+
|
|
10
114
|
## [0.13.1] - 2026-10-26
|
|
11
115
|
|
|
12
116
|
### Added
|
data/docs/usage.md
CHANGED
|
@@ -175,6 +175,10 @@ gemset: # optional (gad.2): target runtime + required gem s
|
|
|
175
175
|
gems: # exact versions, unique names
|
|
176
176
|
- name: ace-lab
|
|
177
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)
|
|
178
182
|
```
|
|
179
183
|
|
|
180
184
|
Statuses: accounts `created|exists|would_create|failed`; directories
|
|
@@ -328,6 +332,35 @@ Behavior and guarantees:
|
|
|
328
332
|
`would_install`/`would_update`/`exists`, nothing is created or
|
|
329
333
|
installed, and a runtime that would need privileges predicts the wet
|
|
330
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.
|
|
331
364
|
- **Exit codes.** A wet run with failed items prints the report, then
|
|
332
365
|
exits non-zero enumerating each `<section>/<item>: <message>`;
|
|
333
366
|
`--dry-run` exits 0 whenever a report renders (conflicts appear as
|
|
@@ -606,3 +639,100 @@ The installed authority/admission/journal/command path is removed in L1: the
|
|
|
606
639
|
and `command execute` commands no longer exist. The local file-based flow does
|
|
607
640
|
not use them. The Forgejo merge adapter and its trusted configuration loader
|
|
608
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`.
|
|
@@ -31,9 +31,10 @@ module Ace
|
|
|
31
31
|
DEFAULT_MODE = "0755"
|
|
32
32
|
DEFAULT_KEY_TYPE = "ed25519"
|
|
33
33
|
KEY_TYPES = [DEFAULT_KEY_TYPE].freeze
|
|
34
|
-
GEMSET_KEYS = %w[gem_home gems].freeze
|
|
34
|
+
GEMSET_KEYS = %w[gem_home gems extensions].freeze
|
|
35
35
|
GEM_KEYS = %w[name version].freeze
|
|
36
36
|
GEM_NAME_PATTERN = /\A[A-Za-z0-9._-]+\z/
|
|
37
|
+
EXTENSION_KEYS = %w[gem path account].freeze
|
|
37
38
|
|
|
38
39
|
class << self
|
|
39
40
|
# @param document [Object] parsed YAML document
|
|
@@ -77,7 +78,7 @@ module Ace
|
|
|
77
78
|
"grants" => normalize_grants(document["grants"], projects.map { |p| p["id"] }),
|
|
78
79
|
"adapters" => normalize_adapters(document["adapters"]),
|
|
79
80
|
"tools" => normalize_tools(document["tools"]),
|
|
80
|
-
"gemset" => normalize_gemset(document["gemset"])
|
|
81
|
+
"gemset" => normalize_gemset(document["gemset"], accounts.map { |a| a["username"] })
|
|
81
82
|
}
|
|
82
83
|
end
|
|
83
84
|
|
|
@@ -323,16 +324,22 @@ module Ace
|
|
|
323
324
|
tools.map(&:strip).uniq
|
|
324
325
|
end
|
|
325
326
|
|
|
326
|
-
# Target lab runtime
|
|
327
|
-
# spec 8xg.t.sbz.7
|
|
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
|
|
328
330
|
# lab's gems must live (`gem_home`, converged as GEM_HOME/
|
|
329
|
-
# GEM_PATH)
|
|
330
|
-
#
|
|
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
|
|
331
335
|
# gem names; versions must parse as exact Gem versions (the
|
|
332
336
|
# same parser the runtime uses) — ranges and bare names are
|
|
333
|
-
# rejected so convergence is always exact-version.
|
|
334
|
-
|
|
335
|
-
|
|
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?
|
|
336
343
|
unless raw.is_a?(Hash)
|
|
337
344
|
raise_invalid "gemset must be a mapping"
|
|
338
345
|
end
|
|
@@ -347,7 +354,61 @@ module Ace
|
|
|
347
354
|
raise_invalid "gemset gem_home is required and must be an absolute path"
|
|
348
355
|
end
|
|
349
356
|
|
|
350
|
-
{"gem_home" => gem_home.strip, "gems" => normalize_gems(raw["gems"])
|
|
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
|
|
351
412
|
end
|
|
352
413
|
|
|
353
414
|
def normalize_gems(raw)
|
|
@@ -401,7 +462,7 @@ module Ace
|
|
|
401
462
|
unless value.is_a?(String) && !value.strip.empty?
|
|
402
463
|
raise_invalid_entry(section_name, "#{field} is required and must be a non-empty string")
|
|
403
464
|
end
|
|
404
|
-
value
|
|
465
|
+
value.strip
|
|
405
466
|
end
|
|
406
467
|
|
|
407
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
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "json"
|
|
5
|
+
require "fileutils"
|
|
6
|
+
require "digest"
|
|
7
|
+
require "time"
|
|
8
|
+
|
|
9
|
+
module Ace
|
|
10
|
+
module Lab
|
|
11
|
+
module Auth
|
|
12
|
+
# The ONE owner of the minimal lease record (task 8xs.t.pu0.7):
|
|
13
|
+
# one JSON file per lease under the trusted leases dir (default
|
|
14
|
+
# /var/lib/lab/ace-lab/auth/leases), private to the issuer. The
|
|
15
|
+
# record carries the correlation (task/assignment/profile/role/
|
|
16
|
+
# principal), the provider TOKEN REFERENCE (never the secret
|
|
17
|
+
# value), the deadlines and the cleanup outcome — never task
|
|
18
|
+
# progress, queues, or another tool's state.
|
|
19
|
+
#
|
|
20
|
+
# Lifecycle: a record is born PREPARING (issue in flight — a hard
|
|
21
|
+
# process death in that window leaves a recoverable record, never
|
|
22
|
+
# a phantom "live" grant); it becomes ISSUED only after a
|
|
23
|
+
# complete, usable credential handoff; an unresolved remote
|
|
24
|
+
# outcome (lost response, lost revoke) is REVOKE-PENDING until
|
|
25
|
+
# reconciled; REVOKED is terminal. Every write is atomic
|
|
26
|
+
# (temporary file + fsync + rename) under a STABLE sidecar write
|
|
27
|
+
# lock — a crash mid-write can never truncate or half-replace a
|
|
28
|
+
# record, and an unparseable record surfaces as a visible CORRUPT
|
|
29
|
+
# marker instead of silently vanishing from the inventory.
|
|
30
|
+
class LeaseStore
|
|
31
|
+
STATES = %w[preparing issued revoke-pending revoked].freeze
|
|
32
|
+
|
|
33
|
+
attr_reader :dir
|
|
34
|
+
|
|
35
|
+
def initialize(dir)
|
|
36
|
+
@dir = dir
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param id [String, nil] caller-generated lease id (so the
|
|
40
|
+
# provider's deterministic token_ref can be computed from it
|
|
41
|
+
# and written in the SAME atomic creation write)
|
|
42
|
+
# @param token_ref [String, nil] the provider's DETERMINISTIC
|
|
43
|
+
# remote identity for this lease, written at creation so an
|
|
44
|
+
# interrupted issue can still be reconciled against the
|
|
45
|
+
# remote side (the response may never have arrived).
|
|
46
|
+
# @return [Hash] the created (PREPARING) lease record
|
|
47
|
+
def create(profile:, role:, principal:, task:, assignment:, repos:,
|
|
48
|
+
use_until:, provider_expiry:, token_ref: nil, id: nil)
|
|
49
|
+
lease = {"id" => id || "lease-#{SecureRandom.hex(8)}",
|
|
50
|
+
"profile" => profile, "role" => role, "principal" => principal,
|
|
51
|
+
"task" => task, "assignment" => assignment, "repos" => repos.sort,
|
|
52
|
+
"state" => "preparing", "requested_at" => now,
|
|
53
|
+
"use_until" => use_until, "provider_expiry" => provider_expiry,
|
|
54
|
+
"token_ref" => token_ref, "cleanup" => nil}
|
|
55
|
+
write(lease)
|
|
56
|
+
lease
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Idempotency: the LIVE lease for the same task/assignment/
|
|
60
|
+
# profile/role correlation, if any. Only an ISSUED record is
|
|
61
|
+
# live: preparing (issue in flight or dead mid-issue) and
|
|
62
|
+
# revoke-pending (unresolved remote outcome) records are never
|
|
63
|
+
# returned — recovery settles them instead. A finished or
|
|
64
|
+
# scope-changed correlation does not match, and
|
|
65
|
+
# DEADLINE-AWARENESS is part of the match: an issued record
|
|
66
|
+
# past its use_until/provider expiry is not returned, so a
|
|
67
|
+
# retry mints a fresh grant while reconcile retires the expired
|
|
68
|
+
# one — an expired grant is never handed back as "live".
|
|
69
|
+
def find_live(task:, assignment:, profile:, role:)
|
|
70
|
+
all.filter_map do |lease|
|
|
71
|
+
next unless lease["task"] == task && lease["assignment"] == assignment &&
|
|
72
|
+
lease["profile"] == profile && lease["role"] == role &&
|
|
73
|
+
lease["state"] == "issued"
|
|
74
|
+
lease if live?(lease)
|
|
75
|
+
end.min_by { |lease| lease["requested_at"].to_s }
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def load(id)
|
|
79
|
+
path = lease_path(id)
|
|
80
|
+
raise Error, "unknown lease '#{id}'" unless File.file?(path)
|
|
81
|
+
|
|
82
|
+
JSON.parse(File.read(path))
|
|
83
|
+
rescue JSON::ParserError
|
|
84
|
+
raise Error, "lease record '#{id}' is unreadable"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The full inventory, including CORRUPT records as explicit
|
|
88
|
+
# markers: a record that cannot be parsed is a visible recovery
|
|
89
|
+
# problem (the remote token may still be live) — never a
|
|
90
|
+
# silently skipped entry that disappears from reconcile.
|
|
91
|
+
def all
|
|
92
|
+
return [] unless Dir.exist?(@dir)
|
|
93
|
+
|
|
94
|
+
Dir.glob(File.join(@dir, "*.json")).sort.filter_map do |path|
|
|
95
|
+
JSON.parse(File.read(path))
|
|
96
|
+
rescue Errno::EACCES
|
|
97
|
+
nil
|
|
98
|
+
rescue JSON::ParserError
|
|
99
|
+
{"id" => File.basename(path, ".json"), "state" => nil,
|
|
100
|
+
"corrupt" => true, "_path" => path}
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Atomic update under a STABLE sidecar write lock: the record is
|
|
105
|
+
# read, modified and replaced via temporary-file + fsync +
|
|
106
|
+
# rename. A crash at any point leaves either the old or the new
|
|
107
|
+
# complete record — never a truncated file — and the lock
|
|
108
|
+
# survives the replacement (it is not held on the record inode).
|
|
109
|
+
def update(id)
|
|
110
|
+
path = lease_path(id)
|
|
111
|
+
with_write_lock(id) do
|
|
112
|
+
lease = begin
|
|
113
|
+
JSON.parse(File.read(path))
|
|
114
|
+
rescue JSON::ParserError, Errno::ENOENT => e
|
|
115
|
+
# A corrupt/missing record is a named store failure, never a
|
|
116
|
+
# raw parser error escaping through the update seam.
|
|
117
|
+
raise Error, "lease '#{id}' cannot be read at #{path}: #{e.class} #{e.message}"
|
|
118
|
+
end
|
|
119
|
+
yield(lease)
|
|
120
|
+
atomic_write(path, lease)
|
|
121
|
+
lease
|
|
122
|
+
end
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def write(lease)
|
|
126
|
+
atomic_write(lease_path(lease["id"]), lease)
|
|
127
|
+
lease
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def with_write_lock(id)
|
|
131
|
+
FileUtils.mkdir_p(@dir)
|
|
132
|
+
File.open(File.join(@dir, "#{id}.write.lock"), File::CREAT | File::RDWR) do |lock|
|
|
133
|
+
lock.flock(File::LOCK_EX)
|
|
134
|
+
yield
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# One request flow at a time per correlation (task/assignment/
|
|
139
|
+
# profile/role): a BLOCKING exclusive lock on the correlation's
|
|
140
|
+
# lockfile so the find_live + create + issue sequence is atomic
|
|
141
|
+
# against a concurrent identical request — the second caller
|
|
142
|
+
# waits and then returns the first caller's live lease.
|
|
143
|
+
def with_correlation_lock(task:, assignment:, profile:, role:)
|
|
144
|
+
FileUtils.mkdir_p(@dir)
|
|
145
|
+
key = Digest::SHA256.hexdigest([task, assignment, profile, role].join("\e"))
|
|
146
|
+
File.open(File.join(@dir, "correlation-#{key}.lock"), File::CREAT | File::RDWR) do |lock|
|
|
147
|
+
lock.flock(File::LOCK_EX)
|
|
148
|
+
yield
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# One exec at a time per lease: an exclusive lock on the lease's
|
|
153
|
+
# exec lockfile, non-blocking — a parallel exec is a refusal.
|
|
154
|
+
def with_exec_lock(id)
|
|
155
|
+
FileUtils.mkdir_p(@dir)
|
|
156
|
+
File.open(File.join(@dir, "#{id}.exec.lock"), File::CREAT | File::RDWR) do |lock|
|
|
157
|
+
unless lock.flock(File::LOCK_EX | File::LOCK_NB)
|
|
158
|
+
raise Error, "lease '#{id}' already has a running exec"
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
yield
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# Usable NOW: only an ISSUED record inside its deadlines. A
|
|
166
|
+
# preparing record was never delivered (or the process died
|
|
167
|
+
# mid-issue) and a revoke-pending record has no valid handoff
|
|
168
|
+
# or is mid-revocation — neither is ever usable or "live".
|
|
169
|
+
def live?(lease, at: Time.now)
|
|
170
|
+
return false unless lease["state"] == "issued"
|
|
171
|
+
return false if lease["use_until"] && Time.parse(lease["use_until"]) <= at
|
|
172
|
+
if lease["provider_expiry"] && Time.parse(lease["provider_expiry"]) <= at
|
|
173
|
+
return false
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
true
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
private
|
|
180
|
+
|
|
181
|
+
def atomic_write(path, lease)
|
|
182
|
+
FileUtils.mkdir_p(@dir)
|
|
183
|
+
temporary = "#{path}.#{Process.pid}.tmp"
|
|
184
|
+
File.open(temporary, "w") do |file|
|
|
185
|
+
file.write(JSON.pretty_generate(lease))
|
|
186
|
+
file.flush
|
|
187
|
+
file.fsync
|
|
188
|
+
end
|
|
189
|
+
# 0600 BEFORE the rename: the record never exists at the target
|
|
190
|
+
# path with umask-default permissions, even for an instant.
|
|
191
|
+
FileUtils.chmod(0o600, temporary)
|
|
192
|
+
begin
|
|
193
|
+
File.rename(temporary, path)
|
|
194
|
+
rescue Errno::ENOENT
|
|
195
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
196
|
+
File.rename(temporary, path)
|
|
197
|
+
end
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def lease_path(id)
|
|
201
|
+
raise Error, "invalid lease id" unless id.to_s.match?(/\Alease-[a-f0-9]{16}\z/)
|
|
202
|
+
|
|
203
|
+
File.join(@dir, "#{id}.json")
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def now
|
|
207
|
+
Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
208
|
+
end
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|