ace-lab 0.14.1 → 0.15.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 +6 -0
- data/docs/usage.md +78 -16
- data/lib/ace/lab/atoms/install_schema.rb +71 -5
- data/lib/ace/lab/auth/credential_handoff.rb +34 -10
- data/lib/ace/lab/auth/error.rb +13 -0
- data/lib/ace/lab/auth/lease_store.rb +91 -29
- data/lib/ace/lab/auth/local_command.rb +62 -0
- data/lib/ace/lab/auth/plugins/forgejo_provider.rb +123 -55
- data/lib/ace/lab/auth/profile_schema.rb +6 -1
- data/lib/ace/lab/auth/providers.rb +8 -0
- data/lib/ace/lab/auth/service.rb +350 -112
- data/lib/ace/lab/auth/session_configuration.rb +44 -0
- data/lib/ace/lab/cli/commands/prepare.rb +2 -1
- data/lib/ace/lab/cli/commands/verify.rb +2 -1
- data/lib/ace/lab/molecules/extension_projection.rb +60 -1
- data/lib/ace/lab/molecules/extension_provisioner.rb +21 -4
- data/lib/ace/lab/molecules/gem_payload_permissions.rb +12 -5
- data/lib/ace/lab/molecules/gemset_provisioner.rb +6 -1
- data/lib/ace/lab/organisms/verify.rb +56 -13
- data/lib/ace/lab/version.rb +1 -1
- data/libexec/forgejo-askpass +10 -0
- metadata +12 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3244c9ffaf1f5854e26967c6df9c6fa19f7b60a517acaa51a80a5788fbd31031
|
|
4
|
+
data.tar.gz: 52ef532f9422e18473e4921dfe200e1c45943f62a49bc0f7768abba9bba4df00
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c271c58273db132886b0a74c1024a4757116f7369957f2eecb8dd325e7dfb7d855dd920983f31e5bd51193abeb488aae9f3feb9393dda28f0a0c723e68f3566d
|
|
7
|
+
data.tar.gz: 685fd0944e9485a54fa179c04d934310d476708382532731d0dafa46af50eb7fa9f318e7db83283825b00f00bfe7a3de3f46486112be977cd3fe9eeabfa18ac1
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.15.0] - 2026-11-10
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Configure ordinary role processes with project-scoped environment files; installation and verification require no session issuer service. Converge protected extension permissions and runtime dependency closure. Execute temporary grants in ordinary process groups with deadlines, cleanup, principal checks and durable interrupted-execution reconciliation; use repository-scoped Forgejo token APIs.
|
|
15
|
+
|
|
10
16
|
## [0.14.1] - 2026-10-31
|
|
11
17
|
|
|
12
18
|
### Fixed
|
data/docs/usage.md
CHANGED
|
@@ -108,9 +108,8 @@ conflated:
|
|
|
108
108
|
carries the path-bearing message.
|
|
109
109
|
|
|
110
110
|
Runtime *configuration* is echoed (project/user `runtime/config.yml`, then the
|
|
111
|
-
`ACE_RUNTIME` environment variable) with its value and origin.
|
|
112
|
-
|
|
113
|
-
`ace-herdr status`'s dimension.
|
|
111
|
+
`ACE_RUNTIME` environment variable) with its value and origin. This status
|
|
112
|
+
does not detect runtime availability; that is `ace-herdr status`'s dimension.
|
|
114
113
|
|
|
115
114
|
Local state covers `.ace-tasks/`, `.ace-tasks/_overseer/` (presence only —
|
|
116
115
|
private, never enumerated), `.ace-local/assign/`, `.ace-local/hitl/`,
|
|
@@ -167,6 +166,9 @@ projects: # optional (consumed by ace-lab install)
|
|
|
167
166
|
path: /Users/ace/lab/atlas # absolute, unique
|
|
168
167
|
remote: git@forge:atlas/atlas.git # optional clone source
|
|
169
168
|
label: Atlas platform # optional display label
|
|
169
|
+
environment_files: # optional role-specific JSON environment sources
|
|
170
|
+
worker: /home/mo/.config/ace/atlas-worker.json
|
|
171
|
+
integrator: /home/mo/.config/ace/atlas-integrator.json
|
|
170
172
|
grants: # optional: trusted-tier principal grants
|
|
171
173
|
ace-overseer: # principal → declared project ids
|
|
172
174
|
- atlas
|
|
@@ -175,12 +177,39 @@ gemset: # optional (gad.2): target runtime + required gem s
|
|
|
175
177
|
gems: # exact versions, unique names
|
|
176
178
|
- name: ace-lab
|
|
177
179
|
version: 0.13.0 # must be an exact gem version (no ranges)
|
|
180
|
+
- name: ace-wake
|
|
181
|
+
version: 0.2.0
|
|
178
182
|
extensions: # optional (8xs.t.pu0.0): harness extension projections
|
|
179
183
|
- gem: ace-wake # must be declared in gems above
|
|
180
|
-
path: /
|
|
184
|
+
path: /opt/lab/pi-agent/extensions # root-controlled operational directory
|
|
181
185
|
account: lab-overseer # must be a declared account (the loading executor)
|
|
186
|
+
session_runtime: # explicit installed Pi launch configuration
|
|
187
|
+
node: /opt/ace/node/bin/node
|
|
188
|
+
pi: /opt/ace/pi/node_modules/@mariozechner/pi-coding-agent/dist/cli.js
|
|
189
|
+
path: [/usr/bin] # additional root-controlled executable directories
|
|
190
|
+
provider: zai
|
|
191
|
+
model: glm-5.3
|
|
192
|
+
credentials: /etc/lab/ace-lab/pi-auth.json # private Pi auth JSON; no inline secrets
|
|
182
193
|
```
|
|
183
194
|
|
|
195
|
+
`session_runtime` selects the installed Pi command; it does not install Node,
|
|
196
|
+
Pi or model credentials. The launcher starts an ordinary child in the selected
|
|
197
|
+
project worktree with its explicit environment and `ACE_TOOLCHAIN=installed`.
|
|
198
|
+
Standing instructions come from the selected role and resolve through installed
|
|
199
|
+
ACE. Missing runtime is a readiness failure, never a checkout fallback.
|
|
200
|
+
|
|
201
|
+
`projects[].environment_files` maps a role to a private JSON environment file;
|
|
202
|
+
`coordinator.environment_files` does the same for the lab Overseer. Files must be
|
|
203
|
+
mode 0600 and owned by the launch user or root. Only the selected project's role
|
|
204
|
+
file is loaded. Values can point at a private provider login or contain an
|
|
205
|
+
already repo-scoped token. The forge enforces the token's repository access;
|
|
206
|
+
selecting environment variables does not narrow an administrator token.
|
|
207
|
+
|
|
208
|
+
This is a worktree/process/environment boundary between trusted agents. It does
|
|
209
|
+
not create a systemd service, change OS users, or protect against a malicious
|
|
210
|
+
process running as the same account. The lab Overseer delegates bootstrap;
|
|
211
|
+
project Overseers delegate implementation, review and integration.
|
|
212
|
+
|
|
184
213
|
Statuses: accounts `created|exists|would_create|failed`; directories
|
|
185
214
|
`created|exists|mode_fixed|would_create|would_fix_mode|failed`; keys
|
|
186
215
|
`created|exists|would_create|would_repair_public|would_fix_mode|failed`
|
|
@@ -350,8 +379,18 @@ Behavior and guarantees:
|
|
|
350
379
|
- **Extension projections (8xs.t.pu0.0).** Each `gemset.extensions`
|
|
351
380
|
entry projects the installed gem's `handbook/extensions` tree — at
|
|
352
381
|
its EXACT declared version, never the checkout and never a newer
|
|
353
|
-
sibling — into the declared target directory.
|
|
354
|
-
|
|
382
|
+
sibling — into the declared target directory. In `ACE_TOOLCHAIN=installed`
|
|
383
|
+
mode, the projection is root-owned; its source, destination,
|
|
384
|
+
receipt and existing ancestors must be root-controlled and not writable
|
|
385
|
+
by group or others. Configure the harness to load this protected location;
|
|
386
|
+
projecting files alone does not change the harness's extension discovery.
|
|
387
|
+
Installed Linux lab Pi starts use one shared `ace-wake` projection and
|
|
388
|
+
refuse an ambiguous manifest with multiple `ace-wake` entries. They also
|
|
389
|
+
require the Herdr Pi integration alongside it. Outside a configured lab,
|
|
390
|
+
installed Pi retains normal extension discovery. Configured lab launches
|
|
391
|
+
preserve the installed toolchain and load the declared operational extension
|
|
392
|
+
paths. No issuer socket or systemd invocation is needed for readiness.
|
|
393
|
+
The projection is receipt-based: only files recorded in the target's
|
|
355
394
|
`.ace-lab-extension-projection.json` receipt are ever removed, so
|
|
356
395
|
user-authored extensions in the same directory stay untouched. The
|
|
357
396
|
report carries `loaded_version` (the version the running harness
|
|
@@ -662,10 +701,11 @@ auth:
|
|
|
662
701
|
scopes: ["write:repository"]
|
|
663
702
|
ttl_minutes: 30 # default 30; max 60 unless the host sets otherwise
|
|
664
703
|
expiry_mode: host-revoke # provider | host-revoke | process
|
|
665
|
-
issuer: #
|
|
704
|
+
issuer: # host-only administrator Basic credential
|
|
666
705
|
url: https://forge.example.com
|
|
667
706
|
account: lab-role-dev
|
|
668
|
-
|
|
707
|
+
admin_username: forge-admin
|
|
708
|
+
password_file: /etc/lab/ace-lab/secrets/forgejo.admin.password
|
|
669
709
|
```
|
|
670
710
|
|
|
671
711
|
```bash
|
|
@@ -707,11 +747,17 @@ Contract highlights:
|
|
|
707
747
|
returns the child's code after printing the report); on process success a
|
|
708
748
|
cleanup failure is itself an error and the report carries both outcomes;
|
|
709
749
|
one exec at a time per lease; normal end/cancel revokes immediately, a
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
750
|
+
ordinary execution enforces its deadline and terminates its process group.
|
|
751
|
+
There is no background revoke scheduler. After a hard process death, run
|
|
752
|
+
`reconcile` before retrying; unconfirmed execution or revocation stays visibly
|
|
753
|
+
unresolved. Forgejo grants have no claimed native expiry; GitHub expiry is
|
|
754
|
+
accepted only when the provider returns a valid deadline. The child runs with
|
|
755
|
+
an ALLOWLISTED environment (PATH/HOME, ACE_TOOLCHAIN, GEM_HOME, GEM_PATH,
|
|
756
|
+
and the leased credential
|
|
713
757
|
reference) — ambient credential overrides (GH_TOKEN, GIT_* auth, ...) are
|
|
714
758
|
never inherited past the profile's scope.
|
|
759
|
+
When the selected toolchain is `installed`, its runtime paths also take
|
|
760
|
+
precedence over caller and provider environment values.
|
|
715
761
|
- Refusals are non-zero with a reason and the escalation addressee; there is
|
|
716
762
|
no fallback identity. A network-shaped revoke stays visibly
|
|
717
763
|
`revoke-pending` — a revocation is never claimed before the provider
|
|
@@ -720,12 +766,15 @@ Contract highlights:
|
|
|
720
766
|
LEASE --force` → forced-terminal with the unconfirmed outcome recorded),
|
|
721
767
|
never silently marked revoked.
|
|
722
768
|
- Plugins (explicit registry, like the ace-git providers): `forgejo` (one
|
|
723
|
-
scoped token per lease through
|
|
724
|
-
|
|
725
|
-
|
|
769
|
+
scoped token per lease through `/api/v1/users/{account}/tokens`, using
|
|
770
|
+
administrator Basic authentication and a `Sudo` header. The setup process
|
|
771
|
+
reads the configured source credential and passes only the resulting
|
|
772
|
+
project-scoped token to the child. Remote
|
|
773
|
+
issuers require HTTPS; plain HTTP is accepted only on loopback. Tokens use
|
|
774
|
+
owner/name RepoTargetOption repository targets —
|
|
726
775
|
a repo without an owner is refused, never an all-repositories token — and
|
|
727
|
-
the response
|
|
728
|
-
that exact token name
|
|
776
|
+
the response must confirm exactly the requested scopes and repositories.
|
|
777
|
+
Revoke deletes that exact token name; 404 = already invalid), `github` (App JWT →
|
|
729
778
|
installation token for chosen repos/permissions with the real `expires_at`;
|
|
730
779
|
DELETE to revoke; 401 = already invalid), `env` (named values of configured
|
|
731
780
|
safe sources, process-scoped only — a standalone env profile cannot declare
|
|
@@ -736,3 +785,16 @@ Contract highlights:
|
|
|
736
785
|
Forgejo-shaped subjects like `write:repository`. Provider Authorization
|
|
737
786
|
headers travel in 0600 ephemeral files (`curl -H @file`), never in argv
|
|
738
787
|
visible to `ps`.
|
|
788
|
+
|
|
789
|
+
During a Forgejo `auth exec` on Linux, the child receives a private temporary `fj`
|
|
790
|
+
login and Git askpass bound to the issuer origin and role account. Ambient
|
|
791
|
+
credential helpers are disabled for that child. The projection is removed
|
|
792
|
+
on exit or by lease cleanup after an interrupted executor. Other platforms
|
|
793
|
+
refuse this projection rather than falling back to ambient credentials.
|
|
794
|
+
|
|
795
|
+
A pre-existing project-scoped token supplied through a role environment file is
|
|
796
|
+
static configuration: ending the session removes its local projection, not the
|
|
797
|
+
remote token shared by other sessions. Temporary grants created by `auth request`
|
|
798
|
+
use `auth exec` and its normal revoke/reconcile lifecycle. Secret values belong
|
|
799
|
+
only to private files or the child environment, never prompts, argv, task records
|
|
800
|
+
or reports.
|
|
@@ -15,13 +15,13 @@ module Ace
|
|
|
15
15
|
SCHEMA_VERSION = 1
|
|
16
16
|
TOP_LEVEL_KEYS =
|
|
17
17
|
%w[schema_version machine accounts directories keys roles projects grants
|
|
18
|
-
adapters tools gemset].freeze
|
|
18
|
+
adapters tools gemset session_runtime coordinator].freeze
|
|
19
19
|
ENTRY_KEYS = {
|
|
20
20
|
"accounts" => %w[username role create home],
|
|
21
21
|
"directories" => %w[path owner mode],
|
|
22
22
|
"keys" => %w[account type path],
|
|
23
23
|
"roles" => %w[id account instructions],
|
|
24
|
-
"projects" => %w[id path remote label],
|
|
24
|
+
"projects" => %w[id path remote label environment_files],
|
|
25
25
|
"adapters" => %w[id kind command config]
|
|
26
26
|
}.freeze
|
|
27
27
|
USERNAME_PATTERN = /\A[a-z_][a-z0-9_-]{0,31}\z/
|
|
@@ -66,6 +66,7 @@ module Ace
|
|
|
66
66
|
accounts = normalize_accounts(document["accounts"], os)
|
|
67
67
|
usernames = accounts.map { |account| account["username"] }
|
|
68
68
|
projects = normalize_projects(document["projects"])
|
|
69
|
+
coordinator = normalize_coordinator(document["coordinator"], projects)
|
|
69
70
|
|
|
70
71
|
{
|
|
71
72
|
"schema_version" => SCHEMA_VERSION,
|
|
@@ -75,15 +76,78 @@ module Ace
|
|
|
75
76
|
"keys" => normalize_keys(document["keys"], accounts),
|
|
76
77
|
"roles" => normalize_roles(document["roles"], usernames),
|
|
77
78
|
"projects" => projects,
|
|
78
|
-
"
|
|
79
|
+
"coordinator" => coordinator,
|
|
80
|
+
"grants" => normalize_grants(document["grants"], projects.map { |p| p["id"] } + [coordinator&.fetch("id")].compact),
|
|
79
81
|
"adapters" => normalize_adapters(document["adapters"]),
|
|
80
82
|
"tools" => normalize_tools(document["tools"]),
|
|
81
|
-
"gemset" => normalize_gemset(document["gemset"], accounts.map { |a| a["username"] })
|
|
83
|
+
"gemset" => normalize_gemset(document["gemset"], accounts.map { |a| a["username"] }),
|
|
84
|
+
"session_runtime" => normalize_session_runtime(document["session_runtime"])
|
|
82
85
|
}
|
|
83
86
|
end
|
|
84
87
|
|
|
85
88
|
private
|
|
86
89
|
|
|
90
|
+
def normalize_coordinator(raw, projects)
|
|
91
|
+
return nil if raw.nil?
|
|
92
|
+
unless raw.is_a?(Hash) && (raw.keys - %w[id path environment_files]).empty? && (%w[id path] - raw.keys).empty? &&
|
|
93
|
+
raw["id"].is_a?(String) && ROLE_ID_PATTERN.match?(raw["id"]) &&
|
|
94
|
+
raw["path"].is_a?(String) && raw["path"].match?(%r{\A/lab(?:/[a-zA-Z0-9][a-zA-Z0-9_.-]*)?\z}) &&
|
|
95
|
+
File.expand_path(raw["path"]) == raw["path"] && !raw["path"].match?(/[[:cntrl:]]/)
|
|
96
|
+
raise_invalid "coordinator requires an id and canonical absolute path"
|
|
97
|
+
end
|
|
98
|
+
if projects.any? { |project| project["id"] == raw["id"] || project["path"] == raw["path"] }
|
|
99
|
+
raise_invalid "coordinator must be distinct from projects"
|
|
100
|
+
end
|
|
101
|
+
raw.key?("environment_files") ? raw.merge("environment_files" => normalize_environment_files(raw["environment_files"])) : raw.dup
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def normalize_environment_files(raw)
|
|
105
|
+
return {} if raw.nil?
|
|
106
|
+
roles = %w[lab-overseer project-overseer worker verifier integrator]
|
|
107
|
+
unless raw.is_a?(Hash) && raw.all? { |role, path|
|
|
108
|
+
roles.include?(role) && path.is_a?(String) && path.start_with?("/") &&
|
|
109
|
+
File.expand_path(path) == path && !path.match?(/[[:cntrl:]]/)
|
|
110
|
+
}
|
|
111
|
+
raise_invalid "environment_files must map execution roles to canonical absolute paths"
|
|
112
|
+
end
|
|
113
|
+
raw.dup
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def normalize_session_runtime(raw)
|
|
117
|
+
return nil if raw.nil?
|
|
118
|
+
keys = %w[node pi path provider model credentials]
|
|
119
|
+
unless raw.is_a?(Hash) && (raw.keys - keys - ["herdr"]).empty? && (keys - raw.keys).empty?
|
|
120
|
+
raise_invalid "session_runtime requires exactly node, pi, path, provider, model and credentials"
|
|
121
|
+
end
|
|
122
|
+
%w[node pi credentials].each do |key|
|
|
123
|
+
path = raw[key]
|
|
124
|
+
unless path.is_a?(String) && path.start_with?("/") && File.expand_path(path) == path && !path.match?(/[[:cntrl:]]/)
|
|
125
|
+
raise_invalid "session_runtime #{key} must be a canonical absolute path"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
paths = raw["path"]
|
|
129
|
+
unless paths.is_a?(Array) && !paths.empty? && paths.uniq == paths && paths.all? { |path|
|
|
130
|
+
path.is_a?(String) && path.start_with?("/") && File.expand_path(path) == path && !path.match?(/[[:cntrl:]:]/)
|
|
131
|
+
}
|
|
132
|
+
raise_invalid "session_runtime path must contain unique canonical absolute directories"
|
|
133
|
+
end
|
|
134
|
+
%w[provider model].each do |key|
|
|
135
|
+
unless raw[key].is_a?(String) && raw[key].match?(%r{\A[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}\z})
|
|
136
|
+
raise_invalid "session_runtime #{key} is invalid"
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
if raw.key?("herdr")
|
|
140
|
+
endpoint = raw["herdr"]
|
|
141
|
+
unless endpoint.is_a?(Hash) && endpoint.keys.sort == %w[account socket] &&
|
|
142
|
+
endpoint["account"].is_a?(String) && endpoint["account"].match?(/\A[a-z_][a-z0-9_-]*\z/) &&
|
|
143
|
+
endpoint["socket"].is_a?(String) && endpoint["socket"].start_with?("/") &&
|
|
144
|
+
File.expand_path(endpoint["socket"]) == endpoint["socket"] && !endpoint["socket"].match?(/[[:cntrl:]]/)
|
|
145
|
+
raise_invalid "session_runtime herdr requires an account and canonical socket path"
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
raw.transform_values { |value| value.is_a?(Array) ? value.map(&:dup) : value.dup }
|
|
149
|
+
end
|
|
150
|
+
|
|
87
151
|
def raise_invalid(reason)
|
|
88
152
|
raise Ace::Lab::InvalidConfigurationError,
|
|
89
153
|
"invalid lab install manifest: #{reason}"
|
|
@@ -229,7 +293,9 @@ module Ace
|
|
|
229
293
|
remote = optional_string(entry, "projects", "remote")
|
|
230
294
|
label = optional_string(entry, "projects", "label")
|
|
231
295
|
|
|
232
|
-
{"id" => id, "path" => path, "remote" => remote, "label" => label}
|
|
296
|
+
normalized = {"id" => id, "path" => path, "remote" => remote, "label" => label}
|
|
297
|
+
normalized["environment_files"] = normalize_environment_files(entry["environment_files"]) if entry.key?("environment_files")
|
|
298
|
+
normalized
|
|
233
299
|
end.tap do |projects|
|
|
234
300
|
duplicates_of(projects.map { |project| project["id"] }) do |_id|
|
|
235
301
|
raise_invalid_entry("projects", "id is declared more than once")
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "fileutils"
|
|
4
|
+
require "tempfile"
|
|
4
5
|
|
|
5
6
|
module Ace
|
|
6
7
|
module Lab
|
|
@@ -12,35 +13,58 @@ module Ace
|
|
|
12
13
|
# REFERENCE (path), never the value in argv or the serialized
|
|
13
14
|
# report. Removal after the lease is idempotent.
|
|
14
15
|
class CredentialHandoff
|
|
15
|
-
def
|
|
16
|
+
def self.projection_prefix(credential)
|
|
17
|
+
"#{File.basename(credential)}.forgejo-session-"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def initialize(credentials_dir, recipient:)
|
|
16
21
|
@credentials_dir = credentials_dir
|
|
17
22
|
@recipient = recipient
|
|
18
23
|
end
|
|
19
24
|
|
|
20
25
|
def path(lease_id)
|
|
26
|
+
raise Error, "invalid credential lease id" unless lease_id.to_s.match?(/\Alease-[a-zA-Z0-9_-]+\z/)
|
|
21
27
|
File.join(@credentials_dir, "#{lease_id}-#{@recipient}")
|
|
22
28
|
end
|
|
23
29
|
|
|
24
|
-
# @return [String] the credential file path (the reference)
|
|
25
30
|
def deliver(lease_id, secret)
|
|
26
|
-
FileUtils.mkdir_p(@credentials_dir)
|
|
31
|
+
FileUtils.mkdir_p(@credentials_dir, mode: 0o700)
|
|
27
32
|
target = path(lease_id)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
temporary = "#{target}.#{Process.pid}.tmp"
|
|
31
|
-
File.write(temporary, secret.to_s)
|
|
32
|
-
FileUtils.chmod(0o400, temporary)
|
|
33
|
-
File.rename(temporary, target)
|
|
34
|
-
target
|
|
33
|
+
deliver_into(@credentials_dir, target, secret)
|
|
35
34
|
end
|
|
36
35
|
|
|
37
36
|
def clear(lease_id)
|
|
38
37
|
target = path(lease_id)
|
|
39
38
|
File.delete(target) if File.file?(target) && !File.symlink?(target)
|
|
39
|
+
# A killed Forgejo executor may leave its private fj login
|
|
40
|
+
# projection behind; it belongs to this same handoff lifecycle.
|
|
41
|
+
prefix = self.class.projection_prefix(target)
|
|
42
|
+
Dir.children(@credentials_dir).grep(/^#{Regexp.escape(prefix)}/).each do |name|
|
|
43
|
+
dir = File.join(@credentials_dir, name)
|
|
44
|
+
raise Error, "session projection is a symlink; refusing cleanup" if File.symlink?(dir)
|
|
45
|
+
FileUtils.remove_entry_secure(dir) if File.directory?(dir)
|
|
46
|
+
end
|
|
40
47
|
:cleared
|
|
41
48
|
rescue Errno::ENOENT
|
|
42
49
|
:cleared
|
|
43
50
|
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def deliver_into(directory, public_target, secret)
|
|
55
|
+
target = File.join(directory, File.basename(public_target))
|
|
56
|
+
raise Error, "credential handoff path is a symlink; refusing" if File.symlink?(target)
|
|
57
|
+
|
|
58
|
+
Tempfile.create([".credential-", ".tmp"], directory) do |temporary|
|
|
59
|
+
temporary.write(secret.to_s)
|
|
60
|
+
temporary.flush
|
|
61
|
+
temporary.fsync
|
|
62
|
+
temporary.chmod(0o400)
|
|
63
|
+
File.rename(temporary.path, target)
|
|
64
|
+
end
|
|
65
|
+
public_target
|
|
66
|
+
end
|
|
67
|
+
|
|
44
68
|
end
|
|
45
69
|
end
|
|
46
70
|
end
|
|
@@ -5,6 +5,7 @@ require "json"
|
|
|
5
5
|
require "fileutils"
|
|
6
6
|
require "digest"
|
|
7
7
|
require "time"
|
|
8
|
+
require_relative "error"
|
|
8
9
|
|
|
9
10
|
module Ace
|
|
10
11
|
module Lab
|
|
@@ -46,6 +47,9 @@ module Ace
|
|
|
46
47
|
# @return [Hash] the created (PREPARING) lease record
|
|
47
48
|
def create(profile:, role:, principal:, task:, assignment:, repos:,
|
|
48
49
|
use_until:, provider_expiry:, token_ref: nil, id: nil)
|
|
50
|
+
unless [profile, role, principal, task, assignment].all? { |value| value.is_a?(String) && value.valid_encoding? && !value.strip.empty? }
|
|
51
|
+
raise Error, "lease correlation is incomplete"
|
|
52
|
+
end
|
|
49
53
|
lease = {"id" => id || "lease-#{SecureRandom.hex(8)}",
|
|
50
54
|
"profile" => profile, "role" => role, "principal" => principal,
|
|
51
55
|
"task" => task, "assignment" => assignment, "repos" => repos.sort,
|
|
@@ -64,8 +68,8 @@ module Ace
|
|
|
64
68
|
# scope-changed correlation does not match, and
|
|
65
69
|
# DEADLINE-AWARENESS is part of the match: an issued record
|
|
66
70
|
# past its use_until/provider expiry is not returned, so a
|
|
67
|
-
# retry
|
|
68
|
-
#
|
|
71
|
+
# retry must settle the expired grant before minting a replacement;
|
|
72
|
+
# an expired grant is never handed back as "live".
|
|
69
73
|
def find_live(task:, assignment:, profile:, role:)
|
|
70
74
|
all.filter_map do |lease|
|
|
71
75
|
next unless lease["task"] == task && lease["assignment"] == assignment &&
|
|
@@ -77,9 +81,11 @@ module Ace
|
|
|
77
81
|
|
|
78
82
|
def load(id)
|
|
79
83
|
path = lease_path(id)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
84
|
+
parse_record(path)
|
|
85
|
+
rescue Errno::ENOENT
|
|
86
|
+
raise Error, "unknown lease '#{id}'"
|
|
87
|
+
rescue SystemCallError => e
|
|
88
|
+
raise Error, "lease record '#{id}' is unavailable (#{e.class})"
|
|
83
89
|
rescue JSON::ParserError
|
|
84
90
|
raise Error, "lease record '#{id}' is unreadable"
|
|
85
91
|
end
|
|
@@ -89,13 +95,17 @@ module Ace
|
|
|
89
95
|
# problem (the remote token may still be live) — never a
|
|
90
96
|
# silently skipped entry that disappears from reconcile.
|
|
91
97
|
def all
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
rescue
|
|
97
|
-
|
|
98
|
-
|
|
98
|
+
names = begin
|
|
99
|
+
Dir.children(@dir)
|
|
100
|
+
rescue Errno::ENOENT
|
|
101
|
+
return []
|
|
102
|
+
rescue SystemCallError => e
|
|
103
|
+
raise Error, "lease inventory is unavailable (#{e.class})"
|
|
104
|
+
end
|
|
105
|
+
names.reject { |name| name.start_with?(".") }.select { |name| name.end_with?(".json") }.sort.filter_map do |name|
|
|
106
|
+
path = File.join(@dir, name)
|
|
107
|
+
parse_record(path)
|
|
108
|
+
rescue SystemCallError, JSON::ParserError
|
|
99
109
|
{"id" => File.basename(path, ".json"), "state" => nil,
|
|
100
110
|
"corrupt" => true, "_path" => path}
|
|
101
111
|
end
|
|
@@ -110,8 +120,8 @@ module Ace
|
|
|
110
120
|
path = lease_path(id)
|
|
111
121
|
with_write_lock(id) do
|
|
112
122
|
lease = begin
|
|
113
|
-
|
|
114
|
-
rescue JSON::ParserError,
|
|
123
|
+
parse_record(path)
|
|
124
|
+
rescue JSON::ParserError, SystemCallError => e
|
|
115
125
|
# A corrupt/missing record is a named store failure, never a
|
|
116
126
|
# raw parser error escaping through the update seam.
|
|
117
127
|
raise Error, "lease '#{id}' cannot be read at #{path}: #{e.class} #{e.message}"
|
|
@@ -128,10 +138,12 @@ module Ace
|
|
|
128
138
|
end
|
|
129
139
|
|
|
130
140
|
def with_write_lock(id)
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
lock
|
|
141
|
+
lock = open_lock(File.join(@dir, "#{id}.write.lock"))
|
|
142
|
+
begin
|
|
143
|
+
lock_flock(lock, File::LOCK_EX)
|
|
134
144
|
yield
|
|
145
|
+
ensure
|
|
146
|
+
lock.close
|
|
135
147
|
end
|
|
136
148
|
end
|
|
137
149
|
|
|
@@ -141,24 +153,28 @@ module Ace
|
|
|
141
153
|
# against a concurrent identical request — the second caller
|
|
142
154
|
# waits and then returns the first caller's live lease.
|
|
143
155
|
def with_correlation_lock(task:, assignment:, profile:, role:)
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
lock
|
|
156
|
+
key = Digest::SHA256.hexdigest(JSON.generate([task, assignment, profile, role]))
|
|
157
|
+
lock = open_lock(File.join(@dir, "correlation-#{key}.lock"))
|
|
158
|
+
begin
|
|
159
|
+
lock_flock(lock, File::LOCK_EX)
|
|
148
160
|
yield
|
|
161
|
+
ensure
|
|
162
|
+
lock.close
|
|
149
163
|
end
|
|
150
164
|
end
|
|
151
165
|
|
|
152
166
|
# One exec at a time per lease: an exclusive lock on the lease's
|
|
153
167
|
# exec lockfile, non-blocking — a parallel exec is a refusal.
|
|
154
168
|
def with_exec_lock(id)
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
unless lock
|
|
169
|
+
lock = open_lock(File.join(@dir, "#{id}.exec.lock"))
|
|
170
|
+
begin
|
|
171
|
+
unless lock_flock(lock, File::LOCK_EX | File::LOCK_NB)
|
|
158
172
|
raise Error, "lease '#{id}' already has a running exec"
|
|
159
173
|
end
|
|
160
174
|
|
|
161
175
|
yield
|
|
176
|
+
ensure
|
|
177
|
+
lock.close
|
|
162
178
|
end
|
|
163
179
|
end
|
|
164
180
|
|
|
@@ -168,33 +184,79 @@ module Ace
|
|
|
168
184
|
# or is mid-revocation — neither is ever usable or "live".
|
|
169
185
|
def live?(lease, at: Time.now)
|
|
170
186
|
return false unless lease["state"] == "issued"
|
|
187
|
+
return false if lease.key?("execution") || lease["containment"] == "unconfirmed"
|
|
171
188
|
return false if lease["use_until"] && Time.parse(lease["use_until"]) <= at
|
|
172
189
|
if lease["provider_expiry"] && Time.parse(lease["provider_expiry"]) <= at
|
|
173
190
|
return false
|
|
174
191
|
end
|
|
175
192
|
|
|
176
193
|
true
|
|
194
|
+
rescue ArgumentError, TypeError => e
|
|
195
|
+
raise Error, "lease deadline is invalid (#{e.class})"
|
|
177
196
|
end
|
|
178
197
|
|
|
179
198
|
private
|
|
180
199
|
|
|
200
|
+
def open_lock(path)
|
|
201
|
+
FileUtils.mkdir_p(@dir)
|
|
202
|
+
lock = File.open(path, File::CREAT | File::RDWR | File::NOFOLLOW, 0o600)
|
|
203
|
+
held, named = lock.stat, File.lstat(path)
|
|
204
|
+
unless held.file? && !named.symlink? && held.uid == Process::Sys.geteuid && held.nlink == 1 &&
|
|
205
|
+
(held.mode & 0o022).zero? && [held.dev, held.ino] == [named.dev, named.ino]
|
|
206
|
+
raise Error, "lease lock is not safely owned"
|
|
207
|
+
end
|
|
208
|
+
lock
|
|
209
|
+
rescue SystemCallError => e
|
|
210
|
+
lock&.close unless lock&.closed?
|
|
211
|
+
raise Error, "lease lock is unavailable (#{e.class})"
|
|
212
|
+
rescue Exception
|
|
213
|
+
lock&.close unless lock&.closed?
|
|
214
|
+
raise
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
def lock_flock(lock, flags)
|
|
218
|
+
lock.flock(flags)
|
|
219
|
+
rescue SystemCallError => e
|
|
220
|
+
raise Error, "lease lock could not be acquired (#{e.class})"
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def parse_record(path)
|
|
224
|
+
record = JSON.parse(File.read(path))
|
|
225
|
+
unless record.is_a?(Hash) && record["id"] == File.basename(path, ".json")
|
|
226
|
+
raise JSON::ParserError, "lease record identity differs"
|
|
227
|
+
end
|
|
228
|
+
record
|
|
229
|
+
end
|
|
230
|
+
|
|
181
231
|
def atomic_write(path, lease)
|
|
182
232
|
FileUtils.mkdir_p(@dir)
|
|
183
|
-
temporary = "#{path}.#{
|
|
184
|
-
|
|
233
|
+
temporary = "#{path}.#{SecureRandom.hex(16)}.tmp"
|
|
234
|
+
created = false
|
|
235
|
+
File.open(temporary, File::WRONLY | File::CREAT | File::EXCL, 0o600) do |file|
|
|
236
|
+
created = true
|
|
237
|
+
file.chmod(0o600)
|
|
185
238
|
file.write(JSON.pretty_generate(lease))
|
|
186
239
|
file.flush
|
|
187
240
|
file.fsync
|
|
188
241
|
end
|
|
189
|
-
# 0600 BEFORE the rename: the record never exists at the target
|
|
190
|
-
# path with umask-default permissions, even for an instant.
|
|
191
|
-
FileUtils.chmod(0o600, temporary)
|
|
192
242
|
begin
|
|
193
243
|
File.rename(temporary, path)
|
|
194
244
|
rescue Errno::ENOENT
|
|
195
245
|
FileUtils.mkdir_p(File.dirname(path))
|
|
196
246
|
File.rename(temporary, path)
|
|
197
247
|
end
|
|
248
|
+
# Persist the directory entry as well as the file data.
|
|
249
|
+
File.open(File.dirname(path), File::RDONLY) { |directory| directory.fsync }
|
|
250
|
+
rescue SystemCallError, IOError => e
|
|
251
|
+
raise Error, "lease record write is unavailable (#{e.class})"
|
|
252
|
+
ensure
|
|
253
|
+
if created
|
|
254
|
+
begin
|
|
255
|
+
File.unlink(temporary)
|
|
256
|
+
rescue SystemCallError
|
|
257
|
+
nil
|
|
258
|
+
end
|
|
259
|
+
end
|
|
198
260
|
end
|
|
199
261
|
|
|
200
262
|
def lease_path(id)
|