ace-lab 0.8.0 → 0.9.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 +12 -0
- data/docs/usage.md +188 -0
- data/lib/ace/lab/cli/commands/assignment.rb +48 -1
- data/lib/ace/lab/cli/commands/command_execute.rb +28 -0
- data/lib/ace/lab/cli/commands/run.rb +65 -0
- data/lib/ace/lab/cli.rb +10 -0
- data/lib/ace/lab/molecules/command_owner_admission.rb +183 -0
- data/lib/ace/lab/molecules/command_policy.rb +158 -0
- data/lib/ace/lab/molecules/grant_resolver.rb +15 -0
- data/lib/ace/lab/molecules/ordinary_owner_admission.rb +13 -0
- data/lib/ace/lab/organisms/command_executor.rb +220 -0
- data/lib/ace/lab/organisms/command_run_client.rb +131 -0
- data/lib/ace/lab/organisms/ordinary_assignment_client.rb +12 -41
- data/lib/ace/lab/organisms/ordinary_authority_composition.rb +35 -5
- data/lib/ace/lab/organisms/ordinary_merge_receiver.rb +9 -18
- data/lib/ace/lab/version.rb +1 -1
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a7d3d64373a6eaadb24dde7d501cd5c4b8e2ebfb370e054334297fc0796a8c9c
|
|
4
|
+
data.tar.gz: 98961e2da9d0b7383cb48a3ae3a801b7018a21c8822fa95fde779c94f2ddfb87
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fae21d841c34b8ff24586a806778377589a476ea4e824e9716c6ff55700d207fc9a501656fe2d1712c5c8358a29e22a31a2c8fb2cde8713d464e700f6fdea795
|
|
7
|
+
data.tar.gz: feacdf38956ff9a1c3afe9296e4cc762deba5029ece4dbc6e9d6bd3a1d6e28e825d746e29cd0f52268c39ba5fe4bd5d97a48e93d7a8f901da38ad9225db41ffc
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.9.0] - 2026-10-16
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Wire ordinary artifact retention to fresh installed project visibility and the existing exact service or canonical proposal decision; retention does not select an executor or consume publication authorization.
|
|
15
|
+
- Expose ordinary candidate upload, assigned review export/acceptance and canonical merge evidence through retained assignment requests. Compose their proof owner with the existing merge receiver and command executor journal.
|
|
16
|
+
- Expose ordinary driver status, bootstrap, progress, result and exact recovery commands using the selected Assign owner; authenticate coordinator replacement with bounded process-generation observation.
|
|
17
|
+
|
|
18
|
+
### Changed
|
|
19
|
+
|
|
20
|
+
- Require an explicit `purpose: command` or `purpose: test` on every installed command-policy entry. Only test commands with exact candidate head/tree and canonical successful execution can authorize merge evidence; previous entries without purpose are refused.
|
|
21
|
+
|
|
10
22
|
## [0.8.0] - 2026-10-16
|
|
11
23
|
|
|
12
24
|
### Added
|
data/docs/usage.md
CHANGED
|
@@ -276,6 +276,143 @@ must read the same canonical status and adopt its original attempt/result IDs.
|
|
|
276
276
|
Changed owner selection refuses new admission and dispatch until a controlled
|
|
277
277
|
restart. Existing canonical receipts remain the source for reconciliation.
|
|
278
278
|
|
|
279
|
+
## Ordinary candidate, review and merge evidence
|
|
280
|
+
|
|
281
|
+
Keep the real delivery attempt running through review, test execution and merge.
|
|
282
|
+
Finish it only after settlement. A completed coder attempt can seed a new real
|
|
283
|
+
registered delivery attempt; never reopen its old authority.
|
|
284
|
+
|
|
285
|
+
The existing `assignment request --input request.json` interface also supports:
|
|
286
|
+
|
|
287
|
+
| Operation | Params in addition to assignment_id/attempt_id/head/candidate_generation | Authority / transfer |
|
|
288
|
+
|---|---|---|
|
|
289
|
+
| `submit_candidate` | `pane`, `expected_generation` | Original worker; one complete Git bundle; candidate_generation is the previous generation (0 initially) |
|
|
290
|
+
| `assign_review` | `pane`, `expected_generation`, `reviewer_assignment_id`, `reviewer_attempt_id` | Current canonical supervisor selects an already started independent reviewer |
|
|
291
|
+
| `export_candidate` | `pane`, `review_id` | Assigned reviewer; null mutation; `--output NEW_DIRECTORY` downloads canonical bundle |
|
|
292
|
+
| `accept_review` | `pane`, `expected_generation`, `review_id`, `receipt_sha256` | Assigned reviewer uploads exact receipt followed by its ordered report artifacts |
|
|
293
|
+
| `cancel_review` | `pane`, `expected_generation`, `review_id` | Current supervisor revokes that exact review |
|
|
294
|
+
| `review_status` | None | Null mutation; revalidates accepted original receipt and artifacts |
|
|
295
|
+
| `merge_evidence` | `test_execution_ids` (nonempty array) | Null mutation; projects exact accepted review and canonical executed-test references |
|
|
296
|
+
|
|
297
|
+
First submit the candidate, retaining its returned head, tree and new generation.
|
|
298
|
+
Prepare/register/start a separate review assignment through the ordinary native
|
|
299
|
+
entry. Its agent must have a different actual process generation, session UUID
|
|
300
|
+
and pane. The supervisor assigns that exact reviewer assignment/attempt at its
|
|
301
|
+
honestly observed starting base. Export gives the assigned agent canonical candidate
|
|
302
|
+
bytes; it then materializes/checks out the candidate. Receipt acceptance requires
|
|
303
|
+
that exact candidate HEAD and the unchanged original native reviewer binding. A receipt uses the delivery attempt's identifiers,
|
|
304
|
+
scope and actual producer; its `review.reviewer.actor` is the real reviewer's
|
|
305
|
+
account. The owner derives independence from authenticated native origins, so
|
|
306
|
+
shared-account agents do not need invented actor aliases.
|
|
307
|
+
|
|
308
|
+
`accept_review` retains the original receipt and up to 15 report artifacts in
|
|
309
|
+
CanonicalEvidence, verifies their exact digests, and returns the accepted review
|
|
310
|
+
ID/digest. The receipt declares operation review, succeeded, approved at the exact
|
|
311
|
+
candidate head, and executed review checks. These review checks are not test
|
|
312
|
+
execution authority. Missing/corrupt reports, a different caller, terminal delivery
|
|
313
|
+
attempt, changed head/generation or cancellation refuse. Retrying the exact
|
|
314
|
+
accepted mutation cannot accept a second review; changed bytes under that mutation
|
|
315
|
+
refuse. This first ingress accepts direct independent review of the exact candidate
|
|
316
|
+
generation. Campaign receipts and accepted-head carry are deliberately unavailable
|
|
317
|
+
until the existing ace-review campaign/assessment owner is connected; a head change
|
|
318
|
+
currently refuses prior approval. This is a remaining delivery capability, not the
|
|
319
|
+
final policy of requiring a full independent review after every SHA change.
|
|
320
|
+
|
|
321
|
+
Execute the project's root-selected test command through the existing command
|
|
322
|
+
transport. Only an explicit `purpose: test` definition with exact immutable
|
|
323
|
+
candidate head/tree, root-witnessed child, and canonical exit0/no signal can supply
|
|
324
|
+
test evidence. `merge_evidence` resolves those execution IDs and returns
|
|
325
|
+
`review_evidence` and `test_evidence` for the fixed merge request. Copy those exact
|
|
326
|
+
projections into the retained request before obtaining its digest-bound decision.
|
|
327
|
+
Plain worker check strings or generic allowed commands are not substitutes.
|
|
328
|
+
|
|
329
|
+
The sole owner revalidates evidence in the merge admission CAS and before each
|
|
330
|
+
Forge effect. Assignment exclusion prevents candidate changes, review cancellation
|
|
331
|
+
or finish racing an executing merge. Policy revocation or missing canonical bytes
|
|
332
|
+
refuses without dispatch. Existing unknown outcomes retain their original merge
|
|
333
|
+
reference and require reconciliation.
|
|
334
|
+
|
|
335
|
+
The dynamic candidate-to-immutable-command-view binding and installed two-worker
|
|
336
|
+
acceptance remain separate required gates. Static source fixtures do not authorize
|
|
337
|
+
per-PR administrative head/path edits or claim installed autonomous merging.
|
|
338
|
+
|
|
339
|
+
## Ordinary driver and supervisor recovery
|
|
340
|
+
|
|
341
|
+
`ace-overseer ordinary --operation prepare` retains one context per fresh worker.
|
|
342
|
+
From that worker's native tool process, drive only the installed owner:
|
|
343
|
+
|
|
344
|
+
```sh
|
|
345
|
+
ace-lab assignment drive --project atlas --context /private/work/context.json --operation status
|
|
346
|
+
ace-lab assignment drive --project atlas --context /private/work/context.json --operation start
|
|
347
|
+
ace-lab assignment drive --project atlas --context /private/work/context.json --operation bootstrap --output /private/work/accepted-context
|
|
348
|
+
ace-lab assignment drive --project atlas --context /private/work/context.json --operation next
|
|
349
|
+
ace-lab assignment drive --project atlas --context /private/work/context.json --operation step-start --generation 1 --step 010.01.01
|
|
350
|
+
ace-lab assignment drive --project atlas --context /private/work/context.json --operation step-finish --generation 2 --step 010.01.01 --report /private/work/check.md
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
Use returned generations and actual step numbers, not the illustrative values.
|
|
354
|
+
`HERDR_PANE` supplies the caller pane unless `--pane` is explicit; the selected
|
|
355
|
+
owner authenticates actual native ancestry independently. `bootstrap` returns
|
|
356
|
+
accepted context paths, byte counts, SHA256 values and manifest digest. `next`
|
|
357
|
+
reads the canonical queue including reports and returns current/next instructions.
|
|
358
|
+
`fail --generation N --message REASON` records a failed scope; it never fabricates
|
|
359
|
+
successful completion. The workflow `wfi://assign/drive` selects this protocol
|
|
360
|
+
before local assignment resolution when `--ordinary`/the ordinary environment
|
|
361
|
+
is present.
|
|
362
|
+
|
|
363
|
+
Submit one bounded execution receipt JSON with the following fields, using
|
|
364
|
+
actual observed values (no placeholders in submitted evidence):
|
|
365
|
+
|
|
366
|
+
```json
|
|
367
|
+
{
|
|
368
|
+
"assignment_id": "original-assignment", "attempt_id": "original-attempt",
|
|
369
|
+
"project_id": "atlas", "scope": "010.01", "operation": "check",
|
|
370
|
+
"producer": {"actor": "actual-user", "role": "worker", "runtime": "herdr"},
|
|
371
|
+
"head": "0123456789012345678901234567890123456789",
|
|
372
|
+
"verdict": "succeeded", "checks": [{"name": "tests", "verdict": "passed"}],
|
|
373
|
+
"artifacts": [{"path": "check.md", "sha256": "actual-64-hex-artifact-digest"}]
|
|
374
|
+
}
|
|
375
|
+
```
|
|
376
|
+
|
|
377
|
+
The producer comes from canonical start status; checks and artifacts must describe
|
|
378
|
+
executed work. Run `--operation result --generation N --receipt receipt.json
|
|
379
|
+
--artifact check.md` with files in receipt order. The existing receipt verifier
|
|
380
|
+
and canonical import validate attribution, evidence and exact bytes. A result
|
|
381
|
+
submission leaves the worker running until its canonical supervisor executes
|
|
382
|
+
`--operation adopt` and receives terminal status. Keep the actual worker process
|
|
383
|
+
and checkout alive until that acceptance; a terminal pane without the required
|
|
384
|
+
native binding is insufficient.
|
|
385
|
+
|
|
386
|
+
Mutations retain their exact request and transferred bytes under the original
|
|
387
|
+
context directory before sending. A lost response is recovered with canonical
|
|
388
|
+
`status`/`next`, then `--operation replay --request /original/requests/.../request.json`.
|
|
389
|
+
Never update an existing request's generation, mutation ID or bytes to force a
|
|
390
|
+
retry. The requester directory is not an accepted-state store. The result hint
|
|
391
|
+
file is ignored by canonical reads/adoption, including when forged or stale.
|
|
392
|
+
Inbound Inbox settlement is not provisioned by this driver; nonempty references
|
|
393
|
+
still require the separately selected existing Inbox owner and fail closed otherwise.
|
|
394
|
+
|
|
395
|
+
A replacement coordinator reads `--operation supervisor`, then from its actual
|
|
396
|
+
installed native supervisor placement uses:
|
|
397
|
+
|
|
398
|
+
```sh
|
|
399
|
+
ace-lab assignment drive --project atlas --context /private/work/context.json \
|
|
400
|
+
--operation adopt-supervisor --generation 1 --previous-binding-sha256 EXACT_OLD_DIGEST
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
The definition chain records old→new bindings under its original generation CAS.
|
|
404
|
+
Only a positively ended old generation (kernel process absence or a different
|
|
405
|
+
exact process birth) permits adoption; live, inaccessible, remote-host and unknown
|
|
406
|
+
identities refuse. A lost adoption response reuses its original retained request.
|
|
407
|
+
No worker is re-registered or relaunched. Existing pending results, uncertain
|
|
408
|
+
operations, attempts and request IDs remain unchanged. Finish, including replay,
|
|
409
|
+
checks the current canonical supervisor binding and rejects the displaced one.
|
|
410
|
+
|
|
411
|
+
Wire additions are `supervisor_status` (assignment only, null mutation), and
|
|
412
|
+
`adopt_supervisor` (assignment, actual pane, expected definition generation,
|
|
413
|
+
previous_binding_sha256; original mutation). They use the same installed owner,
|
|
414
|
+
project policy, journal and lifecycle exclusion as ordinary execution.
|
|
415
|
+
|
|
279
416
|
## Error semantics
|
|
280
417
|
|
|
281
418
|
Errors are one deterministic JSON document on stdout plus a non-zero exit.
|
|
@@ -447,3 +584,54 @@ Handlers retain the fixed closed environment and 30-second execution deadline, w
|
|
|
447
584
|
The existing protected receiver exposes `recover_no_effect(binding:, input_bytes:, mutation_id:, expected_generation:)`. Supply the original assignment/attempt/candidate/head/request binding, original structured input, stable mutation ID and unchanged original positive authority generation. The receiver snapshots those inputs, authenticates canonical status and input before any settled return, and adopts an already accepted current challenge without claiming again. A needed claim uses the supplied generation unchanged. Lost acknowledgements remain uncertain; status retry never repeats the original effect.
|
|
448
585
|
|
|
449
586
|
Trusted operation configuration must provide a distinct fixed `no_effect_argv` for the original project/service/executor. Inspection may proceed after the effect lease expires, but it never renews effect permission or selects normal effect argv. The selected inspector must produce operation-specific original target/handler/writer observations and existing strict challenge-bound artifacts; only the authority's canonical import can establish `failed-settled`. This source API and controlled composition do not deliver the outstanding gad.b inspector or installed acceptance.
|
|
587
|
+
|
|
588
|
+
## Project commands
|
|
589
|
+
|
|
590
|
+
`ace-lab run --project ace -- /usr/bin/id` invokes one exact installed command
|
|
591
|
+
policy. `--as publisher` selects a permitted executor; repeated `--service NAME`
|
|
592
|
+
replaces the configured default set. Every service and the entire combination
|
|
593
|
+
must be granted. Arguments after `--` remain literal, including command options.
|
|
594
|
+
No shell is added. Duplicate `--project`/`--as` or empty argv are usage errors.
|
|
595
|
+
|
|
596
|
+
The root-owned `/etc/lab/ace-lab/project-commands.yml` is the only command-policy
|
|
597
|
+
source. Project preferences and caller environment cannot create grants. Its
|
|
598
|
+
version1 project entries contain `launcher`, `default_executor`, `default_services`,
|
|
599
|
+
`executors`, `services`, `commands`, and `grants`. Executors declare UID/GID/groups,
|
|
600
|
+
HOME/cwd/PATH and nonsecret environment; services declare nonsecret environment
|
|
601
|
+
and secret references; commands bind exact argv, executable SHA256, source
|
|
602
|
+
head/tree and required `purpose: command` or `purpose: test`. Test purpose requires
|
|
603
|
+
nonnull source head/tree and is never inferred from executable name or exit0.
|
|
604
|
+
With `purpose: command`, source head/tree may both be null for a non-Git probe;
|
|
605
|
+
Git source uses two exact object IDs. Grants bind requester kind/UID, command, executor
|
|
606
|
+
and the exact service combination. Accepted code/cwd must be root-owned and
|
|
607
|
+
unwritable by the executor. Missing or changed context refuses execution.
|
|
608
|
+
|
|
609
|
+
An operator grant authorizes an OS account. Agents sharing that account share
|
|
610
|
+
its operator authority; an environment hint does not prove human presence.
|
|
611
|
+
Agent-selected invocation additionally checks actual current native ancestry.
|
|
612
|
+
Separate identities are required when operator authority must exclude agents.
|
|
613
|
+
|
|
614
|
+
The CLI retains an invocation under `.ace-local/lab-commands/` and prints its
|
|
615
|
+
execution reference before contacting the canonical Assign owner. Inspect it with
|
|
616
|
+
`ace-lab run-status --project ace --reference REF`. Inspection starts no command
|
|
617
|
+
and remains available to the original requester account after a grant is revoked.
|
|
618
|
+
After response loss, inspect that original reference before requesting new work.
|
|
619
|
+
An existing claim or a replayed acknowledgment cannot release another child.
|
|
620
|
+
|
|
621
|
+
Interactive terminals preserve no-echo input, dimensions and signals. Pipe mode
|
|
622
|
+
preserves EOF, separate stdout/stderr and actual exit/signal status. Socket closure
|
|
623
|
+
without the authenticated result is uncertainty. Root launch/exit observations
|
|
624
|
+
remain separate from accepted product delivery evidence; exit0 alone does not
|
|
625
|
+
prove publication, installation or completed delivery.
|
|
626
|
+
|
|
627
|
+
This implementation slice admits nonsecret commands. A nonempty selected secret
|
|
628
|
+
reference set is explicitly refused before claim until the secret execution
|
|
629
|
+
capability is supplied. Publication also requires the publisher's writable
|
|
630
|
+
artifact/cache destination outside immutable accepted source, or retained exact
|
|
631
|
+
artifacts. These remaining joins and installed acceptance are tracked by ei9 .5;
|
|
632
|
+
source/kernel fixture success does not assert installed autonomous publication.
|
|
633
|
+
|
|
634
|
+
In interactive mode, input EOF sends the terminal's conventional Ctrl-D byte; a
|
|
635
|
+
program that changes its terminal to raw mode may interpret that byte itself.
|
|
636
|
+
Noninteractive EOF closes the command input pipe. The dedicated Linux helper owns
|
|
637
|
+
and reaps detached command descendants as well as the original process group.
|
|
@@ -1,12 +1,59 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require_relative "../../organisms/ordinary_assignment_client"
|
|
4
|
+
require "ace/assign/organisms/ordinary_driver"
|
|
4
5
|
|
|
5
6
|
module Ace
|
|
6
7
|
module Lab
|
|
7
8
|
module CLI
|
|
8
9
|
module Commands
|
|
9
10
|
module Assignment
|
|
11
|
+
class Drive < Ace::Support::Cli::Command
|
|
12
|
+
desc "Drive or inspect one original ordinary execution through its installed owner"
|
|
13
|
+
option :project, required: true, desc: "Installed project ID; never inferred from local Assign"
|
|
14
|
+
option :context, required: true, desc: "Original retained ordinary context.json"
|
|
15
|
+
option :operation, required: true, desc: "status, supervisor, adopt-supervisor, bootstrap, next, start, step-start, step-finish, fail, result, adopt, replay"
|
|
16
|
+
option :previous_binding_sha256, desc: "Exact canonical old supervisor binding digest for adoption"
|
|
17
|
+
option :output, desc: "New private directory for accepted bootstrap context"
|
|
18
|
+
option :pane, desc: "Actual native caller pane (defaults to HERDR_PANE)"
|
|
19
|
+
option :generation, type: :integer, desc: "Exact canonical generation observed before mutation"
|
|
20
|
+
option :step, desc: "Exact queue step within the retained scope"
|
|
21
|
+
option :report, desc: "Executed step's bounded UTF-8 report file"
|
|
22
|
+
option :message, desc: "Failure reason"
|
|
23
|
+
option :receipt, desc: "Attributable execution receipt JSON"
|
|
24
|
+
option :artifact, type: :array, desc: "Artifact files in receipt order"
|
|
25
|
+
option :request, desc: "Original mutation request to replay after reconciliation"
|
|
26
|
+
|
|
27
|
+
def call(project:, context:, operation:, **options)
|
|
28
|
+
transport = Organisms::OrdinaryAssignmentClient.new(project: project)
|
|
29
|
+
driver = Ace::Assign::Organisms::OrdinaryDriver.new(path: context, project: project, transport: transport)
|
|
30
|
+
pane = options[:pane] || ENV["HERDR_PANE"]
|
|
31
|
+
result = case operation
|
|
32
|
+
when "status" then driver.status
|
|
33
|
+
when "supervisor" then driver.supervisor
|
|
34
|
+
when "adopt-supervisor"
|
|
35
|
+
driver.adopt_supervisor(pane: pane, generation: options.fetch(:generation),
|
|
36
|
+
previous_binding_sha256: options.fetch(:previous_binding_sha256))
|
|
37
|
+
when "bootstrap" then driver.bootstrap(output: options.fetch(:output))
|
|
38
|
+
when "next" then driver.queue
|
|
39
|
+
when "start" then driver.start(pane: pane)
|
|
40
|
+
when "step-start", "step-finish", "fail"
|
|
41
|
+
driver.progress(operation: {"step-start" => "queue_start", "step-finish" => "queue_finish", "fail" => "queue_fail"}.fetch(operation),
|
|
42
|
+
pane: pane, generation: options.fetch(:generation), step: options[:step],
|
|
43
|
+
report: operation == "step-finish" ? options.fetch(:report) : nil,
|
|
44
|
+
message: operation == "fail" ? options.fetch(:message) : nil)
|
|
45
|
+
when "result"
|
|
46
|
+
driver.submit(pane: pane, generation: options.fetch(:generation), receipt: options.fetch(:receipt), artifacts: options[:artifact] || [])
|
|
47
|
+
when "adopt" then driver.adopt(pane: pane)
|
|
48
|
+
when "replay" then driver.replay(input: options.fetch(:request))
|
|
49
|
+
else raise ArgumentError, "unknown ordinary driver operation"
|
|
50
|
+
end
|
|
51
|
+
puts JSON.generate(result)
|
|
52
|
+
rescue ArgumentError, KeyError, SystemCallError, Ace::Assign::Error, Ace::Runtime::Error, Ace::Lab::Error => error
|
|
53
|
+
raise Ace::Support::Cli::Error, "ordinary execution unavailable: #{error.message}; inspect the original context with --operation status"
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
10
57
|
class Prepare < Ace::Support::Cli::Command
|
|
11
58
|
desc "Retain reviewed task input for ordinary Assign registration"
|
|
12
59
|
option :project, required: true, desc: "Installed project ID"
|
|
@@ -16,7 +63,7 @@ module Ace
|
|
|
16
63
|
option :dependency_reports, desc: "JSON array of explicit dependency report selections"
|
|
17
64
|
|
|
18
65
|
def call(project:, task:, pane:, output:, **options)
|
|
19
|
-
reports = options[:dependency_reports] ? JSON.parse(
|
|
66
|
+
reports = options[:dependency_reports] ? JSON.parse(Ace::Assign::Authority::RetainedInput.read_file(
|
|
20
67
|
options.fetch(:dependency_reports), 65_536), allow_duplicate_key: false, create_additions: false, max_nesting: 16) : []
|
|
21
68
|
puts JSON.generate(Organisms::OrdinaryAssignmentClient.prepare(project: project, task: task,
|
|
22
69
|
pane: pane, output: output, dependency_reports: reports))
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require_relative "../../organisms/command_executor"
|
|
3
|
+
module Ace
|
|
4
|
+
module Lab
|
|
5
|
+
module CLI
|
|
6
|
+
module Commands
|
|
7
|
+
class CommandExecute < Ace::Support::Cli::Command
|
|
8
|
+
desc "Execute one admitted command for the installed root launcher"
|
|
9
|
+
option :project, required: true
|
|
10
|
+
option :fd, type: :integer, required: true
|
|
11
|
+
def call(project:, fd:, **)
|
|
12
|
+
raise SecurityError unless Process.uid.zero? && Process.euid.zero? && fd >= 3
|
|
13
|
+
bytes = $stdin.read(16_385)
|
|
14
|
+
raise ArgumentError if bytes.bytesize > 16_384
|
|
15
|
+
request = JSON.parse(bytes, allow_duplicate_key: false, create_additions: false, max_nesting: 16)
|
|
16
|
+
socket = UNIXSocket.for_fd(fd)
|
|
17
|
+
Organisms::CommandExecutor.new(project: project).run(socket: socket, request: request)
|
|
18
|
+
0 # Child status belongs to the framed result, not helper transport.
|
|
19
|
+
rescue StandardError, SecurityError
|
|
20
|
+
raise Ace::Support::Cli::Error, "command uncertain; inspect original execution reference"
|
|
21
|
+
ensure
|
|
22
|
+
socket&.close unless socket&.closed?
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
require "optparse"
|
|
3
|
+
require_relative "../../organisms/command_run_client"
|
|
4
|
+
module Ace
|
|
5
|
+
module Lab
|
|
6
|
+
module CLI
|
|
7
|
+
module Commands
|
|
8
|
+
class Run < Ace::Support::Cli::Command
|
|
9
|
+
desc "Run one explicitly authorized project command"
|
|
10
|
+
option :project, required: true, desc: "Installed project ID"
|
|
11
|
+
option :as, desc: "Configured permitted executor"
|
|
12
|
+
option :service, repeat: true, desc: "Replace default services with this complete set"
|
|
13
|
+
argument :argv, type: :array, desc: "Literal command argv after --"
|
|
14
|
+
|
|
15
|
+
def self.parse(args)
|
|
16
|
+
separator = args.index("--")
|
|
17
|
+
raise ArgumentError, "run requires -- followed by literal command argv" unless separator && separator < args.size - 1
|
|
18
|
+
switches, argv = args.first(separator), args.drop(separator + 1)
|
|
19
|
+
options = {executor: nil, services: nil}
|
|
20
|
+
seen = {}
|
|
21
|
+
parser = OptionParser.new
|
|
22
|
+
%w[project as].each do |name|
|
|
23
|
+
parser.on("--#{name} VALUE") do |value|
|
|
24
|
+
raise ArgumentError, "duplicate --#{name}" if seen[name]
|
|
25
|
+
seen[name] = true
|
|
26
|
+
options[name == "as" ? :executor : :project] = value
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
parser.on("--service VALUE") { |value| (options[:services] ||= []) << value }
|
|
30
|
+
parser.parse!(switches)
|
|
31
|
+
raise ArgumentError, "run requires one --project and no extra options" unless seen["project"] && switches.empty?
|
|
32
|
+
options.merge(argv: argv)
|
|
33
|
+
rescue OptionParser::ParseError, ArgumentError
|
|
34
|
+
raise Ace::Support::Cli::Error, "usage_error: run requires unique project/executor options and literal argv after --"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def self.invoke(args)
|
|
38
|
+
options = parse(args)
|
|
39
|
+
project = options.delete(:project)
|
|
40
|
+
client = Organisms::CommandRunClient.new(project: project)
|
|
41
|
+
begin
|
|
42
|
+
client.run(**options, pane: ENV["HERDR_PANE_ID"])
|
|
43
|
+
rescue StandardError, SecurityError
|
|
44
|
+
# Once admission may have crossed the owner boundary, even a
|
|
45
|
+
# malformed stream is uncertainty, never a fresh usage refusal.
|
|
46
|
+
raise Ace::Support::Cli::Error, "command_uncertain: inspect the original execution reference before new work"
|
|
47
|
+
end
|
|
48
|
+
rescue Ace::Lab::InvalidConfigurationError
|
|
49
|
+
raise Ace::Support::Cli::Error, "invalid_command_context: installed project selection is unavailable"
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
class RunStatus < Ace::Support::Cli::Command
|
|
53
|
+
desc "Inspect an original command execution without running it"
|
|
54
|
+
option :project, required: true
|
|
55
|
+
option :reference, required: true
|
|
56
|
+
def call(project:, reference:, **)
|
|
57
|
+
puts JSON.generate(Organisms::CommandRunClient.new(project: project).status(reference))
|
|
58
|
+
rescue StandardError, SecurityError
|
|
59
|
+
raise Ace::Support::Cli::Error, "command_status_unavailable: preserve original execution reference"
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
data/lib/ace/lab/cli.rb
CHANGED
|
@@ -10,6 +10,8 @@ require_relative "cli/commands/resolve"
|
|
|
10
10
|
require_relative "cli/commands/route"
|
|
11
11
|
require_relative "cli/commands/service"
|
|
12
12
|
require_relative "cli/commands/assignment"
|
|
13
|
+
require_relative "cli/commands/command_execute"
|
|
14
|
+
require_relative "cli/commands/run"
|
|
13
15
|
|
|
14
16
|
module Ace
|
|
15
17
|
module Lab
|
|
@@ -21,6 +23,8 @@ module Ace
|
|
|
21
23
|
|
|
22
24
|
# Application commands with descriptions (for help output)
|
|
23
25
|
REGISTERED_COMMANDS = [
|
|
26
|
+
["run", "Run one explicitly authorized project command"],
|
|
27
|
+
["run-status", "Inspect an original command execution without running it"],
|
|
24
28
|
["projects", "List lab projects visible to the verified caller"],
|
|
25
29
|
["agents", "List lab agents in a project by stable ID"],
|
|
26
30
|
["services", "List lab services in a project by stable ID"],
|
|
@@ -30,6 +34,7 @@ module Ace
|
|
|
30
34
|
["service status", "Read a service request outcome"],
|
|
31
35
|
["service listen", "Serve the ordinary fixed merge receiver on its configured socket"],
|
|
32
36
|
["assignment prepare", "Retain reviewed task input for ordinary Assign registration"],
|
|
37
|
+
["assignment drive", "Drive one original execution through the installed ordinary owner"],
|
|
33
38
|
["assignment request", "Send one retained operation to the installed ordinary Assign owner"]
|
|
34
39
|
].freeze
|
|
35
40
|
|
|
@@ -45,6 +50,7 @@ module Ace
|
|
|
45
50
|
# @param args [Array<String>] Command-line arguments
|
|
46
51
|
# @return [Integer] Exit code (0 for success, non-zero for failure)
|
|
47
52
|
def self.start(args)
|
|
53
|
+
return CLI::Commands::Run.invoke(args.drop(1)) if args.first == "run" && !(args.take(args.index("--") || args.size).include?("--help") || args.take(args.index("--") || args.size).include?("-h"))
|
|
48
54
|
Ace::Support::Cli::Runner.new(self).call(args: args)
|
|
49
55
|
end
|
|
50
56
|
|
|
@@ -57,7 +63,11 @@ module Ace
|
|
|
57
63
|
register "service request", CLI::Commands::Service::Request.new
|
|
58
64
|
register "service status", CLI::Commands::Service::Status.new
|
|
59
65
|
register "service listen", CLI::Commands::Service::Listen.new
|
|
66
|
+
register "run", CLI::Commands::Run.new
|
|
67
|
+
register "run-status", CLI::Commands::RunStatus.new
|
|
68
|
+
register "command execute", CLI::Commands::CommandExecute.new
|
|
60
69
|
register "assignment prepare", CLI::Commands::Assignment::Prepare.new
|
|
70
|
+
register "assignment drive", CLI::Commands::Assignment::Drive.new
|
|
61
71
|
register "assignment request", CLI::Commands::Assignment::Request.new
|
|
62
72
|
|
|
63
73
|
# Register version command
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "find"
|
|
4
|
+
require "ace/assign/authority/command_execution"
|
|
5
|
+
require "ace/herdr/molecules/bounded_observation"
|
|
6
|
+
require "ace/runtime/molecules/process_identity"
|
|
7
|
+
require_relative "command_policy"
|
|
8
|
+
|
|
9
|
+
module Ace
|
|
10
|
+
module Lab
|
|
11
|
+
module Molecules
|
|
12
|
+
# Adds the project-command principals to the same installed listener.
|
|
13
|
+
# Root launch facts do not impersonate a participant/native agent.
|
|
14
|
+
class CommandOwnerAdmission
|
|
15
|
+
def initialize(base:, project:, policy_loader: nil)
|
|
16
|
+
@base, @project = base, project
|
|
17
|
+
@policy_loader = policy_loader || -> { CommandPolicy.load(project: @project) }
|
|
18
|
+
@peer = Ace::Assign::Authority::OrdinaryPeer.new
|
|
19
|
+
end
|
|
20
|
+
def service = @base.service
|
|
21
|
+
def verify_listener! = @base.verify_listener!
|
|
22
|
+
def peer(socket, deadline:) = @base.peer(socket, deadline: deadline)
|
|
23
|
+
def authorize!(request:, peer:, deadline:)
|
|
24
|
+
return @base.authorize!(request: request, peer: peer, deadline: deadline) unless Ace::Assign::Authority::CommandExecution::OPERATIONS.include?(request["operation"])
|
|
25
|
+
raise Ace::Assign::AttemptErrors::UnauthorizedIdentity unless request["project_id"] == @project
|
|
26
|
+
if %w[command_claim command_bind command_complete].include?(request["operation"])
|
|
27
|
+
command_launcher!(peer: peer, deadline: deadline)
|
|
28
|
+
elsif request["operation"] == "command_status"
|
|
29
|
+
@peer.live!(peer, deadline: deadline) # Handler checks original requester before exposing data.
|
|
30
|
+
else
|
|
31
|
+
command_reader!(peer: peer, deadline: deadline)
|
|
32
|
+
end
|
|
33
|
+
:worker
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def command_reader!(peer:, deadline:, definition: nil)
|
|
37
|
+
@peer.live!(peer, deadline: deadline)
|
|
38
|
+
return true if peer["uid"] == 0 && command_launcher!(peer: peer, deadline: deadline)
|
|
39
|
+
if definition
|
|
40
|
+
# Inspection never grants a second effect. Original requester UID
|
|
41
|
+
# retains reconciliation after policy revocation or process restart.
|
|
42
|
+
unless definition.dig("requester", "uid") == peer.fetch("uid")
|
|
43
|
+
raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "command reference belongs to another requester"
|
|
44
|
+
end
|
|
45
|
+
return true
|
|
46
|
+
end
|
|
47
|
+
# Authorize visibility through the same closed selection machinery;
|
|
48
|
+
# absence of any configured grant is a refusal, not native inference.
|
|
49
|
+
document = policy
|
|
50
|
+
document.reader!(peer.fetch("uid"))
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def command_request!(selection:, peer:, deadline:)
|
|
54
|
+
@peer.live!(peer, deadline: deadline)
|
|
55
|
+
selected = policy.select(selection: selection, peer: peer)
|
|
56
|
+
if selected.fetch("requester_kind") == "agent"
|
|
57
|
+
selected["native_binding"] = @base.identity!(peer: peer, pane: selection.fetch("pane"), deadline: deadline).runtime_binding
|
|
58
|
+
end
|
|
59
|
+
verify_context!(selected, deadline)
|
|
60
|
+
selected
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Historical execution can outlive its requester. Current root policy
|
|
64
|
+
# and immutable source/executable must still grant this exact TEST
|
|
65
|
+
# command; requester liveness does not invent a new execution.
|
|
66
|
+
def command_test_evidence!(definition:, deadline:)
|
|
67
|
+
unless definition["purpose"] == "test" && definition.dig("source", "head") && definition.dig("source", "tree") &&
|
|
68
|
+
policy.select(selection: definition.fetch("selection"), peer: definition.fetch("requester")) == definition.except("native_binding")
|
|
69
|
+
raise Ace::Assign::AttemptErrors::ReceiptRejected, "executed test command policy was revoked or changed"
|
|
70
|
+
end
|
|
71
|
+
verify_context!(definition, deadline)
|
|
72
|
+
rescue SecurityError, Ace::Lab::InvalidConfigurationError
|
|
73
|
+
raise Ace::Assign::AttemptErrors::ReceiptRejected, "executed test command policy or source is unavailable"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def command_launcher!(peer:, deadline:)
|
|
77
|
+
@peer.live!(peer, deadline: deadline)
|
|
78
|
+
expected = JSON.parse(GrantResolver.read_verified(policy.launcher.fetch("identity_path")) || "null")
|
|
79
|
+
unless expected.is_a?(Hash) && expected.keys.sort == %w[identity schema] &&
|
|
80
|
+
expected["schema"] == "labd-command-launcher/v1" && expected["identity"].is_a?(Hash) &&
|
|
81
|
+
expected["identity"]["uid"] == 0 && peer["uid"] == 0
|
|
82
|
+
raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "installed command launcher differs"
|
|
83
|
+
end
|
|
84
|
+
target = expected.fetch("identity")
|
|
85
|
+
@peer.live!(target, deadline: deadline)
|
|
86
|
+
cursor = peer
|
|
87
|
+
64.times do
|
|
88
|
+
return target if cursor.slice("pid", "uid", "host", "started_at") == target.slice("pid", "uid", "host", "started_at")
|
|
89
|
+
break unless RUBY_PLATFORM.include?("linux") && cursor["uid"] == 0
|
|
90
|
+
stat = File.read("/proc/#{cursor.fetch('pid')}/stat")
|
|
91
|
+
parent = Integer(stat[(stat.rindex(")") + 2)..].split.fetch(1), 10)
|
|
92
|
+
break if parent <= 1
|
|
93
|
+
@peer.live!(cursor, deadline: deadline)
|
|
94
|
+
identity = Ace::Runtime::Molecules::ProcessIdentity.new(executor:
|
|
95
|
+
Ace::Herdr::Molecules::BoundedObservation.new(deadline: deadline)).capture(parent)
|
|
96
|
+
break unless identity
|
|
97
|
+
cursor = identity
|
|
98
|
+
end
|
|
99
|
+
raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "command helper is outside installed launcher ancestry"
|
|
100
|
+
rescue JSON::ParserError, SystemCallError, IndexError, ArgumentError
|
|
101
|
+
raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "installed command launcher is unavailable"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def command_claim!(definition:, caller:, launcher:, deadline:)
|
|
105
|
+
command_launcher!(peer: launcher, deadline: deadline)
|
|
106
|
+
unless caller == definition.fetch("requester") && command_request!(selection: definition.fetch("selection"), peer: caller, deadline: deadline) == definition
|
|
107
|
+
raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "command requester or policy changed"
|
|
108
|
+
end
|
|
109
|
+
true
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def command_child!(definition:, child:, launcher:, deadline:)
|
|
113
|
+
command_launcher!(peer: launcher, deadline: deadline)
|
|
114
|
+
unless command_request!(selection: definition.fetch("selection"), peer: definition.fetch("requester"), deadline: deadline) == definition
|
|
115
|
+
raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "command admission changed before exec"
|
|
116
|
+
end
|
|
117
|
+
# The authenticated root daemon witnesses cwd/identity after dropping
|
|
118
|
+
# the gated child. Owner independently captures its kernel generation.
|
|
119
|
+
keys = %w[pid uid gid groups started_at host cwd process_group no_new_privs capabilities]
|
|
120
|
+
actual = Ace::Runtime::Molecules::ProcessIdentity.new(executor:
|
|
121
|
+
Ace::Herdr::Molecules::BoundedObservation.new(deadline: deadline)).capture(child["pid"])
|
|
122
|
+
executor = definition.fetch("executor")
|
|
123
|
+
unless child.is_a?(Hash) && child.keys.sort == keys.sort && actual &&
|
|
124
|
+
actual == child.slice("pid", "uid", "started_at", "host") &&
|
|
125
|
+
child["no_new_privs"] == 1 && child["capabilities"].is_a?(Hash) &&
|
|
126
|
+
child["capabilities"].keys.sort == %w[CapAmb CapEff CapInh CapPrm] &&
|
|
127
|
+
child["capabilities"].values.all? { |value| value.is_a?(String) && value.match?(/\A0{16}\z/) } &&
|
|
128
|
+
child.values_at("uid", "gid", "groups", "cwd", "process_group") ==
|
|
129
|
+
[executor.fetch("uid"), executor.fetch("gid"), executor.fetch("groups"), executor.fetch("cwd"), child.fetch("pid")]
|
|
130
|
+
raise Ace::Assign::AttemptErrors::UnauthorizedIdentity, "gated command child differs"
|
|
131
|
+
end
|
|
132
|
+
{"kernel_identity" => actual, "root_witness" => child, "witnessed_by" => launcher}
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private
|
|
136
|
+
|
|
137
|
+
def policy = @policy_loader.call
|
|
138
|
+
|
|
139
|
+
def verify_context!(definition, deadline)
|
|
140
|
+
executor = definition.fetch("executor")
|
|
141
|
+
cwd = executor.fetch("cwd")
|
|
142
|
+
wire = Ace::Runtime::Molecules::ProtectedSocket
|
|
143
|
+
wire.root_path!(cwd, directory: true)
|
|
144
|
+
executor.fetch("path").each { |entry| GrantResolver.trusted_directory!(entry) }
|
|
145
|
+
home = executor.fetch("home")
|
|
146
|
+
GrantResolver.trusted_directory!(home) if File.exist?(home) || File.symlink?(home)
|
|
147
|
+
count = 0
|
|
148
|
+
Find.find(cwd) do |path|
|
|
149
|
+
raise InvalidConfigurationError, "command source exceeds verification bound" if (count += 1) > 20_000 || Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
|
|
150
|
+
stat = File.lstat(path)
|
|
151
|
+
unless stat.uid.zero? && (stat.mode & 0o022).zero? && !stat.symlink? && (stat.file? || stat.directory?)
|
|
152
|
+
raise InvalidConfigurationError, "command source must be an immutable installed view"
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
executable = File.expand_path(definition.fetch("argv").first, cwd)
|
|
156
|
+
raise InvalidConfigurationError, "command executable exceeds verification bound" if File.size(executable) > 64 * 1024 * 1024
|
|
157
|
+
bytes = GrantResolver.read_verified(executable)
|
|
158
|
+
unless bytes && File.executable?(executable) && Digest::SHA256.hexdigest(bytes) == definition.fetch("executable_sha256")
|
|
159
|
+
raise InvalidConfigurationError, "command executable differs"
|
|
160
|
+
end
|
|
161
|
+
source = definition.fetch("source")
|
|
162
|
+
if source.fetch("head")
|
|
163
|
+
result = Ace::Herdr::Molecules::BoundedProcess.call(["/usr/bin/git", "-c", "safe.directory=#{cwd}",
|
|
164
|
+
"-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "rev-parse", "HEAD", "HEAD^{tree}"],
|
|
165
|
+
environment: Ace::Assign::Molecules::InstalledJournalRead::ENVIRONMENT, chdir: cwd,
|
|
166
|
+
stdin_data: "", timeout_s: deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), output_limit: 1024, cleanup_group: true)
|
|
167
|
+
unless result.status.success? && result.stdout.lines.map(&:strip) == source.values_at("head", "tree")
|
|
168
|
+
raise InvalidConfigurationError, "command accepted source differs"
|
|
169
|
+
end
|
|
170
|
+
clean = Ace::Herdr::Molecules::BoundedProcess.call(["/usr/bin/git", "-c", "safe.directory=#{cwd}",
|
|
171
|
+
"-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", "status", "--porcelain=v1", "-z", "--untracked-files=all"],
|
|
172
|
+
environment: Ace::Assign::Molecules::InstalledJournalRead::ENVIRONMENT, chdir: cwd,
|
|
173
|
+
stdin_data: "", timeout_s: deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC), output_limit: 65_536, cleanup_group: true)
|
|
174
|
+
unless clean.status.success? && clean.stdout.empty? && !clean.oversized
|
|
175
|
+
raise InvalidConfigurationError, "command accepted source is dirty"
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
true
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|