ace-hitl 0.11.0 → 0.11.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 579408795de68b05baeeb590eac46687706134301423cb6114874c377d8183ed
4
- data.tar.gz: ba4f300554670fb40acd58c470183d70342626b170889bda9944468aa54df6e8
3
+ metadata.gz: 2529e6b24197617c3554da5d1ff7348b19681769562259b05f6d88f469527cf1
4
+ data.tar.gz: 76b205cac4da0c4d7a34f391ebf569b8c997af87d47edf8b607d602727f82582
5
5
  SHA512:
6
- metadata.gz: f421f0a4df5ea2eb782766c259336f1298844b3e8ebcefad34885c446865538f02d4d7da3bedcbc84c254464e8a589ff4d3007de8fbe14d0158ad23edf6118d3
7
- data.tar.gz: 5102b14720d671f3252eae1b37b942620128f33df90983877e25354958b147bda0545b86ff946aa35f973210abdea603a342547acf11621b4baf2829c0372a65
6
+ metadata.gz: 977f6398c3f1e6a10e198c267c9b65af6f6d3ff0a2e668c3e80e4a0ad79a0fc332b9f03081fd0f4bfcd7f995a8c6b3458d5afc1bb39063dafaa4516f2b51d04c
7
+ data.tar.gz: be81c083da2b5af0a5c97646b621ec2b71a6950025e0da4a608c25a2d28142ec67bba8dab032ccf06c004c3021d1e3d660a2c384a58f4925f4b4690895ea53cb
data/CHANGELOG.md CHANGED
@@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.11.1] - 2026-10-04
11
+
12
+ ### Fixed
13
+ - Enforce the persisted OTP challenge deadline at locked consumption, including vault reads that cross expiry. Expired secrets are discarded without a success receipt; memory retention is bounded by both vault TTL and challenge expiry.
14
+
15
+ - Preserve the authenticated service endpoint when another HITL startup is refused. Serialize listener ownership and remove only the socket acquired by the stopping invocation; stale recovery requires a protected service-owned socket.
16
+
17
+ ### Changed
18
+
19
+ - Load shared provider references, results, and errors from `ace-hitl-contract`; retain provider registration and locked assignment authority in HITL.
20
+
10
21
  ## [0.11.0] - 2026-10-04
11
22
 
12
23
  ### Added
@@ -17,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
17
28
  - **OTP exact-operation challenges**: an `otp` request exists only with its non-secret publisher evidence (`{operation, result_ref, input_digest, expires_at}`, bounded to 24h); consumption requires the challenge's authorized operation; duplicate consumes replay the receipt WITHOUT the secret bytes. OTP effects are forbidden (the authorized operation consumes the OTP transiently). The service holds OTP bytes in a bounded, TTL-bound memory vault (`OtpVault::MemoryVault`) — secret bytes persist nowhere, transfer exactly once, and a restart deliberately loses the challenge (prompt again).
18
29
 
19
30
  ### Changed
31
+ - **Hardened privileged lifecycle boundary handling**: boundary-facing provider flows no longer trust ambient test fixtures and the lab-side authorizer validates the same privileged transition rules the service enforces.
20
32
  - **BREAKING (pre-1.0, ADR-024)**: `ace-hitl ask/deliver/consume/cancel/pending/states/duty` run through the boundary client — the CLI no longer touches shared store files directly, and broker operations no longer require root (the configured transport identity from the grants document does). The admin binding bypass is removed: EVERY request validates against its live attempt. The `LAB_ATTEMPT_ID` environment default is gone (environment is never attempt authority); `--attempt` is required, and binding requires `--assignment` (managed) or `--work` (legacy path, kept until vs2 switches consumers). Answers and store files are service-owned `0600` — requesters receive bytes over the authenticated boundary, not through file ownership. The multi-UID acceptance fixture (`test/edge/lifecycle/`) proves requester isolation, protected ownership, forged-identity rejection, and one-terminal-transition races under real distinct OS accounts (requires root; CI records the run as advisory evidence).
