ace-lab 0.13.1 → 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: 347fa4748ebbcf945253d67f0e9f2f7de3cfd88a11abf134cf2aaf40128c9054
4
- data.tar.gz: 934f7b130e93180ab259a0ec4efa2598980af7ce3935628bf9e295e8b4689451
3
+ metadata.gz: 3c2b81c8fa6e9c3e9838a696ccb0813d28e2a18f7cd46f49e724cc3122cb7ed5
4
+ data.tar.gz: 1b5338586438ce21609dad591ddd740d1fe4277f7d997f4c84eb7a0a097c8f6a
5
5
  SHA512:
6
- metadata.gz: 1def278779e6c0593732ebb7872e0ee05aeaed5bacff5c6d623d13a15a1edea755cfe7bb6506edd11d2b22f8c71d8d64c3870bf9b35207395622f2f9c8ccd781
7
- data.tar.gz: 0b459c559a716459349d6b7b96ff3dfb521413c526593549e06a18d48a1b682d0d1e006e783ffa80eea9409dabf639341e8556dcf17dcd40fc2baec3c7da9259
6
+ metadata.gz: 803eb4a258b8776e6dc07e98ce77fe58f903236e68bfb486d1bb785b7d888128649a151d637b1b6e5f710a8574c5f1b13238148ae43c9cae83d9501543dbdb19
7
+ data.tar.gz: 327c1a76590d7b5695545c1003c4ad97d6638b73287524f0fc51464837f5e4524f915e1e3828f23b89e4d4fdac776d64531f5270ec375444dca5b8aa7d651a5f
data/CHANGELOG.md CHANGED
@@ -5,7 +5,100 @@ 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`.
9
102
 
10
103
  ## [0.13.1] - 2026-10-26
11
104
 
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 and required gem set (gad.2 gap #1, owner
327
- # spec 8xg.t.sbz.7): a sibling of `tools` declaring WHERE the
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) and WHICH gems at WHICH exact versions. Absent =
330
- # no runtime declared, behavior unchanged. Names must be real
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
- def normalize_gemset(raw)
335
- return {"gem_home" => nil, "gems" => []} if raw.nil?
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