ace-lab 0.1.0 → 0.2.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 54eee52265f20d22376fcdf7ac0a748be0c8ec5d7400259310a1a5a37d2f373e
4
- data.tar.gz: e9b3746081e8790a2764f8dcbbd54650ddb3a23890cdb653c689c5a5d6ad7d5c
3
+ metadata.gz: c6487b764fe853b0272a43afa43fd2e6819669c6dc3593022ab55946925b2a2e
4
+ data.tar.gz: c588bd4bc130eff246e02546b40a11fa9ed06e8c575560d292a291c5d2d1665e
5
5
  SHA512:
6
- metadata.gz: 78d706d9b60416afdd98a12bbf98dfe90a12e2cd4105c29f3bab5b177dcdea70f8ac0753e35926107790afbdd1fc03c9e792b9c4fd85a45b1dac716122032060
7
- data.tar.gz: 646f3f2fae98bc011fabf589a1175c6248f36cd4cd89a7dc2172e0010287affbb16bebe4dc33ce8af5b53e263d2743d1af7c4410c2a307c0e08b215a57aad18f
6
+ metadata.gz: 3c6974042b3ac8cc638d3782b3d2e4c4be19de2de5860cc6c91e3d020cdc0338e7cb187d932ae64353f7994304b3b0f284533cef21b6919233e313a7af1bea6a
7
+ data.tar.gz: 2e6908502fdd3870f7403e7d6f56b07d1b5edb07acbdc6591135aae8a0227ae85ab8724c7e079e4a1d91bbf61a53277d0a84e38c23be958e540c1b9c7d8306ae
data/CHANGELOG.md CHANGED
@@ -5,6 +5,21 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [Unreleased]
9
+
10
+ ## [0.2.0] - 2026-10-02
11
+
12
+ ### Added
13
+
14
+ - `service request` and `service status` for exact authorized operations,
15
+ idempotent assignment-journal claims, peer-verified execution receipts,
16
+ dry-run previews, and conservative uncertain-state recovery.
17
+
18
+ ### Fixed
19
+
20
+ - Recheck the configured operation, authorization, and lease at dispatch;
21
+ bind terminal receipts to the assignment request before acceptance.
22
+
8
23
  ## [0.1.0] - 2026-09-28
9
24
 
10
25
  ### Added
data/README.md CHANGED
@@ -1,12 +1,14 @@
1
1
  ## ace-lab
2
2
 
3
- Topology and routing CLI for the Lab: address projects, agents, and services
4
- by stable IDs. Part of ACE (Agentic Coding Environment).
3
+ Topology, routing, and scoped service request CLI for the Lab. Part of ACE
4
+ (Agentic Coding Environment).
5
5
 
6
6
  `ace-lab` answers one question -- *what is the stable ID of the project, agent,
7
7
  or service I mean, and what is it authorized and able to do?* -- from
8
- configuration alone. It never invokes Lab, never reads credentials, tracks no
9
- work, and never guesses transient pane or session identifiers.
8
+ configuration. Topology queries never invoke a service. The `service`
9
+ commands submit configured operations through a verified executor and use
10
+ `ace-assign` for durable request evidence. No command hands credentials to
11
+ the caller or guesses transient pane or session identifiers.
10
12
 
11
13
  ### Install
12
14
 
@@ -17,12 +19,12 @@ work, and never guesses transient pane or session identifiers.
17
19
  Configure topology via the ADR-022 cascade (`~/.ace/lab/config.yml` or
18
20
  `.ace/lab/config.yml`; deployed values are owned by the `lab-config`
19
21
  repository). Authorization grants live in a separate deployment-controlled
20
- file at the fixed path `/etc/lab/ace-lab/authorization.yml` — root-owned and
21
- not group/world-writable, verified at every query — never in the
22
+ file at the fixed path `/etc/lab/ace-lab/authorization.yml` -- root-owned and
23
+ not group/world-writable, verified at every query -- never in the
22
24
  caller-writable cascade and never at a caller-selected location:
23
25
 