21
33
 
22
34
 
data/docs/usage.md CHANGED
@@ -251,7 +251,12 @@ A consume timeout bounds ONLY the local wait — the request stays
251
251
  pending and answerable. OTP consumption requires the challenge's
252
252
  authorized operation (`--operation <name>`); the secret transfers
253
253
  exactly once and a consumed retry replays the receipt WITHOUT the
254
- bytes. Cancel is the ONLY way to abandon a request, requester-only;
254
+ bytes. The persisted challenge deadline is checked at the locked handoff;
255
+ consumption at or after that deadline refuses with an expired error and
256
+ discards the pending secret. Memory retention ends at the earlier of the
257
+ challenge deadline and the vault TTL. Delivery, duplicate delivery, waits,
258
+ and service restarts cannot extend the authorized window. Request a fresh
259
+ authorized challenge after expiry. Cancel is the ONLY way to abandon a request, requester-only;
255
260
  it records `cancelled_by` and the `reason` in the public projection,
256
261
  and late answers/consumes fail closed against the committed receipt.
257
262
 
@@ -90,7 +90,10 @@ module Ace
90
90
  def write(_store, value, answer)
91
91
  key = key_for(value)
92
92
  @entries.shift while @entries.length >= @max_entries
93
- @entries[key] = Entry.new(answer.dup.freeze, @clock.now.to_i + @ttl_seconds)
93
+ retention_deadline = @clock.now.to_i + @ttl_seconds
94
+ challenge_deadline = value.dig("otp", "expires_at")
95
+ deadline = challenge_deadline ? [retention_deadline, Integer(challenge_deadline)].min : retention_deadline
96
+ @entries[key] = Entry.new(answer.dup.freeze, deadline)
94
97
  nil
95
98
  end
96
99
 
@@ -101,7 +104,10 @@ module Ace
101
104
  def read(store, value)
102
105
  entry = @entries[key_for(value)]
103
106
  raise MissError, "no OTP is pending for this request" unless entry
104
- raise ExpiredError, "the OTP challenge has expired; request a new one" if entry.expires_at <= @clock.now.to_i
107
+ if entry.expires_at <= @clock.now.to_i
108
+ @entries.delete(key_for(value))
109
+ raise ExpiredError, "the OTP challenge has expired; request a new one"
110
+ end
105
111
 
106
112
  @entries.delete(key_for(value))
107
113
  entry.answer.dup
@@ -50,6 +50,8 @@ module Ace
50
50
  @connections_mutex = Mutex.new
51
51
  @stopping = false
52
52
  @server = nil
53
+ @endpoint_identity = nil
54
+ @endpoint_lock = nil
53
55
  end
54
56
 
55
57
  # Serve until #stop. Installs TERM/INT handlers for a clean
@@ -125,9 +127,10 @@ module Ace
125
127
  def prepare!
126
128
  begin
127
129
  verify_socket_directory!
128
- refuse_live_service!
129
- FileUtils.rm_f(@socket_path)
130
+ acquire_endpoint_lock!
131
+ recover_stale_endpoint!
130
132
  @server = UNIXServer.open(@socket_path)
133
+ @endpoint_identity = endpoint_identity
131
134
  rescue SystemCallError, IOError => e
132
135
  raise TransportError, "the HITL boundary endpoint is not usable (#{e.class}: #{e.message})"
133
136
  end
@@ -148,8 +151,20 @@ module Ace
148
151
  end
149
152
 
150
153
  def shutdown!
151
- @server&.close rescue nil
152
- FileUtils.rm_f(@socket_path)
154
+ begin
155
+ @server&.close
156
+ rescue SystemCallError, IOError
157
+ nil
158
+ end
159
+ begin
160
+ File.unlink(@socket_path) if @endpoint_identity && endpoint_identity == @endpoint_identity
161
+ rescue Errno::ENOENT
162
+ nil
163
+ ensure
164
+ @endpoint_identity = nil
165
+ @endpoint_lock&.close
166
+ @endpoint_lock = nil
167
+ end
153
168
  log("stopped")
