ace-lab 0.14.0 → 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.
@@ -1,15 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "etc"
4
+ require "date"
4
5
  require "json"
5
6
  require "open3"
7
+ require "securerandom"
8
+ require "time"
9
+ require "yaml"
10
+ require_relative "error"
11
+ require_relative "local_command"
6
12
 
7
13
  module Ace
8
14
  module Lab
9
15
  module Auth
10
- # Explicit error base for the auth surface (the CLI rescues it).
11
- class Error < ::Ace::Lab::Error; end
12
-
13
16
  # The auth service core (task 8xs.t.pu0.7): request / status /
14
17
  # exec / revoke / reconcile over the trusted host configuration
15
18
  # and the plugin registry. It owns the lease lifecycle and the
@@ -26,34 +29,20 @@ module Ace
26
29
  # credential reference — the issuer's whole environment (and
27
30
  # any ambient credential override like GH_TOKEN in it) is never
28
31
  # inherited past the lease's own scope.
29
- CHILD_ENV_ALLOWLIST = %w[PATH HOME TMPDIR LANG LC_ALL TZ USER LOGNAME SHELL].freeze
32
+ CHILD_ENV_ALLOWLIST = %w[PATH HOME TMPDIR LANG LC_ALL TZ USER LOGNAME SHELL
33
+ ACE_TOOLCHAIN GEM_HOME GEM_PATH].freeze
30
34
 
31
35
  attr_reader :config, :store
32
36
 
33
37
  # @param authorization_path [String] trusted document location
34
38
  # @param runner [Proc] argv runner for the exec'd child
39
+ # @param principal [Proc, nil] trusted callable returning the account name
35
40
  def initialize(authorization_path: nil, runner: nil, principal: nil)
41
+ raise ArgumentError, "principal must be callable" unless principal.nil? || principal.respond_to?(:call)
36
42
  @authorization_path = authorization_path || Ace::Lab.authorization_path
37
- # The default runner spawns the child through `env -i` so the
38
- # allowlisted child environment is EXACTLY what the child
39
- # sees — Ruby's system(env, ...) MERGES the hash into the
40
- # inherited environment, which would leak every ambient
41
- # credential override past the profile's scope. The real exit
42
- # status is preserved (nil-status spawn failure degrades to
43
- # 127, signals to 128+signum).
44
- @runner = runner || lambda { |argv, env|
45
- spawned = ["env", "-i", *env.map { |key, value| "#{key}=#{value}" }, *argv]
46
- system(*spawned)
47
- status = $?
48
- if status.nil?
49
- 127
50
- elsif status.success?
51
- 0
52
- else
53
- status.exitstatus || 128 + status.termsig.to_i
54
- end
55
- }
43
+ @injected_runner = !runner.nil?
56
44
  @principal = principal || -> { Etc.getpwuid(Process.uid).name }
45
+ @runner = runner
57
46
  @config = load_config
58
47
  @store = LeaseStore.new(@config["leases_dir"] || DEFAULT_LEASES_DIR)
59
48
  @credentials_dir = @config["credentials_dir"] || DEFAULT_CREDENTIALS_DIR
@@ -66,11 +55,18 @@ module Ace
66
55
  # scope/principal must match; a mismatched retry (e.g. a
67
56
  # narrower repo list) is refused, never silently answered with
68
57
  # the wider existing grant. The granted scope is the
69
- # INTERSECTION of the host profile, the request and the task's
70
- # repo — a request may narrow, never widen; editing the task or
58
+ # intersection of the requested repos and the host profile, whose
59
+ # empty repo list imposes no further restriction. A request may
60
+ # narrow, never widen; editing the task or
71
61
  # project config grants nothing.
72
62
  def request(profile_name:, role:, task:, assignment:, repos: [],
73
63
  ttl_minutes: nil)
64
+ unless [profile_name, role, task, assignment].all? { |value| value.is_a?(String) && value.valid_encoding? && !value.strip.empty? }
65
+ raise Refusal.new("profile, role, task and assignment must be non-empty strings")
66
+ end
67
+ if Process.euid.zero? && !@injected_runner
68
+ raise Refusal.new("run credential issuance as the configured executor account")
69
+ end
74
70
  profile = profile_for(profile_name)
75
71
  unless profile["roles"].include?(role)
76
72
  raise Refusal.new("role '#{role}' is not allowed by profile '#{profile_name}'")
@@ -92,8 +88,8 @@ module Ace
92
88
 
