ace-lab 0.14.1 → 0.15.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: a3291bf80fb998a68f1e58c0427dfcaa77e8392761b329db1a637700037f3dfb
4
- data.tar.gz: 657770601dfa66713d0f0b0de8fb6b9434348a236c486a111f46b8e8df75784c
3
+ metadata.gz: 3244c9ffaf1f5854e26967c6df9c6fa19f7b60a517acaa51a80a5788fbd31031
4
+ data.tar.gz: 52ef532f9422e18473e4921dfe200e1c45943f62a49bc0f7768abba9bba4df00
5
5
  SHA512:
6
- metadata.gz: 3e3cb3595c7042859c5bd9b7dbe721657a614fffbeeac2a6c422d7607ca0ad0510460f68085a802eaacb71a60c723ba98301d3e44a13e7090f36d4e88c766069
7
- data.tar.gz: e6dd9400e2424f955b178c394c042ce9b1a4aa42f7a854c6bdbd6965d9d43038fccc6d2a732a2e92896d5ec4f3737672578dc478e9996ea503e5606290bfcc92
6
+ metadata.gz: c271c58273db132886b0a74c1024a4757116f7369957f2eecb8dd325e7dfb7d855dd920983f31e5bd51193abeb488aae9f3feb9393dda28f0a0c723e68f3566d
7
+ data.tar.gz: 685fd0944e9485a54fa179c04d934310d476708382532731d0dafa46af50eb7fa9f318e7db83283825b00f00bfe7a3de3f46486112be977cd3fe9eeabfa18ac1
data/CHANGELOG.md CHANGED
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.15.0] - 2026-11-10
11
+
12
+ ### Added
13
+
14
+ - Configure ordinary role processes with project-scoped environment files; installation and verification require no session issuer service. Converge protected extension permissions and runtime dependency closure. Execute temporary grants in ordinary process groups with deadlines, cleanup, principal checks and durable interrupted-execution reconciliation; use repository-scoped Forgejo token APIs.
15
+
10
16
  ## [0.14.1] - 2026-10-31
11
17
 
12
18
  ### Fixed
data/docs/usage.md CHANGED
@@ -108,9 +108,8 @@ conflated:
108
108
  carries the path-bearing message.
109
109
 
110
110
  Runtime *configuration* is echoed (project/user `runtime/config.yml`, then the
111
- `ACE_RUNTIME` environment variable) with its value and origin. No runtime
112
- detection and no `ace-runtime` dependency: runtime availability is
113
- `ace-herdr status`'s dimension.
111
+ `ACE_RUNTIME` environment variable) with its value and origin. This status
112
+ does not detect runtime availability; that is `ace-herdr status`'s dimension.
114
113
 
115
114
  Local state covers `.ace-tasks/`, `.ace-tasks/_overseer/` (presence only —
116
115
  private, never enumerated), `.ace-local/assign/`, `.ace-local/hitl/`,
@@ -167,6 +166,9 @@ projects: # optional (consumed by ace-lab install)
167
166
  path: /Users/ace/lab/atlas # absolute, unique
168
167
  remote: git@forge:atlas/atlas.git # optional clone source
169
168
  label: Atlas platform # optional display label
169
+ environment_files: # optional role-specific JSON environment sources
170
+ worker: /home/mo/.config/ace/atlas-worker.json
171
+ integrator: /home/mo/.config/ace/atlas-integrator.json
170
172
  grants: # optional: trusted-tier principal grants
171
173
  ace-overseer: # principal → declared project ids
172
174
  - atlas
@@ -175,12 +177,39 @@ gemset: # optional (gad.2): target runtime + required gem s
175
177
  gems: # exact versions, unique names
176
178
  - name: ace-lab
177
179
  version: 0.13.0 # must be an exact gem version (no ranges)
180
+ - name: ace-wake
181
+ version: 0.2.0
178
182
  extensions: # optional (8xs.t.pu0.0): harness extension projections
179
183
  - gem: ace-wake # must be declared in gems above
180
- path: /Users/overseer/.pi/agent/extensions # absolute target directory
184
+ path: /opt/lab/pi-agent/extensions # root-controlled operational directory
181
185
  account: lab-overseer # must be a declared account (the loading executor)
