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 +4 -4
- data/CHANGELOG.md +132 -1
- data/docs/usage.md +168 -4
- data/lib/ace/lab/atoms/install_schema.rb +135 -3
- 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/commands/prepare.rb +4 -2
- data/lib/ace/lab/cli/commands/verify.rb +2 -2
- 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/gemset_provisioner.rb +441 -0
- data/lib/ace/lab/molecules/project_locations.rb +62 -0
- data/lib/ace/lab/organisms/install.rb +13 -8
- data/lib/ace/lab/organisms/prepare.rb +44 -4
- data/lib/ace/lab/organisms/verify.rb +200 -2
- 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: 3c2b81c8fa6e9c3e9838a696ccb0813d28e2a18f7cd46f49e724cc3122cb7ed5
|
|
4
|
+
data.tar.gz: 1b5338586438ce21609dad591ddd740d1fe4277f7d997f4c84eb7a0a097c8f6a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
## [
|
|
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
|
|
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,
|
|
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
|