93
89
  # The granted scope is computed BEFORE the live-lease lookup
94
90
  # so a retry is judged against what THIS request asked for.
95
- requested = Array(repos).map(&:to_s).sort
96
- repos = (profile["repos"].empty? ? requested : profile["repos"] & requested)
91
+ requested = Array(repos).map(&:to_s).uniq.sort
92
+ repos = (profile["repos"].empty? ? requested : profile["repos"] & requested).sort
97
93
  if repos.empty?
98
94
  raise Refusal.new("the granted repository scope would be empty (profile " \
99
95
  "'#{profile_name}' repos: [#{profile["repos"].join(", ")}]; requested: " \
@@ -104,15 +100,22 @@ module Ace
104
100
  # The provider is resolved and capability-verified BEFORE any
105
101
  # record exists: a failed resolve or an expiry the provider
106
102
  # cannot back must not orphan a lease record.
107
- issue = Providers.resolve(profile["provider"])
103
+ begin
104
+ issue = Providers.resolve(profile["provider"])
105
+ rescue Providers::UnsupportedError => e
106
+ raise Refusal.new("provider unavailable: #{e.message}")
107
+ end
108
108
  # A provider unable to back the profile's NATIVE expiry must
109
109
  # be visible BEFORE handoff, never hidden behind use_until.
110
+ capabilities = issue.capabilities if profile["expiry_mode"] == "provider"
110
111
  if profile["expiry_mode"] == "provider" &&
111
- issue.capabilities.fetch("expiry_mode") != "provider"
112
+ (!capabilities.is_a?(Hash) || capabilities["expiry_mode"] != "provider")
112
113
  raise Refusal.new("provider '#{profile["provider"]}' cannot guarantee the " \
113
114
  "profile's native expiry")
114
115
  end
115
116
 
117
+ handoff = CredentialHandoff.new(@credentials_dir, recipient: principal)
118
+
116
119
  # One request flow at a time per correlation: find_live +
117
120
  # create + issue are atomic against a concurrent identical
118
121
  # request — the second waits, then returns the first's LIVE
@@ -122,6 +125,9 @@ module Ace
122
125
  existing = @store.find_live(task: task, assignment: assignment,
123
126
  profile: profile_name, role: role)
124
127
  if existing && @store.live?(existing)
128
+ if existing.key?("execution")
129
+ raise Refusal.new("a previous command's execution is unresolved; no lease can be reused")
130
+ end
125
131
  # Idempotency holds only for the IDENTICAL request: a live
126
132
  # lease with a different granted scope or principal is a
127
133
  # mismatched retry — refusing it never silently reuses a
@@ -132,6 +138,9 @@ module Ace
132
138
  "[#{existing["repos"].join(", ")}] for principal '#{existing["principal"]}'; " \
133
139
  "revoke it first or repeat the identical request")
134
140
  end
141
+ unless File.file?(handoff_for(existing).path(existing.fetch("id")))
142
+ raise Refusal.new("live lease '#{existing["id"]}' has no credential handoff; revoke it before requesting a replacement")
143
+ end
135
144
  next existing.merge("state_detail" => "live")
136
145
  end
137
146
 
@@ -143,7 +152,7 @@ module Ace
143
152
  # cannot be confirmed refuses the retry: a local pending
144
153
  # record is never proof the remote token is gone.
145
154
  settle_unresolved(task: task, assignment: assignment,
146
- profile: profile_name, role: role, principal: principal)
155
+ profile: profile_name, role: role)
147
156
 
148
157
  lease_id = "lease-#{SecureRandom.hex(8)}"
149
158
  # Duck-typed capability: bundled providers always implement
@@ -161,17 +170,32 @@ module Ace
161
170
  begin
162
171
  granted = issue.issue(profile: profile, lease: lease)
163
172
  rescue Providers::RefusedError => e
164
- @store.update(lease["id"]) { |record| record["state"] = "revoked" }
173
+ @store.update(lease["id"]) do |record|
174
+ record["state"] = "revoked"
175
+ record["cleanup"] = {"result" => "not-issued", "at" => Time.now.utc.strftime("%Y-%m-%dT%H:%M:%SZ")}
176
+ end
165
177
  raise Refusal.new("provider refused: #{e.message}")
166
178
  rescue Providers::TransientError, Providers::OutcomeUnknownError => e
167
179
  @store.update(lease["id"]) { |record| record["state"] = "revoke-pending" }
