ace-runtime 0.3.0 → 0.4.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: 1a5a447921ab8d6b478e41a38e6b56866977accc8a75250e394c89c6fa0ac658
4
- data.tar.gz: efe75a6544cb3284a9bf544deaaa30975b60971a5c821799666502240442ec7f
3
+ metadata.gz: 9b9c53bb970a918797a57b7aa00c36587a8da5420944cca83cee41a6f6ae2792
4
+ data.tar.gz: 4c4486463938f67ce14882472ede27fd96016a95c92a60d38a85fa60e5e87027
5
5
  SHA512:
6
- metadata.gz: 46343d278d6041fcd7f41c859207ee0303e28811d156b923bc195b88cf73aef852d3f44b50df62a90425f0cec8a942984504ca9a7c52e4cd9152ffc77679d87a
7
- data.tar.gz: b61c518fb64562ddb1975be5b6b71242134f013ec80de029bf259c5d92acdd2572b428f60788e4e4578e7f5f2a59abb3e50ec8664eaa462ceb65482ce3b6857e
6
+ metadata.gz: 3b02211e6604c9b51ea8e2e294dac5678cad47b52f0b1b1e5fc971888bed07876974a16a3eb0178b5d4551f274b2559aba374e4ec8c578121a99ec6f888b0795
7
+ data.tar.gz: f36be2a74247d133b118cdfdbe58e24628f8051b262848e171523a6897c4bfd99d503cdac0de5c87f182c4c2179051ca4c01f64ed71a2fda3b310ba83496ca05
data/CHANGELOG.md CHANGED
@@ -6,6 +6,18 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.4.1] - 2026-10-18
10
+
11
+ ### Changed
12
+
13
+ - Validate execution-scope unit names with the existing systemd unit shape, `.slice`/`.service` suffix and effective isolation checks only. A unit name that contains a role word such as `overseer` is no longer rejected: a role label is not a security boundary, and the real checks stay unchanged.
14
+
15
+ ## [0.4.0] - 2026-10-16
16
+
17
+ ### Added
18
+
19
+ - Distinguish positively ended process generations from inaccessible observations for explicit coordinator adoption; retain conservative unknown liveness for existing recovery observers.
20
+
9
21
  ## [0.3.0] - 2026-10-10
10
22
 
11
23
  - Ship the protected task-context/worker entry and readiness source used by the current consumers; these required modules were absent from published 0.2.0. Ordinary agent execution remains the ace-llm terminal CLI, with no additional Codex service.
data/README.md CHANGED
@@ -1,8 +1,13 @@
1
1
  # ace-runtime
2
2
 
3
- Runtime-neutral terminal intent contract for ACE (spec 8wq.t.k86.0). One
3
+ > **State owner:** none. `ace-runtime` is a stateless terminal intent contract;
4
+ > tmux/Herdr adapters own only terminal transport. Task, assignment, and HITL
5
+ > state belong to `.ace-tasks/` and the local stores under `.ace-local/`
6
+ > ([docs/lab-flow.md](../docs/lab-flow.md)).
7
+
8
+ Runtime-neutral terminal intent contract for ACE. One
4
9
  duck-typed intent API that terminal runtimes implement and all consumers
5
- call — the structural change that makes herdr an equal partner to tmux
10
+ call -- the structural change that makes herdr an equal partner to tmux
6
11
  instead of a side gem.
7
12
 
8
13
  Adapters live INSIDE the existing wrapper gems: `ace-tmux` and `ace-herdr`
@@ -17,22 +22,22 @@ Eleven operations, keyword-arg surfaces mirroring today's consumer calls:
17
22
  | Operation | Surface |
18
23
  |-----------|---------|
19
24
  | Context | `runtime.context` → `{in_runtime:, session:, window:, pane:}` |
20
- | Ensure window | `runtime.ensure_window(name:, root:, preset: nil)` — idempotent by normalized name; conflicting root/preset raises `WindowConflictError` |
25
+ | Ensure window | `runtime.ensure_window(name:, root:, preset: nil)` -- idempotent by normalized name; conflicting root/preset raises `WindowConflictError` |
21
26
  | Process identity | `runtime.process_binding(pane:, caller_pid:)` → verified owner/native binding or nil (unknown); read-only, no authorization |
22
- | Prepare pane | `runtime.prepare_pane(window:)` — splits when needed; target stays alive after a submitted command exits |
27
+ | Prepare pane | `runtime.prepare_pane(window:)` -- splits when needed; target stays alive after a submitted command exits |
23
28
  | Focus | `runtime.focus(window:)` |
24
- | Send | `runtime.send(pane:, command: nil, items: [])` — ordered `{message: String}` / `{key: String}` entries |
29
+ | Send | `runtime.send(pane:, command: nil, items: [])` -- ordered `{message: String}` / `{key: String}` entries |
25
30
  | Capture | `runtime.capture(pane:, lines: 40)` → text |
26
31
  | Wait output | `runtime.wait_output(pane:, pattern:, timeout:)` |