24
26
  ```yaml
25
- # .ace/lab/config.yml — topology only
27
+ # .ace/lab/config.yml -- topology only
26
28
  schema_version: 1
27
29
  topology:
28
30
  projects:
@@ -56,6 +58,17 @@ Query it:
56
58
  ace-lab resolve --id atlas-planner --format json
57
59
  ace-lab route --project atlas --capability search --format json
58
60
 
61
+ Request a configured operation for an active, managed assignment attempt:
62
+
63
+ ace-lab service request --project atlas --assignment A --attempt ATT \
64
+ --operation publish --input release.json --authorization DECISION \
65
+ --request-id REQUEST --dry-run
66
+ ace-lab service status --request REQUEST --format json
67
+
68
+ The trusted grants file may also contain exact `operations` and
69
+ `authorizations` mappings. See [usage](docs/usage.md) for the request schema,
70
+ executor transport, and uncertain-state recovery.
71
+
59
72
  Every command prints one deterministic JSON document:
60
73
 
61
74
  {"status":"ok","data":{"entry":{"id":"atlas-search","project":"atlas",
@@ -92,19 +105,18 @@ Failures are classified: `missing`, `ambiguous`, `stale`, `unauthorized`,
92
105
  are `missing`; several equal candidates need a configured `default_for`, or
93
106
  the result is `ambiguous`. Another project's service is never picked.
94
107
 
95
- - **Read-only by contract.** No Lab invocation, no execution state, no
108
+ - **Execution evidence is separate from topology.** `ace-lab service` uses a
96
109
 
97
- scheduling, no credentials -- service invocation and execution state belong
98
- to separate tools.
110
+ managed `ace-assign` attempt and its evidence ref. A lost executor receipt
111
+ remains uncertain and a repeated request ID never replays the effect.
99
112
 
100
113
  ### Non-goals
101
114
 
102
- `ace-lab` is not an execution-state engine. It does not invoke services
103
- (`8wr.t.qjx` owns the service contract), track Works or assignment state, or
104
- schedule anything. Deployed topology values are owned by `lab-config`
115
+ `ace-lab` does not track Works or schedule anything. `ace-assign` owns attempt
116
+ state; deployed operation handlers and topology values are owned by `lab-config`
105
117
  (`8wl.t.gad`); this package defines and validates the schema.
106
118
 
107
119
  ### Development
108
120
 
109
121
  bundle install
110
- ace-test ace-lab # or: bundle exec rake test from ace-lab/
122
+ bin/ace-test ace-lab all
data/docs/usage.md CHANGED
@@ -1,13 +1,14 @@
1
1
  # ace-lab usage
2
2
 
3
- Topology and routing CLI for the Lab. Resolves stable project, agent, and
4
- service IDs from configuration -- without knowing transient pane IDs or
5
- holding service credentials.
3
+ Topology, routing, and scoped service requests for the Lab. Resolves stable
4
+ project, agent, and service IDs without transient pane IDs or shared service
5
+ credentials.
6
6
 
7
7
  ## Commands
8
8
 
9
- All commands accept `--format json` (the only supported format; anything else
10
- is rejected) and `-q/--quiet` (suppress stdout; exit semantics unchanged).
9
+ Topology commands accept `--format json` (the only supported format) and
10
+ `-q/--quiet`. Service status accepts `--format json`; service request always
11
+ emits JSON.
11
12
 
12
13
  ### `ace-lab projects`
13
14
 
@@ -58,6 +59,68 @@ Select a configured capable service in the requested project.
58
59
 
59
60
  Routing never invokes the service and never grants credentials.
60
61
 
62
+ ### `ace-lab service request`
63
+
64
+ **Goal:** Ask a configured service to perform one named operation for an
65
+ active, task-attached assignment attempt. First create a JSON input file:
66
+
67
+ ```json
68
+ {"target":{"resource":"release/1.2.3","artifact_digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"},"arguments":{"version":"1.2.3"}}
69
+ ```
70
+
71
+ Run a preview, then submit the exact request:
72
+
73
+ ```sh
74
+ ace-lab service request --project atlas --assignment A --attempt ATT \
75
+ --operation publish --input release.json --authorization DECISION \
76
+ --request-id REQUEST --dry-run
77
+ ace-lab service request --project atlas --assignment A --attempt ATT \
78
+ --operation publish --input release.json --authorization DECISION \
79
+ --request-id REQUEST
80
+ ```
81
+
82
+ The preview reports `outcome: accepted` with `dry_run: true` and the selected
83
+ service ID, target, and candidate head. It creates no request claim or effect.
84
+ The second command returns `succeeded`, `failed`, or `uncertain` with a
85
+ non-secret receipt when the executor confirms an outcome. A missing response
86
+ leaves `uncertain` in the assignment evidence ref; do not retry with a new ID
87
+ until the external outcome is reconciled. Repeating the same ID and input
88
+ returns its stored state without dispatching again. A changed binding under
89
+ the same ID is a `conflict` error.
90
+
91
+ Input must be a JSON object of at most 64 KiB. `target.resource` is a stable
92
+ resource ID; `target.artifact_digest`, when supplied, is a SHA-256 hex digest.
93
+ Credential, password, secret, token, private-key, authorization, and env keys
94
+ are rejected recursively. Operation arguments remain structured JSON sent to
95
+ the executor; no caller-provided shell command or argv is accepted.
96
+
97
+ | Option | Purpose |
98
+ |--------|---------|
99
+ | `--project` | Project stable ID |
100
+ | `--assignment` | Assignment ID |
101
+ | `--attempt` | Active managed attempt ID |
102
+ | `--operation` | Configured named operation |
103
+ | `--input` | Structured JSON file |
104
+ | `--authorization` | Exact decision or configured automation reference |
105
+ | `--request-id` | Idempotency key; use the same ID for retries/status |
106
+ | `--dry-run` | Validate and show scope without an effect |
107
+
108
+ ### `ace-lab service status`
109
+
110
+ ```sh
111
+ ace-lab service status --request REQUEST --format json
112
+ ```
113
+
114
+ Expected output after a lost executor receipt:
115
+
116
+ ```json
117
+ {"status":"ok","data":{"request_id":"REQUEST","outcome":"uncertain","state":"uncertain"}}
118
+ ```
119
+
120
+ Only the original OS caller identity with project visibility can read the
121
+ request. The authoritative state survives loss of the local assignment cache
122
+ because it lives in the separate assignment evidence Git ref.
123
+
61
124
  ## Error semantics
62
125
 
63
126
  Errors are one deterministic JSON document on stdout plus a non-zero exit.
@@ -69,6 +132,10 @@ Errors are one deterministic JSON document on stdout plus a non-zero exit.
69
132
  | `stale` | Binding is inactive, unattested, or the instance identity was replaced |
70
133
  | `unauthorized` | Caller's verified local identity has no principal for the project |
71
134
  | `invalid_configuration` | Topology config violates the schema; message is actionable |
135
+ | `invalid_input` | Service input or request ID is malformed |
136
+ | `invalid_attempt` | Assignment attempt is absent, unmanaged, terminal, or has a stale candidate head |
137
+ | `conflict` | Request ID is already bound to different input or scope |
138
+ | `evidence_unavailable` | Assignment evidence ref cannot be written or read |
72
139
 
73
140
  Example:
74
141
 
@@ -115,7 +182,7 @@ Grants never live in the cascade: project and user documents are
115
182
  caller-writable, so an `authorization` section there is rejected as
116
183
  `invalid_configuration`. Grants come from a single deployment-controlled
117
184
  file at the **fixed path** `/etc/lab/ace-lab/authorization.yml` (installed
118
- by the `lab-config` deployment). The location is not caller-selectable —
185
+ by the `lab-config` deployment). The location is not caller-selectable --
119
186
  there is no flag or environment override. At every query the tool verifies
120
187
  the file and every directory on its real path are root-owned and not
121
188
  group/world-writable, and opens the file `O_NOFOLLOW`; any failed
@@ -130,12 +197,51 @@ principals:
130
197
  The grants file is machine-global: it is validated structurally only, so a
131
198
  principal may reference projects absent from the current directory's
132
199
  topology. Such grants are valid but never match a locally configured
133
- project. All-digit principal keys are matched as uids only — an all-digit
200
+ project. All-digit principal keys are matched as uids only -- an all-digit
134
201
  passwd username is authorized solely through its uid, so it can never
135
202
  consume a different account's numeric-uid grant.
136
203
 
137
204
  A missing trusted file means nobody is authorized (fail closed).
138
205
 
206
+ ### Trusted service policy
207
+
208
+ The same deployment-owned file may contain `operations` and
209
+ `authorizations`. An operation names one exact project and stable service ID,
210
+ an executor OS UID, a valid lease, and either a fixed local argv or an absolute
211
+ Unix socket path. The domain deployment owns actual handlers and credentials.
212
+ The Unix service must authenticate the client's OS peer identity, validate
213
+ the exact authorization and current lease itself, and return a structured
214
+ receipt; ACE verifies the service peer UID before accepting that response.
215
+
216
+ ```yaml
217
+ operations:
218
+ forge-sync:
219
+ project: atlas
220
+ service_id: atlas-sync
221
+ transport: unix
222
+ socket_path: /run/lab/atlas-sync.sock
223
+ executor_uid: 997
224
+ lease_expires_at: '2026-10-02T16:00:00Z'
225
+ authorizations:
226
+ DECISION:
227
+ operation: forge-sync
228
+ project_id: atlas
229
+ assignment_id: A
230
+ attempt_id: ATT
231
+ input_digest: <sha256-of-canonical-json>
232
+ target: { resource: forge/repository, artifact_digest: null }
233
+ candidate_head: <exact-git-head>
234
+ caller_uid: 1000
235
+ expires_at: '2026-10-02T16:00:00Z'
236
+ ```
237
+
238
+ The decision must match every listed field exactly and remain valid. A
239
+ decision never supplies missing executor capability or credentials. For
240
+ authenticated host maintenance, the policy also identifies an executable and
241
+ evidence sink outside the deployment being replaced; its domain handler must
242
+ enforce quiescence for all other product work. Environment `review-approval`
243
+ is a named operation and never substitutes for an independent code review.
244
+
139
245
  Validation of the topology cascade rejects duplicate IDs (globally unique
140
246
  across projects, agents, services), unknown project references, malformed
141
247
  capabilities, defaults for undeclared capabilities, unusable endpoints
@@ -146,7 +252,7 @@ unknown projects.
146
252
  **Error messages are value-free by design:** validation runs before
147
253
  authorization, so messages use positional field locations
148
254
  (`topology.agents[0].project references an unknown project`) and never echo
149
- configured IDs, project names, or principal names — configuration defects
255
+ configured IDs, project names, or principal names -- configuration defects
150
256
  cannot disclose topology to unauthorized callers.
151
257
 
152
258
  **Ownership:** deployed topology and authorization values are maintained by
@@ -172,7 +278,7 @@ reads; it never provisions.
172
278
  classify it `stale` -- an explicit unavailable result, never a routeable
173
279
  answer.
174
280
 
175
- - No Works, assignment state, or scheduling. Service invocation belongs to
281
+ - No Works or scheduling. Service request claims and receipts use
176
282
 
177
- `8wr.t.qjx`; execution state to `8wr.t.qjl`. The Lab execution binary
283
+ `ace-assign`'s evidence journal. The Lab execution binary
178
284
  (`/usr/local/bin/lab`) is never invoked or required.
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ module Ace
7
+ module Lab
8
+ module Atoms
9
+ # Bounded structured input for a named service operation. Only the
10
+ # digest enters the request index; input values never enter the journal.
11
+ module ServiceInput
12
+ MAX_BYTES = 64 * 1024
13
+ FORBIDDEN_KEYS = /credential|password|secret|token|private.key|authorization|\Aenv\z/i
14
+ SHA256 = /\A[0-9a-f]{64}\z/
15
+ RESOURCE = /\A[a-zA-Z0-9_.:\/-]{1,256}\z/
16
+
17
+ def self.load(path)
18
+ raise ArgumentError, "input file is missing" unless File.file?(path)
19
+ raise ArgumentError, "input exceeds #{MAX_BYTES} bytes" if File.size(path) > MAX_BYTES
20
+ data = JSON.parse(File.read(path))
21
+ raise ArgumentError, "input must be a JSON object" unless data.is_a?(Hash)
22
+ validate!(data)
23
+ data
24
+ rescue JSON::ParserError
25
+ raise ArgumentError, "input must be valid JSON"
26
+ end
27
+
28
+ def self.digest(data)
29
+ Digest::SHA256.hexdigest(JSON.generate(canonical(data)))
30
+ end
31
+
32
+ def self.target(data)
33
+ target = data["target"]
34
+ raise ArgumentError, "input.target must be an object" unless target.is_a?(Hash)
35
+ resource = target["resource"]
36
+ artifact = target["artifact_digest"]
37
+ raise ArgumentError, "input.target.resource must be a stable resource ID" unless
38
+ resource.is_a?(String) && resource.match?(RESOURCE)
39
+ if artifact && (!artifact.is_a?(String) || !artifact.match?(SHA256))
40
+ raise ArgumentError, "input.target.artifact_digest must be SHA-256"
41
+ end
42
+ {"resource" => resource, "artifact_digest" => artifact}
43
+ end
44
+
45
+ def self.canonical(value)
46
+ case value
47
+ when Hash then value.keys.sort.each_with_object({}) { |key, result| result[key] = canonical(value[key]) }
48
+ when Array then value.map { |item| canonical(item) }
49
+ else value
50
+ end
51
+ end
52
+
53
+ def self.validate!(value, depth = 0)
54
+ raise ArgumentError, "input nesting is too deep" if depth > 16
55
+ case value
56
+ when Hash
57
+ value.each do |key, item|
58
+ raise ArgumentError, "input has a forbidden field" if !key.is_a?(String) || key.match?(FORBIDDEN_KEYS)
59
+ validate!(item, depth + 1)
60
+ end
61
+ when Array then value.each { |item| validate!(item, depth + 1) }
62
+ when String, Integer, Float, TrueClass, FalseClass, NilClass
63
+ raise ArgumentError, "input contains a non-finite number" if value.is_a?(Float) && !value.finite?
64
+ else
65
+ raise ArgumentError, "input contains an unsupported value"
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,68 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "ace/support/cli"
5
+ require_relative "support"
6
+
7
+ module Ace
8
+ module Lab
9
+ module CLI
10
+ module Commands
11
+ module Service
12
+ module Output
13
+ def service
14
+ @service ||= Organisms::ServiceRequestService.new
15
+ end
16
+
17
+ def emit(envelope)
18
+ puts JSON.generate(envelope)
19
+ return if envelope["status"] == "ok"
20
+ error = envelope.fetch("error")
21
+ code = error.fetch("code")
22
+ message = error.fetch("message")
23
+ raise Ace::Support::Cli::Error, "#{code}: #{message}"
24
+ end
25
+ end
26
+
27
+ class Request < Ace::Support::Cli::Command
28
+ include Ace::Support::Cli::Base
29
+ include Runtime
30
+ include Output
31
+
32
+ desc "Request one authorized, configured service operation"
33
+ option :project, type: :string, required: true, desc: "Project stable ID"
34
+ option :assignment, type: :string, required: true, desc: "Assignment ID"
35
+ option :attempt, type: :string, required: true, desc: "Managed attempt ID"
36
+ option :operation, type: :string, required: true, desc: "Named operation"
37
+ option :input, type: :string, required: true, desc: "Structured JSON input file"
38
+ option :authorization, type: :string, required: true, desc: "Exact authorization reference"
39
+ option :request_id, type: :string, required: true, desc: "Idempotency key"
40
+ option :dry_run, type: :boolean, desc: "Validate without claiming or executing"
41
+
42
+ def call(project:, assignment:, attempt:, operation:, input:, authorization:, request_id:, **options)
43
+ reject_identity_flags!(options)
44
+ emit(service.request(project: project, assignment: assignment, attempt: attempt,
45
+ operation: operation, input_path: input, authorization: authorization,
46
+ request_id: request_id, dry_run: options[:dry_run]))
47
+ end
48
+ end
49
+
50
+ class Status < Ace::Support::Cli::Command
51
+ include Ace::Support::Cli::Base
52
+ include Runtime
53
+ include Output
54
+
55
+ desc "Read the authoritative outcome of a service request"
56
+ option :request, type: :string, required: true, desc: "Request ID"
57
+ option :format, type: :string, desc: "Output format (json only)"
58
+
59
+ def call(request:, **options)
60
+ ensure_json_format!(options)
61
+ emit(service.status(request_id: request))
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
data/lib/ace/lab/cli.rb CHANGED
@@ -8,6 +8,7 @@ require_relative "cli/commands/agents"
8
8
  require_relative "cli/commands/services"
9
9
  require_relative "cli/commands/resolve"
10
10
  require_relative "cli/commands/route"
11
+ require_relative "cli/commands/service"
11
12
 
12
13
  module Ace
13
14
  module Lab
@@ -23,7 +24,9 @@ module Ace
23
24
  ["agents", "List lab agents in a project by stable ID"],
24
25
  ["services", "List lab services in a project by stable ID"],
25
26
  ["resolve", "Resolve one lab entry by exact stable ID"],
26
- ["route", "Select a configured capable service in a project"]
27
+ ["route", "Select a configured capable service in a project"],
28
+ ["service request", "Request one authorized service operation"],
29
+ ["service status", "Read a service request outcome"]
27
30
  ].freeze
28
31
 
29
32
  HELP_EXAMPLES = [
@@ -47,6 +50,8 @@ module Ace
47
50
  register "services", CLI::Commands::Services.new
48
51
  register "resolve", CLI::Commands::Resolve.new
49
52
  register "route", CLI::Commands::Route.new
53
+ register "service request", CLI::Commands::Service::Request.new
54
+ register "service status", CLI::Commands::Service::Status.new
50
55
 
51
56
  # Register version command
52
57
  version_cmd = Ace::Support::Cli::VersionCommand.build(
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ace
4
+ module Lab
5
+ module Models
6
+ # Non-secret result submitted to the assignment evidence coordinator.
7
+ # The coordinator independently verifies every binding before acceptance.
8
+ module ServiceReceipt
9
+ BINDING_FIELDS = %w[request_id assignment_id attempt_id project_id operation
10
+ input_digest target candidate_head].freeze
11
+
12
+ def self.build(binding, executor_result)
13
+ BINDING_FIELDS.to_h { |field| [field, binding.fetch(field)] }.merge(executor_result)
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -23,6 +23,22 @@ module Ace
23
23
  MAX_SYMLINK_HOPS = 8
24
24
 
25
25
  class << self
26
+ # Read the full deployment-owned policy for service execution.
27
+ # Topology remains a routing hint; it never supplies authority.
28
+ def trusted_document(path)
29
+ content = read_verified(path)
30
+ return {} if content.nil?
31
+ document = YAML.safe_load(content, permitted_classes: [Date], aliases: true)
32
+ unless document.is_a?(Hash)
33
+ raise Ace::Lab::InvalidConfigurationError,
34
+ "invalid lab configuration: trusted authorization file must contain a mapping"
35
+ end
36
+ document
37
+ rescue Psych::Exception
38
+ raise Ace::Lab::InvalidConfigurationError,
39
+ "invalid lab configuration: trusted authorization file could not be parsed"
40
+ end
41
+
26
42
  # @param documents [Array<Hash>] {path:, document:, defaults:}
27
43
  # from Ace::Lab.cascade_documents
28
44
  # @param topology [Hash] normalized topology (project IDs)
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "open3"
5
+ require "socket"
6
+ require "timeout"
7
+ require "time"
8
+
9
+ module Ace
10
+ module Lab
11
+ module Molecules
12
+ # Executes one fixed, deployment-configured argv under its OS account.
13
+ # Domain handlers receive structured JSON on stdin and return a small
14
+ # receipt; their stdout/stderr are never journaled or returned to callers.
15
+ class ServiceExecutor
16
+ def execute(operation:, request:, input:, policy:, authorization:)
17
+ current = policy.operation!(request.fetch("operation"), project: request.fetch("project_id"),
18
+ service_id: request.fetch("service_id"))
19
+ raise SecurityError, "executor operation changed before dispatch" unless current == operation
20
+ policy.authorize!(authorization, request)
21
+ if Time.iso8601(operation.fetch("lease_expires_at")) <= Time.now.utc
22
+ raise SecurityError, "executor lease has expired"
23
+ end
24
+ out = if operation.fetch("transport", "local") == "unix"
25
+ invoke_unix(operation, request, input)
26
+ else
27
+ invoke_local(operation, request, input)
28
+ end
29
+ return nil unless out && out.bytesize <= 16 * 1024
30
+ response = JSON.parse(out)
31
+ return nil unless valid_response?(response, request)
32
+ {"outcome" => response.fetch("outcome"), "evidence" => response.fetch("evidence"),
33
+ "executor_uid" => operation.fetch("executor_uid")}
34
+ rescue JSON::ParserError, Errno::ENOENT, Errno::EACCES, Errno::ECONNREFUSED, EOFError, Timeout::Error
35
+ nil
36
+ end
37
+
38
+ private
39
+
40
+ def invoke_local(operation, request, input)
41
+ unless Process.uid == operation.fetch("executor_uid") && Process.euid == operation.fetch("executor_uid")
42
+ raise SecurityError, "current OS identity is not the configured executor"
43
+ end
44
+ out, _stderr, status = Timeout.timeout(30) do
45
+ Open3.capture3(*operation.fetch("argv"),
46
+ stdin_data: JSON.generate({"request" => request, "input" => input}))
47
+ end
48
+ status.success? ? out : nil
49
+ end
50
+
51
+ def invoke_unix(operation, request, input)
52
+ path = operation.fetch("socket_path")
53
+ stat = File.lstat(path)
54
+ unless stat.socket? && stat.uid == operation.fetch("executor_uid") && (stat.mode & 0o002).zero?
55
+ raise SecurityError, "service socket owner or permissions do not match the executor"
56
+ end
57
+ UNIXSocket.open(path) do |socket|
58
+ peer_uid, = socket.getpeereid
59
+ raise SecurityError, "service peer identity does not match the executor" unless peer_uid == stat.uid
60
+ socket.write(JSON.generate({"request" => request, "input" => input}) + "\n")
61
+ socket.flush
62
+ Timeout.timeout(30) { socket.gets(16 * 1024 + 1) }
63
+ end
64
+ end
65
+
66
+ def valid_response?(response, request)
67
+ response.is_a?(Hash) && response.keys.sort == %w[evidence input_digest outcome request_id] &&
68
+ response["request_id"] == request["request_id"] &&
69
+ response["input_digest"] == request["input_digest"] &&
70
+ %w[succeeded failed].include?(response["outcome"]) &&
71
+ response["evidence"].is_a?(Array) && !response["evidence"].empty? &&
72
+ response["evidence"].all? do |item|
73
+ item.is_a?(Hash) && item.keys.sort == %w[ref sha256] &&
74
+ item["ref"].is_a?(String) && item["ref"].match?(/\A[a-zA-Z0-9_.:\/-]{1,256}\z/) &&
75
+ item["sha256"].is_a?(String) && item["sha256"].match?(/\A[0-9a-f]{64}\z/)
76
+ end
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Ace
6
+ module Lab
7
+ module Molecules
8
+ # Deployment-owned operation and authorization facts. The caller may
9
+ # name a reference but cannot define its scope or executor binding.
10
+ class ServicePolicy
11
+ NAME = /\A[a-z][a-z0-9-]{0,63}\z/
12
+ ID = /\A[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}\z/
13
+
14
+ def initialize(document)
15
+ @operations = document.fetch("operations", {})
16
+ @authorizations = document.fetch("authorizations", {})
17
+ unless @operations.is_a?(Hash) && @authorizations.is_a?(Hash)
18
+ raise Ace::Lab::InvalidConfigurationError, "trusted service policy must contain mappings"
19
+ end
20
+ end
21
+
22
+ def operation!(name, project:, service_id:)
23
+ raise ArgumentError, "invalid operation name" unless name.to_s.match?(NAME)
24
+ operation = @operations[name]
25
+ raise SecurityError, "operation is not configured" unless operation.is_a?(Hash)
26
+ unless operation["project"] == project && operation["service_id"] == service_id
27
+ raise SecurityError, "operation does not authorize the selected service in this project"
28
+ end
29
+ transport = operation.fetch("transport", "local")
30
+ unless %w[local unix].include?(transport)
31
+ raise Ace::Lab::InvalidConfigurationError, "configured service transport is invalid"
32
+ end
33
+ if transport == "local"
34
+ argv = operation["argv"]
35
+ unless argv.is_a?(Array) && !argv.empty? && argv.all? { |v| v.is_a?(String) && !v.empty? } &&
36
+ argv.first.start_with?("/") && File.file?(argv.first) && File.executable?(argv.first)
37
+ raise Ace::Lab::InvalidConfigurationError, "configured operation needs a fixed executable argv"
38
+ end
39
+ elsif !operation["socket_path"].is_a?(String) || !operation["socket_path"].start_with?("/")
40
+ raise Ace::Lab::InvalidConfigurationError, "unix service needs an absolute socket path"
41
+ end
42
+ uid = operation["executor_uid"]
43
+ raise Ace::Lab::InvalidConfigurationError, "configured executor UID is invalid" unless uid.is_a?(Integer) && uid >= 0
44
+ validate_executable!(operation["argv"].first, uid) if transport == "local"
45
+ expires_at = parse_time(operation["lease_expires_at"])
46
+ raise SecurityError, "executor lease has expired" if expires_at <= Time.now.utc
47
+ if operation["host_maintenance"] == true
48
+ deployment = operation["deployment_root"].to_s
49
+ sink = operation["evidence_sink"].to_s
50
+ executable = operation.dig("argv", 0).to_s
51
+ unless deployment.start_with?("/") && sink.start_with?("/") && executable.start_with?("/") &&
52
+ !inside?(executable, deployment) && !inside?(sink, deployment)
53
+ raise Ace::Lab::InvalidConfigurationError,
54
+ "host maintenance needs executable and evidence sink outside the replaced deployment"
55
+ end
56
+ validate_executable!(executable, uid)
57
+ end
58
+ operation
59
+ end
60
+
61
+ def authorize!(reference, binding)
62
+ raise ArgumentError, "invalid authorization reference" unless reference.to_s.match?(ID)
63
+ decision = @authorizations[reference]
64
+ raise SecurityError, "authorization reference is unresolved" unless decision.is_a?(Hash)
65
+ required = %w[operation project_id assignment_id attempt_id input_digest target candidate_head caller_uid]
66
+ unless required.all? { |key| decision[key] == binding[key] }
67
+ raise SecurityError, "authorization does not match the exact service request"
68
+ end
69
+ raise SecurityError, "authorization has expired" if parse_time(decision["expires_at"]) <= Time.now.utc
70
+ decision
71
+ end
72
+
73
+ private
74
+
75
+ def validate_executable!(path, uid)
76
+ file = File.stat(path)
77
+ parent = File.stat(File.dirname(File.realpath(path)))
78
+ unless [0, uid].include?(file.uid) && [0, uid].include?(parent.uid) &&
79
+ (file.mode & 0o022).zero? && (parent.mode & 0o022).zero?
80
+ raise Ace::Lab::InvalidConfigurationError,
81
+ "configured executable or containing directory is writable by another identity"
82
+ end
83
+ rescue Errno::ENOENT, Errno::EACCES
84
+ raise Ace::Lab::InvalidConfigurationError, "configured executable is unavailable"
85
+ end
86
+
87
+ def inside?(path, root)
88
+ expanded = File.expand_path(path)
89
+ base = File.expand_path(root)
90
+ expanded == base || expanded.start_with?(base + File::SEPARATOR)
91
+ end
92
+
93
+ def parse_time(value)
94
+ Time.iso8601(value.to_s)
95
+ rescue ArgumentError
96
+ raise Ace::Lab::InvalidConfigurationError, "trusted policy has invalid expiry"
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "open3"
4
+ require "ace/assign"
5
+
6
+ module Ace
7
+ module Lab
8
+ module Organisms
9
+ # Coordinates the generic request contract. ace-assign owns the durable
10
+ # claim and receipt; this class never writes evidence files directly.
11
+ class ServiceRequestService
12
+ def initialize(topology: nil, policy: nil, coordinator: nil, executor: nil, repo_root: Dir.pwd)
13
+ @topology = topology || TopologyService.from_config
14
+ @policy = policy
15
+ @coordinator = coordinator || Ace::Assign::Organisms::AttemptCoordinator.new(repo_root: repo_root)
16
+ @executor = executor || Molecules::ServiceExecutor.new
17
+ @repo_root = repo_root
18
+ end
19
+
20
+ def request(project:, assignment:, attempt:, operation:, input_path:, authorization:, request_id:, dry_run: false)
21
+ {project: project, assignment: assignment, attempt: attempt, request: request_id}.each do |name, value|
22
+ raise ArgumentError, "invalid #{name} ID" unless value.is_a?(String) &&
23
+ value.match?(Molecules::ServicePolicy::ID)
24
+ end
25
+ input = Atoms::ServiceInput.load(input_path)
26
+ target = Atoms::ServiceInput.target(input)
27
+ route = @topology.route(project: project, capability: operation)
28
+ return route.envelope unless route.ok?
29
+ service_id = route.data.fetch("entry").fetch("id")
30
+ trusted = Molecules::GrantResolver.trusted_document(Ace::Lab.authorization_path) unless @policy
31
+ policy = @policy || Molecules::ServicePolicy.new(trusted)
32
+ head = current_head
33
+ binding = {"request_id" => request_id, "assignment_id" => assignment, "attempt_id" => attempt,
34
+ "project_id" => project, "operation" => operation,
35
+ "input_digest" => Atoms::ServiceInput.digest(input), "target" => target,
36
+ "candidate_head" => head, "caller_uid" => Process.uid}
37
+ binding["authorization"] = authorization
38
+ binding["service_id"] = service_id
39
+ binding["caller_uid"] = Process.uid
40
+ validate_attempt!(binding)
41
+ begin
42
+ operation_policy = policy.operation!(operation, project: project, service_id: service_id)
43
+ policy.authorize!(authorization, binding)
44
+ rescue SecurityError, Ace::Lab::InvalidConfigurationError
45
+ @coordinator.reject_service_request(binding, reason: "policy_rejected") unless dry_run
46
+ raise
47
+ end
48
+
49
+ if dry_run
50
+ preview = {"outcome" => "accepted", "dry_run" => true, "request_id" => request_id,
51
+ "project" => project, "operation" => operation, "service_id" => service_id,
52
+ "target" => target, "candidate_head" => head}
53
+ return {"status" => "ok", "data" => preview}
54
+ end
55
+
56
+ claimed = @coordinator.claim_service_request(binding)
57
+ return result(claimed) unless claimed["state"] == "accepted" && claimed["journal_commit"]
58
+
59
+ # From here, a crash or lost executor response must not authorize a
60
+ # replay. Record uncertainty before invoking an external process.
61
+ @coordinator.transition_service_request(request_id, state: "uncertain")
62
+ receipt = @executor.execute(operation: operation_policy, request: binding, input: input,
63
+ policy: policy, authorization: authorization)
64
+ return result(@coordinator.service_request_status(request_id)) if receipt.nil?
65
+
66
+ state = receipt.fetch("outcome")
67
+ full_receipt = Models::ServiceReceipt.build(binding, receipt)
68
+ result(@coordinator.transition_service_request(request_id, state: state, receipt: full_receipt))
69
+ rescue ArgumentError => e
70
+ failure("invalid_input", e.message)
71
+ rescue SecurityError => e
72
+ failure("unauthorized", e.message)
73
+ rescue Ace::Lab::InvalidConfigurationError => e
74
+ failure("invalid_configuration", e.message)
75
+ rescue Ace::Assign::AttemptErrors::Conflict => e
76
+ failure("conflict", e.message)
77
+ rescue Ace::Assign::AttemptErrors::NotFound, Ace::Assign::AttemptErrors::ReceiptRejected => e
78
+ failure("invalid_attempt", e.message)
79
+ rescue Ace::Assign::AttemptErrors::InvalidState => e
80
+ failure("invalid_attempt", e.message)
81
+ rescue Ace::Assign::AttemptErrors::EvidenceUnavailable => e
82
+ failure("evidence_unavailable", e.message)
83
+ end
84
+
85
+ def status(request_id:)
86
+ request = @coordinator.service_request_status(request_id)
87
+ return failure("missing", "service request not found") unless request
88
+ return failure("unauthorized", "caller does not own the request") unless request["caller_uid"] == Process.uid
89
+ visibility = @topology.services(project: request["project_id"])
90
+ return visibility.envelope unless visibility.ok?
91
+ result(request)
92
+ rescue ArgumentError => e
93
+ failure("invalid_input", e.message)
94
+ rescue Ace::Assign::AttemptErrors::EvidenceUnavailable => e
95
+ failure("evidence_unavailable", e.message)
96
+ end
97
+
98
+ private
99
+
100
+ def validate_attempt!(binding)
101
+ attempt = @coordinator.service_attempt(binding)
102
+ unless attempt.candidate_head.nil? || attempt.candidate_head == binding["candidate_head"]
103
+ raise SecurityError, "request is not bound to an active managed attempt"
104
+ end
105
+ end
106
+
107
+ def current_head
108
+ out, _stderr, status = Open3.capture3("git", "rev-parse", "HEAD", chdir: @repo_root)
109
+ raise SecurityError, "candidate head is unavailable" unless status.success?
110
+ out.strip
111
+ end
112
+
113
+ def result(request)
114
+ public = request.slice("request_id", "assignment_id", "attempt_id", "project_id", "operation",
115
+ "input_digest", "target", "candidate_head", "service_id", "state", "receipt")
116
+ {"status" => "ok", "data" => public.merge("outcome" => request.fetch("state"))}
117
+ end
118
+
119
+ def failure(code, message)
120
+ {"status" => "error", "error" => {"code" => code, "message" => message}}
121
+ end
122
+ end
123
+ end
124
+ end
125
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ace
4
4
  module Lab
5
- VERSION = "0.1.0"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
data/lib/ace/lab.rb CHANGED
@@ -16,16 +16,21 @@ end
16
16
  require_relative "lab/atoms/topology_schema"
17
17
  require_relative "lab/atoms/binding_freshness"
18
18
  require_relative "lab/atoms/public_projection"
19
+ require_relative "lab/atoms/service_input"
20
+ require_relative "lab/models/service_receipt"
19
21
  require_relative "lab/models/runtime_binding"
20
22
  require_relative "lab/models/topology_entry"
21
23
  require_relative "lab/models/query_result"
22
24
  require_relative "lab/molecules/topology_loader"
23
25
  require_relative "lab/molecules/caller_authorizer"
24
26
  require_relative "lab/molecules/grant_resolver"
27
+ require_relative "lab/molecules/service_policy"
28
+ require_relative "lab/molecules/service_executor"
25
29
  require_relative "lab/molecules/inventory_query"
26
30
  require_relative "lab/molecules/exact_resolver"
27
31
  require_relative "lab/molecules/capability_router"
28
32
  require_relative "lab/organisms/topology_service"
33
+ require_relative "lab/organisms/service_request_service"
29
34
  require_relative "lab/cli"
30
35
 
31
36
  module Ace
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ace-lab
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Michal Czyz
8
8
  bindir: exe
9
9
  cert_chain: []
10
- date: 2026-09-29 00:00:00.000000000 Z
10
+ date: 2026-10-02 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: ace-support-cli
@@ -51,6 +51,20 @@ dependencies:
51
51
  - - "~>"
52
52
  - !ruby/object:Gem::Version
53
53
  version: '0.18'
54
+ - !ruby/object:Gem::Dependency
55
+ name: ace-assign
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.61'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - "~>"
66
+ - !ruby/object:Gem::Version
67
+ version: '0.61'
54
68
  - !ruby/object:Gem::Dependency
55
69
  name: ace-support-test-helpers
56
70
  requirement: !ruby/object:Gem::Requirement
@@ -155,23 +169,29 @@ files:
155
169
  - lib/ace/lab.rb
156
170
  - lib/ace/lab/atoms/binding_freshness.rb
157
171
  - lib/ace/lab/atoms/public_projection.rb
172
+ - lib/ace/lab/atoms/service_input.rb
158
173
  - lib/ace/lab/atoms/topology_schema.rb
159
174
  - lib/ace/lab/cli.rb
160
175
  - lib/ace/lab/cli/commands/agents.rb
161
176
  - lib/ace/lab/cli/commands/projects.rb
162
177
  - lib/ace/lab/cli/commands/resolve.rb
163
178
  - lib/ace/lab/cli/commands/route.rb
179
+ - lib/ace/lab/cli/commands/service.rb
164
180
  - lib/ace/lab/cli/commands/services.rb
165
181
  - lib/ace/lab/cli/commands/support.rb
166
182
  - lib/ace/lab/models/query_result.rb
167
183
  - lib/ace/lab/models/runtime_binding.rb
184
+ - lib/ace/lab/models/service_receipt.rb
168
185
  - lib/ace/lab/models/topology_entry.rb
169
186
  - lib/ace/lab/molecules/caller_authorizer.rb
170
187
  - lib/ace/lab/molecules/capability_router.rb
171
188
  - lib/ace/lab/molecules/exact_resolver.rb
172
189
  - lib/ace/lab/molecules/grant_resolver.rb
173
190
  - lib/ace/lab/molecules/inventory_query.rb
191
+ - lib/ace/lab/molecules/service_executor.rb
192
+ - lib/ace/lab/molecules/service_policy.rb
174
193
  - lib/ace/lab/molecules/topology_loader.rb
194
+ - lib/ace/lab/organisms/service_request_service.rb
175
195
  - lib/ace/lab/organisms/topology_service.rb
176
196
  - lib/ace/lab/version.rb
177
197
  homepage: https://github.com/cs3b/ace