168
180
  raise Error.new("issuance failed with unknown outcome: #{e.message}; " \
169
- "reconcile resolves the pending record")
181
+ "reconcile retries cleanup; the outcome may remain unresolved")
170
182
  end
171
183
 
184
+ expiry = granted["provider_expiry"]&.to_s
185
+ expiry_valid = begin
186
+ if expiry
187
+ parsed_expiry = Time.parse(expiry)
188
+ expiry = parsed_expiry.utc.iso8601
189
+ parsed_expiry > Time.now.utc
190
+ else
191
+ profile["expiry_mode"] != "provider"
192
+ end
193
+ rescue ArgumentError, TypeError
194
+ false
195
+ end
172
196
  @store.update(lease["id"]) do |record|
173
197
  record["token_ref"] = granted["token_ref"] || record["token_ref"]
174
- record["provider_expiry"] = granted["provider_expiry"]&.to_s
198
+ record["provider_expiry"] = expiry_valid ? expiry : nil
175
199
  record["via"] = granted["via"]
176
200
  end
177
201
  # The secret goes ONLY to the ephemeral handoff; the returned
@@ -180,13 +204,18 @@ module Ace
180
204
  # provider-side, so the record becomes revoke-pending for
181
205
  # reconcile to finish — an interrupted handoff must not be
182
206
  # reusable by a retry.
183
- handoff = CredentialHandoff.new(@credentials_dir, recipient: principal)
184
207
  begin
208
+
185
209
  handoff.deliver(lease["id"], granted["token"])
210
+
186
211
  rescue StandardError => e
187
212
  @store.update(lease["id"]) { |record| record["state"] = "revoke-pending" }
188
213
  raise Error.new("credential handoff failed: #{e.message}; " \
189
- "reconcile resolves the pending record")
214
+ "reconcile retries cleanup; the outcome may remain unresolved")
215
+ end
216
+ unless expiry_valid
217
+ @store.update(lease["id"]) { |record| record["state"] = "revoke-pending" }
218
+ raise Error, "provider returned a missing, invalid or expired expiry; reconcile must revoke the grant"
190
219
  end
191
220
  # ISSUED means a COMPLETE, usable grant: credential
192
221
  # delivered, deadlines recorded, cleanup due. Until this
@@ -208,9 +237,13 @@ module Ace
208
237
  # --- status --------------------------------------------------------
209
238
 
210
239
  def status(lease_id: nil)
211
- return public_view(@store.load(lease_id)) if lease_id
240
+ if lease_id
241
+ lease = @store.load(lease_id)
242
+ return public_view(lease)
243
+ end
212
244
 
213
- @store.all.map { |lease| public_view(lease) }
245
+ records = @store.all
246
+ records.map { |lease| public_view(lease) }
214
247
  end
215
248
 
216
249
  # --- exec ----------------------------------------------------------
@@ -220,21 +253,40 @@ module Ace
220
253
  # env names), never a value in argv. The process exit code is
221
254
  # preserved; on process success a cleanup failure is itself an
222
255
  # error; the report carries both outcomes. One exec at a time
223
- # per lease.
256
+ # per lease; a completed attempt triggers cleanup, making the lease
257
+ # single-use once revocation is confirmed.
224
258
  def exec(lease_id:, argv:, env: {})
259
+ unless argv.is_a?(Array) && !argv.empty? && argv.all? { |arg| arg.is_a?(String) && !arg.include?("\0") } && !argv.first.empty?
260
+ raise Refusal.new("execution requires a valid command")
261
+ end
262
+ unless env.is_a?(Hash) && env.all? { |key, value|
263
+ CHILD_ENV_ALLOWLIST.include?(key) && value.is_a?(String) && !value.include?("\0")
264
+ }
265
+ raise Refusal.new("execution environment contains invalid or disallowed variables")
266
+ end
225
267
  lease = @store.load(lease_id)
268
+ unless lease["principal"] == @principal.call
269
+ raise Refusal.new("lease '#{lease_id}' belongs to a different principal")
270
+ end
271
+ if Process.euid.zero? && !@injected_runner
272
+ raise Refusal.new("run credential execution as the configured executor account")
273
+ end
226
274
  raise Refusal.new("lease '#{lease_id}' is not live") unless @store.live?(lease)
227
275
 
228
276
  @store.with_exec_lock(lease_id) do
277
+ lease = @store.load(lease_id)
278
+ unless lease["principal"] == @principal.call && @store.live?(lease) && !lease.key?("execution")
279
+ raise Refusal.new("lease '#{lease_id}' is no longer live for this principal")
280
+ end
229
281
  profile = profile_for(lease["profile"])