27
32
  | Wait agent | `runtime.wait_agent(pane:, states:, timeout:)` |
28
- | Wait lifecycle | `runtime.wait_lifecycle(condition:, target:, timeout:)` — `window-exits`/`window-active`/`pane-exists`/`pane-exited` observations only |
33
+ | Wait lifecycle | `runtime.wait_lifecycle(condition:, target:, timeout:)` -- `window-exits`/`window-active`/`pane-exists`/`pane-exited` observations only |
29
34
  | Close | `runtime.close_window(window:)` |
30
35
  | List | `runtime.list_windows` / `runtime.list_panes(window:)` |
31
36
 
32
37
  Convenience shapes: `send_command(pane:, command:)`,
33
38
  `send_text(pane:, text:)`, `send_keys(pane:, keys:)`.
34
39
 
35
- Target identity is opaque adapter-owned handles at every boundary — the
40
+ Target identity is opaque adapter-owned handles at every boundary -- the
36
41
  contract never predicts or formats pane ids (tmux `%id` and herdr
37
42
  `w1:p1` stay adapter-internal). Window operations take the (sanitized)
38
43
  window name; pane operations take the handle returned by the adapter.
@@ -52,30 +57,33 @@ CLI/runtime selection precedence (highest wins):
52
57
 
53
58
  1. explicit name (`ace-runtime send --runtime herdr`)
54
59
  2. `ACE_RUNTIME` environment (consumer forks set this plus target context)
55
- 3. configured runtime — `.ace/runtime/config.yml` key `runtime`
60
+ 3. configured runtime -- `.ace/runtime/config.yml` key `runtime`
56
61
  4. auto-detection from the environment (`TMUX` / `ACE_TMUX_SESSION`, or
57
62
  `HERDR_SESSION` + `HERDR_PANE`). When both are live, tmux wins
58
63
  (existing default). Only assign auto launch mode selects headless
59
64
  outside any runtime; that decision lives above this contract.
60
65
 
61
66
  An explicitly selected runtime that turns out unknown or unavailable
62
- fails closed — never a silent switch or headless downgrade.
67
+ fails closed -- never a silent switch or headless downgrade.
63
68
 
64
69
  ## Send matrix
65
70
 
66
71
  `send` is the only submission primitive; the granular ops are shapes of
67
- it. `items` is an ORDERED list — the only shape that preserves
72
+ it. `items` is an ORDERED list -- the only shape that preserves
68
73
  message/key interleaving. When `command` is present, `items` holds
69
74
  exclusively post-command keys; leading keys are a usage error before any
70
75
  transport call (the CLI enforces `--cmd` before every `--key`).
71
76
 
72
77
  - **Plain-pane adapters** (`send_profile == :plain_pane`, e.g. tmux):
78
+
73
79
  messages are raw text (no implicit submission), keys are keystrokes,
74
80
  each Enter submits pending text; `command` submits once, then trailing
75
81
  keys deliver.
82
+
76
83
  - **Agent-aware adapters** (`send_profile == :agent_aware`, e.g. herdr):
84
+
77
85
  `command` or the concatenated messages become ONE prompt that submits