154
169
  end
155
170
 
@@ -229,27 +244,53 @@ module Ace
229
244
  ->(limit) { answer[0, limit] }
230
245
  end
231
246
 
232
- # A connectable socket has a live listener: a second service must
233
- # refuse to start instead of stealing the endpoint (review
234
- # 8x327buq). Only a dead socket (ECONNREFUSED) is removed.
235
- def refuse_live_service!
236
- return unless File.exist?(@socket_path)
247
+ # Keep one stable lock inode for the endpoint. Holding the lock
248
+ # through shutdown prevents simultaneous starters from recovering
249
+ # or replacing a socket another invocation is still acquiring.
250
+ # Never unlink the lock file: waiters must all use the same inode.
251
+ def acquire_endpoint_lock!
252
+ @endpoint_lock = File.open("#{@socket_path}.lock", File::RDWR | File::CREAT | File::NOFOLLOW, 0o600)
253
+ stat = @endpoint_lock.stat
254
+ unless stat.file? && stat.uid == Process.uid && stat.nlink == 1 && (stat.mode & 0o077).zero?
255
+ raise TransportError, "HITL boundary endpoint lock is not protected"
256
+ end
257
+ return if @endpoint_lock.flock(File::LOCK_EX | File::LOCK_NB)
258
+
259
+ raise TransportError,
260
+ "a HITL boundary service is already serving #{@socket_path}; stop it before starting another"
261
+ end
262
+
263
+ def endpoint_identity
264
+ stat = File.lstat(@socket_path)
265
+ [stat.dev, stat.ino] if stat.socket?
266
+ rescue Errno::ENOENT
267
+ nil
268
+ end
237
269
 
270
+ # Only an owned socket whose connection is explicitly refused is
271
+ # stale. Permission, type and other probe failures cannot grant
272
+ # authority to delete an endpoint.
273
+ def recover_stale_endpoint!
274
+ stat = File.lstat(@socket_path)
275
+ unless stat.socket? && stat.uid == Process.uid && (stat.mode & 0o002).zero?
276
+ raise TransportError, "HITL boundary endpoint is not a protected service-owned socket"
277
+ end
278
+ identity = [stat.dev, stat.ino]
238
279
  begin
239
280
  probe = UNIXSocket.open(@socket_path)
240
- rescue SystemCallError, IOError
281
+ rescue Errno::ECONNREFUSED
282
+ unless endpoint_identity == identity
283
+ raise TransportError, "HITL boundary endpoint changed during stale socket recovery"
284
+ end
285
+ File.unlink(@socket_path)
241
286
  return
242
- end
243
- begin
244
- probe.write(Protocol.encode_request("ping"))
245
- probe.flush
246
- rescue SystemCallError, IOError
247
- nil
248
287
  ensure
249
- probe.close rescue nil
288
+ probe&.close
250
289
  end
251
290
  raise TransportError,
252
291
  "a HITL boundary service is already serving #{@socket_path}; stop it before starting another"
292
+ rescue Errno::ENOENT
293
+ nil
253
294
  end
254
295
 
255
296
  def read_frame(socket)
@@ -235,11 +235,18 @@ module Ace
235
235
  # fails closed instead of handing over an answer.
236
236
  with_live_authority!(value, requester: @identity.username) do
237
237
  verify_operation!(value, operation)
238
+ verify_otp_deadline!(value)
238
239
  answer = read_answer(value)
239
240
  unless answer
240
241
  next
241
242
  end
242
243
 
244
+ begin
245
+ verify_otp_deadline!(value)
246
+ rescue OtpVault::ExpiredError
247
+ answer.clear if value["sensitive"] == true && !answer.frozen?
248
+ raise
249
+ end
243
250
  commit_terminal!(value, "consumed",
244
251
  answer: value["sensitive"] == true ? nil : answer)
245
252
  update_public(value, "consumed")
