ace-assign 0.70.0 → 0.71.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 +20 -0
- data/handbook/skills/as-assign-drive/SKILL.md +5 -2
- data/handbook/workflow-instructions/assign/drive.wf.md +77 -0
- data/lib/ace/assign/atoms/candidate_limits.rb +20 -0
- data/lib/ace/assign/atoms/git_blob_batch.rb +59 -0
- data/lib/ace/assign/authority/candidate_blob_batch.rb +55 -0
- data/lib/ace/assign/authority/candidate_head_comparison.rb +139 -0
- data/lib/ace/assign/authority/candidate_transfer.rb +55 -25
- data/lib/ace/assign/authority/command_execution.rb +180 -0
- data/lib/ace/assign/authority/endcap.rb +4 -2
- data/lib/ace/assign/authority/endcap_campaign_rounds.rb +1 -1
- data/lib/ace/assign/authority/endcap_publication.rb +0 -166
- data/lib/ace/assign/authority/endcap_service_context.rb +38 -0
- data/lib/ace/assign/authority/fixed_publication_lifecycle.rb +176 -0
- data/lib/ace/assign/authority/{endcap_services.rb → fixed_service_lifecycle.rb} +42 -46
- data/lib/ace/assign/authority/ordinary_completion.rb +13 -3
- data/lib/ace/assign/authority/ordinary_lifecycle.rb +40 -4
- data/lib/ace/assign/authority/ordinary_merge_evidence.rb +81 -0
- data/lib/ace/assign/authority/ordinary_release_artifacts.rb +143 -0
- data/lib/ace/assign/authority/ordinary_results.rb +11 -1
- data/lib/ace/assign/authority/ordinary_review_evidence.rb +180 -0
- data/lib/ace/assign/authority/ordinary_reviews.rb +214 -0
- data/lib/ace/assign/authority/ordinary_supervisor.rb +99 -0
- data/lib/ace/assign/authority/prepared_work.rb +7 -2
- data/lib/ace/assign/authority/release_artifact_manifest.rb +91 -0
- data/lib/ace/assign/authority/retained_input.rb +53 -0
- data/lib/ace/assign/authority/transfer_codec.rb +5 -3
- data/lib/ace/assign/cli/commands/submit_candidate.rb +1 -1
- data/lib/ace/assign/molecules/canonical_evidence.rb +25 -8
- data/lib/ace/assign/molecules/evidence_journal.rb +17 -30
- data/lib/ace/assign/molecules/journal_mutation.rb +2 -1
- data/lib/ace/assign/molecules/receipt_verifier.rb +8 -2
- data/lib/ace/assign/molecules/runtime_control_surface_runner.rb +2 -1
- data/lib/ace/assign/organisms/attempt_coordinator.rb +40 -3
- data/lib/ace/assign/organisms/ordinary_driver.rb +310 -0
- data/lib/ace/assign/version.rb +1 -1
- metadata +19 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 5330cebc5f70e683451c7a69082c04f7fb6d8a5d92b24b92c73b37d30c56c4a9
|
|
4
|
+
data.tar.gz: d1687b8ba44c21a2a0618e61199c3043a67a8c6f0ecf7be38635635696c57b71
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e792c5dae1dda172bfa11a6bf7cc08c074796e70dd5fd67b2ba42294975b71b46ef7fbef858ca7e848675ae83fc4d567dcffb5cf5e5867ea3425b6aba25e161a
|
|
7
|
+
data.tar.gz: 564c47ff609931ae807a8a0f2cd8c1806160272ab9ecc3405a90118e42f184dc93c180b6b7b834844cc3668699f2bcc2fef3632db773ba7da4aa6e29cb0fd912
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.71.0] - 2026-10-16
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- Compare two independently retained candidate bundles through bounded bare Git object reads, including nonancestor rebases, full context inventories, binary patches and historical symlink bytes without executing or materializing candidate code.
|
|
15
|
+
- Ground candidate and assignment-snapshot wire/expansion ceilings in closed 128MiB budgets backed by a shared fixed `cat-file --batch` codec, explicit 20,000-object bounds (per tree inventory and across the comparison's unique-blob union), and chunked batch reads instead of one Git process per blob; historical journal reads reuse the same codec.
|
|
16
|
+
- Bound candidate, prepared and head-comparison tree paths to finite total/component lengths (4096/255 bytes) before any inventory, delta or filesystem prefix work, so a hostile tree cannot push unbounded path bytes into inventories or directory creation.
|
|
17
|
+
- Retain built ordinary release gems as canonical binary evidence bound to the actual native producer, current accepted candidate, independent review and exact release decision. Recheck authorization on replay; preserve report limits and keep publication dispatch separate.
|
|
18
|
+
- Admit ordinary candidates and independently started native reviewers into the canonical journal. Revalidate original review artifacts and root-witnessed test executions before fixed merge admission and effects; preserve strict protected-review attribution.
|
|
19
|
+
- Drive ordinary work through the installed canonical owner with retained requests, accepted context snapshots and result adoption. Record coordinator replacement in the existing definition chain only after positive old-process termination; preserve worker attempts and reject stale supervisor completion.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- Preserve exact lost-response result replay while allowing canonical-generation CAS conflicts to recover without replacing original inputs.
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- Keep protected launch identity construction and comparison in its boundary owner while shared fixed service transitions reuse the same checks.
|
|
28
|
+
- Require ace-review 0.61 for the shared complete-head-context ceiling; raise the candidate transfer deadline from 30s to a measured 60s with byte/count bounds unchanged (two full admits plus the complete comparison of the real 82,809,675-byte candidate legitimately need 23–27s on a loaded host; 30s failed closed under recorded load).
|
|
29
|
+
|
|
10
30
|
## [0.70.0] - 2026-10-16
|
|
11
31
|
|
|
12
32
|
### Added
|
|
@@ -6,12 +6,13 @@ description: Drive agent execution through an active assignment
|
|
|
6
6
|
user-invocable: true
|
|
7
7
|
allowed-tools:
|
|
8
8
|
- Bash(ace-assign:*)
|
|
9
|
+
- Bash(ace-lab:*)
|
|
9
10
|
- Bash(ace-bundle:*)
|
|
10
11
|
- Read
|
|
11
12
|
- Write
|
|
12
13
|
- AskUserQuestion
|
|
13
14
|
- Skill
|
|
14
|
-
argument-hint: "[assignment[@scope]]"
|
|
15
|
+
argument-hint: "[assignment[@scope] | --ordinary <context.json>]"
|
|
15
16
|
last_modified: 2026-04-23
|
|
16
17
|
source: ace-assign
|
|
17
18
|
skill:
|
|
@@ -23,6 +24,8 @@ skill:
|
|
|
23
24
|
|
|
24
25
|
Load and run `ace-bundle wfi://assign/drive` in the current project, then follow the loaded workflow as the source of truth and execute it end-to-end instead of only summarizing it.
|
|
25
26
|
|
|
27
|
+
When `--ordinary` or `ACE_ASSIGN_ORDINARY_CONTEXT` is present, follow the workflow’s ordinary installed branch before resolving local assignment state. Its canonical `ace-lab assignment drive` operations own status, start, progress, results and recovery; local commands below apply only outside that branch.
|
|
28
|
+
|
|
26
29
|
Hard stop rule:
|
|
27
30
|
|
|
28
31
|
- Do not stop after intermediate progress.
|
|
@@ -30,7 +33,7 @@ Hard stop rule:
|
|
|
30
33
|
- Treat `ace-assign status --assignment <id>@<root>` as the source of truth for fork completion; quiet terminal output is not enough reason to stop or declare a stall.
|
|
31
34
|
- If a prior terminal or drive session ended, re-enter from assignment state and continue from the next runnable work instead of depending on the old terminal handle.
|
|
32
35
|
- Before any final response, re-check pinned assignment status. If any runnable `pending` or `active` work remains, continue driving.
|
|
33
|
-
-
|
|
36
|
+
- Preserve unrelated dirty files; commit only task-owned changes.
|
|
34
37
|
- Use progress updates for partial status only.
|
|
35
38
|
- Return a final user-facing completion response only when the assignment is complete or the workflow reaches an explicit blocker/failure stop condition.
|
|
36
39
|
- If pending work remains runnable, continue driving.
|
|
@@ -3,6 +3,7 @@ name: assign-drive
|
|
|
3
3
|
description: Drive an ace-assign assignment until completion or an explicit blocker.
|
|
4
4
|
allowed-tools:
|
|
5
5
|
- Bash(ace-assign:*)
|
|
6
|
+
- Bash(ace-lab:*)
|
|
6
7
|
- Bash(ace-bundle:*)
|
|
7
8
|
- Read
|
|
8
9
|
- Write
|
|
@@ -27,6 +28,82 @@ Drive agent execution through an active assignment by continuously checking stat
|
|
|
27
28
|
- An active assignment exists (created via `ace-assign create` or `/as-assign-create`)
|
|
28
29
|
- Assignment has at least one pending or active step
|
|
29
30
|
|
|
31
|
+
## Ordinary installed execution
|
|
32
|
+
|
|
33
|
+
Select this branch **before resolving any local assignment** when invoked with
|
|
34
|
+
`--ordinary <context.json>` or when `ACE_ASSIGN_ORDINARY_CONTEXT` is set. Require
|
|
35
|
+
`ACE_ASSIGN_ORDINARY_PROJECT` and use the exact retained context path throughout.
|
|
36
|
+
Conflicting explicit and environment paths are an error. In this branch every
|
|
37
|
+
execution read and write uses `ace-lab assignment drive --project <project>
|
|
38
|
+
--context <context.json>` against the installed owner. The local assignment
|
|
39
|
+
commands below, `.current`, `.latest`, `fork-run`, and protected `work-on --project`
|
|
40
|
+
are not fallback routes.
|
|
41
|
+
|
|
42
|
+
1. Run `--operation status`. If `not_started`, run `--operation start` from the
|
|
43
|
+
actual native worker pane. A launch/send acknowledgement is not a start receipt.
|
|
44
|
+
Verify canonical status is `running` with the expected assignment, attempt,
|
|
45
|
+
scope, native binding and producer. Never adopt another pane's execution.
|
|
46
|
+
2. Run `--operation bootstrap --output <new-private-directory>`. Read the returned
|
|
47
|
+
accepted task/context/report paths and retain their exact manifest and file
|
|
48
|
+
digests in the work report. These bytes come from canonical prepared work;
|
|
49
|
+
mutable task files in a checkout cannot replace them. Resolve referenced
|
|
50
|
+
project workflows/guides with the selected installed packages and record each
|
|
51
|
+
effective path and digest. A missing or conflicting required asset is a blocker.
|
|
52
|
+
3. Run `--operation next`. Execute the returned current step, or start the returned
|
|
53
|
+
next step using `--operation step-start --step <number> --generation <observed>`.
|
|
54
|
+
The accepted root's children run in this already admitted worker; do not fork
|
|
55
|
+
another execution because a prepared root originated in a batch preset.
|
|
56
|
+
4. After executing a step, write its bounded UTF-8 report and run
|
|
57
|
+
`--operation step-finish --step <number> --report <path> --generation <observed>`.
|
|
58
|
+
On an unrecoverable step failure, use `--operation fail --message <reason>
|
|
59
|
+
--generation <observed>`. Read `next` again after every acknowledged mutation.
|
|
60
|
+
Do not mark an unexecuted step successful. Nested skills may perform the work,
|
|
61
|
+
but their local Assign bookkeeping must not replace these canonical operations.
|
|
62
|
+
5. When the scope is complete or failed, prepare an execution receipt and exact
|
|
63
|
+
artifact files using the receipt contract in the Lab usage documentation.
|
|
64
|
+
Its producer is the canonical start producer; HEAD is the actual committed
|
|
65
|
+
candidate; checks describe executed checks. Submit with `--operation result
|
|
66
|
+
--generation <observed> --receipt <json> --artifact <file>` in receipt order.
|
|
67
|
+
Submission is not terminal acceptance, merge, publication or installation.
|
|
68
|
+
6. Keep the worker's native process and checkout available until the supervisor
|
|
69
|
+
accepts the result through `--operation adopt` and canonical `status` becomes
|
|
70
|
+
terminal. The filesystem watch emits only a hint. Never infer completion from
|
|
71
|
+
a quiet pane, a hint file, an old message or a send acknowledgement.
|
|
72
|
+
|
|
73
|
+
Each mutation prints its retained original request path. After any lost reply,
|
|
74
|
+
read canonical `status`/`next` first and use `--operation replay --request <path>`
|
|
75
|
+
only for that original input. Preserve assignment, attempt, mutation, candidate
|
|
76
|
+
and pane identities. Never edit an original request's generation or bytes. For a
|
|
77
|
+
result/finish CAS refusal, reread canonical status. An accepted result replays its
|
|
78
|
+
exact original input; an accepted terminal outcome needs no new finish. If no
|
|
79
|
+
result/terminal was accepted and the attempt generation advanced, the original
|
|
80
|
+
CAS can no longer accept: result/finish may retain a separate request at the new
|
|
81
|
+
observed generation. The driver preserves all old inputs and performs these
|
|
82
|
+
checks; unavailable status remains a blocker. Changed content, project or owner
|
|
83
|
+
is not a generation retry, and never authorizes another worker.
|
|
84
|
+
|
|
85
|
+
A supervisor installs the `watch_command` returned by `ace-overseer ordinary`
|
|
86
|
+
exactly once in its native session, runs a harmless turn to persist it, and uses
|
|
87
|
+
exact-session resume thereafter. Do not recreate an intentionally removed watch.
|
|
88
|
+
A watch merely requests canonical inspection; recurring native progress audits
|
|
89
|
+
also find accepted results when a wake or reply is lost. Wrong project/attempt
|
|
90
|
+
hints carry no authority. Existing inbound Inbox settlement remains a separate
|
|
91
|
+
owner configuration: without it, nonempty Inbox references refuse completion.
|
|
92
|
+
|
|
93
|
+
After replacement of the coordinator process, read `--operation supervisor`.
|
|
94
|
+
A new coordinator uses `--operation adopt-supervisor --pane <actual-pane>
|
|
95
|
+
--generation <old-selection-generation> --previous-binding-sha256 <old-digest>`.
|
|
96
|
+
The installed owner authenticates its actual placement and positively verifies
|
|
97
|
+
that the original process generation ended. Live or unobservable old generations
|
|
98
|
+
refuse adoption. Replay the original adoption request after a lost reply. This
|
|
99
|
+
changes only the canonical supervisor selection; existing workers, uncertain
|
|
100
|
+
launches, attempts, pending results and mutation IDs remain intact. Then resume
|
|
101
|
+
canonical inspection and result adoption. Never re-register or relaunch workers
|
|
102
|
+
because the coordinator restarted.
|
|
103
|
+
|
|
104
|
+
**End of ordinary branch.** The remaining sections apply only to local assignment
|
|
105
|
+
execution; do not enter them from an ordinary installed context.
|
|
106
|
+
|
|
30
107
|
## Assignment Context Propagation
|
|
31
108
|
|
|
32
109
|
When working with multiple concurrent assignments, the active assignment is resolved in this order:
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Assign
|
|
5
|
+
module Atoms
|
|
6
|
+
# Closed candidate-purpose budgets. Reports and release gems retain their
|
|
7
|
+
# separate bounds; callers cannot select a larger wire/storage ceiling.
|
|
8
|
+
module CandidateLimits
|
|
9
|
+
BYTES = 128 * 1024 * 1024
|
|
10
|
+
PREPARED_BYTES = 64 * 1024 * 1024
|
|
11
|
+
REVIEW_SNAPSHOT_BYTES = 64 * 1024 * 1024
|
|
12
|
+
# Explicit finite path shape (PATH_MAX/NAME_MAX-sized) so a hostile
|
|
13
|
+
# tree entry cannot push unbounded path bytes into inventories,
|
|
14
|
+
# filesystem prefix creation or per-component scans.
|
|
15
|
+
PATH_BYTES = 4096
|
|
16
|
+
PATH_COMPONENT_BYTES = 255
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Assign
|
|
7
|
+
module Atoms
|
|
8
|
+
# Git's fixed cat-file batch framing. Owners select process, immutable
|
|
9
|
+
# repository, deadline and byte/count limits before invoking this codec.
|
|
10
|
+
module GitBlobBatch
|
|
11
|
+
OID = /\A[0-9a-f]{40}\z/
|
|
12
|
+
class Invalid < StandardError
|
|
13
|
+
attr_reader :reason
|
|
14
|
+
def initialize(reason)
|
|
15
|
+
@reason = reason
|
|
16
|
+
super("invalid Git blob batch #{reason}")
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.sizes(oids, output)
|
|
21
|
+
unless oids.is_a?(Array) && oids.all? { |oid| oid.is_a?(String) && OID.match?(oid) } && output.is_a?(String)
|
|
22
|
+
raise Invalid, :selector
|
|
23
|
+
end
|
|
24
|
+
lines = output.b.lines
|
|
25
|
+
raise Invalid, :count unless lines.size == oids.size
|
|
26
|
+
lines.zip(oids).map do |line, oid|
|
|
27
|
+
match = /\A([0-9a-f]{40}) blob (0|[1-9][0-9]*)\n\z/.match(line)
|
|
28
|
+
raise Invalid, :metadata unless match && match[1] == oid
|
|
29
|
+
match[2].to_i
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.decode(entries, output)
|
|
34
|
+
unless entries.is_a?(Array) && entries.all? { |entry|
|
|
35
|
+
entry.is_a?(Array) && entry.size == 2 && entry[0].is_a?(String) && OID.match?(entry[0]) &&
|
|
36
|
+
entry[1].is_a?(Integer) && entry[1] >= 0 } && output.is_a?(String)
|
|
37
|
+
raise Invalid, :selector
|
|
38
|
+
end
|
|
39
|
+
output = output.b
|
|
40
|
+
offset = 0
|
|
41
|
+
entries.map do |oid, size|
|
|
42
|
+
ending = output.index("\n", offset)
|
|
43
|
+
unless ending && ending - offset <= 127 && output.byteslice(offset, ending - offset) == "#{oid} blob #{size}"
|
|
44
|
+
raise Invalid, :header
|
|
45
|
+
end
|
|
46
|
+
offset = ending + 1
|
|
47
|
+
content = output.byteslice(offset, size)
|
|
48
|
+
unless content&.bytesize == size && output.byteslice(offset + size, 1) == "\n" &&
|
|
49
|
+
Digest::SHA1.new.update("blob #{size}\0").update(content).hexdigest == oid
|
|
50
|
+
raise Invalid, :bytes
|
|
51
|
+
end
|
|
52
|
+
offset += size + 1
|
|
53
|
+
content
|
|
54
|
+
end.tap { raise Invalid, :trailing unless offset == output.bytesize }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../atoms/git_blob_batch"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Assign
|
|
7
|
+
module Authority
|
|
8
|
+
# Same cat-file batch dialect/codec as the canonical journal. Chunked
|
|
9
|
+
# bodies avoid retaining the entire expanded tree a second time.
|
|
10
|
+
module CandidateBlobBatch
|
|
11
|
+
BATCH_BYTES = 8 * 1024 * 1024
|
|
12
|
+
BATCH_OBJECTS = 512
|
|
13
|
+
MAX_OBJECTS = 20_000
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def candidate_blob_sizes(repository, deadline, oids)
|
|
18
|
+
unless oids.is_a?(Array) && oids.size <= MAX_OBJECTS &&
|
|
19
|
+
oids.all? { |oid| oid.is_a?(String) && self.class::SHA.match?(oid) }
|
|
20
|
+
reject!("Candidate blob inventory is invalid or oversized")
|
|
21
|
+
end
|
|
22
|
+
oids.each_slice(64).flat_map do |slice|
|
|
23
|
+
raw = git(repository, deadline, "cat-file", "--batch-check", stdin_data: slice.join("\n") + "\n", limit: slice.size * 128)
|
|
24
|
+
Atoms::GitBlobBatch.sizes(slice, raw)
|
|
25
|
+
end
|
|
26
|
+
rescue Atoms::GitBlobBatch::Invalid => error
|
|
27
|
+
reject!("Candidate blob batch #{error.reason} differs")
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def each_candidate_blob(repository, deadline, entries)
|
|
31
|
+
batch = []
|
|
32
|
+
bytes = 0
|
|
33
|
+
entries.each do |entry|
|
|
34
|
+
if !batch.empty? && (batch.size >= BATCH_OBJECTS || bytes + entry.last > BATCH_BYTES)
|
|
35
|
+
yield_candidate_blob_batch(repository, deadline, batch) { |oid, content| yield oid, content }
|
|
36
|
+
batch = []
|
|
37
|
+
bytes = 0
|
|
38
|
+
end
|
|
39
|
+
batch << entry
|
|
40
|
+
bytes += entry.last
|
|
41
|
+
end
|
|
42
|
+
yield_candidate_blob_batch(repository, deadline, batch) { |oid, content| yield oid, content } unless batch.empty?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def yield_candidate_blob_batch(repository, deadline, entries)
|
|
46
|
+
limit = entries.sum { |_oid, size| size + 128 }
|
|
47
|
+
raw = git(repository, deadline, "cat-file", "--batch", stdin_data: entries.map { |oid, _size| "#{oid}\n" }.join, limit: limit)
|
|
48
|
+
Atoms::GitBlobBatch.decode(entries, raw).zip(entries).each { |content, (oid, _size)| yield oid, content }
|
|
49
|
+
rescue Atoms::GitBlobBatch::Invalid => error
|
|
50
|
+
reject!("Candidate blob batch #{error.reason} differs")
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Assign
|
|
5
|
+
module Authority
|
|
6
|
+
# Compare retained Git objects in a private bare repository. Historical
|
|
7
|
+
# links are object bytes, never paths to materialize or follow. This view
|
|
8
|
+
# supplies evidence; it does not classify a change or grant acceptance.
|
|
9
|
+
# Host: CandidateTransfer supplies bounded Git/admission, byte encoding,
|
|
10
|
+
# private-root storage, monotonic deadlines and rejection helpers.
|
|
11
|
+
module CandidateHeadComparison
|
|
12
|
+
class ReadView
|
|
13
|
+
def initialize(owner:, repository:, reviewed:, candidate:, deadline:)
|
|
14
|
+
@owner, @repository, @reviewed, @candidate, @deadline = owner, repository, reviewed, candidate, deadline
|
|
15
|
+
@thread, @active = Thread.current, true
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def close = @active = false
|
|
19
|
+
|
|
20
|
+
def context
|
|
21
|
+
unless @active && Thread.current.equal?(@thread)
|
|
22
|
+
raise AttemptErrors::EvidenceUnavailable, "head comparison escaped its source lifetime"
|
|
23
|
+
end
|
|
24
|
+
@owner.send(:head_comparison_context, @repository, @deadline, @reviewed, @candidate)
|
|
25
|
+
rescue Timeout::Error
|
|
26
|
+
raise AttemptErrors::EvidenceUnavailable, "head comparison deadline expired"
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def with_head_comparison(before:, after:, before_base:, after_base:)
|
|
31
|
+
raise ArgumentError, "head comparison requires a block" unless block_given?
|
|
32
|
+
[before, after].each do |input|
|
|
33
|
+
unless input.is_a?(Hash) && input.keys.sort == %i[bytes head sha256 size tree] &&
|
|
34
|
+
input[:tree].is_a?(String) && input[:tree].match?(self.class::SHA)
|
|
35
|
+
reject!("Head comparison candidate binding is invalid")
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
[before_base, after_base].each do |base|
|
|
39
|
+
reject!("Head comparison base is invalid") unless base.is_a?(String) && base.match?(self.class::SHA)
|
|
40
|
+
end
|
|
41
|
+
deadline = monotonic + self.class::DEADLINE
|
|
42
|
+
# Each complete bundle is independently verified against an empty
|
|
43
|
+
# repository before either can contribute objects to the comparison.
|
|
44
|
+
admit(**before.reject { |key, _| key == :tree }, deadline: deadline) do |_old_repo, _old_deadline, reviewed|
|
|
45
|
+
reject!("Reviewed tree differs from accepted tree") unless reviewed.fetch("tree") == before[:tree]
|
|
46
|
+
admit(**after.reject { |key, _| key == :tree }, deadline: deadline) do |repository, _new_deadline, candidate|
|
|
47
|
+
reject!("Candidate tree differs from accepted tree") unless candidate.fetch("tree") == after[:tree]
|
|
48
|
+
Tempfile.create(["reviewed-", ".bundle"], @root) do |file|
|
|
49
|
+
file.binmode
|
|
50
|
+
file.write(reviewed.fetch("bundle")); file.flush
|
|
51
|
+
git(repository, deadline, "fetch", "--no-tags", file.path, "#{before[:head]}:refs/heads/reviewed")
|
|
52
|
+
reader = ReadView.new(owner: self, repository: repository,
|
|
53
|
+
reviewed: {"head" => before[:head], "base" => before_base, "tree" => before[:tree]},
|
|
54
|
+
candidate: {"head" => after[:head], "base" => after_base, "tree" => after[:tree]}, deadline: deadline)
|
|
55
|
+
yield reader
|
|
56
|
+
ensure
|
|
57
|
+
reader&.close
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def head_comparison_context(repository, deadline, reviewed, candidate)
|
|
66
|
+
context = {"schema" => "ace.candidate-head-comparison/v1", "reviewed" => reviewed,
|
|
67
|
+
"candidate" => candidate, "inventories" => {}, "objects" => {}, "changes" => {}}
|
|
68
|
+
remaining = self.class::MAX_REVIEW_BYTES
|
|
69
|
+
{"reviewed_head" => reviewed["head"], "reviewed_base" => reviewed["base"],
|
|
70
|
+
"candidate_head" => candidate["head"], "candidate_base" => candidate["base"]}.each do |label, commit|
|
|
71
|
+
git(repository, deadline, "cat-file", "-e", "#{commit}^{commit}")
|
|
72
|
+
tree = git(repository, deadline, "rev-parse", "#{commit}^{tree}").strip
|
|
73
|
+
inventory = head_comparison_inventory(repository, deadline, tree)
|
|
74
|
+
remaining -= JSON.generate(inventory).bytesize
|
|
75
|
+
reject!("Complete head comparison context is oversized") if remaining.negative?
|
|
76
|
+
context.fetch("inventories")[label] = {"tree" => tree, "entries" => inventory}
|
|
77
|
+
end
|
|
78
|
+
oids = context.fetch("inventories").values.flat_map { |inventory| inventory.fetch("entries").map { |entry| entry.fetch("oid") } }.uniq
|
|
79
|
+
sizes = oids.zip(candidate_blob_sizes(repository, deadline, oids)).to_h
|
|
80
|
+
reject!("Complete head comparison object content is oversized") if sizes.values.sum > remaining
|
|
81
|
+
each_candidate_blob(repository, deadline, sizes.to_a) do |oid, content|
|
|
82
|
+
object = {"size" => content.bytesize, "sha256" => Digest::SHA256.hexdigest(content), "content" => review_bytes(content)}
|
|
83
|
+
remaining -= JSON.generate(object).bytesize
|
|
84
|
+
reject!("Complete head comparison context is oversized") if remaining.negative?
|
|
85
|
+
context.fetch("objects")[oid] = object
|
|
86
|
+
end
|
|
87
|
+
{"candidate_delta" => [reviewed["head"], candidate["head"]],
|
|
88
|
+
"base_delta" => [reviewed["base"], candidate["base"]],
|
|
89
|
+
"original_scope" => [reviewed["base"], reviewed["head"]],
|
|
90
|
+
"current_scope" => [candidate["base"], candidate["head"]]}.each do |label, revisions|
|
|
91
|
+
delta = head_comparison_delta(repository, deadline, revisions, remaining)
|
|
92
|
+
remaining -= JSON.generate(delta).bytesize
|
|
93
|
+
reject!("Complete head comparison context is oversized") if remaining.negative?
|
|
94
|
+
context.fetch("changes")[label] = delta
|
|
95
|
+
end
|
|
96
|
+
# Include structural JSON overhead in the final bound as well as all
|
|
97
|
+
# original bytes. No partial inventory or truncated patch is returned.
|
|
98
|
+
reject!("Complete head comparison context is oversized") if JSON.generate(context).bytesize > self.class::MAX_REVIEW_BYTES
|
|
99
|
+
context
|
|
100
|
+
rescue AttemptErrors::ReceiptRejected => error
|
|
101
|
+
reject!("Head comparison read failed: #{error.message}")
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def head_comparison_inventory(repository, deadline, tree)
|
|
105
|
+
entries = git(repository, deadline, "ls-tree", "-r", "-z", tree, limit: self.class::MAX_BYTES).b.split("\0")
|
|
106
|
+
reject!("Complete head comparison inventory is oversized") if entries.size > self.class::MAX_OBJECTS
|
|
107
|
+
entries.map do |entry|
|
|
108
|
+
metadata, path = entry.split("\t", 2)
|
|
109
|
+
mode, kind, oid = metadata.to_s.split(" ")
|
|
110
|
+
# Inventory entries carry whole paths only; bound their bytes
|
|
111
|
+
# before JSON encoding or delta cross-checks consume them.
|
|
112
|
+
reject!("Complete head comparison path exceeds bounded length") unless path && !path.empty? &&
|
|
113
|
+
path.bytesize <= Atoms::CandidateLimits::PATH_BYTES
|
|
114
|
+
unless kind == "blob" && %w[100644 100755 120000].include?(mode) && oid&.match?(self.class::SHA)
|
|
115
|
+
reject!("Complete head comparison contains an unsupported Git entry")
|
|
116
|
+
end
|
|
117
|
+
{"path" => review_bytes(path), "mode" => mode, "oid" => oid}
|
|
118
|
+
end
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def head_comparison_delta(repository, deadline, revisions, remaining)
|
|
122
|
+
options = ["--no-ext-diff", "--no-textconv", "--no-renames", "--ignore-submodules=none"]
|
|
123
|
+
patch = git(repository, deadline, "diff", *options, "--binary", "--full-index", "--src-prefix=a/", "--dst-prefix=b/",
|
|
124
|
+
*revisions, "--", limit: [remaining, 1].max).b
|
|
125
|
+
paths = git(repository, deadline, "diff", *options, "--name-only", "-z", *revisions, "--", limit: [remaining, 1].max).b.split("\0")
|
|
126
|
+
stats = git(repository, deadline, "diff", *options, "--numstat", "-z", *revisions, "--", limit: [remaining, 1].max).b.split("\0")
|
|
127
|
+
binary = stats.filter_map do |entry|
|
|
128
|
+
added, removed, path = entry.split("\t", 3)
|
|
129
|
+
reject!("Complete head comparison diff inventory is invalid") unless path && paths.include?(path)
|
|
130
|
+
path if added == "-" || removed == "-"
|
|
131
|
+
end
|
|
132
|
+
{"from" => revisions.first, "to" => revisions.last, "patch" => review_bytes(patch),
|
|
133
|
+
"patch_sha256" => Digest::SHA256.hexdigest(patch), "paths" => paths.map { |path| review_bytes(path) },
|
|
134
|
+
"binary_paths" => binary.map { |path| review_bytes(path) }}
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
@@ -6,7 +6,11 @@ require "fileutils"
|
|
|
6
6
|
require "tmpdir"
|
|
7
7
|
require "tempfile"
|
|
8
8
|
require "ace/herdr/molecules/bounded_process"
|
|
9
|
+
require "ace/review/atoms/campaign_contract"
|
|
9
10
|
require_relative "private_directory"
|
|
11
|
+
require_relative "../atoms/candidate_limits"
|
|
12
|
+
require_relative "candidate_head_comparison"
|
|
13
|
+
require_relative "candidate_blob_batch"
|
|
10
14
|
|
|
11
15
|
module Ace
|
|
12
16
|
module Assign
|
|
@@ -14,8 +18,17 @@ module Ace
|
|
|
14
18
|
# Untrusted bundles enter a clean bare quarantine. Only complete verified
|
|
15
19
|
# objects are retained; no uploader configuration or worktree is read.
|
|
16
20
|
class CandidateTransfer
|
|
17
|
-
|
|
18
|
-
|
|
21
|
+
include CandidateHeadComparison
|
|
22
|
+
include CandidateBlobBatch
|
|
23
|
+
MAX_BYTES = Atoms::CandidateLimits::BYTES
|
|
24
|
+
MAX_REVIEW_BYTES = Ace::Review::Atoms::CampaignContract::MAX_HEAD_CONTEXT_BYTES
|
|
25
|
+
# Measured on the real 82,809,675-byte candidate (9,203 entries,
|
|
26
|
+
# 71,583,200 expanded bytes): two full admits alone need ~14s of
|
|
27
|
+
# bounded Git work and the complete comparison ~23s on a loaded host
|
|
28
|
+
# (evidence in .ace-tasks/8xf.t.ei9-wave-3 evidence). 30s failed
|
|
29
|
+
# closed under legitimate load; 60s keeps a finite secondary guard
|
|
30
|
+
# with measured headroom. Inputs stay byte/count-bounded separately.
|
|
31
|
+
DEADLINE = 60
|
|
19
32
|
SHA = /\A[0-9a-f]{40}\z/
|
|
20
33
|
|
|
21
34
|
class CampaignReadView
|
|
@@ -165,20 +178,29 @@ module Ace
|
|
|
165
178
|
File.chmod(0700, destination)
|
|
166
179
|
begin
|
|
167
180
|
entries = git(repository, deadline, "ls-tree", "-r", "-z", head, limit: MAX_BYTES).b.split("\0")
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
entries.each do |entry|
|
|
181
|
+
reject!("Candidate file inventory is oversized") if entries.size > MAX_OBJECTS
|
|
182
|
+
parsed = entries.map do |entry|
|
|
171
183
|
metadata, path = entry.split("\t", 2)
|
|
172
184
|
mode, kind, oid = metadata.to_s.split(" ")
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
185
|
+
# Total path bytes are bounded before any component or
|
|
186
|
+
# filesystem prefix work consumes them.
|
|
187
|
+
reject!("Candidate path exceeds bounded length") unless path && !path.start_with?("/") &&
|
|
188
|
+
path.bytesize <= Atoms::CandidateLimits::PATH_BYTES
|
|
189
|
+
parts = path.split("/", -1)
|
|
190
|
+
unless parts.none? { |part| part.empty? || part.bytesize > Atoms::CandidateLimits::PATH_COMPONENT_BYTES ||
|
|
191
|
+
%w[. .. .git].include?(part.downcase) } &&
|
|
192
|
+
kind == "blob" && %w[100644 100755 120000].include?(mode) && oid&.match?(SHA)
|
|
177
193
|
reject!("Candidate contains unsafe filesystem entry")
|
|
178
194
|
end
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
195
|
+
[path, mode, oid]
|
|
196
|
+
end
|
|
197
|
+
by_oid = parsed.group_by(&:last)
|
|
198
|
+
sizes = by_oid.keys.zip(candidate_blob_sizes(repository, deadline, by_oid.keys)).to_h
|
|
199
|
+
reject!("Candidate expanded content is oversized") if parsed.sum { |_path, _mode, oid| sizes.fetch(oid) } > MAX_BYTES
|
|
200
|
+
links = []
|
|
201
|
+
ordered = parsed.each
|
|
202
|
+
each_candidate_blob(repository, deadline, parsed.map { |_path, _mode, oid| [oid, sizes.fetch(oid)] }) do |oid, content|
|
|
203
|
+
path, mode, _oid = ordered.next
|
|
182
204
|
target = File.join(destination, path)
|
|
183
205
|
FileUtils.mkdir_p(File.dirname(target), mode: 0700)
|
|
184
206
|
if mode == "120000"
|
|
@@ -242,7 +264,7 @@ module Ace
|
|
|
242
264
|
result = materialize(bytes: bytes, sha256: sha256, size: size, head: head, tree: tree, root: root) do |path, mode, oid, content|
|
|
243
265
|
entry = JSON.generate("path" => review_bytes(path), "mode" => mode, "oid" => oid, "content" => review_bytes(content))
|
|
244
266
|
separator = first ? "" : ","
|
|
245
|
-
reject!("Complete candidate review snapshot is oversized") if subject.bytesize + separator.bytesize + entry.bytesize + 2 >
|
|
267
|
+
reject!("Complete candidate review snapshot is oversized") if subject.bytesize + separator.bytesize + entry.bytesize + 2 > Atoms::CandidateLimits::REVIEW_SNAPSHOT_BYTES
|
|
246
268
|
subject << separator << entry
|
|
247
269
|
first = false
|
|
248
270
|
end
|
|
@@ -256,19 +278,26 @@ module Ace
|
|
|
256
278
|
def prepared_files(repository:, deadline:, head:)
|
|
257
279
|
entries = git(repository, deadline, "ls-tree", "-r", "-z", head, limit: MAX_BYTES).b.split("\0")
|
|
258
280
|
reject!("Prepared file inventory is oversized") unless entries.size.between?(4, 4096)
|
|
259
|
-
|
|
260
|
-
files = entries.to_h do |entry|
|
|
281
|
+
parsed = entries.map do |entry|
|
|
261
282
|
metadata, path = entry.split("\t", 2)
|
|
262
283
|
mode, kind, oid = metadata.to_s.split(" ")
|
|
263
|
-
|
|
264
|
-
|
|
284
|
+
reject!("Prepared tree path exceeds bounded length") unless path && !path.start_with?("/") &&
|
|
285
|
+
path.bytesize <= Atoms::CandidateLimits::PATH_BYTES
|
|
286
|
+
unless mode == "100644" && kind == "blob" && oid&.match?(SHA) &&
|
|
287
|
+
path.split("/", -1).none? { |part| part.empty? || part.bytesize > Atoms::CandidateLimits::PATH_COMPONENT_BYTES ||
|
|
288
|
+
%w[. .. .git].include?(part.downcase) }
|
|
265
289
|
reject!("Prepared tree contains unsafe entry")
|
|
266
290
|
end
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
291
|
+
[path, oid]
|
|
292
|
+
end
|
|
293
|
+
by_oid = parsed.group_by(&:last)
|
|
294
|
+
sizes = by_oid.keys.zip(candidate_blob_sizes(repository, deadline, by_oid.keys)).to_h
|
|
295
|
+
reject!("Prepared text is oversized") unless sizes.values.all? { |size| size.between?(1, 1024 * 1024) }
|
|
296
|
+
reject!("Prepared expanded content is oversized") if parsed.sum { |_path, oid| sizes.fetch(oid) } > Atoms::CandidateLimits::PREPARED_BYTES
|
|
297
|
+
files = {}
|
|
298
|
+
ordered = parsed.each
|
|
299
|
+
each_candidate_blob(repository, deadline, parsed.map { |_path, oid| [oid, sizes.fetch(oid)] }) do |_oid, content|
|
|
300
|
+
files[ordered.next.first] = content
|
|
272
301
|
end
|
|
273
302
|
reject!("Prepared inventory has duplicate paths") unless files.size == entries.size
|
|
274
303
|
files
|
|
@@ -289,18 +318,19 @@ module Ace
|
|
|
289
318
|
PrivateDirectory.verify!(@root)
|
|
290
319
|
end
|
|
291
320
|
|
|
292
|
-
def git(repository, deadline, *arguments, limit: 65_536)
|
|
321
|
+
def git(repository, deadline, *arguments, limit: 65_536, stdin_data: "")
|
|
293
322
|
remaining = deadline - monotonic
|
|
294
323
|
raise Timeout::Error if remaining <= 0
|
|
295
324
|
# Git variables may redirect object/config storage even in a clean
|
|
296
325
|
# repository. Strip every inherited Git setting and global config.
|
|
297
326
|
environment = ENV.keys.to_h { |key| [key, nil] }
|
|
298
327
|
environment.merge!("GIT_CONFIG_NOSYSTEM" => "1", "GIT_CONFIG_GLOBAL" => "/dev/null",
|
|
328
|
+
"GIT_ATTR_NOSYSTEM" => "1",
|
|
299
329
|
"GIT_TERMINAL_PROMPT" => "0", "GIT_ALLOW_PROTOCOL" => "file",
|
|
300
330
|
"GIT_NO_REPLACE_OBJECTS" => "1", "PATH" => "/usr/bin:/bin", "LC_ALL" => "C")
|
|
301
331
|
result = Herdr::Molecules::BoundedProcess.call([environment, "/usr/bin/git", "-C", repository,
|
|
302
|
-
"-c", "core.hooksPath=/dev/null", "-c", "protocol.file.allow=always", *arguments],
|
|
303
|
-
stdin_data:
|
|
332
|
+
"-c", "core.hooksPath=/dev/null", "-c", "core.attributesFile=/dev/null", "-c", "protocol.file.allow=always", *arguments],
|
|
333
|
+
stdin_data: stdin_data, timeout_s: remaining, output_limit: limit)
|
|
304
334
|
reject!("Candidate Git verification failed") unless result.status.success? && !result.oversized
|
|
305
335
|
result.stdout
|
|
306
336
|
end
|