78
- itself (message-only input DOES submit once on agent panes — intended
86
+ itself (message-only input DOES submit once on agent panes -- intended
79
87
  divergence); at most one trailing Enter is dropped and reported via
80
88
  the result; keys-only sequences go to agent keys; every other
81
89
  interleaving is rejected before any transport call.
@@ -87,16 +95,18 @@ profiles.
87
95
 
88
96
  All failures subclass `Ace::Runtime::Error`:
89
97
 
90
- - `UnknownRuntimeError` — with the sorted available list
91
- - `RuntimeUnavailableError` — known runtime, unusable right now
92
- - `TargetNotFoundError` — window/pane target does not exist
93
- - `WindowConflictError` — same normalized name, incompatible root/preset
94
- - `SendRejectedError` — pre-send rejection; terminal, no transport call
95
- - `SendStalledError` — submission accepted but the target never started
98
+ - `UnknownRuntimeError` -- with the sorted available list
99
+ - `RuntimeUnavailableError` -- known runtime, unusable right now
100
+ - `TargetNotFoundError` -- window/pane target does not exist
101
+ - `WindowConflictError` -- same normalized name, incompatible root/preset
102
+ - `SendRejectedError` -- pre-send rejection; terminal, no transport call
103
+ - `SendStalledError` -- submission accepted but the target never started
104
+
96
105
  processing; OUTCOME UNCERTAIN, callers must not auto-resend
97
- - `WaitTimeoutError` — a wait exceeded its deadline (seconds context)
98
106
 
99
- `pane-exited` is a runtime observation only — never assignment success,
107
+ - `WaitTimeoutError` -- a wait exceeded its deadline (seconds context)
108
+
109
+ `pane-exited` is a runtime observation only -- never assignment success,
100
110
  receipt confirmation, or prune authorization.
101
111
 
102
112
  ## Adapter contract
@@ -110,12 +120,16 @@ Ace::Runtime.register(:<name>, -> { Ace::Tmux::RuntimeAdapter.new })
110
120
  ```
111
121
 
112
122
  The contract resolves an unregistered known name by attempting to load
113
- that entrypoint lazily — no gem dependency in either direction.
123
+ that entrypoint lazily -- no gem dependency in either direction.
114
124
 
115
125
  The packaged shared suite `Ace::Runtime::Testing::AdapterContract` is
116
126
  the acceptance bar for ANY adapter. See `docs/usage.md` for the
117
127
  adapter-authoring walkthrough.
118
128
 
129
+ > **Planned (L2):** retirement or redesign of the legacy native bootstrap
130
+ > artifacts under `native/` -- not available yet. They are not required by the
131
+ > file-based flow.
132
+
119
133
  Note: adapters intentionally define `send` (the intent operation),
120
134
  which overrides `Object#send`. Use `__send__` for reflective dispatch
121
135
  on adapter objects.
data/docs/usage.md CHANGED
@@ -30,15 +30,22 @@ ace-runtime send --pane %1 --cmd 'vim .' --key C-w
30
30
  Rules enforced before any transport call:
31
31
 
32
32
  - `--pane` is required; at least one of `--cmd`, `--msg`, `--key` is
33
+
33
34
  required (none = usage error).
34
- - `--key` before `--cmd` is a usage error — `--cmd` must be declared
35
+
36
+ - `--key` before `--cmd` is a usage error -- `--cmd` must be declared
37
+
35
38
  before every key. Keys declared after `--cmd` are post-submission
36
39
  keystrokes.
40
+
37
41
  - The CLI builds direct-API items as all supplied messages followed by
42
+
38
43
  all supplied keys. Direct Ruby callers use `send(items:)` when they
39
44
  need arbitrary interleaving (for example `msg, key, msg` on plain
40
45
  panes).
46
+
41
47
  - On agent panes (`:agent_aware` profile) the callback shapes submit
48
+
42
49
  exactly once: `--msg ... --key Enter` becomes one self-submitting
43
50
  prompt with the trailing Enter dropped and reported; interleaved
44
51
  items, multiple Enters, `--cmd` with messages, and `--cmd` with
@@ -87,7 +94,7 @@ name)`; an existing window with an incompatible root or preset raises
87
94
  `WindowConflictError` instead of silently reusing the wrong worktree.
88
95
 
89
96
  `wait_lifecycle` accepts only `window-exists`, `window-active`,
90
- `pane-exists`, `pane-exited`. It reports the condition only — never
97
+ `pane-exists`, `pane-exited`. It reports the condition only -- never
91
98
  authorizes completion, receipts, or pruning. `pane-exited` in
92
99
  particular is an observation, not assignment success or safe prune
93
100
  evidence.
@@ -105,14 +112,14 @@ evidence.
105
112
 
106
113
  Explicit selections never downgrade: unknown names fail closed with
107
114
  the available list (`UnknownRuntimeError`); a selected-but-unavailable
108
- runtime raises `RuntimeUnavailableError` — no headless fallback, no
115
+ runtime raises `RuntimeUnavailableError` -- no headless fallback, no
109
116
  silent switch. The assign auto launch mode is the only consumer that
110
117
  selects headless outside any runtime, and it does so above this
111
118
  contract.
112
119
 
113
120
  ## Adapter authoring
114
121
 
115
- Adapters are duck-typed — no base class (ace-hitl provider-registry
122
+ Adapters are duck-typed -- no base class (ace-hitl provider-registry
116
123
  pattern). An adapter package:
117
124
 
118
125
  1. declares a dependency on `ace-runtime` (never the reverse),
@@ -140,11 +147,16 @@ Non-negotiables enforced by the contract suite:
140
147
 
141
148
  - target handles stay opaque at every public boundary,
142
149
  - `prepare_pane` yields a writable live shell/agent target retained
150
+
143
151
  after a submitted command exits (bare command panes do not satisfy
144
152
  preparation),
153
+
145
154
  - `detect` and adapter availability checks are side-effect-free; an
155
+
146
156
  unavailable explicit runtime never becomes headless,
157
+
147
158
  - installed tests must verify both runtimes; never assert untested
159
+
148
160
  behavior.
149
161
 
150
162
  ### The acceptance bar
@@ -170,7 +182,7 @@ class TmuxAdapterContractTest < AceTestCase
170
182
  end
171
183
  ```
172
184
 
173
- The suite drives your adapter over `ScriptedRuntime` — an in-memory
185
+ The suite drives your adapter over `ScriptedRuntime` -- an in-memory
174
186
  terminal (windows, panes, raw text writes, named keys, capture, agent
175
187
  state) that records every mutation op so the suite can assert delivery
176
188
  ordering and rejection-before-transport. Wire your adapter's native
@@ -188,125 +200,10 @@ in the ace-runtime gem; the in-gem suite
188
200
  (`test/contract/adapter_contract_test.rb`) proves the battery against
189
201
  it for both send profiles.
190
202
 