230
- handoff = CredentialHandoff.new(@credentials_dir, recipient: lease["principal"])
282
+ handoff = handoff_for(lease)
231
283
  credential = handoff.path(lease_id)
232
284
  # A live lease without its delivered credential is not
233
285
  # executable — degrade with a clear reason instead of an
234
286
  # unrescued ENOENT from the child env merge.
235
287
  unless File.file?(credential)
236
288
  raise Refusal.new("lease '#{lease_id}' has no delivered credential at " \
237
- "'#{credential}'; run reconcile or request a fresh lease")
289
+ "'#{credential}'; revoke the lease before requesting a replacement")
238
290
  end
239
291
 
240
292
  # ALLOWLISTED child environment: required non-secret
@@ -250,16 +302,78 @@ module Ace
250
302
  "ACE_LAB_CREDENTIAL_FILE" => credential)
251
303
  if profile["provider"] == "env"
252
304
  begin
253
- child_env.merge!(JSON.parse(File.read(credential)))
254
- rescue JSON::ParserError, Errno::ENOENT => e
305
+ values = JSON.parse(File.read(credential))
306
+ unless values.is_a?(Hash) && values.keys.sort == profile.fetch("env_names").sort &&
307
+ values.values.all? { |value| value.is_a?(String) && !value.empty? && !value.include?("\0") }
308
+ raise TypeError, "invalid env handoff"
309
+ end
310
+ child_env.merge!(values)
311
+ rescue JSON::ParserError, SystemCallError, IOError, TypeError => e
255
312
  raise Refusal.new("lease '#{lease_id}' credential could not be read as " \
256
313
  "an env handoff (#{e.class.name}); reconcile or request a fresh lease")
257
314
  end
258
315
  end
259
316
 
260
- exit_code = @runner.call(argv, child_env)
261
- cleanup = cleanup(lease, profile, handoff)
262
- if exit_code.zero? && cleanup != :revoked && cleanup != :cleared
317
+ # A process lock disappears on SIGKILL. Persist the in-flight
318
+ # execution before starting a child so recovery cannot mistake
319
+ # a dead issuer for proof that its descendants have stopped.
320
+ @store.update(lease["id"]) do |record|
321
+ unless record["principal"] == @principal.call && @store.live?(record) && !record.key?("execution")
322
+ raise Refusal.new("lease '#{lease_id}' is no longer live for this principal")
323
+ end
324
+ record["execution"] = "in-progress"
325
+ end
326
+ uncontained = false
327
+ begin
328
+ provider = Providers.resolve(profile["provider"])
329
+ exit_code = if provider.respond_to?(:with_exec_environment)
330
+ provider.with_exec_environment(profile: profile, credential: credential) do |provider_env|
331
+ run_leased_command(argv, preserve_installed_runtime!(child_env.merge(provider_env)), lease["id"])
332
+ end
333
+ else
334
+ run_leased_command(argv, preserve_installed_runtime!(child_env), lease["id"])
335
+ end
336
+ unless exit_code.is_a?(Integer) && exit_code.between?(0, 255)
337
+ raise Error, "execution returned an invalid exit status"
338
+ end
339
+ rescue ExecutionCleanupError
340
+ uncontained = true
341
+ raise
342
+ rescue Providers::RefusedError, Providers::UnsupportedError => e
343
+ raise Refusal.new("provider execution refused: #{e.message}")
344
+ rescue SystemCallError, IOError, ArgumentError => e
345
+ raise Error, "execution could not start (#{e.class})"
346
+ ensure
347
+ # A failed spawn, provider error or interrupted caller must not
348
+ # leave an issued credential live merely because no exit code
349
+ # was returned. Reconciliation retains unresolved revocations.
350
+ primary_error = $!
351
+ begin
352
+ if uncontained
353
+ @store.update(lease["id"]) do |record|
354
+ record["state"] = "revoke-pending"
355
+ record["containment"] = "unconfirmed"
356
+ record["execution"] = "unconfirmed"
357
+ end
358
+ # Revoking the remote credential limits exposure, but does
359
+ # not establish that the command's processes have stopped.
360
+ cleanup = cleanup(lease, profile, handoff, allow_executing: true)
361
+ else
362
+ @store.update(lease["id"]) do |record|
363
+ record.delete("execution")
364
+ record["state"] = "revoke-pending" unless record["state"] == "revoked"
365
+ end
366
+ cleanup = cleanup(lease, profile, handoff)
367
+ end
368
+ rescue Error, SystemCallError, IOError => error
369
+ if primary_error.is_a?(ExecutionCleanupError)
370
+ raise ExecutionCleanupError.new(primary_error.message), cause: error
371
+ end
372
+ raise primary_error, cause: nil if primary_error
373
+ raise Error, "credential cleanup failed (#{error.class})"
374
+ end
375
+ end
376
+ if exit_code.zero? && !%i[revoked already_invalid cleared not-issued].include?(cleanup)
263
377
  raise Error.new("process succeeded (exit 0) but cleanup failed: #{cleanup}")