186
+ session_runtime: # explicit installed Pi launch configuration
187
+ node: /opt/ace/node/bin/node
188
+ pi: /opt/ace/pi/node_modules/@mariozechner/pi-coding-agent/dist/cli.js
189
+ path: [/usr/bin] # additional root-controlled executable directories
190
+ provider: zai
191
+ model: glm-5.3
192
+ credentials: /etc/lab/ace-lab/pi-auth.json # private Pi auth JSON; no inline secrets
182
193
  ```
183
194
 
195
+ `session_runtime` selects the installed Pi command; it does not install Node,
196
+ Pi or model credentials. The launcher starts an ordinary child in the selected
197
+ project worktree with its explicit environment and `ACE_TOOLCHAIN=installed`.
198
+ Standing instructions come from the selected role and resolve through installed
199
+ ACE. Missing runtime is a readiness failure, never a checkout fallback.
200
+
201
+ `projects[].environment_files` maps a role to a private JSON environment file;
202
+ `coordinator.environment_files` does the same for the lab Overseer. Files must be
203
+ mode 0600 and owned by the launch user or root. Only the selected project's role
204
+ file is loaded. Values can point at a private provider login or contain an
205
+ already repo-scoped token. The forge enforces the token's repository access;
206
+ selecting environment variables does not narrow an administrator token.
207
+
208
+ This is a worktree/process/environment boundary between trusted agents. It does
209
+ not create a systemd service, change OS users, or protect against a malicious
210
+ process running as the same account. The lab Overseer delegates bootstrap;
211
+ project Overseers delegate implementation, review and integration.
212
+
184
213
  Statuses: accounts `created|exists|would_create|failed`; directories
185
214
  `created|exists|mode_fixed|would_create|would_fix_mode|failed`; keys
186
215
  `created|exists|would_create|would_repair_public|would_fix_mode|failed`
@@ -350,8 +379,18 @@ Behavior and guarantees:
350
379
  - **Extension projections (8xs.t.pu0.0).** Each `gemset.extensions`
351
380
  entry projects the installed gem's `handbook/extensions` tree — at
352
381
  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
382
+ sibling — into the declared target directory. In `ACE_TOOLCHAIN=installed`
383
+ mode, the projection is root-owned; its source, destination,
384
+ receipt and existing ancestors must be root-controlled and not writable
385
+ by group or others. Configure the harness to load this protected location;
386
+ projecting files alone does not change the harness's extension discovery.
387
+ Installed Linux lab Pi starts use one shared `ace-wake` projection and
388
+ refuse an ambiguous manifest with multiple `ace-wake` entries. They also
389
+ require the Herdr Pi integration alongside it. Outside a configured lab,
390
+ installed Pi retains normal extension discovery. Configured lab launches
391
+ preserve the installed toolchain and load the declared operational extension
392
+ paths. No issuer socket or systemd invocation is needed for readiness.
393
+ The projection is receipt-based: only files recorded in the target's
355
394
  `.ace-lab-extension-projection.json` receipt are ever removed, so
356
395
  user-authored extensions in the same directory stay untouched. The
357
396
  report carries `loaded_version` (the version the running harness
@@ -662,10 +701,11 @@ auth:
662
701
  scopes: ["write:repository"]
663
702
  ttl_minutes: 30 # default 30; max 60 unless the host sets otherwise
664
703
  expiry_mode: host-revoke # provider | host-revoke | process
665
- issuer: # an ADMIN PAT: the token-authenticated admin API
704
+ issuer: # host-only administrator Basic credential
666
705
  url: https://forge.example.com
667
706
  account: lab-role-dev
668
- token_file: /etc/lab/ace-lab/secrets/forgejo.admin.token
707
+ admin_username: forge-admin
708
+ password_file: /etc/lab/ace-lab/secrets/forgejo.admin.password
669
709
  ```
670
710
 