191
- ## Root-produced network installation evidence
192
-
193
- The source-only `Ace::Runtime::Molecules::NetworkInstallationEvidence` consumer
194
- checks the protected content selected by the installed scope owner:
195
-
196
- ```ruby
197
- result = Ace::Runtime::Molecules::NetworkInstallationEvidence.verify!(
198
- selection: boundary_manifest.fetch("network_installation"),
199
- expected: {
200
- "slot_id" => slot_id,
201
- "namespace_path" => installed_namespace_path,
202
- "boot_id" => current_boot_id,
203
- "namespace_identity" => {"device" => pinned_device, "inode" => pinned_inode},
204
- "installer_artifact_sha256" => installed_producer_sha256
205
- }
206
- )
207
- ```
203
+ ## Native bootstrap and network evidence (legacy)
208
204
 
209
- `selection` is exactly `{profile,policy_export,report,installer_artifact}`; each
210
- reference is exactly `{path,sha256,bytes}`. All keys are strings, paths canonical
211
- and absolute, SHA256 lowercase, and lengths positive integers. There is no
212
- caller-uploaded evidence or caller-selected trust root. The namespace identity
213
- above must come from the owner's actual held, authenticated nsfs network object;
214
- the owner validates its type/current boot and keeps the namespace pinned through
215
- admission. This verifier reads content artifacts and never opens/substitutes the
216
- namespace, probes traffic or grants runtime networking privilege.
217
-
218
- Successful output contains exactly `report_id`, `boot_id`, `slot_id`,
219
- `namespace_path`, `namespace_identity`, `profile_sha256`, `policy_export_sha256`,
220
- `report_sha256` and `installer_artifact_sha256`, deeply frozen. It authenticates
221
- installation content, not actual enforcement from a summary boolean. Missing,
222
- unreadable, malformed, conflicting, oversized or context-mismatched evidence
223
- raises `Ace::Runtime::RuntimeUnavailableError`; the authority boundary exposes
224
- that refusal as `evidence_unavailable` and retains public `invalid_input`
225
- classification for malformed public selectors. No absent-evidence fallback.
226
-
227
- The reader pins regular-file and directory descriptors for the complete graph,
228
- checks exact lengths/digests and revalidates original objects/modes at completion.
229
- It uses root ownership and no group/other write on supported Linux POSIX local
230
- filesystems (`ext2`, `ext3`, `ext4`, `xfs`, `btrfs`, `tmpfs`): group mode bits
231
- represent the access ACL mask, so named non-root ACL users/groups cannot obtain
232
- write through it. Unknown filesystem models refuse. All ancestors are checked;
233
- no symlink, untrusted writable ancestor or mutable worker artifact qualifies.
234
- Content bounds are 1MiB per artifact, 16MiB aggregate and 256 distinct artifacts.
235
- Trusted installer code verifying a larger explicitly selected source closure can
236
- construct `ProtectedArtifactSet.new(count_limit: 4096)`; the value must be a
237
- positive integer supplied by trusted code, never copied from an artifact or
238
- request. Existing `file_limit:` and `total_limit:` budgets still apply. Mixed
239
- `read!`/`read_path!` calls share the count; repeating a retained reference spends
240
- no additional entry, and the first new artifact beyond the limit refuses.
241
- Trusted source installers can use `with_readonly_handle!(reference)` inside an
242
- active `ProtectedArtifactSet#with` callback to hand off the authenticated retained
243
- regular file. It yields a read-only duplicate without reopening the selected
244
- pathname, verifies the held set before and after the callback, and closes the
245
- duplicate on success or error. No descriptor or maintenance authority survives
246
- the callback. Existing reference/budget/root protection rules still apply.
247
-
248
- Network evidence verifiers retain their default budgets. Held descriptors also
249
- require sufficient process file-descriptor capacity in the installer environment.
250
- The profile's 256-entry bound counts its four outer collections only; nested
251
- ranges, ports and helper/configuration refs are bounded by the profile bytes and
252
- separate artifact graph limits. Strict JSON parsing requires the declared
253
- `json >= 2.20, < 3` dependency to reject duplicate keys and comments explicitly.
254
-
255
- The trusted domain installer owns complete effective policy/topology comparison,
256
- independent correlated flow checks, immutable digest-bearing evidence filenames
257
- and publication that preserves historical artifacts. The generic reference
258
- schema specifies no basename grammar: this consumer checks selected content and
259
- protection, and cannot establish publication history. Raw policy/topology/setup
260
- and correlated observations remain required; unsupported or incomplete domain
261
- checks must never produce a passing report. Domain source/installed review proves
262
- those obligations. For each loopback tool, the fixed domain procedure inspects
263
- the actual selected executable against its profile `artifact_sha256` and retains
264
- the observation in the exact tool-target trace. The schema provides no executable
265
- path/ref: generic ACE authenticates the trace/raw refs and digest format; it does
266
- not compare a tool executable digest with observation bytes or invent a ref.
267
- Domain acceptance must exercise tool-executable mismatch refusal.
268
-
269
- Root installation lifetime, private admission CAS, stage joins,
270
- namespace containment and maintenance ordering belong to their respective
271
- installed owners; this verifier does not implement them or supply native,
272
- packet-filter or installed acceptance by itself.
273
-
274
- ### Original boot proof and fixed live readiness views
275
-
276
- `ExecutionBootBaseline#select!(expected:)` authenticates the fixed protected
277
- per-slot publication pointer at provisioning and returns its immutable proof
278
- reference. `#verify!(selection:, expected:)` reads only that original reference,
279
- joining whole-file bytes, slot, original boot, mapping digest, root PID1 birth
280
- context and exact original network installer artifact. It never discovers an old
281
- namespace or substitutes a current pointer. Proof JSON is closed and at most
282
- 16KiB. Trusted host capture, per-boot refresh and cold-start admission inhibition
283
- remain domain gad.8/gad.b producer obligations.
284
-
285
- `ServerResourceObservation` uses the same-UID native server's pinned root and
286
- namespace descriptors without entering or changing a namespace. It reports the
287
- complete mount table (at most256 rows), fixed API/tmp view mount IDs obtained from
288
- contained O_PATH descriptors, server/hook IPC identities and the exact authority
289
- socket identity. It also holds the original server’s `/proc/<pid>/ns/net` object,
290
- requires the nsfs network-namespace type and rechecks its identity after collection.
291
- The report includes that observed `network_namespace_identity`; it never substitutes
292
- the hook’s namespace or a configured namespace identity. The private report shares
293
- a65,536-byte ceiling. Mount path
294
- prefixes, typed isolation flags and filesystem names alone do not prove live
295
- isolation. `KernelViewTopology` verifies the fixed supported profile, read-only
296
- API views, private IPC host difference and exact writable original backing;
297
- unknown writable aliases and even declared writable API descendants refuse.
298
-
299
- The fixed unit exposes only the selected read-only source-equals-view authority
300
- socket and has `BindLogSockets=false`. Protected endpoint type/owner/incarnation,
301
- contained view identity and authenticated peer remain separate required joins.
302
- These observations supply admission evidence, not terminality or installed
303
- effectiveness.
304
- The supported devpts profile additionally requires an authenticated per-slot
305
- `newinstance` backing, distinct from original host devpts. The trusted boot
306
- baseline pins original host and selected instance identities plus the exact host
307
- `pts/ptmx` link. The unit binds the selected root at `/dev/pts` and its read-only
308
- ptmx node at `/dev/pts/ptmx`; the contained observer verifies the unchanged link,
309
- character device and actual mount identity. `PrivateDevices` alone cannot prove
310
- this isolation. Shared host RO/RW views and another slot's backing refuse.
311
- gad.8 provisioning and gad.b refresh must deliver the selected source before
312
- activation; controlled tests do not establish installed effectiveness.
205
+ > The native Linux worker bootstrap, the root-produced network installation
206
+ > evidence, the boot baseline and the kernel view topology readers belong to the
207
+ > replaced protected model. They are not part of the file-based flow and no
208
+ > command, task, or assignment requires them. Their removal/redesign is tracked
209
+ > in L1/L2; treat this package's runtime contract above as the supported surface.
@@ -299,7 +299,7 @@ module Ace
299
299
  %w[root_directory runtime_directory network_namespace_path].all? { |key| path?(scope[key]) } &&