264
378
  end
265
379
 
@@ -280,13 +394,38 @@ module Ace
280
394
  # recorded — never a claimed revocation.
281
395
  def revoke(lease_id:, force: false)
282
396
  lease = @store.load(lease_id)
283
- handoff = CredentialHandoff.new(@credentials_dir, recipient: lease["principal"])
397
+ unless %w[task assignment profile role].all? { |field| lease[field].is_a?(String) && !lease[field].empty? }
398
+ raise Error, "lease correlation is incomplete; host recovery is required"
399
+ end
400
+ @store.with_correlation_lock(task: lease.fetch("task"), assignment: lease.fetch("assignment"),
401
+ profile: lease.fetch("profile"), role: lease.fetch("role")) do
402
+ revoke_locked(lease_id: lease_id, force: force)
403
+ end
404
+ end
405
+
406
+ def revoke_locked(lease_id:, force:)
407
+ lease = @store.load(lease_id)
408
+ principal = @principal.call
409
+ operator = Process.uid.zero? && principal == "root"
410
+ unless (lease["principal"] == principal) || operator
411
+ raise Refusal.new("lease '#{lease_id}' belongs to a different principal")
412
+ end
413
+ if force && !operator
414
+ raise Refusal.new("forced terminal revocation requires the root operator")
415
+ end
416
+ handoff = handoff_for(lease)
417
+
418
+ if force && lease.key?("execution")
419
+ raise Refusal.new("cannot force-close a lease while command execution is unresolved")
420
+ end
284
421
 
285
422
  # An already-revoked lease never re-dials the provider: the
286
423
  # idempotent result stands and only the handoff is re-cleared.
287
424
  if lease["state"] == "revoked"
288
425
  handoff.clear(lease_id)
289
- return :revoked
426
+ return :forced_terminal if lease.dig("cleanup", "result") == "forced-terminal"
427
+ return :revoked if confirmed_revocation?(lease)
428
+ raise Error, "lease '#{lease_id}' has an unconfirmed terminal cleanup outcome"
290
429
  end
291
430
 
292
431
  if force && lease["state"] == "revoke-pending" &&
@@ -306,12 +445,17 @@ module Ace
306
445
  begin
307
446
  profile = profile_for(lease["profile"])
308
447
  rescue Refusal
448
+ # Keep the handoff while remote revocation is unresolved:
449
+ # token-value providers need it if the profile is restored.
309
450
  @store.update(lease_id) { |record| record["state"] = "revoke-pending" }
310
451
  return :"revoke-pending (profile '#{lease["profile"]}' removed from the authorization document)"
311
452
  end
312
453
 
313
- cleanup(lease, profile, handoff)
454
+ # Explicit revocation is allowed during execution to cut remote
455
+ # access immediately; the execution marker still prevents reuse.
456
+ cleanup(lease, profile, handoff, allow_executing: true)
314
457
  end
458
+ private :revoke_locked
315
459
 
316
460
  # --- reconcile -----------------------------------------------------
317
461
 
@@ -325,43 +469,75 @@ module Ace
325
469
  def reconcile
326
470
  results = []
327
471
  @store.all.each do |lease|
328
- handoff = CredentialHandoff.new(@credentials_dir, recipient: lease["principal"])
329
- outcome =
472
+ begin
330
473
  if lease["corrupt"]
331
- # An unparseable record is a visible recovery problem —
332
- # the remote token may still be live; it must never
333
- # silently vanish from the inventory.
334
474
  record = File.basename(lease["_path"].to_s)
335
- :"corrupt (unparseable lease record #{record}; manual recovery required — the remote token may still be live)"
336
- elsif lease["state"] == "revoked"
337
- handoff.clear(lease["id"])
338
- :already_revoked
475
+ results << {"lease" => lease["id"], "state" => lease["state"], "reconciled" =>
476
+ "corrupt (unreadable lease record #{record}; manual recovery required — the remote token may still be live)"}
339
477
  else