671
711
  ```bash
@@ -707,11 +747,17 @@ Contract highlights:
707
747
  returns the child's code after printing the report); on process success a
708
748
  cleanup failure is itself an error and the report carries both outcomes;
709
749
  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
750
+ ordinary execution enforces its deadline and terminates its process group.
751
+ There is no background revoke scheduler. After a hard process death, run
752
+ `reconcile` before retrying; unconfirmed execution or revocation stays visibly
753
+ unresolved. Forgejo grants have no claimed native expiry; GitHub expiry is
754
+ accepted only when the provider returns a valid deadline. The child runs with
755
+ an ALLOWLISTED environment (PATH/HOME, ACE_TOOLCHAIN, GEM_HOME, GEM_PATH,
756
+ and the leased credential
713
757
  reference) — ambient credential overrides (GH_TOKEN, GIT_* auth, ...) are
714
758
  never inherited past the profile's scope.
759
+ When the selected toolchain is `installed`, its runtime paths also take
760
+ precedence over caller and provider environment values.
715
761
  - Refusals are non-zero with a reason and the escalation addressee; there is
716
762
  no fallback identity. A network-shaped revoke stays visibly
717
763
  `revoke-pending` — a revocation is never claimed before the provider
@@ -720,12 +766,15 @@ Contract highlights:
720
766
  LEASE --force` → forced-terminal with the unconfirmed outcome recorded),
721
767
  never silently marked revoked.
722
768
  - 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 —
769
+ scoped token per lease through `/api/v1/users/{account}/tokens`, using
770
+ administrator Basic authentication and a `Sudo` header. The setup process
771
+ reads the configured source credential and passes only the resulting
772
+ project-scoped token to the child. Remote
773
+ issuers require HTTPS; plain HTTP is accepted only on loopback. Tokens use
774
+ owner/name RepoTargetOption repository targets —
726
775
  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 →
776
+ the response must confirm exactly the requested scopes and repositories.
777
+ Revoke deletes that exact token name; 404 = already invalid), `github` (App JWT →
729
778
  installation token for chosen repos/permissions with the real `expires_at`;
730
779
  DELETE to revoke; 401 = already invalid), `env` (named values of configured
731
780
  safe sources, process-scoped only — a standalone env profile cannot declare
@@ -736,3 +785,16 @@ Contract highlights:
736
785
  Forgejo-shaped subjects like `write:repository`. Provider Authorization
737
786
  headers travel in 0600 ephemeral files (`curl -H @file`), never in argv
738
787
  visible to `ps`.
788
+
789
+ During a Forgejo `auth exec` on Linux, the child receives a private temporary `fj`
790
+ login and Git askpass bound to the issuer origin and role account. Ambient
791
+ credential helpers are disabled for that child. The projection is removed
792
+ on exit or by lease cleanup after an interrupted executor. Other platforms
793
+ refuse this projection rather than falling back to ambient credentials.
794
+
795
+ A pre-existing project-scoped token supplied through a role environment file is
796
+ static configuration: ending the session removes its local projection, not the
797
+ remote token shared by other sessions. Temporary grants created by `auth request`
798
+ use `auth exec` and its normal revoke/reconcile lifecycle. Secret values belong
799
+ only to private files or the child environment, never prompts, argv, task records
800
+ or reports.
@@ -15,13 +15,13 @@ module Ace
15
15
  SCHEMA_VERSION = 1
16
16
  TOP_LEVEL_KEYS =
17
17
  %w[schema_version machine accounts directories keys roles projects grants
18
- adapters tools gemset].freeze
18
+ adapters tools gemset session_runtime coordinator].freeze
19
19
  ENTRY_KEYS = {
20
20
  "accounts" => %w[username role create home],
21
21
  "directories" => %w[path owner mode],
22
22
  "keys" => %w[account type path],
23
23
  "roles" => %w[id account instructions],
24
- "projects" => %w[id path remote label],
24
+ "projects" => %w[id path remote label environment_files],
25
25
  "adapters" => %w[id kind command config]
26
26
  }.freeze
27
27
  USERNAME_PATTERN = /\A[a-z_][a-z0-9_-]{0,31}\z/
@@ -66,6 +66,7 @@ module Ace
66
66
  accounts = normalize_accounts(document["accounts"], os)
67
67
  usernames = accounts.map { |account| account["username"] }
68
68
  projects = normalize_projects(document["projects"])
69
+ coordinator = normalize_coordinator(document["coordinator"], projects)
69
70
 