@@ -341,6 +348,7 @@ module Ace
341
348
  # under the lock; report it without a second write or effect.
342
349
  return delivered_result(value) if answer_present?(value)
343
350
  with_live_authority!(value, requester: value["requester"]) do
351
+ verify_otp_deadline!(value)
344
352
  write_answer(value, answer)
345
353
  update_public(value, "answer-delivered")
346
354
  unless value["sensitive"] == true
@@ -839,6 +847,15 @@ module Ace
839
847
  answer
840
848
  end
841
849
 
850
+ # The persisted authorization window applies to every vault and
851
+ # is checked under the request and live-authority locks at handoff.
852
+ def verify_otp_deadline!(value)
853
+ return unless value["otp"] && Integer(value["otp"]["expires_at"]) <= Time.now.to_i
854
+
855
+ @vault.discard(self, value)
856
+ raise OtpVault::ExpiredError, "the OTP challenge has expired; request a new one"
857
+ end
858
+
842
859
  # Persist the answer service-owned (0600): requesters never touch
843
860
  # store files — consumption transfers the bytes over the
844
861
  # authenticated boundary. The temporary is unique per invocation
@@ -2,7 +2,7 @@
2
2
 
3
3
  require "socket"
4
4
  require "json"
5
- require_relative "../errors"
5
+ require "ace/hitl/contract"
6
6
  require_relative "../../lifecycle/errors"
7
7
 
8
8
  module Ace
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "securerandom"
4
- require_relative "errors"
4
+ require "ace/hitl/contract"
5
5
  require_relative "lab/daemon_binding"
6
6
  require_relative "lab/assignment_binding"
7
7
  require_relative "lab/composite_binding"
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative "errors"
4
- require_relative "ref"
3
+ require "ace/hitl/contract"
5
4
  require_relative "lab"
6
5
 
7
6
  module Ace
@@ -10,13 +9,6 @@ module Ace
10
9
  # transports behind one interface. Agent-facing ace-hitl paths resolve
11
10
  # adapters through this registry only — never a concrete transport.
12
11
  module Providers
13
- # ask(question:, ...) result: local event id + relay request id.
14
- AskResult = Struct.new(:event_id, :request_id, keyword_init: true)
15
-
16
- # deliver(ref, answer) result; state is :delivered, :retryable
17
- # (safe to re-push identical content) or :failed.
18
- DeliverResult = Struct.new(:ref, :state, keyword_init: true)
19
-
20
12
  REGISTRY = {
21
13
  Lab::PROVIDER_NAME => -> { Lab.new }
22
14
  }.freeze
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Hitl
5
- VERSION = "0.11.0"
5
+ VERSION = "0.11.1"
6
6
  end
7
7
  end
data/lib/ace/hitl.rb CHANGED
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "ace/support/items"
4
+ require "ace/hitl/contract"
4
5
  require_relative "hitl/version"
5
6
  require_relative "hitl/atoms/hitl_file_pattern"
6
7
  require_relative "hitl/atoms/hitl_id_formatter"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-hitl
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.0
4
+ version: 0.11.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
@@ -9,6 +9,20 @@ bindir: exe
9
9
  cert_chain: []
10
10
  date: 2026-10-04 00:00:00.000000000 Z
11
11
  dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: ace-hitl-contract
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.1'
12
26
  - !ruby/object:Gem::Dependency
13
27
  name: ace-support-core
14
28
  requirement: !ruby/object:Gem::Requirement
@@ -99,14 +113,14 @@ dependencies:
99
113
  requirements:
100
114
  - - "~>"
101
115
  - !ruby/object:Gem::Version
102
- version: '0.62'
116
+ version: 0.63.1
103
117
  type: :runtime
104
118
  prerelease: false
105
119
  version_requirements: !ruby/object:Gem::Requirement
106
120
  requirements:
107
121
  - - "~>"
108
122
  - !ruby/object:Gem::Version
109
- version: '0.62'
123
+ version: 0.63.1
110
124
  description: Provides the dedicated ace-hitl package surface for human in the loop
