ace-lab 0.7.0 → 0.8.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 +4 -4
- data/CHANGELOG.md +9 -0
- data/docs/usage.md +155 -0
- data/lib/ace/lab/cli/commands/assignment.rb +45 -0
- data/lib/ace/lab/cli/commands/service.rb +12 -28
- data/lib/ace/lab/cli.rb +6 -1
- data/lib/ace/lab/molecules/ordinary_admission_resolver.rb +20 -1
- data/lib/ace/lab/molecules/ordinary_listener.rb +41 -17
- data/lib/ace/lab/molecules/ordinary_merge_read.rb +45 -0
- data/lib/ace/lab/molecules/ordinary_native_read.rb +106 -0
- data/lib/ace/lab/molecules/ordinary_owner_admission.rb +139 -0
- data/lib/ace/lab/molecules/ordinary_owner_config.rb +232 -0
- data/lib/ace/lab/molecules/ordinary_receiver_config.rb +12 -4
- data/lib/ace/lab/molecules/ordinary_socket.rb +6 -9
- data/lib/ace/lab/molecules/ordinary_worktree_read.rb +216 -0
- data/lib/ace/lab/molecules/service_executor.rb +9 -39
- data/lib/ace/lab/molecules/service_policy.rb +15 -4
- data/lib/ace/lab/organisms/authority_composition.rb +1 -1
- data/lib/ace/lab/organisms/ordinary_assignment_client.rb +149 -0
- data/lib/ace/lab/organisms/ordinary_authority_composition.rb +123 -0
- data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +68 -43
- data/lib/ace/lab/organisms/ordinary_merge_request.rb +41 -7
- data/lib/ace/lab/version.rb +1 -1
- metadata +27 -6
- data/lib/ace/lab/molecules/ordinary_ff_merge.rb +0 -86
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bb4f48b950b3a32e27a457e36af99148a65e8a95cdbbc55a1586e4f9f2d2abd9
|
|
4
|
+
data.tar.gz: 361f1a4f8b0167365fe8fee52c7e78047c0f31a9d8f1b1dd9644de79e13b540f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c556ae2dfb4f3137d2e3a45473287c385cc265b2da115aa9db6a2fe42b1e1e61b5a4e6839d98b4b76ce093ace09032c1e8795373f41b33987c0e0127ba1e084a
|
|
7
|
+
data.tar.gz: 7d470523a46606f392ea1ed22cffd708861c5e635c34ff9d1321ff64d542ef45f30e5cc763ac422ec28a9add7c7ef6a26e8fb705d01f25cdfba291746c19c520
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.8.0] - 2026-10-16
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Compose ordinary Assign, HITL and Forgejo merge under one installed owner and journal, with readiness barriers and fresh native/project admission.
|
|
15
|
+
- Optionally serve existing mTLS HITL beside the ordinary Unix listener under the same canonical owner, journal, proposal policy and one-use OTP vault. Validate the configured project and service identity before either listener starts.
|
|
16
|
+
- Add retained assignment prepare/request clients and bounded canonical worktree observations. Separate stable configuration identity from changing decisions, preserve no-effect settlement after slow provider calls, and clean delivery handler process groups.
|
|
17
|
+
- This source cut enables initial installation; it does not claim autonomous release or installed two-project acceptance.
|
|
18
|
+
|
|
10
19
|
## [0.7.0] - 2026-10-16
|
|
11
20
|
|
|
12
21
|
### Added
|
data/docs/usage.md
CHANGED
|
@@ -121,6 +121,161 @@ Only the original OS caller identity with project visibility can read the
|
|
|
121
121
|
request. The authoritative state survives loss of the local assignment cache
|
|
122
122
|
because it lives in the separate assignment evidence Git ref.
|
|
123
123
|
|
|
124
|
+
### `ace-lab service listen --mode ordinary-merge`
|
|
125
|
+
|
|
126
|
+
The installed ordinary service owns the Assign, HITL and merge endpoints in one
|
|
127
|
+
process. Start it as the configured service account:
|
|
128
|
+
|
|
129
|
+
```sh
|
|
130
|
+
ace-lab service listen --mode ordinary-merge --project atlas \
|
|
131
|
+
--config /etc/lab/ace-lab/atlas-receiver.yml
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
The receiver configuration selects its existing forge, repository, principal and
|
|
135
|
+
merge socket. `/etc/lab/ace-lab/assign-owners.yml` separately selects the canonical
|
|
136
|
+
Assign journal and native execution owner. Both selections must agree with the
|
|
137
|
+
ordinary project mapping in the trusted authorization document. `repo_path` is
|
|
138
|
+
the merge candidate repository; it does not choose the execution journal.
|
|
139
|
+
|
|
140
|
+
Each project entry in the root-installed assignment document contains:
|
|
141
|
+
|
|
142
|
+
| Entry | Installed selection |
|
|
143
|
+
|---|---|
|
|
144
|
+
| `service` | Non-root owner UID/GID, assignment socket and private state root |
|
|
145
|
+
| `journal` | Service-owned repository, execution ref and private evidence checkout |
|
|
146
|
+
| `assignment_root`, `lifecycle_root` | Separate private prepared-input and exclusion directories |
|
|
147
|
+
| `runtime_binary`, `native` | Installed executable and exact native server/socket generation from the installation receipt |
|
|
148
|
+
| `worktrees` | Allowed worktree root and Git common directory |
|
|
149
|
+
| `participants` | OS UID/GID/actor, native workspace and supervisor panes |
|
|
150
|
+
| `hitl` | Private store root, distinct socket, dedicated group, trusted grants path and optional installed TLS authority reference |
|
|
151
|
+
|
|
152
|
+
For example, the `hitl` entry inside an already selected project can be:
|
|
153
|
+
|
|
154
|
+
```yaml
|
|
155
|
+
hitl:
|
|
156
|
+
root: /var/lib/lab/assign/atlas/hitl
|
|
157
|
+
socket_path: /run/lab/assign/atlas/hitl.sock
|
|
158
|
+
group: atlas-lifecycle
|
|
159
|
+
grants_path: /etc/lab/ace-lab/authorization.yml
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
The store must be a separate child of that project's `service.state_root`; the
|
|
163
|
+
installer creates `service.state_root` with mode `0700`, the HITL root with its
|
|
164
|
+
maintained mode `0711`, and socket access before startup. The private parent keeps
|
|
165
|
+
the HITL tree inaccessible to other accounts; Store maintains private request and
|
|
166
|
+
secret subdirectories. Startup and restart validate the same layout.
|
|
167
|
+
`hitl.service_uid` in the grants document must equal the Assign owner UID. Native
|
|
168
|
+
identities come from the installed receipt and live observation, not the project
|
|
169
|
+
configuration cascade. HITL requests, decisions and merge claims use the same
|
|
170
|
+
canonical Assign journal and lifecycle exclusion.
|
|
171
|
+
|
|
172
|
+
For the existing remote Hermes transport, install its project-scoped mTLS
|
|
173
|
+
authority and add its absolute configuration path to this same `hitl` entry:
|
|
174
|
+
|
|
175
|
+
```yaml
|
|
176
|
+
tls_authority: /etc/lab/ace-hitl/atlas-tls.json
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
The referenced file uses the maintained `ace.hitl.tls.authority/v1` schema and
|
|
180
|
+
credential provisioning layout from `ace-hitl`. Its project must match this
|
|
181
|
+
owner; `service_user` and `service_group` must resolve to the owner's actual UID
|
|
182
|
+
and primary GID. The dedicated Unix control group remains `hitl.group`. Root
|
|
183
|
+
installs the credential references and Incus TCP proxy; callers cannot choose
|
|
184
|
+
them. Invalid ownership, identity, project or TLS material prevents startup.
|
|
185
|
+
|
|
186
|
+
The TLS listener runs in this same service process and shares its exact HITL
|
|
187
|
+
binding, store, decision policy and in-memory one-time OTP vault. Hermes uses its
|
|
188
|
+
verified project certificate as a remote transport identity; it does not need a
|
|
189
|
+
synthetic Unix transport UID. Keep `transport_uids` empty when no local transport
|
|
190
|
+
is installed. Configure the actual local proposer separately with
|
|
191
|
+
`proposal_uids` and its project grant. A proposer grant never authorizes replies
|
|
192
|
+
or delegates silence-based decisions. Restart the composed service after changing
|
|
193
|
+
its TLS authority or credential selection, retaining the canonical journal.
|
|
194
|
+
|
|
195
|
+
All selected endpoints must become ready before assignment ingress opens. A
|
|
196
|
+
failed endpoint shuts down the complete owner. A stale assignment endpoint needs
|
|
197
|
+
installer inspection; startup does not remove it or recover merge effects first.
|
|
198
|
+
|
|
199
|
+
## Ordinary assignment client
|
|
200
|
+
|
|
201
|
+
`ace-lab assignment` selects the root-installed project owner and uses the
|
|
202
|
+
existing Assign protocol. Assign remains the only accepted execution journal.
|
|
203
|
+
Preparation captures reviewed task context and the existing managed assignment
|
|
204
|
+
graph; it does not start a worker or register accepted work.
|
|
205
|
+
|
|
206
|
+
```sh
|
|
207
|
+
ace-lab assignment prepare --project atlas --task 8wr.t.abc --pane w1:pS \
|
|
208
|
+
--output .ace-local/prepared-task
|
|
209
|
+
ace-lab assignment request --project atlas --input .ace-local/prepared-task/request.json
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
The output directory must not exist. Preparation retains the definition, bundle,
|
|
213
|
+
and registration request with one stable mutation ID. `--dependency-reports FILE`
|
|
214
|
+
selects an explicit JSON array of `{task_id, assignment_id, number}` entries for
|
|
215
|
+
dependency reports. A failed preparation keeps partial files for inspection;
|
|
216
|
+
it never rebuilds an existing directory. In this source checkout use
|
|
217
|
+
`bundle exec ace-lab` because no `bin/ace-lab` binstub is committed.
|
|
218
|
+
|
|
219
|
+
Every request file has exactly these fields:
|
|
220
|
+
|
|
221
|
+
```json
|
|
222
|
+
{
|
|
223
|
+
"version": 1,
|
|
224
|
+
"project_id": "atlas",
|
|
225
|
+
"operation": "start_attempt",
|
|
226
|
+
"mutation_id": "original-start-001",
|
|
227
|
+
"params": {
|
|
228
|
+
"assignment_id": "assignment-id",
|
|
229
|
+
"attempt_id": "attempt-id",
|
|
230
|
+
"expected_generation": 0,
|
|
231
|
+
"scope": "010",
|
|
232
|
+
"head": "0123456789012345678901234567890123456789",
|
|
233
|
+
"pane": "w1:p2"
|
|
234
|
+
},
|
|
235
|
+
"parts": []
|
|
236
|
+
}
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
Use actual retained IDs, scope, head and native pane. The actual worker executes
|
|
240
|
+
its start request: the owner verifies caller ancestry, live process and checkout.
|
|
241
|
+
A supervisor cannot impersonate another pane by changing `pane`. A new terminal
|
|
242
|
+
or prompt acknowledgement alone is not running proof.
|
|
243
|
+
|
|
244
|
+
| Operation | `params` beyond `assignment_id` | Transfer / authority |
|
|
245
|
+
|---|---|---|
|
|
246
|
+
| `register_assignment` | Generated by prepare | Exact prepared bundle; supervisor |
|
|
247
|
+
| `registration_status` | None | Read; `mutation_id: null` |
|
|
248
|
+
| `start_attempt` | `attempt_id`, `expected_generation: 0`, `scope`, `head`, `pane` | Actual worker start |
|
|
249
|
+
| `assignment_snapshot` | `attempt_id` (or null), `kind: queue` or `prepared_work` | Download; `mutation_id: null` |
|
|
250
|
+
| `queue_start` | `attempt_id`, `expected_generation`, `pane`, `step` (or null for next) | Original worker |
|
|
251
|
+
| `queue_finish` | Same as queue_start | One UTF-8 report part |
|
|
252
|
+
| `queue_fail` | `attempt_id`, `expected_generation`, `pane`, `message` | Original worker; unfinished work stays failed |
|
|
253
|
+
| `submit_result` | `attempt_id`, `expected_generation`, `pane`, `head`, `receipt_sha256` | Receipt first, then its ordered artifacts; original worker |
|
|
254
|
+
| `result_status` | `attempt_id` | Read; `mutation_id: null` |
|
|
255
|
+
| `result_snapshot` | `attempt_id`, `result_id` | Canonical receipt/artifacts download; `mutation_id: null` |
|
|
256
|
+
| `finish` | `attempt_id`, `expected_generation`, `pane`, `head`, `result_id`, `receipt_digest` | Supervisor accepts exact submitted result after settlement |
|
|
257
|
+
|
|
258
|
+
Upload `parts` are ordered `{file, bytes, sha256}` entries. Relative files resolve
|
|
259
|
+
beside the retained request. Changed, oversized or symlinked inputs refuse before
|
|
260
|
+
connection. Requesters never supply a wire `transfer` descriptor. Downloads require
|
|
261
|
+
`--output NEW_DIRECTORY`; numbered byte files and `response.json` are private
|
|
262
|
+
projections of the authenticated reply, not another execution store. For queue
|
|
263
|
+
snapshots, the first byte file contains the current step instructions and reports
|
|
264
|
+
as JSON; prepared-work snapshots contain the original Git bundle.
|
|
265
|
+
|
|
266
|
+
Retain each request and its exact parts before invoking it. On response loss,
|
|
267
|
+
inspect `registration_status` or `result_status` and reuse the same original
|
|
268
|
+
mutation when replay is appropriate. Never refresh its generation, replace its
|
|
269
|
+
bytes, or create a second worker because a reply was lost. Queue completion and
|
|
270
|
+
`submit_result` keep the attempt running; only the canonical accepted `finish`
|
|
271
|
+
reply proves terminal completion. Unknown effect or Inbox settlement refuses it.
|
|
272
|
+
|
|
273
|
+
These commands do not silently route to a local `ace-assign` cache. Missing or
|
|
274
|
+
changed installed owner selection is an explicit failure. Native wake consumers
|
|
275
|
+
must read the same canonical status and adopt its original attempt/result IDs.
|
|
276
|
+
Changed owner selection refuses new admission and dispatch until a controlled
|
|
277
|
+
restart. Existing canonical receipts remain the source for reconciliation.
|
|
278
|
+
|
|
124
279
|
## Error semantics
|
|
125
280
|
|
|
126
281
|
Errors are one deterministic JSON document on stdout plus a non-zero exit.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../../organisms/ordinary_assignment_client"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Lab
|
|
7
|
+
module CLI
|
|
8
|
+
module Commands
|
|
9
|
+
module Assignment
|
|
10
|
+
class Prepare < Ace::Support::Cli::Command
|
|
11
|
+
desc "Retain reviewed task input for ordinary Assign registration"
|
|
12
|
+
option :project, required: true, desc: "Installed project ID"
|
|
13
|
+
option :task, required: true, desc: "Reviewed leaf task ID"
|
|
14
|
+
option :pane, required: true, desc: "Actual supervisor pane for registration"
|
|
15
|
+
option :output, required: true, desc: "New private directory for retained input"
|
|
16
|
+
option :dependency_reports, desc: "JSON array of explicit dependency report selections"
|
|
17
|
+
|
|
18
|
+
def call(project:, task:, pane:, output:, **options)
|
|
19
|
+
reports = options[:dependency_reports] ? JSON.parse(Organisms::OrdinaryAssignmentClient.read_file(
|
|
20
|
+
options.fetch(:dependency_reports), 65_536), allow_duplicate_key: false, create_additions: false, max_nesting: 16) : []
|
|
21
|
+
puts JSON.generate(Organisms::OrdinaryAssignmentClient.prepare(project: project, task: task,
|
|
22
|
+
pane: pane, output: output, dependency_reports: reports))
|
|
23
|
+
rescue JSON::ParserError, ArgumentError, KeyError, SystemCallError, Ace::Assign::Error, Ace::Runtime::Error => error
|
|
24
|
+
raise Ace::Support::Cli::Error, "ordinary preparation incomplete: #{error.message}; retain existing files"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
class Request < Ace::Support::Cli::Command
|
|
29
|
+
desc "Send one retained operation to the installed ordinary Assign owner"
|
|
30
|
+
option :project, required: true, desc: "Installed project ID"
|
|
31
|
+
option :input, required: true, desc: "Retained request JSON with original mutation and part digests"
|
|
32
|
+
option :output, desc: "New private directory for a canonical snapshot download"
|
|
33
|
+
|
|
34
|
+
def call(project:, input:, **options)
|
|
35
|
+
service = Organisms::OrdinaryAssignmentClient.new(project: project)
|
|
36
|
+
puts JSON.generate(service.request(input: input, output: options[:output]))
|
|
37
|
+
rescue ArgumentError, KeyError, SystemCallError, Ace::Assign::Error, Ace::Runtime::Error, Ace::Lab::Error => error
|
|
38
|
+
raise Ace::Support::Cli::Error, "ordinary assignment unavailable: #{error.message}; retain the original request; inspect status before new work"
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -7,6 +7,7 @@ require_relative "support"
|
|
|
7
7
|
require_relative "../../organisms/protected_service_request"
|
|
8
8
|
require_relative "../../organisms/ordinary_merge_request"
|
|
9
9
|
require_relative "../../organisms/ordinary_merge_receiver"
|
|
10
|
+
require_relative "../../organisms/ordinary_authority_composition"
|
|
10
11
|
require_relative "../../molecules/ordinary_listener"
|
|
11
12
|
require_relative "../../molecules/ordinary_receiver_config"
|
|
12
13
|
|
|
@@ -176,7 +177,7 @@ module Ace
|
|
|
176
177
|
# The ordinary receiver serve entrypoint (spec 8x3.t.xz9.4
|
|
177
178
|
# execution clauses; 8x3.t.xz9.5 integration handoff): binds the
|
|
178
179
|
# configured unix socket, mutually authenticates every connection
|
|
179
|
-
# through
|
|
180
|
+
# through OS peer credentials, admits through the canonical Assign
|
|
180
181
|
# coordinator, executes the authorized fixed operation — one REAL
|
|
181
182
|
# Forgejo merge under the scoped host-installed service identity —
|
|
182
183
|
# and settles the exact terminal receipt proven by real forge
|
|
@@ -188,7 +189,7 @@ module Ace
|
|
|
188
189
|
include Ace::Support::Cli::Base
|
|
189
190
|
include Output
|
|
190
191
|
|
|
191
|
-
desc "Serve
|
|
192
|
+
desc "Serve ordinary Assign, HITL and fixed merge under the installed journal owner"
|
|
192
193
|
option :mode, type: :string, required: true,
|
|
193
194
|
desc: "ordinary-merge selects the ordinary fixed merge receiver"
|
|
194
195
|
option :project, type: :string, required: true, desc: "Project stable ID"
|
|
@@ -217,36 +218,19 @@ module Ace
|
|
|
217
218
|
request_id: "unknown", input_digest: nil
|
|
218
219
|
))
|
|
219
220
|
end
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
# producer coordinator uses). Never an integration-checkout
|
|
228
|
-
# duplicate; the service identity's reviewed permissions are
|
|
229
|
-
# journal ref read/append, evidence checkout and no-effect
|
|
230
|
-
# attestation writes inside that repository.
|
|
231
|
-
coordinator = Ace::Assign::Organisms::AttemptCoordinator.new(
|
|
232
|
-
repo_root: receiver_config.repo_path
|
|
233
|
-
)
|
|
234
|
-
receiver = Organisms::OrdinaryMergeReceiver.new(
|
|
235
|
-
config: receiver_config, coordinator: coordinator,
|
|
236
|
-
policy_document: policy_document
|
|
237
|
-
)
|
|
238
|
-
listener = Molecules::OrdinaryListener.new(
|
|
239
|
-
socket_path: receiver_config.socket_path,
|
|
240
|
-
peer_uid: receiver_config.principal_uid,
|
|
241
|
-
handler: receiver
|
|
242
|
-
)
|
|
243
|
-
receiver.recover_pending!
|
|
244
|
-
listener.serve
|
|
245
|
-
rescue Ace::Lab::InvalidConfigurationError, Ace::Assign::AttemptErrors::EvidenceUnavailable => e
|
|
221
|
+
unless receiver_config.receiver_uid == Process.uid
|
|
222
|
+
raise Ace::Lab::InvalidConfigurationError, "receiver must run as its configured UID"
|
|
223
|
+
end
|
|
224
|
+
server = Organisms::OrdinaryAuthorityComposition.new(receiver_config: receiver_config).build
|
|
225
|
+
signals = %w[INT TERM].to_h { |signal| [signal, Signal.trap(signal) { server.request_stop }] }
|
|
226
|
+
server.serve
|
|
227
|
+
rescue Ace::Lab::InvalidConfigurationError, Ace::Assign::Error, Ace::Runtime::Error, Ace::Hitl::Lifecycle::Error => e
|
|
246
228
|
emit(Organisms::OrdinaryMergeRequest.refusal_envelope(
|
|
247
229
|
"ordinary_merge_unavailable", e.message,
|
|
248
230
|
request_id: "unknown", input_digest: nil
|
|
249
231
|
))
|
|
232
|
+
ensure
|
|
233
|
+
signals&.each { |signal, handler| Signal.trap(signal, handler) }
|
|
250
234
|
end
|
|
251
235
|
end
|
|
252
236
|
end
|
data/lib/ace/lab/cli.rb
CHANGED
|
@@ -9,6 +9,7 @@ require_relative "cli/commands/services"
|
|
|
9
9
|
require_relative "cli/commands/resolve"
|
|
10
10
|
require_relative "cli/commands/route"
|
|
11
11
|
require_relative "cli/commands/service"
|
|
12
|
+
require_relative "cli/commands/assignment"
|
|
12
13
|
|
|
13
14
|
module Ace
|
|
14
15
|
module Lab
|
|
@@ -27,7 +28,9 @@ module Ace
|
|
|
27
28
|
["route", "Select a configured capable service in a project"],
|
|
28
29
|
["service request", "Request one authorized service operation"],
|
|
29
30
|
["service status", "Read a service request outcome"],
|
|
30
|
-
["service listen", "Serve the ordinary fixed merge receiver on its configured socket"]
|
|
31
|
+
["service listen", "Serve the ordinary fixed merge receiver on its configured socket"],
|
|
32
|
+
["assignment prepare", "Retain reviewed task input for ordinary Assign registration"],
|
|
33
|
+
["assignment request", "Send one retained operation to the installed ordinary Assign owner"]
|
|
31
34
|
].freeze
|
|
32
35
|
|
|
33
36
|
HELP_EXAMPLES = [
|
|
@@ -54,6 +57,8 @@ module Ace
|
|
|
54
57
|
register "service request", CLI::Commands::Service::Request.new
|
|
55
58
|
register "service status", CLI::Commands::Service::Status.new
|
|
56
59
|
register "service listen", CLI::Commands::Service::Listen.new
|
|
60
|
+
register "assignment prepare", CLI::Commands::Assignment::Prepare.new
|
|
61
|
+
register "assignment request", CLI::Commands::Assignment::Request.new
|
|
57
62
|
|
|
58
63
|
# Register version command
|
|
59
64
|
version_cmd = Ace::Support::Cli::VersionCommand.build(
|
|
@@ -43,13 +43,16 @@ module Ace
|
|
|
43
43
|
# @param path [String, nil] trusted policy path to re-read per decision
|
|
44
44
|
# @param loader [Proc, nil] trusted reader (default: GrantResolver);
|
|
45
45
|
# the only override point, for non-privileged test documents
|
|
46
|
-
def initialize(policy_document, path: nil, loader: nil)
|
|
46
|
+
def initialize(policy_document, path: nil, loader: nil, proposal_resolver: nil, caller_uid: nil)
|
|
47
47
|
unless policy_document.is_a?(Hash) && policy_document["authorizations"].is_a?(Hash)
|
|
48
48
|
raise Ace::Lab::InvalidConfigurationError,
|
|
49
49
|
"trusted policy document must contain an authorizations mapping"
|
|
50
50
|
end
|
|
51
51
|
@authorizations = policy_document.fetch("authorizations")
|
|
52
|
+
@configuration_digest = ServicePolicy.configuration_digest(policy_document)
|
|
52
53
|
@path = path
|
|
54
|
+
@proposal_resolver = proposal_resolver
|
|
55
|
+
@caller_uid = caller_uid
|
|
53
56
|
@loader = loader || ->(trusted_path) { GrantResolver.trusted_document(trusted_path) }
|
|
54
57
|
freeze
|
|
55
58
|
end
|
|
@@ -65,6 +68,18 @@ module Ace
|
|
|
65
68
|
unless binding.is_a?(Hash) && binding.fetch("operation", nil) == OPERATION
|
|
66
69
|
return refuse("only the ordinary merge operation is resolvable")
|
|
67
70
|
end
|
|
71
|
+
if reference.start_with?("proposal-")
|
|
72
|
+
return refuse("canonical proposal authority is unavailable") unless @proposal_resolver && @caller_uid.is_a?(Integer)
|
|
73
|
+
begin
|
|
74
|
+
decision = @proposal_resolver.call(reference, binding.slice(
|
|
75
|
+
"operation", "project_id", "assignment_id", "attempt_id", "input_digest", "candidate_head").merge(
|
|
76
|
+
"target" => {"resource" => binding.fetch("pr_url"), "artifact_digest" => nil},
|
|
77
|
+
"caller_uid" => @caller_uid))
|
|
78
|
+
return Decision.new(authorized?: true, decision: decision, code: nil, message: nil)
|
|
79
|
+
rescue Ace::Assign::Error
|
|
80
|
+
return refuse("canonical proposal does not authorize this exact ordinary merge")
|
|
81
|
+
end
|
|
82
|
+
end
|
|
68
83
|
decision = authorizations[reference]
|
|
69
84
|
return refuse("authorization reference is unresolved") unless decision.is_a?(Hash)
|
|
70
85
|
unless DECISION_KEYS.all? { |key| decision.key?(key) }
|
|
@@ -99,6 +114,10 @@ module Ace
|
|
|
99
114
|
"invalid lab configuration: trusted authorization refresh at #{@path} " \
|
|
100
115
|
"did not yield an authorizations mapping"
|
|
101
116
|
end
|
|
117
|
+
unless ServicePolicy.configuration_digest(document) == @configuration_digest
|
|
118
|
+
raise Ace::Lab::InvalidConfigurationError,
|
|
119
|
+
"ordinary receiver configuration changed; restart with the installed configuration"
|
|
120
|
+
end
|
|
102
121
|
document.fetch("authorizations")
|
|
103
122
|
end
|
|
104
123
|
|
|
@@ -12,7 +12,7 @@ module Ace
|
|
|
12
12
|
# entrypoint). Binds the configured unix socket and answers the exact
|
|
13
13
|
# framed protocol the OrdinarySocket client speaks: one bounded JSON
|
|
14
14
|
# document each way behind the shared handshake line. Authentication is
|
|
15
|
-
# MUTUAL through
|
|
15
|
+
# MUTUAL through OS peer credentials: every connection's peer UID must equal
|
|
16
16
|
# the configured producer principal UID, and the listener itself runs
|
|
17
17
|
# as the configured receiver UID — the identity the client verifies on
|
|
18
18
|
# its side. Every identity, framing, deadline or parse failure is
|
|
@@ -24,11 +24,15 @@ module Ace
|
|
|
24
24
|
LIMIT = OrdinarySocket::LIMIT
|
|
25
25
|
DEADLINE_SECONDS = 30
|
|
26
26
|
|
|
27
|
-
def initialize(socket_path:, peer_uid:, handler:, lock_path: nil, logger: nil)
|
|
27
|
+
def initialize(socket_path:, peer_uid:, handler:, socket_gid: nil, lock_path: nil, logger: nil)
|
|
28
28
|
unless socket_path.is_a?(String) && socket_path.start_with?("/") &&
|
|
29
29
|
peer_uid.is_a?(Integer) && peer_uid >= 0 && handler.respond_to?(:call)
|
|
30
30
|
raise ArgumentError, "ordinary listener needs an absolute socket path, a peer uid and a handler"
|
|
31
31
|
end
|
|
32
|
+
unless socket_gid.nil? || (socket_gid.is_a?(Integer) && socket_gid >= 0)
|
|
33
|
+
raise ArgumentError, "ordinary listener socket_gid must be a non-negative integer"
|
|
34
|
+
end
|
|
35
|
+
@socket_gid = socket_gid
|
|
32
36
|
@socket_path = socket_path
|
|
33
37
|
@peer_uid = peer_uid
|
|
34
38
|
@handler = handler
|
|
@@ -40,12 +44,15 @@ module Ace
|
|
|
40
44
|
# Serve until #stop. Excludes a second listener through one owned
|
|
41
45
|
# lock file, removes only its own stale endpoint, and always removes
|
|
42
46
|
# the endpoint it created.
|
|
43
|
-
def serve
|
|
47
|
+
def serve(on_ready: nil)
|
|
44
48
|
acquire_lock!
|
|
45
49
|
remove_stale_endpoint!
|
|
46
50
|
@listener = UNIXServer.new(@socket_path)
|
|
47
|
-
File.chmod(0o600, @socket_path)
|
|
48
51
|
@endpoint_identity = File.stat(@socket_path)
|
|
52
|
+
File.chown(nil, @socket_gid, @socket_path) if @socket_gid
|
|
53
|
+
File.chmod(@socket_gid ? 0o660 : 0o600, @socket_path)
|
|
54
|
+
on_ready.call if on_ready
|
|
55
|
+
@handler.recover_pending! if @handler.respond_to?(:recover_pending!)
|
|
49
56
|
until @stopping
|
|
50
57
|
socket = @listener.accept
|
|
51
58
|
serve_connection(socket)
|
|
@@ -59,6 +66,11 @@ module Ace
|
|
|
59
66
|
@lock&.close
|
|
60
67
|
end
|
|
61
68
|
|
|
69
|
+
# The existing listener participates in the Assign owner's lifetime.
|
|
70
|
+
def run(on_ready:)
|
|
71
|
+
serve(on_ready: on_ready)
|
|
72
|
+
end
|
|
73
|
+
|
|
62
74
|
def stop
|
|
63
75
|
@stopping = true
|
|
64
76
|
@listener&.close
|
|
@@ -75,26 +87,27 @@ module Ace
|
|
|
75
87
|
verify_peer!(socket)
|
|
76
88
|
submission = read_request!(socket, deadline)
|
|
77
89
|
response = @handler.call(submission)
|
|
78
|
-
|
|
90
|
+
write_response_safely(socket, response, deadline)
|
|
79
91
|
if response.is_a?(Hash) && response["status"] == "ok" && @handler.respond_to?(:execute)
|
|
80
92
|
begin
|
|
81
|
-
#
|
|
93
|
+
# Admission is durable even if the acknowledgment was lost; the
|
|
82
94
|
# execution lifetime belongs to the canonical record, not
|
|
83
95
|
# the short connection. A lost lifecycle stays pending or
|
|
84
96
|
# uncertain and reconciles by the original reference.
|
|
85
97
|
@handler.execute(response.dig("data", "request_id"))
|
|
86
|
-
rescue StandardError
|
|
87
|
-
|
|
98
|
+
rescue StandardError => error
|
|
99
|
+
log_failure("effect", error)
|
|
88
100
|
end
|
|
89
101
|
end
|
|
90
102
|
rescue OrdinaryRefused => e
|
|
91
|
-
|
|
92
|
-
rescue OrdinaryListenerError
|
|
103
|
+
write_response_safely(socket, e.envelope, deadline)
|
|
104
|
+
rescue OrdinaryListenerError, IOError, SystemCallError => error
|
|
93
105
|
# Fail closed without a fabricated envelope; the client classifies
|
|
94
106
|
# a lost/unreadable response as ordinary_merge_unavailable.
|
|
95
|
-
|
|
96
|
-
rescue StandardError
|
|
97
|
-
|
|
107
|
+
log_failure("connection", error)
|
|
108
|
+
rescue StandardError => error
|
|
109
|
+
log_failure("request", error)
|
|
110
|
+
write_response_safely(socket, refusal_envelope("ordinary_merge_unavailable",
|
|
98
111
|
"receiver failed to handle the request"), deadline)
|
|
99
112
|
ensure
|
|
100
113
|
begin
|
|
@@ -119,16 +132,27 @@ module Ace
|
|
|
119
132
|
|
|
120
133
|
private
|
|
121
134
|
|
|
122
|
-
|
|
135
|
+
def write_response_safely(socket, response, deadline)
|
|
136
|
+
write_response!(socket, response, deadline)
|
|
137
|
+
rescue OrdinaryListenerError, IOError, SystemCallError => error
|
|
138
|
+
log_failure("response", error)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def log_failure(phase, error)
|
|
142
|
+
# Arbitrary exception messages may contain provider credentials or
|
|
143
|
+
# request bodies. Journal references remain the source of effect state.
|
|
144
|
+
@logger&.call("ordinary receiver #{phase} failed (#{error.class.name})")
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Mutual half of OS peer authentication: the connecting process must run as the
|
|
123
148
|
# configured producer principal UID. Anything else is refused before
|
|
124
149
|
# one byte of request is read.
|
|
125
150
|
def verify_peer!(socket)
|
|
126
|
-
|
|
127
|
-
_peer_pid, peer_uid, = credentials.unpack("L3")
|
|
151
|
+
peer_uid, = socket.getpeereid
|
|
128
152
|
return if peer_uid == @peer_uid
|
|
129
153
|
raise OrdinaryRefused, refusal_envelope("ordinary_merge_unauthenticated",
|
|
130
154
|
"connecting principal is not the configured ordinary overseer")
|
|
131
|
-
rescue SystemCallError, IOError
|
|
155
|
+
rescue SystemCallError, IOError, SocketError, NotImplementedError
|
|
132
156
|
raise OrdinaryListenerError, "peer credentials are unavailable"
|
|
133
157
|
end
|
|
134
158
|
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "ordinary_worktree_read"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Lab
|
|
7
|
+
module Molecules
|
|
8
|
+
# Local checks are bounded read-only observations. Only the forge provider
|
|
9
|
+
# executes merge; moving a local ref can never prove a delivered merge.
|
|
10
|
+
class OrdinaryMergeRead
|
|
11
|
+
STRATEGY = "ff"
|
|
12
|
+
|
|
13
|
+
def initialize(repo_path:, base_ref:, root: repo_path, common_dir: File.join(repo_path, ".git"),
|
|
14
|
+
deadline: Process.clock_gettime(Process::CLOCK_MONOTONIC) + 30)
|
|
15
|
+
unless repo_path.is_a?(String) && repo_path.start_with?("/") &&
|
|
16
|
+
base_ref.is_a?(String) && base_ref.start_with?("refs/")
|
|
17
|
+
raise ArgumentError, "ordinary merge read needs an absolute repository and full base ref"
|
|
18
|
+
end
|
|
19
|
+
@repo_path, @base_ref = repo_path, base_ref
|
|
20
|
+
@reader = OrdinaryWorktreeRead.new(root: root, common_dir: common_dir, deadline: deadline)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def base_head
|
|
24
|
+
@reader.ref_head(@repo_path, @base_ref)
|
|
25
|
+
rescue Ace::Assign::Error => error
|
|
26
|
+
raise GitUnavailable, error.message
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def candidate_commit?(head)
|
|
30
|
+
@reader.commit?(@repo_path, head)
|
|
31
|
+
rescue Ace::Assign::Error => error
|
|
32
|
+
raise GitUnavailable, error.message
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def fast_forwardable?(head)
|
|
36
|
+
@reader.ancestor?(@repo_path, base_head, head)
|
|
37
|
+
rescue Ace::Assign::Error => error
|
|
38
|
+
raise GitUnavailable, error.message
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
class GitUnavailable < StandardError; end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|