70
71
  {
71
72
  "schema_version" => SCHEMA_VERSION,
@@ -75,15 +76,78 @@ module Ace
75
76
  "keys" => normalize_keys(document["keys"], accounts),
76
77
  "roles" => normalize_roles(document["roles"], usernames),
77
78
  "projects" => projects,
78
- "grants" => normalize_grants(document["grants"], projects.map { |p| p["id"] }),
79
+ "coordinator" => coordinator,
80
+ "grants" => normalize_grants(document["grants"], projects.map { |p| p["id"] } + [coordinator&.fetch("id")].compact),
79
81
  "adapters" => normalize_adapters(document["adapters"]),
80
82
  "tools" => normalize_tools(document["tools"]),
81
- "gemset" => normalize_gemset(document["gemset"], accounts.map { |a| a["username"] })
83
+ "gemset" => normalize_gemset(document["gemset"], accounts.map { |a| a["username"] }),
84
+ "session_runtime" => normalize_session_runtime(document["session_runtime"])
82
85
  }
83
86
  end
84
87
 
85
88
  private
86
89
 
90
+ def normalize_coordinator(raw, projects)
91
+ return nil if raw.nil?
92
+ unless raw.is_a?(Hash) && (raw.keys - %w[id path environment_files]).empty? && (%w[id path] - raw.keys).empty? &&
93
+ raw["id"].is_a?(String) && ROLE_ID_PATTERN.match?(raw["id"]) &&
94
+ raw["path"].is_a?(String) && raw["path"].match?(%r{\A/lab(?:/[a-zA-Z0-9][a-zA-Z0-9_.-]*)?\z}) &&
95
+ File.expand_path(raw["path"]) == raw["path"] && !raw["path"].match?(/[[:cntrl:]]/)
96
+ raise_invalid "coordinator requires an id and canonical absolute path"
97
+ end
98
+ if projects.any? { |project| project["id"] == raw["id"] || project["path"] == raw["path"] }
99
+ raise_invalid "coordinator must be distinct from projects"
100
+ end
101
+ raw.key?("environment_files") ? raw.merge("environment_files" => normalize_environment_files(raw["environment_files"])) : raw.dup
102
+ end
103
+
104
+ def normalize_environment_files(raw)
105
+ return {} if raw.nil?
106
+ roles = %w[lab-overseer project-overseer worker verifier integrator]
107
+ unless raw.is_a?(Hash) && raw.all? { |role, path|
108
+ roles.include?(role) && path.is_a?(String) && path.start_with?("/") &&
109
+ File.expand_path(path) == path && !path.match?(/[[:cntrl:]]/)
110
+ }
111
+ raise_invalid "environment_files must map execution roles to canonical absolute paths"
112
+ end
113
+ raw.dup
114
+ end
115
+
116
+ def normalize_session_runtime(raw)
117
+ return nil if raw.nil?
118
+ keys = %w[node pi path provider model credentials]
119
+ unless raw.is_a?(Hash) && (raw.keys - keys - ["herdr"]).empty? && (keys - raw.keys).empty?
120
+ raise_invalid "session_runtime requires exactly node, pi, path, provider, model and credentials"
121
+ end
122
+ %w[node pi credentials].each do |key|
123
+ path = raw[key]
124
+ unless path.is_a?(String) && path.start_with?("/") && File.expand_path(path) == path && !path.match?(/[[:cntrl:]]/)
125
+ raise_invalid "session_runtime #{key} must be a canonical absolute path"
126
+ end
127
+ end
128
+ paths = raw["path"]
129
+ unless paths.is_a?(Array) && !paths.empty? && paths.uniq == paths && paths.all? { |path|
130
+ path.is_a?(String) && path.start_with?("/") && File.expand_path(path) == path && !path.match?(/[[:cntrl:]:]/)
131
+ }
132
+ raise_invalid "session_runtime path must contain unique canonical absolute directories"
133
+ end
134
+ %w[provider model].each do |key|
135
+ unless raw[key].is_a?(String) && raw[key].match?(%r{\A[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}\z})
136
+ raise_invalid "session_runtime #{key} is invalid"
137
+ end
138
+ end
139
+ if raw.key?("herdr")
140
+ endpoint = raw["herdr"]
141
+ unless endpoint.is_a?(Hash) && endpoint.keys.sort == %w[account socket] &&
142
+ endpoint["account"].is_a?(String) && endpoint["account"].match?(/\A[a-z_][a-z0-9_-]*\z/) &&
143
+ endpoint["socket"].is_a?(String) && endpoint["socket"].start_with?("/") &&
144
+ File.expand_path(endpoint["socket"]) == endpoint["socket"] && !endpoint["socket"].match?(/[[:cntrl:]]/)
145
+ raise_invalid "session_runtime herdr requires an account and canonical socket path"
146
+ end
147
+ end
148
+ raw.transform_values { |value| value.is_a?(Array) ? value.map(&:dup) : value.dup }
149
+ end
150
+
87
151
  def raise_invalid(reason)