111
125
  (HITL) semantics and CLI workflows.
112
126
  email:
@@ -179,13 +193,11 @@ files:
179
193
  - lib/ace/hitl/molecules/resume_dispatcher.rb
180
194
  - lib/ace/hitl/molecules/worktree_scope_resolver.rb
181
195
  - lib/ace/hitl/organisms/hitl_manager.rb
182
- - lib/ace/hitl/providers/errors.rb
183
196
  - lib/ace/hitl/providers/lab.rb
184
197
  - lib/ace/hitl/providers/lab/assignment_binding.rb
185
198
  - lib/ace/hitl/providers/lab/composite_binding.rb
186
199
  - lib/ace/hitl/providers/lab/daemon_binding.rb
187
200
  - lib/ace/hitl/providers/providers.rb
188
- - lib/ace/hitl/providers/ref.rb
189
201
  - lib/ace/hitl/version.rb
190
202
  homepage: https://github.com/cs3b/ace
191
203
  licenses:
@@ -1,24 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Ace
4
- module Hitl
5
- module Providers
6
- # Error model for HITL provider adapters (spec 8wm.t.vrz §2).
7
- class Error < StandardError; end
8
-
9
- # The provider name is not in the registry.
10
- class UnknownProviderError < Error; end
11
-
12
- # The reverse address (herdr session + pane) is absent or invalid.
13
- # Ask fails closed before any event or transport state is created.
14
- class InvalidRefError < Error; end
15
-
16
- # The transport send failed (binary missing, non-zero exit,
17
- # unparseable output) after the local event may already exist.
18
- class ProviderUnavailableError < Error; end
19
-
20
- # The provider does not implement the requested operation.
21
- class UnsupportedOperationError < Error; end
22
- end
23
- end
24
- end
@@ -1,62 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require_relative "errors"
4
-
5
- module Ace
6
- module Hitl
7
- module Providers
8
- # Typed reverse address of the asking agent: herdr session + pane
9
- # (spec 8wm.t.vrz §3). Versioned schema ace.hitl.ref/v1; captured
10
- # fail-closed from the herdr environment at ask time. ace-herdr
11
- # exports both variables when it bootstraps an agent pane.
12
- #
13
- # The ref identifies WHERE the answer goes; it never carries answer
14
- # content.
15
- class Ref
16
- SCHEMA = "ace.hitl.ref/v1"
17
- SESSION_ENV = "HERDR_SESSION"
18
- PANE_ENV = "HERDR_PANE"
19
- MAX_LENGTH = 128
20
- TOKEN_PATTERN = /\A[A-Za-z0-9._:-]+\z/.freeze
21
-
22
- attr_reader :session, :pane
23
-
24
- def initialize(session:, pane:)
25
- @session = session
26
- @pane = pane
27
- end
28
-
29
- # Fail closed: raises InvalidRefError naming the offending variable
30
- # and rule when the reverse address is absent or invalid.
31
- def self.from_env(env = ENV)
32
- session = validate!(env[SESSION_ENV], SESSION_ENV)
33
- pane = validate!(env[PANE_ENV], PANE_ENV)
34
- new(session: session, pane: pane)
35
- end
36
-
37
- def self.validate!(value, source)
38
- token = value.to_s.strip
39
- if token.empty?
40
- raise InvalidRefError,
41
- "#{source} is required (herdr reverse address, #{SCHEMA})"
42
- end
43
- if token.length > MAX_LENGTH
44
- raise InvalidRefError,
45
- "#{source} exceeds #{MAX_LENGTH} characters"
46
- end
47
- unless token.match?(TOKEN_PATTERN)
48
- raise InvalidRefError,
49
- "#{source} contains invalid characters " \
50
- "(allowed: letters, digits, '.', '_', ':', '-')"
51
- end
52
-
53
- token
54
- end
55
-
56
- def to_h
57
- {schema: SCHEMA, session: session, pane: pane}
58
- end
59
- end
60
- end
61
- end
62
- end