ace-assign 0.71.0 → 0.73.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.
Files changed (195) hide show
  1. checksums.yaml +4 -4
  2. data/.ace-defaults/assign/catalog/steps/auto-merge.step.yml +8 -8
  3. data/.ace-defaults/assign/catalog/steps/create-pr.step.yml +1 -1
  4. data/.ace-defaults/assign/catalog/steps/mark-pr-ready.step.yml +1 -1
  5. data/.ace-defaults/assign/config.yml +0 -13
  6. data/.ace-defaults/assign/presets/fix-bug.yml +1 -1
  7. data/.ace-defaults/assign/presets/work-on-task-ace-development.yml +1 -1
  8. data/.ace-defaults/assign/presets/work-on-task-auto-merge.yml +1 -1
  9. data/.ace-defaults/assign/presets/work-on-task.yml +1 -1
  10. data/CHANGELOG.md +3 -2119
  11. data/README.md +58 -31
  12. data/docs/exit-codes.md +36 -44
  13. data/docs/getting-started.md +9 -4
  14. data/docs/usage.md +129 -522
  15. data/handbook/skills/as-assign-drive/SKILL.md +1 -4
  16. data/handbook/workflow-instructions/assign/drive.wf.md +47 -195
  17. data/handbook/workflow-instructions/assign/prepare.wf.md +1 -1
  18. data/handbook/workflow-instructions/assign/verify-test-suite.wf.md +8 -2
  19. data/lib/ace/assign/cli/commands/assignment_target.rb +2 -15
  20. data/lib/ace/assign/cli/commands/create.rb +1 -8
  21. data/lib/ace/assign/cli/commands/fail.rb +0 -6
  22. data/lib/ace/assign/cli/commands/finish.rb +2 -23
  23. data/lib/ace/assign/cli/commands/fork_session.rb +0 -1
  24. data/lib/ace/assign/cli/commands/select.rb +0 -1
  25. data/lib/ace/assign/cli/commands/start.rb +0 -2
  26. data/lib/ace/assign/cli/commands/status.rb +1 -31
  27. data/lib/ace/assign/cli/commands/step.rb +1 -3
  28. data/lib/ace/assign/cli.rb +1 -85
  29. data/lib/ace/assign/errors.rb +8 -80
  30. data/lib/ace/assign/models/assignment.rb +5 -28
  31. data/lib/ace/assign/molecules/assignment_manager.rb +0 -26
  32. data/lib/ace/assign/molecules/fork_session_launcher.rb +4 -12
  33. data/lib/ace/assign/molecules/lifecycle_exclusion.rb +15 -112
  34. data/lib/ace/assign/organisms/assignment_executor.rb +0 -6
  35. data/lib/ace/assign/organisms/task_assignment_creator.rb +3 -8
  36. data/lib/ace/assign/version.rb +1 -1
  37. data/lib/ace/assign.rb +0 -6
  38. metadata +4 -176
  39. data/.ace-defaults/assign/presets/close-delivery.yml +0 -57
  40. data/.ace-defaults/assign/presets/deliver-release.yml +0 -60
  41. data/.ace-defaults/assign/presets/integrate-batch.yml +0 -72
  42. data/.ace-defaults/assign/presets/supervise-delivery.yml +0 -69
  43. data/docs/protected-authority.md +0 -282
  44. data/exe/ace-scope-ready +0 -55
  45. data/lib/ace/assign/atoms/assignment_scope.rb +0 -58
  46. data/lib/ace/assign/atoms/attempt_state_machine.rb +0 -84
  47. data/lib/ace/assign/atoms/candidate_limits.rb +0 -20
  48. data/lib/ace/assign/atoms/delivery_parameters.rb +0 -19
  49. data/lib/ace/assign/atoms/evidence_digest.rb +0 -78
  50. data/lib/ace/assign/atoms/fixed_merge_schemas.rb +0 -398
  51. data/lib/ace/assign/atoms/git_blob_batch.rb +0 -59
  52. data/lib/ace/assign/authority/assignment_definition.rb +0 -35
  53. data/lib/ace/assign/authority/assignment_inventory.rb +0 -290
  54. data/lib/ace/assign/authority/campaign_consumer_policy.rb +0 -100
  55. data/lib/ace/assign/authority/campaign_execution.rb +0 -119
  56. data/lib/ace/assign/authority/campaign_round_transfer.rb +0 -82
  57. data/lib/ace/assign/authority/candidate_blob_batch.rb +0 -55
  58. data/lib/ace/assign/authority/candidate_head_comparison.rb +0 -139
  59. data/lib/ace/assign/authority/candidate_transfer.rb +0 -357
  60. data/lib/ace/assign/authority/cleanup_result_protection.rb +0 -53
  61. data/lib/ace/assign/authority/client.rb +0 -257
  62. data/lib/ace/assign/authority/command_execution.rb +0 -180
  63. data/lib/ace/assign/authority/deployment.rb +0 -627
  64. data/lib/ace/assign/authority/deployment_history.rb +0 -146
  65. data/lib/ace/assign/authority/endcap.rb +0 -457
  66. data/lib/ace/assign/authority/endcap_attempts.rb +0 -274
  67. data/lib/ace/assign/authority/endcap_campaign_results.rb +0 -232
  68. data/lib/ace/assign/authority/endcap_campaign_rounds.rb +0 -266
  69. data/lib/ace/assign/authority/endcap_contexts.rb +0 -23
  70. data/lib/ace/assign/authority/endcap_inboxes.rb +0 -37
  71. data/lib/ace/assign/authority/endcap_prepared_work.rb +0 -80
  72. data/lib/ace/assign/authority/endcap_publication.rb +0 -41
  73. data/lib/ace/assign/authority/endcap_results.rb +0 -398
  74. data/lib/ace/assign/authority/endcap_review_requests.rb +0 -183
  75. data/lib/ace/assign/authority/endcap_reviews.rb +0 -130
  76. data/lib/ace/assign/authority/endcap_service_context.rb +0 -38
  77. data/lib/ace/assign/authority/endcap_service_settlement.rb +0 -152
  78. data/lib/ace/assign/authority/evidence_transfer.rb +0 -67
  79. data/lib/ace/assign/authority/execution_scope_observation.rb +0 -903
  80. data/lib/ace/assign/authority/fixed_publication_lifecycle.rb +0 -176
  81. data/lib/ace/assign/authority/fixed_service_lifecycle.rb +0 -594
  82. data/lib/ace/assign/authority/inbox_context_original.rb +0 -113
  83. data/lib/ace/assign/authority/launch_attempt_consumers.rb +0 -51
  84. data/lib/ace/assign/authority/launch_campaign_registration.rb +0 -214
  85. data/lib/ace/assign/authority/launch_control_channel.rb +0 -313
  86. data/lib/ace/assign/authority/launch_control_exclusions.rb +0 -127
  87. data/lib/ace/assign/authority/launch_driver.rb +0 -393
  88. data/lib/ace/assign/authority/launch_input_inhibition.rb +0 -132
  89. data/lib/ace/assign/authority/launch_lifecycle.rb +0 -997
  90. data/lib/ace/assign/authority/launch_prepared_work.rb +0 -122
  91. data/lib/ace/assign/authority/launch_review.rb +0 -91
  92. data/lib/ace/assign/authority/launch_scope_abort.rb +0 -81
  93. data/lib/ace/assign/authority/launch_scope_admission.rb +0 -215
  94. data/lib/ace/assign/authority/launch_scope_close.rb +0 -146
  95. data/lib/ace/assign/authority/launch_scope_parent.rb +0 -44
  96. data/lib/ace/assign/authority/launch_scope_release.rb +0 -859
  97. data/lib/ace/assign/authority/launch_steering.rb +0 -353
  98. data/lib/ace/assign/authority/launch_stop.rb +0 -254
  99. data/lib/ace/assign/authority/maintenance_workspace_retirement.rb +0 -229
  100. data/lib/ace/assign/authority/ordinary_attempt_start.rb +0 -137
  101. data/lib/ace/assign/authority/ordinary_client.rb +0 -70
  102. data/lib/ace/assign/authority/ordinary_completion.rb +0 -104
  103. data/lib/ace/assign/authority/ordinary_effect_settlement.rb +0 -118
  104. data/lib/ace/assign/authority/ordinary_lifecycle.rb +0 -227
  105. data/lib/ace/assign/authority/ordinary_merge_evidence.rb +0 -81
  106. data/lib/ace/assign/authority/ordinary_peer.rb +0 -54
  107. data/lib/ace/assign/authority/ordinary_progress.rb +0 -162
  108. data/lib/ace/assign/authority/ordinary_queue.rb +0 -115
  109. data/lib/ace/assign/authority/ordinary_release_artifacts.rb +0 -143
  110. data/lib/ace/assign/authority/ordinary_results.rb +0 -206
  111. data/lib/ace/assign/authority/ordinary_review_evidence.rb +0 -180
  112. data/lib/ace/assign/authority/ordinary_reviews.rb +0 -214
  113. data/lib/ace/assign/authority/ordinary_server.rb +0 -75
  114. data/lib/ace/assign/authority/ordinary_supervisor.rb +0 -99
  115. data/lib/ace/assign/authority/original_inbox_selection.rb +0 -41
  116. data/lib/ace/assign/authority/posix_acl.rb +0 -59
  117. data/lib/ace/assign/authority/prepared_input.rb +0 -130
  118. data/lib/ace/assign/authority/prepared_queue.rb +0 -185
  119. data/lib/ace/assign/authority/prepared_task_context.rb +0 -116
  120. data/lib/ace/assign/authority/prepared_work.rb +0 -309
  121. data/lib/ace/assign/authority/prepared_worker.rb +0 -69
  122. data/lib/ace/assign/authority/private_directory.rb +0 -33
  123. data/lib/ace/assign/authority/protected_assignment_context.rb +0 -226
  124. data/lib/ace/assign/authority/readiness_hook.rb +0 -120
  125. data/lib/ace/assign/authority/receipt_transfer.rb +0 -48
  126. data/lib/ace/assign/authority/release_artifact_manifest.rb +0 -91
  127. data/lib/ace/assign/authority/retained_input.rb +0 -53
  128. data/lib/ace/assign/authority/router.rb +0 -81
  129. data/lib/ace/assign/authority/server.rb +0 -131
  130. data/lib/ace/assign/authority/service_cleanup_dispatch.rb +0 -88
  131. data/lib/ace/assign/authority/service_cleanup_evidence.rb +0 -180
  132. data/lib/ace/assign/authority/service_delivery_evidence.rb +0 -101
  133. data/lib/ace/assign/authority/service_evidence.rb +0 -286
  134. data/lib/ace/assign/authority/service_publication_evidence.rb +0 -110
  135. data/lib/ace/assign/authority/socket_server.rb +0 -255
  136. data/lib/ace/assign/authority/task_context_entry.rb +0 -32
  137. data/lib/ace/assign/authority/transfer_codec.rb +0 -189
  138. data/lib/ace/assign/cli/commands/attempt/base.rb +0 -84
  139. data/lib/ace/assign/cli/commands/attempt/evidence.rb +0 -28
  140. data/lib/ace/assign/cli/commands/attempt/finish.rb +0 -51
  141. data/lib/ace/assign/cli/commands/attempt/reconcile.rb +0 -49
  142. data/lib/ace/assign/cli/commands/attempt/start.rb +0 -45
  143. data/lib/ace/assign/cli/commands/attempt/status.rb +0 -54
  144. data/lib/ace/assign/cli/commands/authority/inbox_context_principal.rb +0 -25
  145. data/lib/ace/assign/cli/commands/authority/inbox_context_selection.rb +0 -28
  146. data/lib/ace/assign/cli/commands/authority/launch.rb +0 -97
  147. data/lib/ace/assign/cli/commands/authority/serve.rb +0 -31
  148. data/lib/ace/assign/cli/commands/authority/status.rb +0 -26
  149. data/lib/ace/assign/cli/commands/authority/task_context.rb +0 -31
  150. data/lib/ace/assign/cli/commands/authority/task_context_principal.rb +0 -25
  151. data/lib/ace/assign/cli/commands/authority/task_context_selection.rb +0 -30
  152. data/lib/ace/assign/cli/commands/authority/terminate.rb +0 -29
  153. data/lib/ace/assign/cli/commands/authority/worker.rb +0 -23
  154. data/lib/ace/assign/cli/commands/campaign_export_result.rb +0 -55
  155. data/lib/ace/assign/cli/commands/campaign_record_round.rb +0 -57
  156. data/lib/ace/assign/cli/commands/delivery.rb +0 -107
  157. data/lib/ace/assign/cli/commands/inbox_bind.rb +0 -34
  158. data/lib/ace/assign/cli/commands/protected_submission.rb +0 -61
  159. data/lib/ace/assign/cli/commands/resume.rb +0 -38
  160. data/lib/ace/assign/cli/commands/submit_candidate.rb +0 -34
  161. data/lib/ace/assign/cli/commands/submit_result.rb +0 -61
  162. data/lib/ace/assign/models/attempt.rb +0 -178
  163. data/lib/ace/assign/models/attempt_binding.rb +0 -116
  164. data/lib/ace/assign/models/evidence_event.rb +0 -86
  165. data/lib/ace/assign/models/execution_receipt.rb +0 -139
  166. data/lib/ace/assign/molecules/attempt_reconciler.rb +0 -127
  167. data/lib/ace/assign/molecules/attempt_store.rb +0 -201
  168. data/lib/ace/assign/molecules/canonical_attempt_state.rb +0 -151
  169. data/lib/ace/assign/molecules/canonical_evidence.rb +0 -170
  170. data/lib/ace/assign/molecules/canonical_read_commands.rb +0 -84
  171. data/lib/ace/assign/molecules/canonical_read_snapshot.rb +0 -313
  172. data/lib/ace/assign/molecules/evidence_calculator.rb +0 -237
  173. data/lib/ace/assign/molecules/evidence_journal.rb +0 -1899
  174. data/lib/ace/assign/molecules/execution_identity_resolver.rb +0 -137
  175. data/lib/ace/assign/molecules/execution_scope_lineage.rb +0 -345
  176. data/lib/ace/assign/molecules/fixed_effect_reference.rb +0 -31
  177. data/lib/ace/assign/molecules/installed_journal_read.rb +0 -143
  178. data/lib/ace/assign/molecules/journal_input_inhibition.rb +0 -78
  179. data/lib/ace/assign/molecules/journal_mutation.rb +0 -274
  180. data/lib/ace/assign/molecules/journal_prompt_mutation.rb +0 -241
  181. data/lib/ace/assign/molecules/ordinary_merge_cli.rb +0 -68
  182. data/lib/ace/assign/molecules/ordinary_merge_contract.rb +0 -410
  183. data/lib/ace/assign/molecules/original_launch_binding.rb +0 -37
  184. data/lib/ace/assign/molecules/proposal_journal.rb +0 -179
  185. data/lib/ace/assign/molecules/protected_control_exclusion.rb +0 -303
  186. data/lib/ace/assign/molecules/protected_workspace_exclusion.rb +0 -330
  187. data/lib/ace/assign/molecules/protected_workspace_provisioner.rb +0 -164
  188. data/lib/ace/assign/molecules/receipt_verifier.rb +0 -320
  189. data/lib/ace/assign/molecules/terminal_scope_receipt.rb +0 -88
  190. data/lib/ace/assign/organisms/attempt_coordinator.rb +0 -1374
  191. data/lib/ace/assign/organisms/delivery_coordinator.rb +0 -342
  192. data/lib/ace/assign/organisms/ordinary_driver.rb +0 -310
  193. data/lib/ace/assign/organisms/ordinary_merge_consumer.rb +0 -365
  194. data/lib/ace/assign/organisms/prepared_work_builder.rb +0 -194
  195. data/lib/ace/assign/organisms/protected_delivery_coordinator.rb +0 -100