88
152
  raise Ace::Lab::InvalidConfigurationError,
89
153
  "invalid lab install manifest: #{reason}"
@@ -229,7 +293,9 @@ module Ace
229
293
  remote = optional_string(entry, "projects", "remote")
230
294
  label = optional_string(entry, "projects", "label")
231
295
 
232
- {"id" => id, "path" => path, "remote" => remote, "label" => label}
296
+ normalized = {"id" => id, "path" => path, "remote" => remote, "label" => label}
297
+ normalized["environment_files"] = normalize_environment_files(entry["environment_files"]) if entry.key?("environment_files")
298
+ normalized
233
299
  end.tap do |projects|
234
300
  duplicates_of(projects.map { |project| project["id"] }) do |_id|
235
301
  raise_invalid_entry("projects", "id is declared more than once")
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "fileutils"
4
+ require "tempfile"
4
5
 
5
6
  module Ace
6
7
  module Lab
@@ -12,35 +13,58 @@ module Ace
12
13
  # REFERENCE (path), never the value in argv or the serialized
13
14
  # report. Removal after the lease is idempotent.
14
15
  class CredentialHandoff
15
- def initialize(credentials_dir, recipient: )
16
+ def self.projection_prefix(credential)
17
+ "#{File.basename(credential)}.forgejo-session-"
18
+ end
19
+
20
+ def initialize(credentials_dir, recipient:)
16
21
  @credentials_dir = credentials_dir
17
22
  @recipient = recipient
18
23
  end
19
24
 
20
25
  def path(lease_id)
26
+ raise Error, "invalid credential lease id" unless lease_id.to_s.match?(/\Alease-[a-zA-Z0-9_-]+\z/)
21
27
  File.join(@credentials_dir, "#{lease_id}-#{@recipient}")
22
28
  end
23
29
 
24
- # @return [String] the credential file path (the reference)
25
30
  def deliver(lease_id, secret)
26
- FileUtils.mkdir_p(@credentials_dir)
31
+ FileUtils.mkdir_p(@credentials_dir, mode: 0o700)
27
32
  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
33
+ deliver_into(@credentials_dir, target, secret)
35
34
  end
36
35
 
37
36
  def clear(lease_id)
38
37
  target = path(lease_id)
39
38
  File.delete(target) if File.file?(target) && !File.symlink?(target)