340
- begin
341
- profile = profile_for(lease["profile"])
342
- rescue Refusal
343
- # A removed profile cannot resolve its provider: keep
344
- # the lease a visible revoke-pending open item (the
345
- # operator closes it with `revoke --force` when the
346
- # credential is gone) instead of crashing reconcile.
347
- @store.update(lease["id"]) { |record| record["state"] = "revoke-pending" }
348
- :"revoke-pending (profile '#{lease["profile"]}' removed)"
349
- else
350
- if lease["state"] == "preparing" || lease["state"] == "revoke-pending" ||
351
- !@store.live?(lease)
352
- cleanup(lease, profile, handoff)
353
- else
354
- :live
355
- end
478
+ @store.with_correlation_lock(task: lease.fetch("task"), assignment: lease.fetch("assignment"),
479
+ profile: lease.fetch("profile"), role: lease.fetch("role")) do
480
+ lease = @store.load(lease.fetch("id"))
481
+ results << reconcile_locked(lease)
356
482
  end
357
483
  end
358
- results << {"lease" => lease["id"], "state" => lease["state"], "reconciled" => outcome.to_s}
484
+ rescue Error, SystemCallError, IOError, KeyError, ArgumentError => e
485
+ results << {"lease" => lease["id"], "state" => lease["state"],
486
+ "reconciled" => "reconcile-failed (#{e.class}; manual recovery required)"}
487
+ end
359
488
  end
360
489
  results
361
490
  end
362
491
 
363
492
  private
364
493
 
494
+ def run_leased_command(argv, environment, lease_id)
495
+ return @runner.call(argv, environment) if @runner
496
+
497
+ lease = @store.load(lease_id)
498
+ deadline = [lease.fetch("use_until"), lease["provider_expiry"]].compact.map { |value| Time.iso8601(value) }.min
499
+ LocalCommand.new.call(argv, environment, deadline: deadline)
500
+ end
501
+
502
+ def reconcile_locked(lease)
503
+ if lease.key?("execution")
504
+ expired = [lease["use_until"], lease["provider_expiry"]].compact.any? { |deadline| Time.parse(deadline) <= Time.now }
505
+ if lease["state"] != "revoked" && (lease["state"] == "revoke-pending" || expired)
506
+ profile = profile_for(lease["profile"])
507
+ cleanup(lease, profile, handoff_for(lease), allow_executing: true)
508
+ end
509
+ return {"lease" => lease["id"], "state" => lease["state"],
510
+ "reconciled" => "revoke-pending (command execution unresolved)"}
511
+ end
512
+ handoff = handoff_for(lease)
513
+ outcome = if lease["state"] == "revoked"
514
+ handoff.clear(lease["id"])
515
+ if lease.dig("cleanup", "result") == "forced-terminal"
516
+ :forced_terminal
517
+ elsif confirmed_revocation?(lease)
518
+ :already_revoked
519
+ else
520
+ raise Error, "terminal cleanup outcome is unconfirmed"
521
+ end
522
+ else
523
+ begin
524
+ profile = profile_for(lease["profile"])
525
+ rescue Refusal
526
+ @store.update(lease["id"]) { |record| record["state"] = "revoke-pending" }
527
+ :"revoke-pending (profile '#{lease["profile"]}' removed)"
528
+ else
529
+ if lease["state"] == "preparing" || lease["state"] == "revoke-pending" || !@store.live?(lease)
530
+ cleanup(lease, profile, handoff)
531
+ elsif !File.file?(handoff.path(lease.fetch("id")))
532
+ :"live (credential handoff missing; revoke required)"
533
+ else
534
+ :live
535
+ end
536
+ end
537
+ end
538
+ {"lease" => lease["id"], "state" => lease["state"], "reconciled" => outcome.to_s}
539
+ end
540
+
365
541
  # R5: a previous issue with an UNRESOLVED remote outcome (lost
366
542
  # response after possible issuance, or a hard death while
367
543
  # PREPARING) is settled before any re-mint. Settlement runs the
@@ -372,38 +548,51 @@ module Ace
372
548
  # pending/unknown settlement REFUSES the retry: never a blind
373
549
  # second mint, never treating a local pending record as a
374
550
  # remote revocation.