300
300
  scope.fetch("runtime_directory").start_with?("/run/") && !overlaps?(scope.fetch("root_directory"), scope.fetch("runtime_directory")) &&
301
301
  {"slice_unit" => ".slice", "service_unit" => ".service"}.all? { |key, suffix|
302
- scope[key].is_a?(String) && SystemdScopeManager::UNIT.match?(scope[key]) && scope[key].end_with?(suffix) && !scope[key].downcase.include?("overseer")
302
+ scope[key].is_a?(String) && SystemdScopeManager::UNIT.match?(scope[key]) && scope[key].end_with?(suffix)
303
303
  } &&
304
304
  scope["unit_manifest_sha256"] == service.fetch("unit_manifest").fetch("sha256") &&
305
305
  scope["boundary_manifest_sha256"] == service.fetch("boundary_manifest").fetch("sha256") &&
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fiddle"
4
+
5
+ module Ace
6
+ module Runtime
7
+ module Molecules
8
+ # One ephemeral single-thread helper owns every child in this scope.
9
+ # Linux reparents detached orphans to this subreaper, not container PID1.
10
+ # Unreaped direct children pin their PIDs until this owner reaps them.
11
+ class OwnedChildScope
12
+ class CleanupUncertain < StandardError; end
13
+
14
+ def initialize
15
+ unless RUBY_PLATFORM.include?("linux") && Thread.list.count(&:alive?) == 1
16
+ raise CleanupUncertain, "child scope requires a sole Linux helper thread"
17
+ end
18
+ @owner = Process.pid
19
+ raise CleanupUncertain, "child scope has preexisting children" unless children.empty?
20
+ # An inherited ignore/custom reaper could discard a child's wait
21
+ # status and unpin its PID. This sole ephemeral helper owns the
22
+ # disposition; establish it while the verified child set is empty.
23
+ Signal.trap("CHLD", "DEFAULT")
24
+ prctl = Fiddle::Function.new(Fiddle::Handle::DEFAULT["prctl"],
25
+ [Fiddle::TYPE_INT, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG, Fiddle::TYPE_LONG], Fiddle::TYPE_INT)
26
+ raise CleanupUncertain, "cannot establish child subreaper" unless prctl.call(36, 1, 0, 0, 0).zero?
27
+ end
28
+
29
+ # Preserve the leader's wait status for the executor. Kill/reap only
30
+ # kernel-owned direct children; adoption closes setsid/double-fork escape.
31
+ def cleanup!(leader:, timeout: 5)
32
+ unless Process.pid == @owner && Thread.list.count(&:alive?) == 1 &&
33
+ timeout.is_a?(Numeric) && timeout.finite? && timeout.positive? && timeout <= 5
34
+ raise CleanupUncertain, "child scope ownership is unavailable"
35
+ end
36
+ deadline = monotonic + timeout
37
+ owned = children
38
+ raise CleanupUncertain, "original child is no longer owned" unless owned.include?(leader)
39
+ signal("TERM", -leader)
40
+ loop do
41
+ owned = children
42
+ raise CleanupUncertain, "original child was reaped outside its scope" unless owned.include?(leader)
43
+ owned.each do |pid|
44
+ signal("KILL", pid)
45
+ Process.waitpid(pid, Process::WNOHANG) unless pid == leader
46
+ end
47
+ remaining = children
48
+ return if remaining == [leader] && zombie?(leader)
49
+ raise CleanupUncertain, "owned child cleanup deadline elapsed" if monotonic >= deadline
50
+ IO.select(nil, nil, nil, 0.01)
51
+ end
52
+ rescue Errno::ECHILD, Errno::ENOENT, IOError
53
+ raise CleanupUncertain, "owned child cleanup cannot be confirmed"
54
+ end
55
+
56
+ private
57
+
58
+ def children
59
+ File.open("/proc/self/task/#{@owner}/children", "r") do |file|
60
+ bytes = file.read(65_537) || ""
61
+ raise CleanupUncertain, "owned child inventory exceeds bound" if bytes.bytesize > 65_536
62
+ values = bytes.split.map { |value| Integer(value, 10) }
63
+ unless values.uniq == values && values.all?(&:positive?)
64
+ raise CleanupUncertain, "owned child inventory is invalid"
65
+ end
66
+ values.sort
67
+ end
68
+ end
69
+
70
+ def signal(name, pid)
71
+ Process.kill(name, pid)
72
+ rescue Errno::ESRCH
73
+ nil
74
+ end
75
+
76
+ def zombie?(pid) = File.read("/proc/#{pid}/stat").rpartition(")").last.split.first == "Z"
77
+ def monotonic = Process.clock_gettime(Process::CLOCK_MONOTONIC)
78
+ end
79
+ end
80
+ end
81
+ end
@@ -3,6 +3,7 @@
3
3
  require "open3"