39
+ # A killed Forgejo executor may leave its private fj login
40
+ # projection behind; it belongs to this same handoff lifecycle.
41
+ prefix = self.class.projection_prefix(target)
42
+ Dir.children(@credentials_dir).grep(/^#{Regexp.escape(prefix)}/).each do |name|
43
+ dir = File.join(@credentials_dir, name)
44
+ raise Error, "session projection is a symlink; refusing cleanup" if File.symlink?(dir)
45
+ FileUtils.remove_entry_secure(dir) if File.directory?(dir)
46
+ end
40
47
  :cleared
41
48
  rescue Errno::ENOENT
42
49
  :cleared
43
50
  end
51
+
52
+ private
53
+
54
+ def deliver_into(directory, public_target, secret)
55
+ target = File.join(directory, File.basename(public_target))
56
+ raise Error, "credential handoff path is a symlink; refusing" if File.symlink?(target)
57
+
58
+ Tempfile.create([".credential-", ".tmp"], directory) do |temporary|
59
+ temporary.write(secret.to_s)
60
+ temporary.flush
61
+ temporary.fsync
62
+ temporary.chmod(0o400)
63
+ File.rename(temporary.path, target)
64
+ end
65
+ public_target
66
+ end
67
+
44
68
  end
45
69
  end
46
70
  end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "ace/lab"
4
+
5
+ module Ace
6
+ module Lab
7
+ module Auth
8
+ class Error < ::Ace::Lab::Error; end
9
+ class Refusal < Error; end
10
+ class ExecutionCleanupError < Error; end
11
+ end
12
+ end
13
+ end
@@ -5,6 +5,7 @@ require "json"
5
5
  require "fileutils"
6
6
  require "digest"
7
7
  require "time"
8
+ require_relative "error"
8
9
 
9
10
  module Ace
10
11
  module Lab
@@ -46,6 +47,9 @@ module Ace
46
47
  # @return [Hash] the created (PREPARING) lease record
47
48
  def create(profile:, role:, principal:, task:, assignment:, repos:,
48
49
  use_until:, provider_expiry:, token_ref: nil, id: nil)
50
+ unless [profile, role, principal, task, assignment].all? { |value| value.is_a?(String) && value.valid_encoding? && !value.strip.empty? }
51
+ raise Error, "lease correlation is incomplete"
52
+ end
49
53
  lease = {"id" => id || "lease-#{SecureRandom.hex(8)}",
50
54
  "profile" => profile, "role" => role, "principal" => principal,
51
55
  "task" => task, "assignment" => assignment, "repos" => repos.sort,
@@ -64,8 +68,8 @@ module Ace
64
68
  # scope-changed correlation does not match, and
65
69
  # DEADLINE-AWARENESS is part of the match: an issued record
66
70
  # 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".
71
+ # retry must settle the expired grant before minting a replacement;
72
+ # an expired grant is never handed back as "live".
69
73
  def find_live(task:, assignment:, profile:, role:)
70
74
  all.filter_map do |lease|
71
75
  next unless lease["task"] == task && lease["assignment"] == assignment &&
@@ -77,9 +81,11 @@ module Ace
77
81
 
78
82
  def load(id)
79
83
  path = lease_path(id)
80
- raise Error, "unknown lease '#{id}'" unless File.file?(path)
81
-
82
- JSON.parse(File.read(path))
84
+ parse_record(path)
85
+ rescue Errno::ENOENT
86
+ raise Error, "unknown lease '#{id}'"
87
+ rescue SystemCallError => e
88
+ raise Error, "lease record '#{id}' is unavailable (#{e.class})"
83
89
  rescue JSON::ParserError
84
90
  raise Error, "lease record '#{id}' is unreadable"
85
91
  end
@@ -89,13 +95,17 @@ module Ace
89
95
  # problem (the remote token may still be live) — never a
90
96
  # silently skipped entry that disappears from reconcile.
91
97
  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
98
+ names = begin
99
+ Dir.children(@dir)
100
+ rescue Errno::ENOENT
101
+ return []
102
+ rescue SystemCallError => e
103
+ raise Error, "lease inventory is unavailable (#{e.class})"
104
+ end
105
+ names.reject { |name| name.start_with?(".") }.select { |name| name.end_with?(".json") }.sort.filter_map do |name|
106
+ path = File.join(@dir, name)
107
+ parse_record(path)
108
+ rescue SystemCallError, JSON::ParserError
99
109
  {"id" => File.basename(path, ".json"), "state" => nil,
100
110
  "corrupt" => true, "_path" => path}
101
111
  end
@@ -110,8 +120,8 @@ module Ace
110
120
  path = lease_path(id)
111
121
  with_write_lock(id) do
112
122
  lease = begin
113
- JSON.parse(File.read(path))
114
- rescue JSON::ParserError, Errno::ENOENT => e
123
+ parse_record(path)
124
+ rescue JSON::ParserError, SystemCallError => e
115
125
  # A corrupt/missing record is a named store failure, never a
116
126
  # raw parser error escaping through the update seam.
117
127
  raise Error, "lease '#{id}' cannot be read at #{path}: #{e.class} #{e.message}"
@@ -128,10 +138,12 @@ module Ace
128
138
  end
129
139
 
130
140
  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)
141
+ lock = open_lock(File.join(@dir, "#{id}.write.lock"))
142
+ begin
143
+ lock_flock(lock, File::LOCK_EX)
134
144
  yield
145
+ ensure
146
+ lock.close
135
147
  end
136
148
  end
137
149
 
@@ -141,24 +153,28 @@ module Ace
141
153
  # against a concurrent identical request — the second caller
142
154
  # waits and then returns the first caller's live lease.
143
155
  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)