375
- def settle_unresolved(task:, assignment:, profile:, role:, principal:)
376
- unresolved = @store.all.select do |record|
551
+ def settle_unresolved(task:, assignment:, profile:, role:)
552
+ records = @store.all
553
+ if records.any? { |record| record["corrupt"] ||
554
+ %w[task assignment profile role].any? { |field| !record[field].is_a?(String) || record[field].empty? } ||
555
+ !%w[preparing issued revoke-pending revoked].include?(record["state"]) }
556
+ raise Refusal.new("a lease record is corrupt or incomplete and may represent an unresolved grant; " \
557
+ "no new lease can be issued until the host owner resolves it")
558
+ end
559
+ matching = records.select do |record|
377
560
  record["task"] == task && record["assignment"] == assignment &&
378
- record["profile"] == profile && record["role"] == role &&
379
- %w[preparing revoke-pending].include?(record["state"])
380
- end
381
- return if unresolved.empty?
382
-
383
- record_profile = profile_for(profile)
384
- unresolved.each do |record|
385
- # An unknown ISSUE outcome (revoke-pending after a lost
386
- # response) without a deterministic remote identity can
387
- # NEVER be confirmed settled — the retry is refused until
388
- # reconcile or the provider's native expiry settles it. A
389
- # PREPARING record (issuer died mid-flight, nothing ever
390
- # delivered) is settled best-effort through the provider
391
- # below; only a confirmed outcome lets the retry proceed.
392
- if record["state"] == "revoke-pending" && record["token_ref"].to_s.empty?
393
- raise Refusal.new("a previous issue for task '#{task}' assignment '#{assignment}' " \
394
- "profile '#{profile}' has an unresolved remote outcome (lease '#{record["id"]}', " \
395
- "no deterministic remote identity); it was NOT re-minted — run reconcile, wait " \
396
- "for the provider's native expiry, or close it explicitly before requesting again")
561
+ record["profile"] == profile && record["role"] == role
562
+ end
563
+ matching.each do |record|
564
+ begin
565
+ @store.with_exec_lock(record.fetch("id")) do
566
+ current = @store.load(record.fetch("id"))
567
+ if current.key?("execution") || current["containment"] == "unconfirmed"
568
+ raise Refusal.new("a previous command's execution or containment is unconfirmed; no new lease can be issued")
569
+ end
570
+ expired_issue = current["state"] == "issued" && !@store.live?(current)
571
+ next unless expired_issue || %w[preparing revoke-pending].include?(current["state"])
572
+
573
+ # Keep the execution lock through settlement. A record may
574
+ # have changed since the inventory snapshot was read.
575
+ if current["state"] == "revoke-pending" && current["token_ref"].to_s.empty?
576
+ raise Refusal.new("a previous issue for task '#{task}' assignment '#{assignment}' " \
577
+ "profile '#{profile}' has an unresolved remote outcome (lease '#{current["id"]}', " \
578
+ "no deterministic remote identity); it was NOT re-minted — run reconcile, wait " \
579
+ "for the provider's native expiry, or close it explicitly before requesting again")
580
+ end
581
+ record_profile = profile_for(profile)
582
+ handoff = handoff_for(current)
583
+ outcome = cleanup(current, record_profile, handoff)
584
+ next if %i[revoked already_invalid cleared].include?(outcome)
585
+
586
+ raise Refusal.new("a previous issue for task '#{task}' assignment '#{assignment}' " \
587
+ "profile '#{profile}' has an unresolved remote outcome (lease '#{current["id"]}', " \
588
+ "state #{current["state"]}); it was NOT re-minted — run reconcile, wait for the " \
589
+ "provider's native expiry, or close it explicitly before requesting again")
590
+ end
591
+ rescue Refusal
592
+ raise
593
+ rescue Error
594
+ raise Refusal.new("a previous lease could not be settled; inspect its execution, store and provider state")
397
595
  end
398
-
399
- handoff = CredentialHandoff.new(@credentials_dir, recipient: record["principal"] || principal)
400
- outcome = cleanup(record, record_profile, handoff)
401
- next if %i[revoked already_invalid cleared].include?(outcome)
402
-
403
- raise Refusal.new("a previous issue for task '#{task}' assignment '#{assignment}' " \
404
- "profile '#{profile}' has an unresolved remote outcome (lease '#{record["id"]}', " \
405
- "state #{record["state"]}); it was NOT re-minted — run reconcile, wait for the " \
406
- "provider's native expiry, or close it explicitly before requesting again")
407
596
  end
408
597
  end
409
598
 
@@ -412,16 +601,39 @@ module Ace
412
601
  # installation-token endpoint revokes the token that