4
4
  require "socket"
5
5
  require "fiddle"
6
+ require "time"
6
7
 
7
8
  module Ace
8
9
  module Runtime
@@ -117,6 +118,33 @@ module Ace
117
118
  {"liveness" => "unknown", "observed_at" => at, "reason" => "process identity no longer observable"}
118
119
  end
119
120
  end
121
+
122
+ # Positive evidence that this exact historical generation ended. A
123
+ # failed capture alone says nothing: EPERM/inaccessible probes remain
124
+ # unknown. PID reuse is conclusive only when the exact birth differs.
125
+ def generation_status(identity, process: Process)
126
+ at = Time.now.utc.iso8601
127
+ unknown = {"liveness" => "unknown", "observed_at" => at}
128
+ unless identity.is_a?(Hash) && identity.keys.sort == %w[host pid started_at uid] &&
129
+ identity["host"] == @hostname && identity["pid"].is_a?(Integer) && identity["pid"].positive? &&
130
+ identity["uid"].is_a?(Integer) && identity["uid"] >= 0 &&
131
+ identity["started_at"].is_a?(String) && !identity["started_at"].empty?
132
+ return unknown.merge("reason" => "verified process generation unavailable")
133
+ end
134
+ current = capture(identity.fetch("pid"))
135
+ return {"liveness" => "live", "observed_at" => at, "identity" => current} if current == identity
136
+ if current && current.fetch("started_at") != identity.fetch("started_at")
137
+ return {"liveness" => "ended", "observed_at" => at, "reason" => "pid generation replaced", "identity" => current}
138
+ end
139
+ begin
140
+ process.kill(0, identity.fetch("pid"))
141
+ unknown.merge("reason" => "process address exists but generation is unobservable")
142
+ rescue Errno::ESRCH
143
+ {"liveness" => "ended", "observed_at" => at, "reason" => "kernel reports no process at address"}
144
+ rescue SystemCallError
145
+ unknown.merge("reason" => "kernel process presence is unavailable")
146
+ end
147
+ end
120
148
  end