156
+ key = Digest::SHA256.hexdigest(JSON.generate([task, assignment, profile, role]))
157
+ lock = open_lock(File.join(@dir, "correlation-#{key}.lock"))
158
+ begin
159
+ lock_flock(lock, File::LOCK_EX)
148
160
  yield
161
+ ensure
162
+ lock.close
149
163
  end
150
164
  end
151
165
 
152
166
  # One exec at a time per lease: an exclusive lock on the lease's
153
167
  # exec lockfile, non-blocking — a parallel exec is a refusal.
154
168
  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)
169
+ lock = open_lock(File.join(@dir, "#{id}.exec.lock"))
170
+ begin
171
+ unless lock_flock(lock, File::LOCK_EX | File::LOCK_NB)
158
172
  raise Error, "lease '#{id}' already has a running exec"
159
173
  end
160
174
 
161
175
  yield
176
+ ensure
177
+ lock.close
162
178
  end
163
179
  end
164
180
 
@@ -168,33 +184,79 @@ module Ace
168
184
  # or is mid-revocation — neither is ever usable or "live".
169
185
  def live?(lease, at: Time.now)
170
186
  return false unless lease["state"] == "issued"
187
+ return false if lease.key?("execution") || lease["containment"] == "unconfirmed"
171
188
  return false if lease["use_until"] && Time.parse(lease["use_until"]) <= at
172
189
  if lease["provider_expiry"] && Time.parse(lease["provider_expiry"]) <= at
173
190
  return false
174
191
  end
175
192
 
176
193
  true
194
+ rescue ArgumentError, TypeError => e
195
+ raise Error, "lease deadline is invalid (#{e.class})"
177
196
  end
178
197
 
179
198
  private
180
199
 
200
+ def open_lock(path)
201
+ FileUtils.mkdir_p(@dir)
202
+ lock = File.open(path, File::CREAT | File::RDWR | File::NOFOLLOW, 0o600)
203
+ held, named = lock.stat, File.lstat(path)
204
+ unless held.file? && !named.symlink? && held.uid == Process::Sys.geteuid && held.nlink == 1 &&
205
+ (held.mode & 0o022).zero? && [held.dev, held.ino] == [named.dev, named.ino]
206
+ raise Error, "lease lock is not safely owned"
207
+ end
208
+ lock
209
+ rescue SystemCallError => e
210
+ lock&.close unless lock&.closed?
211
+ raise Error, "lease lock is unavailable (#{e.class})"
212
+ rescue Exception
213
+ lock&.close unless lock&.closed?
214
+ raise
215
+ end
216
+
217
+ def lock_flock(lock, flags)
218
+ lock.flock(flags)
219
+ rescue SystemCallError => e
220
+ raise Error, "lease lock could not be acquired (#{e.class})"
221
+ end
222
+
223
+ def parse_record(path)
224
+ record = JSON.parse(File.read(path))
225
+ unless record.is_a?(Hash) && record["id"] == File.basename(path, ".json")
226
+ raise JSON::ParserError, "lease record identity differs"
227
+ end
228
+ record
229
+ end
230
+
181
231
  def atomic_write(path, lease)
182
232
  FileUtils.mkdir_p(@dir)
183
- temporary = "#{path}.#{Process.pid}.tmp"
184
- File.open(temporary, "w") do |file|
233
+ temporary = "#{path}.#{SecureRandom.hex(16)}.tmp"
234
+ created = false
235
+ File.open(temporary, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
236
+ created = true
237
+ file.chmod(0o600)
185
238
  file.write(JSON.pretty_generate(lease))
186
239
  file.flush
187
240
  file.fsync
188
241
  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
242
  begin
193
243
  File.rename(temporary, path)
194
244
  rescue Errno::ENOENT
195
245
  FileUtils.mkdir_p(File.dirname(path))
196
246
  File.rename(temporary, path)
197
247
  end
248
+ # Persist the directory entry as well as the file data.
249
+ File.open(File.dirname(path), File::RDONLY) { |directory| directory.fsync }
250
+ rescue SystemCallError, IOError => e
251
+ raise Error, "lease record write is unavailable (#{e.class})"
252
+ ensure
253
+ if created
254
+ begin
255
+ File.unlink(temporary)
256
+ rescue SystemCallError
257
+ nil
258
+ end
259
+ end
198
260
  end
199
261
 
200
262
  def lease_path(id)