@@ -1,139 +0,0 @@
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
@@ -1,357 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "digest"
4
- require "json"
5
- require "fileutils"
6
- require "tmpdir"
7
- require "tempfile"
8
- require "ace/herdr/molecules/bounded_process"
9
- require "ace/review/atoms/campaign_contract"
10
- require_relative "private_directory"
11
- require_relative "../atoms/candidate_limits"
12
- require_relative "candidate_head_comparison"
13
- require_relative "candidate_blob_batch"
14
-
15
- module Ace
16
- module Assign
17
- module Authority
18
- # Untrusted bundles enter a clean bare quarantine. Only complete verified
19
- # objects are retained; no uploader configuration or worktree is read.
20
- class CandidateTransfer
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
32
- SHA = /\A[0-9a-f]{40}\z/
33
-
34
- class CampaignReadView
35
- def initialize(owner:, repository:, base:, deadline:)
36
- @owner, @repository, @base, @deadline = owner, repository, base, deadline
37
- @thread, @active = Thread.current, true
38
- end
39
-
40
- def close = @active = false
41
- def head = command("rev-parse", "HEAD").strip
42
- def base
43
- commit!(@base)
44
- @base
45
- end
46
- def commit!(revision)
47
- raise ArgumentError, "campaign revision is invalid" unless revision.is_a?(String) && revision.match?(SHA)
48
- command("cat-file", "-e", "#{revision}^{commit}")
49
- true
50
- end
51
- def full_diff?(base, head)
52
- commit!(base)
53
- commit!(head)
54
- !command("diff", "--raw", "--no-ext-diff", "--no-textconv", base, head, "--").empty?
55
- end
56
- def clean?
57
- command("status", "--porcelain", "-z", "--untracked-files=all").split("\0")
58
- .all? { |entry| entry.start_with?("?? .ace-local/") }
59
- end
60
-
61
- private
62
-
63
- def command(*arguments)
64
- unless @active && Thread.current.equal?(@thread)
65
- raise AttemptErrors::EvidenceUnavailable, "campaign Git read view escaped its source lifetime"
66
- end
67
- @owner.send(:git, @repository, @deadline, *arguments)
68
- rescue Timeout::Error
69
- raise AttemptErrors::EvidenceUnavailable, "campaign Git read deadline expired"
70
- end
71
- end
72
-
73
- def with_campaign_repository(bytes:, sha256:, size:, head:, tree:, base:)
74
- raise ArgumentError, "campaign repository requires a block" unless block_given?
75
- deadline = monotonic + DEADLINE
76
- materialized = materialize_repository(bytes: bytes, sha256: sha256, size: size, head: head, tree: tree, root: @root)
77
- directory = materialized.fetch("directory")
78
- reader = CampaignReadView.new(owner: self, repository: directory, base: base, deadline: deadline)
79
- unless reader.head == head
80
- raise AttemptErrors::EvidenceUnavailable, "campaign Git head differs from original candidate"
81
- end
82
- reader.base
83
- yield reader
84
- ensure
85
- reader&.close
86
- FileUtils.remove_entry(directory) if directory && File.directory?(directory)
87
- end
88
-
89
- def initialize(root:)
90
- @root = File.expand_path(root)
91
- verify_root!
92
- end
93
-
94
- # Export the already validated prepared tree using the same isolated Git
95
- # boundary as admission. The returned bytes are retained by the producer;
96
- # registration must not rebuild them after a lost response.
97
- def export_prepared(work:)
98
- require_relative "prepared_work"
99
- reject!("Prepared export requires validated work") unless work.is_a?(PreparedWork)
100
- verify_root!
101
- deadline = monotonic + DEADLINE
102
- Dir.mktmpdir("prepared-export-", @root) do |directory|
103
- File.chmod(0700, directory)
104
- repository = File.join(directory, "repository")
105
- git(directory, deadline, "init", "--template=", "--initial-branch=prepared", repository)
106
- work.files.each do |path, bytes|
107
- target = File.join(repository, path)
108
- FileUtils.mkdir_p(File.dirname(target), mode: 0700)
109
- File.open(target, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0600) do |file|
110
- file.write(bytes)
111
- end
112
- end
113
- git(repository, deadline, "add", "--", ".")
114
- git(repository, deadline, "-c", "user.name=ACE prepared input", "-c", "user.email=prepared@ace.invalid",
115
- "-c", "commit.gpgSign=false", "commit", "--no-verify", "-m", "Prepared assignment input")
116
- head = git(repository, deadline, "rev-parse", "HEAD").strip
117
- tree = git(repository, deadline, "rev-parse", "HEAD^{tree}").strip
118
- bundle = File.join(directory, "prepared.bundle")
119
- git(repository, deadline, "bundle", "create", bundle, "refs/heads/prepared")
120
- bytes = read_bundle(bundle).freeze
121
- {"head" => head.freeze, "tree" => tree.freeze, "bundle" => bytes,
122
- "bytes" => bytes.bytesize, "sha256" => Digest::SHA256.hexdigest(bytes).freeze,
123
- "definition_bytes" => work.definition_bytes(head: head, tree: tree)}.freeze
124
- end
125
- rescue Timeout::Error
126
- reject!("Prepared export deadline exceeded")
127
- rescue SystemCallError
128
- reject!("Prepared export filesystem is unavailable")
129
- end
130
-
131
- def admit(bytes:, sha256:, size:, head:, deadline: nil, &consumer)
132
- unless bytes.is_a?(String) && size.is_a?(Integer) && size.positive? && size <= MAX_BYTES &&
133
- size == bytes.bytesize && sha256.is_a?(String) &&
134
- Digest::SHA256.hexdigest(bytes.b) == sha256 && head.is_a?(String) && head.match?(SHA)
135
- reject!("Candidate bundle binding or size is invalid")
136
- end
137
- verify_root!
138
- deadline = deadline ? [deadline, monotonic + DEADLINE].min : monotonic + DEADLINE
139
- Dir.mktmpdir("quarantine-", @root) do |directory|
140
- File.chmod(0700, directory)
141
- bundle = File.join(directory, "candidate.bundle")
142
- File.open(bundle, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW, 0600) { |f| f.write(bytes.b) }
143
- repository = File.join(directory, "repository")
144
- git(directory, deadline, "init", "--bare", "--template=", repository)
145
- # Verification against an empty repository rejects incremental
146
- # bundles requiring any caller-supplied prerequisites.
147
- git(repository, deadline, "bundle", "verify", bundle)
148
- advertised = git(repository, deadline, "bundle", "list-heads", bundle).lines.map { |line| line.split.first }
149
- reject!("Candidate head is not advertised") unless advertised.include?(head)
150
- git(repository, deadline, "fetch", "--no-tags", bundle, "#{head}:refs/heads/candidate")
151
- actual = git(repository, deadline, "rev-parse", "refs/heads/candidate^{commit}").strip
152
- reject!("Candidate commit differs from declared head") unless actual == head
153
- git(repository, deadline, "fsck", "--full", "--strict", "--no-reflogs")
154
- tree = git(repository, deadline, "rev-parse", "#{head}^{tree}").strip
155
- exported = File.join(directory, "verified.bundle")
156
- git(repository, deadline, "bundle", "create", exported, "refs/heads/candidate")
157
- verified = read_bundle(exported)
158
- # The journal owns candidate identity/generation. This object is
159
- # only immutable transport bytes, not another authority ledger.
160
- result = {"head" => head, "tree" => tree, "bundle" => verified,
161
- "sha256" => Digest::SHA256.hexdigest(verified), "bytes" => verified.bytesize}
162
- consumer ? consumer.call(repository, deadline, result) : result
163
- end
164
- rescue Timeout::Error
165
- reject!("Candidate transfer deadline exceeded")
166
- rescue SystemCallError
167
- reject!("Candidate transfer filesystem is unavailable")
168
- end
169
-
170
- # root is selected from installed peer credentials by the receiver,
171
- # never a request field. Materialization reads objects rather than Git
172
- # checkout, so .gitattributes, filters and uploader hooks cannot run.
173
- def materialize(bytes:, sha256:, size:, head:, tree:, root:)
174
- self.class.new(root: root)
175
- admit(bytes: bytes, sha256: sha256, size: size, head: head) do |repository, deadline, result|
176
- reject!("Candidate tree differs from accepted tree") unless result["tree"] == tree
177
- destination = Dir.mktmpdir("candidate-", root)
178
- File.chmod(0700, destination)
179
- begin
180
- entries = git(repository, deadline, "ls-tree", "-r", "-z", head, limit: MAX_BYTES).b.split("\0")
181
- reject!("Candidate file inventory is oversized") if entries.size > MAX_OBJECTS
182
- parsed = entries.map do |entry|
183
- metadata, path = entry.split("\t", 2)
184
- mode, kind, oid = metadata.to_s.split(" ")
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)
193
- reject!("Candidate contains unsafe filesystem entry")
194
- end
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
204
- target = File.join(destination, path)
205
- FileUtils.mkdir_p(File.dirname(target), mode: 0700)
206
- if mode == "120000"
207
- resolved = File.expand_path(content, File.dirname(target))
208
- unless !content.include?("\0") && !content.start_with?("/") &&
209
- resolved.start_with?(destination + "/")
210
- reject!("Candidate symlink escapes private checkout")
211
- end
212
- links << [target, content]
213
- else
214
- File.open(target, File::WRONLY | File::CREAT | File::EXCL | File::NOFOLLOW,
215
- mode == "100755" ? 0700 : 0600) { |file| file.write(content) }
216
- end
217
- yield(path.freeze, mode.freeze, oid.freeze, content.freeze) if block_given?
218
- end
219
- links.each { |target, content| File.symlink(content, target) }
220
- links.each do |target, _content|
221
- resolved = File.realpath(target)
222
- reject!("Candidate symlink escapes private checkout") unless resolved.start_with?(destination + "/")
223
- end
224
- result.reject { |key, _value| key == "bundle" }.merge("directory" => destination)
225
- rescue Exception
226
- # Only this freshly created private disposable directory is
227
- # removed. An existing peer scratch tree is never cleaned.
228
- FileUtils.rm_rf(destination)
229
- raise
230
- end
231
- end
232
- rescue SystemCallError, ArgumentError
233
- reject!("Candidate filesystem materialization is unverifiable")
234
- end
235
-
236
- # Executing campaign children need local Git history as well as files.
237
- # Create only our own empty administration; no uploader configuration,
238
- # checkout filters or hooks enter this private repository.
239
- def materialize_repository(bytes:, sha256:, size:, head:, tree:, root:)
240
- deadline = monotonic + DEADLINE
241
- result = materialize(bytes: bytes, sha256: sha256, size: size, head: head, tree: tree, root: root)
242
- directory = result.fetch("directory")
243
- Tempfile.create(["campaign-candidate-", ".bundle"], root) do |file|
244
- file.binmode
245
- file.write(bytes); file.flush
246
- git(directory, deadline, "init", "--template=", "--initial-branch=work")
247
- git(directory, deadline, "fetch", "--no-tags", file.path, "#{head}:refs/heads/candidate")
248
- git(directory, deadline, "symbolic-ref", "HEAD", "refs/heads/candidate")
249
- git(directory, deadline, "read-tree", head)
250
- reject!("Campaign private repository differs") unless git(directory, deadline, "rev-parse", "HEAD^{tree}").strip == tree
251
- end
252
- result
253
- rescue Exception
254
- FileUtils.remove_entry(directory) if directory && File.directory?(directory) && !File.symlink?(directory)
255
- raise
256
- end
257
-
258
- # Full immutable review input comes from the same admitted Git objects
259
- # used for materialization, never a second read of the scratch checkout.
260
- def review_snapshot(bytes:, sha256:, size:, head:, tree:, root:)
261
- subject = JSON.generate("schema" => "ace.candidate-review-snapshot/v1", "head" => head, "tree" => tree)
262
- .delete_suffix("}") + ',"files":['
263
- first = true
264
- result = materialize(bytes: bytes, sha256: sha256, size: size, head: head, tree: tree, root: root) do |path, mode, oid, content|
265
- entry = JSON.generate("path" => review_bytes(path), "mode" => mode, "oid" => oid, "content" => review_bytes(content))
266
- separator = first ? "" : ","
267
- reject!("Complete candidate review snapshot is oversized") if subject.bytesize + separator.bytesize + entry.bytesize + 2 > Atoms::CandidateLimits::REVIEW_SNAPSHOT_BYTES
268
- subject << separator << entry
269
- first = false
270
- end
271
- subject << "]}"
272
- result.merge("subject" => subject.freeze)
273
- end
274
-
275
- # Prepared instructions have a narrower tree contract than candidates:
276
- # no executable entries or symlinks, and bounded UTF-8 files. Read only
277
- # verified Git objects; never run checkout filters or reopen uploader paths.
278
- def prepared_files(repository:, deadline:, head:)
279
- entries = git(repository, deadline, "ls-tree", "-r", "-z", head, limit: MAX_BYTES).b.split("\0")
280
- reject!("Prepared file inventory is oversized") unless entries.size.between?(4, 4096)
281
- parsed = entries.map do |entry|
282
- metadata, path = entry.split("\t", 2)
283
- mode, kind, oid = metadata.to_s.split(" ")
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) }
289
- reject!("Prepared tree contains unsafe entry")
290
- end
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
301
- end
302
- reject!("Prepared inventory has duplicate paths") unless files.size == entries.size
303
- files
304
- end
305
-
306
- private
307
-
308
- def review_bytes(bytes)
309
- text = bytes.dup.force_encoding(Encoding::UTF_8)
310
- if text.valid_encoding? && !text.include?("\0")
311
- {"encoding" => "utf-8", "bytes" => text}
312
- else
313
- {"encoding" => "base64", "bytes" => [bytes].pack("m0")}
314
- end
315
- end
316
-
317
- def verify_root!
318
- PrivateDirectory.verify!(@root)
319
- end
320
-
321
- def git(repository, deadline, *arguments, limit: 65_536, stdin_data: "")
322
- remaining = deadline - monotonic
323
- raise Timeout::Error if remaining <= 0
324
- # Git variables may redirect object/config storage even in a clean
325
- # repository. Strip every inherited Git setting and global config.
326
- environment = ENV.keys.to_h { |key| [key, nil] }
327
- environment.merge!("GIT_CONFIG_NOSYSTEM" => "1", "GIT_CONFIG_GLOBAL" => "/dev/null",
328
- "GIT_ATTR_NOSYSTEM" => "1",
329
- "GIT_TERMINAL_PROMPT" => "0", "GIT_ALLOW_PROTOCOL" => "file",
330
- "GIT_NO_REPLACE_OBJECTS" => "1", "PATH" => "/usr/bin:/bin", "LC_ALL" => "C")
331
- result = Herdr::Molecules::BoundedProcess.call([environment, "/usr/bin/git", "-C", repository,
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)
334
- reject!("Candidate Git verification failed") unless result.status.success? && !result.oversized
335
- result.stdout
336
- end
337
-
338
- def read_bundle(path)
339
- File.open(path, File::RDONLY | File::NOFOLLOW) do |file|
340
- reject!("Verified bundle is oversized") unless file.stat.file? && file.stat.size <= MAX_BYTES
341
- bytes = file.read(MAX_BYTES + 1).b
342
- reject!("Verified bundle is oversized") if bytes.bytesize > MAX_BYTES
343
- bytes
344
- end
345
- end
346
-
347
- def monotonic
348
- Process.clock_gettime(Process::CLOCK_MONOTONIC)
349
- end
350
-
351
- def reject!(message)
352
- raise AttemptErrors::ReceiptRejected, message
353
- end
354
- end
355
- end
356
- end
357
- end
@@ -1,53 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "fiddle"
4
- require "ace/runtime/molecules/protected_artifact_set"
5
-
6
- module Ace
7
- module Assign
8
- module Authority
9
- # Extends the existing held artifact protection solely for the bounded
10
- # root result view. Private archive storage is never admitted here.
11
- class CleanupResultProtection < Ace::Runtime::Molecules::ProtectedArtifactSet::Protection
12
- class LinuxACL
13
- def absent!(handle, attribute)
14
- unless RUBY_PLATFORM.include?("linux")
15
- raise Ace::Runtime::RuntimeUnavailableError, "cleanup result ACL evidence requires Linux"
16
- end
17
- function = Fiddle::Function.new(Fiddle::Handle::DEFAULT["fgetxattr"],
18
- [Fiddle::TYPE_INT, Fiddle::TYPE_VOIDP, Fiddle::TYPE_VOIDP, Fiddle::TYPE_SIZE_T], Fiddle::TYPE_SSIZE_T)
19
- size = function.call(handle.fileno, attribute, 0, 0)
20
- error = Fiddle.last_error
21
- unless size == -1 && error == Errno::ENODATA::Errno
22
- raise Ace::Runtime::RuntimeUnavailableError, "cleanup result ACL protection is unavailable"
23
- end
24
- rescue Fiddle::DLError
25
- raise Ace::Runtime::RuntimeUnavailableError, "cleanup result ACL reader is unavailable"
26
- end
27
- end
28
-
29
- def initialize(result_path:, authority_gid:, acl: LinuxACL.new, **options)
30
- unless result_path.is_a?(String) && result_path.start_with?("/") && !result_path.include?("\0") &&
31
- File.expand_path(result_path) == result_path && authority_gid.is_a?(Integer) && authority_gid.positive?
32
- raise ArgumentError, "invalid fixed cleanup result view"
33
- end
34
- super(**options)
35
- @result_path, @parent, @authority_gid, @acl = result_path.dup.freeze, File.dirname(result_path).freeze, authority_gid, acl
36
- end
37
-
38
- def verify!(path, handle, directory:)
39
- super
40
- return unless path == @result_path || path == @parent
41
- parent = path == @parent
42
- stat = handle.stat
43
- unless directory == parent && stat.gid == @authority_gid &&
44
- (stat.mode & 0o7777) == (parent ? 0o2750 : 0o640)
45
- raise Ace::Runtime::RuntimeUnavailableError, "cleanup result view mode or group differs"
46
- end
47
- @acl.absent!(handle, "system.posix_acl_access")
48
- @acl.absent!(handle, "system.posix_acl_default") if parent
49
- end
50
- end
51
- end
52
- end
53
- end