413
602
  # authenticates the request, so the token VALUE is required
414
603
  # there; id-revoking providers ignore it.
415
- def cleanup(lease, profile, handoff)
416
- provider = Providers.resolve(profile["provider"])
604
+ def handoff_for(lease)
605
+ CredentialHandoff.new(@credentials_dir, recipient: lease.fetch("principal"))
606
+ end
607
+
608
+ def cleanup(lease, profile, handoff, allow_executing: false)
609
+ current = @store.load(lease["id"])
610
+ if current["containment"] == "unconfirmed" && !allow_executing
611
+ return :"revoke-pending (command containment unconfirmed)"
612
+ end
613
+ return :"revoke-pending (command execution unresolved)" if current.key?("execution") && !allow_executing
614
+ # A concurrent revoke may have already confirmed cleanup while an
615
+ # exec was running. Never retry or downgrade that settled record.
616
+ return :revoked if confirmed_revocation?(current)
617
+
417
618
  credential = handoff.path(lease["id"])
418
- token = File.file?(credential) ? File.read(credential) : nil
419
619
  begin
620
+ token = File.file?(credential) ? File.read(credential) : nil
621
+ rescue SystemCallError, IOError => e
622
+ return mark_cleanup_pending(lease["id"], e.class.to_s)
623
+ end
624
+ begin
625
+ provider = Providers.resolve(profile["provider"])
420
626
  outcome = provider.revoke(profile: profile, token_ref: lease["token_ref"],
421
627
  token: token)
422
- rescue Providers::TransientError, Providers::OutcomeUnknownError => e
423
- @store.update(lease["id"]) { |record| record["state"] = "revoke-pending" }
424
- return :"revoke-pending (#{e.class})"
628
+ rescue Providers::TransientError, Providers::OutcomeUnknownError, Providers::RefusedError, Providers::UnsupportedError => e
629
+ return mark_cleanup_pending(lease["id"], e.class.to_s)
630
+ end
631
+
632
+ # An interrupted issue may still be executing at the forge after
633
+ # our HTTP client timed out or died. A 404 before that request
634
+ # commits is not proof that its token can never appear later.
635
+ if outcome == :already_invalid && token.nil? && %w[preparing revoke-pending].include?(current["state"])
636
+ return mark_cleanup_pending(lease["id"], "issue outcome remains unknown")
425
637
  end
426
638
 
427
639
  handoff.clear(lease["id"])
@@ -432,6 +644,21 @@ module Ace
432
644
  outcome
433
645
  end
434
646
 
647
+ def confirmed_revocation?(record)
648
+ record["state"] == "revoked" && %w[revoked already_invalid cleared not-issued].include?(record.dig("cleanup", "result"))
649
+ end
650
+
651
+ def mark_cleanup_pending(id, reason)
652
+ confirmed = false
653
+ @store.update(id) do |record|
654
+ confirmed = confirmed_revocation?(record)
655
+ # A forced terminal result is an operator decision, not proof of
656
+ # remote revocation; preserve it but never report it confirmed.
657
+ record["state"] = "revoke-pending" unless record["state"] == "revoked"
658
+ end
659
+ confirmed ? :revoked : :"revoke-pending (#{reason})"
660
+ end
661
+
435
662
  def profile_for(name)
436
663
  profile = @config.dig("profiles", name)
437
664
  raise Refusal.new("unknown auth profile '#{name}'") if profile.nil?
@@ -439,8 +666,20 @@ module Ace
439
666
  profile
440
667
  end
441
668
 
669
+ # No caller or provider projection may silently replace the selected
670
+ # installed runtime. An absent path stays absent.
671
+ def preserve_installed_runtime!(environment)
672
+ selected = ENV
673
+ if selected["ACE_TOOLCHAIN"] == "installed"
674
+ %w[ACE_TOOLCHAIN PATH GEM_HOME GEM_PATH].each do |key|
675
+ selected.key?(key) ? environment[key] = selected[key] : environment.delete(key)
676
+ end
677
+ end
678
+ environment
679
+ end
680
+
442
681
  def public_view(lease)
443
- lease.reject { |key, _| key == "token_ref" }
682
+ lease.reject { |key, _| key == "token_ref" || key.start_with?("_") }
444
683
  .merge("live" => @store.live?(lease))
445
684
  end
446
685
 
@@ -451,7 +690,6 @@ module Ace
451
690
  end
452
691
  end
453
692
 
454
- class Refusal < Error; end
455
693
  end
456
694
  end
457
695
  end