121
149
  end
122
150
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Runtime
5
- VERSION = "0.3.0"
5
+ VERSION = "0.4.1"
6
6
  end
7
7
  end
data/native/README.md CHANGED
@@ -1,124 +1,13 @@
1
- # Protected Linux worker bootstrap
2
-
3
- The `ace-runtime` gem ships `native/worker_gate.c` and `native/build-worker-gate`.
4
- This is a Linux executable, not a Ruby interpreter wrapper. Build on the target
5
- architecture from the installed gem with the system compiler, json-c parser and OpenSSL 3 libcrypto.
6
- Supported initial architectures are Linux x86_64 and aarch64 with little-endian
7
- ELF64. A bootstrap built on another architecture fails deployment preflight.
8
-
9
- On Debian bookworm install the distribution's `build-essential`, `pkg-config`
10
- `libjson-c-dev` and `libssl-dev` build packages. The executable requires the matching system
11
- `libjson-c5`, OpenSSL 3 `libcrypto.so.3` (`libssl3`), libc and ELF loader at runtime. Use the same distribution/architecture
12
- for build and deployment; retain package versions, source SHA, compiler version,
13
- ELF machine, `readelf -d` dependencies and output SHA256 in installer evidence.
14
- These libraries and their loader search directories must remain root-owned and
15
- non-worker-writable. The pinned Herdr service starts with a clean root-controlled
16
- environment, so no worker loader/preload environment reaches bootstrap startup.
17
-
18
- From the installed gem directory, in a private unprivileged build directory:
19
-
20
- ```sh
21
- native/build-worker-gate /absolute/private/build/ace-worker-gate
22
- sha256sum /absolute/private/build/ace-worker-gate
23
- readelf -h -d /absolute/private/build/ace-worker-gate
24
- ```
25
-
26
- The administrator installs this exact artifact, without setuid/setgid bits:
27
-
28
- ```sh
29
- install -o root -g root -m 0755 /absolute/private/build/ace-worker-gate /usr/libexec/ace-worker-gate
30
- ```
31
-
32
- All installation ancestors are root-owned and non-worker-writable. The fixed
33
- root-owned `/etc/ace/assignment-authorities.json` records `bootstrap` and its exact
34
- `bootstrap_sha256`. ACE verifies digest, ELF architecture, executable permissions,
35
- ownership and ancestry before reservation. The native layout uses only this
36
- absolute executable, installed mapping ID and correlation ticket; cwd and environment come from the root-controlled mapping after authority release.
37
- The mandatory closed `worker_entry` pins interpreter and wrapper references; arbitrary
38
- worker argv and shebang/PATH execution are not admitted.
39
- The map grants no UID switching and the binary is never privileged.
40
-
41
- The root-installed authority, launcher and native server units require:
42
-
43
- ```ini
44
- User=<their distinct mapped account>
45
- NoNewPrivileges=yes
46
- CapabilityBoundingSet=
47
- AmbientCapabilities=
48
- ```
49
-
50
- Worker children inherit the server's empty bounds and no-new-privileges policy.
51
- ACE checks all real/effective/saved/filesystem IDs, supplementary groups, every
52
- capability set and `NoNewPrivs` from kernel status for all mapped participants,
53
- including the pinned server. CAP_DAC_OVERRIDE/CAP_SETUID are refused, as is an
54
- exec context that could gain capabilities or setuid privilege. Yama=2 remains
55
- mandatory; an empty capability set alone does not stop same-UID injection.
56
-
57
- The bootstrap sets non-dumpable before connecting, requires enforced Yama
58
- `ptrace_scope=2`, requires empty Inh/Prm/Eff/Bnd/Amb capability sets and NoNewPrivs=1, verifies
59
- worker IDs/groups and its retained actual kernel parent birth, authenticates authority
60
- SO_PEERCRED and protected socket ancestry, and waits at most 30 seconds on one
61
- stream. It never reconnects or executes after pre-release EOF, malformed input
62
- or timeout. A committed release whose response is lost remains potentially
63
- executed; public inspection must retain ownership rather than resend release.
64
-
65
- The gate accepts only the maintained `ace.assign.authorities/v2` mapping. It
66
- captures its actual parent PID and kernel boot/start-tick birth before reading
67
- the map, checks that same birth before and after parent credentials, and repeats
68
- the check before authority admission and immediately before exec. The canonical
69
- `gate_ready` authority owner associates the actual gate peer and its parent with
70
- the original recorded child/server; the map supplies no `server_identity`.
71
- Changed parent birth, reparenting, inaccessible credentials or changed policy
72
- refuses. The original retained mapping supplies `worker_entry={interpreter:REF,wrapper:REF}`
73
- in the authenticated release, rather than selecting code from the current map.
74
- Interpreter references are bounded to 32MiB and wrapper references to 1MiB.
75
- Both root-protected regular held files are hashed and their metadata rechecked.
76
- The interpreter must be an executable native ELF. The verified wrapper is copied
77
- into a memfd sealed against writes/growth/shrinkage, reopened read-only, and the
78
- writable descriptor dropped. Selected held interpreter FD4 and code FD5 survive
79
- `fexecve(4, ["/proc/self/fd/4", "-I", "-S", "-B", "/proc/self/fd/5", "authority", "worker"], env)`.
80
- Inherited stdin/stdout/stderr remain untouched; no provider lifetime timeout is added.
81
- The retained original source owner inside the wrapper separately validates and
82
- executes the accepted Ruby snapshot and its package closure.
83
-
84
- Release is one compact JSON record containing the original ticket/assignment/
85
- attempt/generation/commit plus `prepared_input`. That closed object pins the
86
- original registration generation/commit, definition and original binding digest,
87
- prepared-work reference, original worker entry pair and exact journal bundle reference/length/SHA256. The
88
- authority verifies the original bundle before durable issue; subsequent prepared
89
- fetch authenticates the accepted release pin before returning body bytes. The
90
- gate validates the closed ten-field pin, including the original workspace exclusion,
91
- digests and unchanged bounds before exec. The exclusion has five closed fields:
92
- key, authority UID/GID, readonly root resource and original cwd resource. Each
93
- resource has the existing eight identity fields. The gate checks integer/path/
94
- filesystem bounds and exact mapping/view/key association; the retained Ruby owner
95
- verifies the canonical cwd digest and held filesystem identity before acquiring
96
- the lifetime reader. This metadata does not itself grant filesystem access.
97
- The complete encoded release is bounded to16KiB before durable issue and at the gate. It
98
- does not receive inline work or propagate an input FD to providers. The worker
99
- uses the existing authenticated original prepared fetch after release.
100
-
101
- An ordinary Docker kernel with Yama=0 can verify compilation and fail-closed
102
- policy rejection. It cannot demonstrate protected launch acceptance. A real
103
- isolated Linux deployment enforcing the complete policy is required for that
104
- positive proof; installing this gem does not change host sysctls or install OS
105
- service/account policy. Downstream gad.8 provisions those requirements and
106
- consumes this shipped build mechanism.
107
-
108
- ## Fixed digest initialization and acceptance limits
109
-
110
- Before any OpenSSL API call the gate selects `OPENSSL_INIT_NO_LOAD_CONFIG`.
111
- It creates a private library context, fixes its provider search path to `/dev/null`
112
- (a non-directory), explicitly loads only `default`, and fetches `SHA256` with
113
- `provider=default`. Failure refuses execution; no config/engine/module fallback
114
- is provided. [Initialization semantics](https://docs.openssl.org/3.0/man3/OPENSSL_init_crypto/),
115
- [provider search path](https://docs.openssl.org/3.0/man3/OSSL_PROVIDER/), and
116
- [fixed provider property](https://docs.openssl.org/3.0/man7/OSSL_PROVIDER-default/) and
117
- [libcrypto built-in default provider](https://docs.openssl.org/3.0/man7/crypto/#default-provider)
118
- are the maintained API basis. The system libcrypto/loader remain in the accepted
119
- root-controlled OS artifact closure; this does not attest an arbitrary library build.
120
-
121
- This source checkpoint does not compile or execute native code. Real ELF,
122
- OpenSSL linkage/provider availability, memfd seals, retained FD execution and
123
- Python script/stdlib behavior require installed Linux acceptance. The trusted
124
- provisioned OS/Python premise remains; no separate Python runtime tree is introduced.
1
+ # Native worker bootstrap (retired from the file-based flow)
2
+
3
+ The `ace-runtime` gem still ships `native/worker_gate.c` and
4
+ `native/build-worker-gate` from the replaced protected model. They are not part
5
+ of the current file-based flow: no command, task, assignment, or local
6
+ documentation requires the native gate, its authority file, or its admission
7
+ checks.
8
+
9
+ Do not treat this directory as an owner of execution state. The supported
10
+ `ace-runtime` surface is the terminal intent contract described in the
11
+ [README](../README.md) and [usage](../docs/usage.md). Removal or redesign of the
12
+ native bootstrap is tracked with the L1/L2 cleanup; this note keeps the
13
+ historical source discoverable without presenting it as a requirement.
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-runtime
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-10-10 00:00:00.000000000 Z
10
+ date: 2026-10-20 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: fiddle
@@ -208,6 +208,7 @@ files:
208
208
  - lib/ace/runtime/molecules/kernel_view_topology.rb
209
209
  - lib/ace/runtime/molecules/linux_mount_info.rb
210
210
  - lib/ace/runtime/molecules/network_installation_evidence.rb
211
+ - lib/ace/runtime/molecules/owned_child_scope.rb
211
212
  - lib/ace/runtime/molecules/process_identity.rb
212
213
  - lib/ace/runtime/molecules/protected_artifact_set.rb
213
214
  - lib/ace/runtime/molecules/protected_linux.rb