[verified] Add fail-closed rework provider readback adapter
This commit is contained in:
@@ -176,10 +176,18 @@ Reviewer가 rework head를 새 snapshot으로 다시 검토해야 하며 이 com
|
|||||||
- `correlation`은 `owner`, `repository`, base-10 Issue 번호, base-10 PR 번호, base-10 review ID, lowercase reviewed head, lowercase final head와 lowercase contract digest를 이 순서로 NUL 하나씩 구분한 UTF-8 bytes의 SHA-256이다. `result-sha256`은 correlation 입력에 넣지 않는다.
|
- `correlation`은 `owner`, `repository`, base-10 Issue 번호, base-10 PR 번호, base-10 review ID, lowercase reviewed head, lowercase final head와 lowercase contract digest를 이 순서로 NUL 하나씩 구분한 UTF-8 bytes의 SHA-256이다. `result-sha256`은 correlation 입력에 넣지 않는다.
|
||||||
- 게시 전 exact PR top-level comment endpoint target과 fresh PR metadata binding을 검증하고 그 scoped collection만 complete pagination한 뒤 같은 correlation을 찾는다. Source Issue comment endpoint, inline-comment·review-reply endpoint와 native review collection은 검색 집합에서 제외하며 endpoint scope와 PR relation을 구분할 수 없으면 fail-closed한다. 0개면 exact body를 한 번 생성하고, 1개이며 author stable account ID·body·marker·digest가 모두 같으면 write 없는 no-op으로 재사용한다. 같은 correlation의 body가 다르거나 2개 이상이면 중복을 만들지 않고 fail-closed한다.
|
- 게시 전 exact PR top-level comment endpoint target과 fresh PR metadata binding을 검증하고 그 scoped collection만 complete pagination한 뒤 같은 correlation을 찾는다. Source Issue comment endpoint, inline-comment·review-reply endpoint와 native review collection은 검색 집합에서 제외하며 endpoint scope와 PR relation을 구분할 수 없으면 fail-closed한다. 0개면 exact body를 한 번 생성하고, 1개이며 author stable account ID·body·marker·digest가 모두 같으면 write 없는 no-op으로 재사용한다. 같은 correlation의 body가 다르거나 2개 이상이면 중복을 만들지 않고 fail-closed한다.
|
||||||
- Comment create timeout·오류 뒤에는 blind retry하지 않는다. Complete comment collection을 한 번 authoritative하게 reconcile해 exact result 하나가 확인될 때만 성공으로 판정한다.
|
- Comment create timeout·오류 뒤에는 blind retry하지 않는다. Complete comment collection을 한 번 authoritative하게 reconcile해 exact result 하나가 확인될 때만 성공으로 판정한다.
|
||||||
- 게시 또는 재사용 후 exact comment ID, author stable account ID, body, created·updated timestamp, marker, digest, verified endpoint target, fresh PR metadata binding과 PR top-level collection cardinality를 readback한다. 신규 게시에서는 scoped PR top-level comment collection에 exact comment 하나만 추가되어야 한다. Provider의 aggregate comment-count field는 fresh top-level·review·inline comment cardinality로 설명 가능해야 하며 설명할 수 없으면 fail-closed한다. 해당 comment timestamp에 인과가 결합된 PR `updated_at` 변경만 허용한다. Source Issue `<issue-number>`는 body digest·title·state·labels·assignees·milestone·comment collection이 불변이어야 한다. `Closes #<issue-number>` 같은 provider relation 때문에 Source Issue `updated_at`만 변경되면 그 시각이 이번 PR comment 생성과 인과적으로 결합된 경우에만 허용한다. Existing exact comment 재사용에서는 PR top-level collection cardinality, PR·underlying issue와 Source Issue field가 모두 불변이어야 한다. 기존 review collection 또는 그 밖의 PR field 변경은 허용하지 않는다.
|
- 게시 또는 재사용 후 exact comment ID, author stable account ID, body, created·updated timestamp, marker, digest, verified endpoint target, fresh PR metadata binding과 PR top-level collection cardinality를 readback한다. 신규 게시에서는 scoped PR top-level comment collection에 exact comment 하나만 추가되어야 한다. Provider의 aggregate comment-count field는 fresh top-level·review·inline comment cardinality로 설명 가능해야 하며 설명할 수 없으면 fail-closed한다. 신규 게시에서 PR·underlying PR-Issue의 `updated_at`은 불변이거나 exact 신규 comment 생성 timestamp와 같을 때만 허용한다. 이는 다른 native metadata가 불변인 상태의 state-delta/exact-timestamp admission이며, GET readback만으로 독립적인 causal event attestation을 제공하지 않는다. Source Issue `<issue-number>`는 `updated_at`을 포함한 전체 native metadata와 comment collection이 완전히 불변이어야 하며, provider relation을 이유로 timestamp 예외를 허용하지 않는다. Existing exact comment 재사용에서는 PR top-level collection cardinality, PR·underlying issue와 Source Issue field가 모두 불변이어야 한다. 기존 review collection 또는 그 밖의 PR field 변경은 허용하지 않는다.
|
||||||
|
|
||||||
완료 조건: exact final head와 finding matrix에 결합된 canonical comment가 impl role author로 정확히 하나 존재하고 authoritative readback을 통과한다.
|
완료 조건: exact final head와 finding matrix에 결합된 canonical comment가 impl role author로 정확히 하나 존재하고 authoritative readback을 통과한다.
|
||||||
|
|
||||||
|
### 정본 provider readback 실행
|
||||||
|
|
||||||
|
- `references/readback.md`의 canonical `scripts/rework_comment.py snapshot`으로 게시 전 baseline을 수집하고 producer의 SHA-256을 독립 보존한다. Impl role의 private credential, stable account ID와 reviewed provider-contract fingerprint를 고정한다.
|
||||||
|
- 승인된 단일 게시 또는 existing-comment 재사용 뒤에는 `scripts/rework_comment.py verify-live`를 `terminal`로 실행한다. Source Issue·PR·underlying PR-Issue·scoped top-level comments·reviews·inline evidence를 fresh GET으로 다시 수집하고 계산된 불변성·cardinality 결과를 기존 verifier에 직접 연결한다.
|
||||||
|
- 정본 출력은 caller가 admission한 Issue runtime의 `rework-baseline.json`과 `rework-receipt.json`이다. Product checkout 밖에서 baseline은 보존하고 receipt는 성공 검증 시에만 update-in-place한다. 실패 때문에 baseline 재수집이나 POST 재시도를 하지 않는다.
|
||||||
|
- Provider version과 aggregate semantics는 명시된 trusted contract에서만 채택한다. Count-header 없는 empty-array와 capability-bound count fallback은 구분하며, 권한 오류·redirect·불완전 pagination은 fallback 사유가 아니다. Counts-only inline fallback을 complete inline-body inventory로 보고하지 않는다.
|
||||||
|
- 기존 offline `verify`는 호환 경로다. 수동 JSON의 success flag나 archived replay만으로 fresh provider readback 또는 외부 게시 완료를 주장하지 않는다.
|
||||||
|
|
||||||
## 절차
|
## 절차
|
||||||
|
|
||||||
### Evidence manifest automation
|
### Evidence manifest automation
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
# Canonical PR rework completion readback
|
||||||
|
|
||||||
|
## Scope and source
|
||||||
|
|
||||||
|
Canonical entry point: `<HARNESS_ROOT>/harness/agents/impl/skills/gitea-issue-rework/scripts/rework_comment.py`.
|
||||||
|
The adjacent `rework_readback.py` derives readback inputs; `rework_readback_cli.py`
|
||||||
|
provides the role-local GET-only transport and private artifact writer. Update
|
||||||
|
these canonical files and this reference in place; do not copy them into another
|
||||||
|
profile or use a review-role credential for impl delivery.
|
||||||
|
|
||||||
|
`snapshot` and `verify-live` never create, edit, retry, or submit a Gitea comment.
|
||||||
|
The `create` action verifies an already observed single comment addition; `reuse`
|
||||||
|
verifies an unchanged collection with exactly one matching existing comment.
|
||||||
|
An independently authorized publication workflow remains responsible for its
|
||||||
|
single external write, uncertain-write reconciliation, Issue lease, and fresh
|
||||||
|
pre-write delegation admission.
|
||||||
|
|
||||||
|
## Prerequisites and independent expectations
|
||||||
|
|
||||||
|
- The canonical access contract is `harness/operations/gitea-access.md`.
|
||||||
|
- Run in the impl role's own `HERMES_HOME`. Only its owned, non-symlink, mode-0600
|
||||||
|
`.env` file supplies `GITEA_TOKEN`. No ambient token or other-profile fallback.
|
||||||
|
- Independently supply the expected stable impl account ID. The first API read
|
||||||
|
must authenticate that ID and the `gitea-impl-agent` login.
|
||||||
|
- Generate `expected.json` with the existing canonical renderer. Its exact target,
|
||||||
|
final head, reviewed head, review ID, body, marker and digest bind the workflow.
|
||||||
|
- Supply a reviewed provider contract and its independently expected SHA-256.
|
||||||
|
The exact provider version is read from the server and must match the contract.
|
||||||
|
A digest calculated from an untrusted delegate file is not independent evidence.
|
||||||
|
- Own a private mode-0700 runtime directory. Inputs must be owned regular files,
|
||||||
|
not group/world writable or symlinked. Output files use mode 0600; a failed
|
||||||
|
verification does not replace an existing receipt.
|
||||||
|
|
||||||
|
## Provider contract
|
||||||
|
|
||||||
|
Schema: `gitea-agent.rework-provider/v1`. The contract explicitly declares the
|
||||||
|
aggregate semantics for PR and underlying PR-Issue `comments` fields. Each
|
||||||
|
`components` array contains unique values from `top_level`, `reviews`, `inline`
|
||||||
|
and must include `top_level`. Choose the reviewed deployment semantics, not the
|
||||||
|
combination that happens to fit a current response. There is no guessed version
|
||||||
|
or automatic capability downgrade.
|
||||||
|
|
||||||
|
Shape example only, **not a claim about this deployment**:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema": "gitea-agent.rework-provider/v1",
|
||||||
|
"version": "<independently-observed-version>",
|
||||||
|
"aggregate": {
|
||||||
|
"pr": {"field": "comments", "components": ["top_level"]},
|
||||||
|
"pr_issue": {"field": "comments", "components": ["top_level"]}
|
||||||
|
},
|
||||||
|
"inline": {
|
||||||
|
"mode": "per_review",
|
||||||
|
"field": "review_comments",
|
||||||
|
"components": ["inline"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`per_review` requires every exact-review comment endpoint. Only the explicit
|
||||||
|
`per_review_or_pr_count` capability permits endpoint-unavailable 404/405 fallback.
|
||||||
|
Permission errors, redirects, malformed JSON and conflicting metadata never
|
||||||
|
trigger fallback. Fallback uses a fresh PR count, the declared review/inline
|
||||||
|
components and complete review `comments_count` records; disagreement is BLOCK.
|
||||||
|
Counts-only fallback is recorded as such and is not proof of inline comment
|
||||||
|
bodies or a complete inline-record inventory.
|
||||||
|
|
||||||
|
Native wire-shape reference: the pinned Gitea `v1.24.6` serializers in
|
||||||
|
`services/convert/pull.go` and `modules/structs/{issue,pull}.go` expose PR `url`
|
||||||
|
and `html_url` as HTML URLs, and PR-Issue `pull_request.html_url` without a native
|
||||||
|
`pull_request.url`. Require the actual native relation URL; do not invent a
|
||||||
|
missing field. This source pin is fixture/protocol evidence, not a claim about
|
||||||
|
the deployment version. The caller's independent provider contract still binds
|
||||||
|
that version. In `per_review` mode only, an omitted `review_comments` is accepted
|
||||||
|
as zero only when complete per-review records and their counters prove a zero
|
||||||
|
projection. Explicit null, nonzero projection or counts-only fallback with a
|
||||||
|
missing scalar is rejected. Raw pagination Link evidence is preserved and
|
||||||
|
`first`, `prev`, `next`, `last` are checked against the declared total, including
|
||||||
|
the empty sentinel page after an exact multiple of the page size.
|
||||||
|
|
||||||
|
## Commands and canonical artifact paths
|
||||||
|
|
||||||
|
The caller's admitted Issue runtime is
|
||||||
|
`<HARNESS_ROOT>/repositories/<owner>/<repository>/worktrees/issue-<issue>/runtime/<run_id>`.
|
||||||
|
Place the canonical `rework-baseline.json` and `rework-receipt.json` there, outside
|
||||||
|
the product checkout. Preserve the baseline SHA-256 returned by its producer
|
||||||
|
independently; never replace the baseline after the external write.
|
||||||
|
|
||||||
|
Through `terminal`, before publication:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 -B <HARNESS_ROOT>/harness/agents/impl/skills/gitea-issue-rework/scripts/rework_comment.py snapshot \
|
||||||
|
--expected <RUNTIME>/expected.json \
|
||||||
|
--provider-contract <TRUSTED_PROVIDER_CONTRACT> \
|
||||||
|
--provider-contract-sha256 <INDEPENDENT_PROVIDER_SHA256> \
|
||||||
|
--actor-id <INDEPENDENT_IMPL_ACCOUNT_ID> \
|
||||||
|
--output <RUNTIME>/rework-baseline.json
|
||||||
|
```
|
||||||
|
|
||||||
|
After the authorized write or existing-comment reuse, through `terminal`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 -B <HARNESS_ROOT>/harness/agents/impl/skills/gitea-issue-rework/scripts/rework_comment.py verify-live \
|
||||||
|
--expected <RUNTIME>/expected.json \
|
||||||
|
--provider-contract <TRUSTED_PROVIDER_CONTRACT> \
|
||||||
|
--provider-contract-sha256 <INDEPENDENT_PROVIDER_SHA256> \
|
||||||
|
--actor-id <INDEPENDENT_IMPL_ACCOUNT_ID> \
|
||||||
|
--baseline <RUNTIME>/rework-baseline.json \
|
||||||
|
--baseline-sha256 <INDEPENDENT_BASELINE_SHA256> \
|
||||||
|
--action create \
|
||||||
|
--output <RUNTIME>/rework-receipt.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `--action reuse` only for an unchanged scoped collection. Do not recapture a
|
||||||
|
baseline or retry a write merely because readback fails. Exit 0 and a receipt are
|
||||||
|
required; stdout reports only the artifact path, fingerprint and read-only flag.
|
||||||
|
The existing offline `verify` command remains compatible, but manually supplied
|
||||||
|
JSON/true flags or archived artifacts alone are not fresh provider readback.
|
||||||
|
|
||||||
|
## Verified invariants
|
||||||
|
|
||||||
|
- Source Issue, PR, underlying PR-Issue, repository, exact source review and heads
|
||||||
|
are independently bound to authenticated endpoint responses.
|
||||||
|
- Pagination is bounded; duplicate IDs, contradictory totals, truncated pages,
|
||||||
|
malformed metadata and off-target continuation links are rejected.
|
||||||
|
- Genuine empty arrays without a count header are supported; null is not silently
|
||||||
|
accepted as an empty collection.
|
||||||
|
- Existing top-level comments, Source Issue fields/comments, native reviews and
|
||||||
|
admitted inline evidence remain unchanged. Create admits exactly one matching
|
||||||
|
impl-authored comment. Reuse admits no addition.
|
||||||
|
- Only provider-declared count projections and the bounded PR/underlying-PR-Issue
|
||||||
|
state-delta/exact-timestamp admission for that verified new comment are admitted;
|
||||||
|
Source Issue metadata, including `updated_at`, is fully immutable. This admission
|
||||||
|
is not proof of causal event attestation; the receipt records that limitation.
|
||||||
|
- The adapter computes normalized inputs and passes them to the same verifier used
|
||||||
|
by `rework_comment.py verify`, rather than accepting caller success flags.
|
||||||
|
|
||||||
|
## Limits and verification
|
||||||
|
|
||||||
|
The CLI enforces verified HTTPS, no redirects, GET-only requests, a one-megabyte
|
||||||
|
response cap, ten-second socket timeouts and a 120-second operation budget. Body
|
||||||
|
budget checks occur between socket reads, so an already-blocking read may add its
|
||||||
|
remaining socket-timeout allowance. There are no automatic retries.
|
||||||
|
|
||||||
|
The authenticated transport and independently supplied actor/provider/baseline
|
||||||
|
expectations are trust inputs, not a sandbox or proof that arbitrary caller code
|
||||||
|
is honest. The adapter cannot prove missing inline bodies from scalar counts, and
|
||||||
|
state deltas plus exact timestamps are not independent causal-event attestation.
|
||||||
|
|
||||||
|
Run focused tests with `terminal`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
python3 -B -m unittest harness.tests.impl.test_rework_readback harness.tests.impl.test_rework_readback_transport harness.tests.impl.test_rework_comment -v
|
||||||
|
python3 -B harness/tests/run.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Local synthetic transport and HTTP fixtures validate the executable paths; they
|
||||||
|
do not constitute a live Gitea readback or an external-write completion record.
|
||||||
@@ -108,8 +108,9 @@ def do_render(a):
|
|||||||
expected={"body":body+marker,"marker":marker,"marker_free_body":body,"result_sha256":rh,"correlation":corr,"target":{"owner":d['owner'],"repository":d['repository'],"issue":int(vals['issue']),"pr":int(vals['pr'])},"review":int(vals['review']),"reviewed_head":d['reviewed_head'],"head":d['final_head'],"contract_sha256":d['contract_sha256']}
|
expected={"body":body+marker,"marker":marker,"marker_free_body":body,"result_sha256":rh,"correlation":corr,"target":{"owner":d['owner'],"repository":d['repository'],"issue":int(vals['issue']),"pr":int(vals['pr'])},"review":int(vals['review']),"reviewed_head":d['reviewed_head'],"head":d['final_head'],"contract_sha256":d['contract_sha256']}
|
||||||
atomic(a.expected,(json.dumps(expected,ensure_ascii=False,sort_keys=True,separators=(",",":"))+"\n").encode())
|
atomic(a.expected,(json.dumps(expected,ensure_ascii=False,sort_keys=True,separators=(",",":"))+"\n").encode())
|
||||||
|
|
||||||
def do_verify(a):
|
def verify_readbacks(exp, pr, comment_readback, ident, *, action, baseline_count):
|
||||||
exp=read_json(a.expected); pr=read_json(a.pr_readback); comment_readback=read_json(a.comments_readback); ident=read_json(a.identity_readback)
|
if action not in ('create', 'reuse') or type(baseline_count) is not int or baseline_count < 0:
|
||||||
|
raise Bad('invalid verification action or baseline count')
|
||||||
body=exp.get("body"); rh=exp.get("result_sha256"); corr=exp.get("correlation"); head=exp.get("head")
|
body=exp.get("body"); rh=exp.get("result_sha256"); corr=exp.get("correlation"); head=exp.get("head")
|
||||||
marker=exp.get("marker","")
|
marker=exp.get("marker","")
|
||||||
marker_free=exp.get("marker_free_body", body[:-len(marker)] if marker and body.endswith(marker) else body)
|
marker_free=exp.get("marker_free_body", body[:-len(marker)] if marker and body.endswith(marker) else body)
|
||||||
@@ -145,9 +146,16 @@ def do_verify(a):
|
|||||||
author=c.get("user",c.get("author",{})); cid=author.get("id") if isinstance(author,dict) else None; clog=author.get("login") if isinstance(author,dict) else None
|
author=c.get("user",c.get("author",{})); cid=author.get("id") if isinstance(author,dict) else None; clog=author.get("login") if isinstance(author,dict) else None
|
||||||
if cid!=aid or clog!=login: raise Bad("author mismatch")
|
if cid!=aid or clog!=login: raise Bad("author mismatch")
|
||||||
if isinstance(c.get("id"),bool) or not isinstance(c.get("id"),int) or c["id"]<1 or not c.get("html_url",c.get("url")) or not c.get("created_at") or not c.get("updated_at"): raise Bad("comment metadata")
|
if isinstance(c.get("id"),bool) or not isinstance(c.get("id"),int) or c["id"]<1 or not c.get("html_url",c.get("url")) or not c.get("created_at") or not c.get("updated_at"): raise Bad("comment metadata")
|
||||||
baseline=a.baseline_count
|
baseline=baseline_count
|
||||||
if baseline<0 or (a.action=="create" and len(comments)!=baseline+1) or (a.action=="reuse" and len(comments)!=baseline): raise Bad("cardinality")
|
if baseline<0 or (action=="create" and len(comments)!=baseline+1) or (action=="reuse" and len(comments)!=baseline): raise Bad("cardinality")
|
||||||
receipt={"schema":"gitea-rework-handoff/v1","status":"verified","target":{"owner":owner,"repository":repository,"issue":int(issue),"pr":int(pr_number),"review":int(review),"object_kind":"pull_request","endpoint_binding_verified":True},"comment_id":c["id"],"comment_url":c.get("html_url",c.get("url")),"comment_body":cb,"marker":marker,"created_at":c["created_at"],"updated_at":c["updated_at"],"final_head":head,"result_sha256":rh,"correlation":corr,"author":{"id":aid,"login":login,"role":"gitea-impl-agent","authenticated":True,"stable_account_verified":True},"pr_metadata_binding_verified":True,"pr_metadata_unchanged":True,"source_issue_unchanged":True,"review_collection_unchanged":True,"provider_counts":provider_counts,"readback_verified":True,"action":a.action,"cardinality":len(comments),"page_count":comment_readback["page_count"]}
|
receipt={"schema":"gitea-rework-handoff/v1","status":"verified","target":{"owner":owner,"repository":repository,"issue":int(issue),"pr":int(pr_number),"review":int(review),"object_kind":"pull_request","endpoint_binding_verified":True},"comment_id":c["id"],"comment_url":c.get("html_url",c.get("url")),"comment_body":cb,"marker":marker,"created_at":c["created_at"],"updated_at":c["updated_at"],"final_head":head,"result_sha256":rh,"correlation":corr,"author":{"id":aid,"login":login,"role":"gitea-impl-agent","authenticated":True,"stable_account_verified":True},"pr_metadata_binding_verified":True,"pr_metadata_unchanged":True,"source_issue_unchanged":True,"review_collection_unchanged":True,"provider_counts":provider_counts,"readback_verified":True,"action":action,"cardinality":len(comments),"page_count":comment_readback["page_count"]}
|
||||||
|
return receipt
|
||||||
|
|
||||||
|
|
||||||
|
def do_verify(a):
|
||||||
|
receipt = verify_readbacks(read_json(a.expected), read_json(a.pr_readback),
|
||||||
|
read_json(a.comments_readback), read_json(a.identity_readback),
|
||||||
|
action=a.action, baseline_count=a.baseline_count)
|
||||||
atomic(a.output,(json.dumps(receipt,sort_keys=True,separators=(",",":"))+"\n").encode())
|
atomic(a.output,(json.dumps(receipt,sort_keys=True,separators=(",",":"))+"\n").encode())
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
@@ -155,8 +163,24 @@ def main():
|
|||||||
r=sp.add_parser("render"); r.add_argument("--input",required=True); r.add_argument("--comment",required=True); r.add_argument("--expected",required=True)
|
r=sp.add_parser("render"); r.add_argument("--input",required=True); r.add_argument("--comment",required=True); r.add_argument("--expected",required=True)
|
||||||
adoption=r.add_mutually_exclusive_group(required=True); adoption.add_argument("--no-delegated-evidence",action="store_true"); adoption.add_argument("--delegation-adoption")
|
adoption=r.add_mutually_exclusive_group(required=True); adoption.add_argument("--no-delegated-evidence",action="store_true"); adoption.add_argument("--delegation-adoption")
|
||||||
v=sp.add_parser("verify"); v.add_argument("--expected",required=True); v.add_argument("--pr-readback",required=True); v.add_argument("--comments-readback",required=True); v.add_argument("--identity-readback",required=True); v.add_argument("--action",choices=("create","reuse"),required=True); v.add_argument("--baseline-count",type=int,required=True); v.add_argument("--output",required=True)
|
v=sp.add_parser("verify"); v.add_argument("--expected",required=True); v.add_argument("--pr-readback",required=True); v.add_argument("--comments-readback",required=True); v.add_argument("--identity-readback",required=True); v.add_argument("--action",choices=("create","reuse"),required=True); v.add_argument("--baseline-count",type=int,required=True); v.add_argument("--output",required=True)
|
||||||
|
for command in ('snapshot', 'verify-live'):
|
||||||
|
live = sp.add_parser(command)
|
||||||
|
live.add_argument('--expected', required=True)
|
||||||
|
live.add_argument('--provider-contract', required=True)
|
||||||
|
live.add_argument('--provider-contract-sha256', required=True)
|
||||||
|
live.add_argument('--actor-id', required=True, type=int)
|
||||||
|
live.add_argument('--output', required=True)
|
||||||
|
if command == 'verify-live':
|
||||||
|
live.add_argument('--baseline', required=True)
|
||||||
|
live.add_argument('--baseline-sha256', required=True)
|
||||||
|
live.add_argument('--action', choices=('create', 'reuse'), required=True)
|
||||||
args=p.parse_args()
|
args=p.parse_args()
|
||||||
try: do_render(args) if args.cmd=="render" else do_verify(args)
|
try:
|
||||||
except (Bad, OSError, KeyError, TypeError, UnicodeError) as e: print(f"error: {e}",file=sys.stderr); return 2
|
if args.cmd == 'render': do_render(args)
|
||||||
|
elif args.cmd == 'verify': do_verify(args)
|
||||||
|
else:
|
||||||
|
from rework_readback_cli import run_live
|
||||||
|
run_live(args, verify_readbacks)
|
||||||
|
except (Bad, OSError, KeyError, TypeError, UnicodeError, ValueError) as e: print(f"error: {e}",file=sys.stderr); return 2
|
||||||
return 0
|
return 0
|
||||||
if __name__=="__main__": raise SystemExit(main())
|
if __name__=="__main__": raise SystemExit(main())
|
||||||
|
|||||||
@@ -0,0 +1,544 @@
|
|||||||
|
"""Read-only provider snapshots normalized for the canonical rework verifier."""
|
||||||
|
import copy
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
|
|
||||||
|
def integer(value, minimum=0):
|
||||||
|
if type(value) is not int or value < minimum:
|
||||||
|
raise ReadbackError('invalid provider integer')
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def object_value(value):
|
||||||
|
if type(value) is not dict:
|
||||||
|
raise ReadbackError('provider object required')
|
||||||
|
return value
|
||||||
|
|
||||||
|
ORIGIN = 'https://gitea.jsfamily.quest/api/v1'
|
||||||
|
CREDENTIAL_SOURCE = '$HERMES_HOME/.env:GITEA_TOKEN'
|
||||||
|
|
||||||
|
|
||||||
|
class ReadbackError(ValueError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def timestamp(value):
|
||||||
|
if type(value) is not str or not re.fullmatch(r'\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,9})?(?:Z|[+-]\d{2}:\d{2})', value):
|
||||||
|
raise ReadbackError('invalid provider timestamp')
|
||||||
|
try: return datetime.fromisoformat(value.replace('Z','+00:00'))
|
||||||
|
except ValueError: raise ReadbackError('invalid provider timestamp') from None
|
||||||
|
|
||||||
|
|
||||||
|
def digest(value):
|
||||||
|
return hashlib.sha256(json.dumps(value, sort_keys=True, ensure_ascii=False,
|
||||||
|
allow_nan=False).encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def text(value):
|
||||||
|
if type(value) is not str:
|
||||||
|
raise ReadbackError('provider text required')
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def actor(value):
|
||||||
|
value = object_value(value)
|
||||||
|
integer(value.get('id'), 1)
|
||||||
|
if type(value.get('login')) is not str or not value['login']:
|
||||||
|
raise ReadbackError('invalid provider actor')
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def issue_metadata(record):
|
||||||
|
record = object_value(record)
|
||||||
|
for field in ('labels', 'assignees'):
|
||||||
|
if field not in record: raise ReadbackError('missing Issue metadata collection')
|
||||||
|
values = record[field]
|
||||||
|
if field == 'assignees' and values is None: continue
|
||||||
|
if type(values) is not list:
|
||||||
|
raise ReadbackError('invalid Issue metadata collection')
|
||||||
|
for value in values:
|
||||||
|
value = object_value(value)
|
||||||
|
integer(value.get('id'), 1)
|
||||||
|
if field == 'labels':
|
||||||
|
text(value.get('name'))
|
||||||
|
else:
|
||||||
|
actor(value)
|
||||||
|
if 'milestone' not in record:
|
||||||
|
raise ReadbackError('missing Issue milestone field')
|
||||||
|
milestone = record.get('milestone')
|
||||||
|
if milestone is not None:
|
||||||
|
milestone = object_value(milestone)
|
||||||
|
integer(milestone.get('id'), 1)
|
||||||
|
text(milestone.get('title'))
|
||||||
|
if milestone.get('state') not in ('open','closed'): raise ReadbackError('invalid milestone state')
|
||||||
|
actor(record.get('user'))
|
||||||
|
timestamp(record.get('created_at'))
|
||||||
|
timestamp(record.get('updated_at'))
|
||||||
|
text(record.get('title'))
|
||||||
|
text(record.get('body'))
|
||||||
|
if record.get('state') not in ('open', 'closed'):
|
||||||
|
raise ReadbackError('invalid Issue state')
|
||||||
|
return record
|
||||||
|
|
||||||
|
|
||||||
|
def branch(value):
|
||||||
|
value = object_value(value)
|
||||||
|
for field in ('ref', 'label', 'sha'):
|
||||||
|
text(value.get(field))
|
||||||
|
if not re.fullmatch(r'[0-9a-f]{40}', value['sha']):
|
||||||
|
raise ReadbackError('invalid PR branch SHA')
|
||||||
|
integer(value.get('repo_id'), 1)
|
||||||
|
repo = object_value(value.get('repo'))
|
||||||
|
if integer(repo.get('id'), 1) != value['repo_id'] or not re.fullmatch(
|
||||||
|
r'[A-Za-z0-9][A-Za-z0-9._-]{0,99}/[A-Za-z0-9][A-Za-z0-9._-]{0,99}',
|
||||||
|
repo.get('full_name', '')):
|
||||||
|
raise ReadbackError('invalid PR branch repository')
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def review_record(value):
|
||||||
|
value = object_value(value)
|
||||||
|
integer(value.get('id'), 1)
|
||||||
|
integer(value.get('comments_count'))
|
||||||
|
if value.get('state') not in ('APPROVED', 'REQUEST_CHANGES', 'COMMENT', 'PENDING'):
|
||||||
|
raise ReadbackError('invalid native review state')
|
||||||
|
text(value.get('body'))
|
||||||
|
if not re.fullmatch(r'[0-9a-f]{40}', text(value.get('commit_id'))):
|
||||||
|
raise ReadbackError('invalid native review commit')
|
||||||
|
timestamp(value.get('submitted_at'))
|
||||||
|
timestamp(value.get('updated_at'))
|
||||||
|
if type(value.get('stale')) is not bool or type(value.get('dismissed')) is not bool:
|
||||||
|
raise ReadbackError('invalid native review flags')
|
||||||
|
actor(value.get('user'))
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class ReworkReadbackAdapter:
|
||||||
|
def __init__(self, request, *, origin, credential_source, actor_id, provider_contract):
|
||||||
|
if (not callable(request) or origin != ORIGIN or credential_source != CREDENTIAL_SOURCE
|
||||||
|
or type(actor_id) is not int or actor_id < 1):
|
||||||
|
raise ReadbackError('invalid authenticated transport binding')
|
||||||
|
self._transport = request
|
||||||
|
self.actor_id = actor_id
|
||||||
|
self.provider = self.provider_contract(provider_contract)
|
||||||
|
|
||||||
|
def provider_contract(self, contract):
|
||||||
|
contract = object_value(contract)
|
||||||
|
if (set(contract) != {'schema','version','aggregate','inline'}
|
||||||
|
or contract.get('schema') != 'gitea-agent.rework-provider/v1'
|
||||||
|
or type(contract.get('version')) is not str or not contract['version']
|
||||||
|
or any(ord(char) < 33 for char in contract['version'])):
|
||||||
|
raise ReadbackError('invalid explicit provider contract')
|
||||||
|
aggregate = object_value(contract.get('aggregate'))
|
||||||
|
if set(aggregate) != {'pr','pr_issue'}: raise ReadbackError('invalid aggregate scope')
|
||||||
|
for scope in ('pr','pr_issue'):
|
||||||
|
spec = object_value(aggregate[scope])
|
||||||
|
parts = spec.get('components')
|
||||||
|
if (set(spec) != {'field','components'} or spec.get('field') != 'comments'
|
||||||
|
or type(parts) is not list or not parts
|
||||||
|
or any(type(part) is not str or part not in ('top_level','reviews','inline') for part in parts)
|
||||||
|
or len(parts) != len(set(parts)) or 'top_level' not in parts):
|
||||||
|
raise ReadbackError('invalid aggregate count declaration')
|
||||||
|
inline = object_value(contract.get('inline'))
|
||||||
|
if (set(inline) != {'mode','field','components'}
|
||||||
|
or inline.get('mode') not in ('per_review','per_review_or_pr_count')
|
||||||
|
or inline.get('field') != 'review_comments'
|
||||||
|
or inline.get('components') not in (['inline'], ['reviews','inline'])):
|
||||||
|
raise ReadbackError('invalid inline count declaration')
|
||||||
|
return copy.deepcopy(contract)
|
||||||
|
|
||||||
|
def request(self, path):
|
||||||
|
name = r'[A-Za-z0-9][A-Za-z0-9._-]{0,99}'
|
||||||
|
route = r'/(?:user|version|repos/'+name+'/'+name+r'(?:/(?:issues/[1-9][0-9]*(?:/comments)?|pulls/[1-9][0-9]*(?:/reviews(?:/[1-9][0-9]*/comments)?)?))?)'
|
||||||
|
if type(path) is not str or not re.fullmatch(route + r'(?:\?page=[1-9][0-9]*&limit=50)?', path):
|
||||||
|
raise ReadbackError('non-canonical provider endpoint')
|
||||||
|
try: result = self._transport(path)
|
||||||
|
except Exception: raise ReadbackError('provider transport failed') from None
|
||||||
|
if type(result) is not tuple or len(result) != 3:
|
||||||
|
raise ReadbackError('invalid provider response tuple')
|
||||||
|
payload, headers, status = result
|
||||||
|
integer(status, 100)
|
||||||
|
headers = object_value(headers)
|
||||||
|
normalized = {}
|
||||||
|
for key, value in headers.items():
|
||||||
|
if type(key) is not str or type(value) is not str or key.lower() in normalized:
|
||||||
|
raise ReadbackError('invalid or conflicting provider header')
|
||||||
|
normalized[key.lower()] = value
|
||||||
|
return copy.deepcopy(payload), normalized, status
|
||||||
|
|
||||||
|
def get(self, path):
|
||||||
|
value, headers, status = self.request(path)
|
||||||
|
if status != 200:
|
||||||
|
raise ReadbackError('provider GET failed')
|
||||||
|
return object_value(value), headers
|
||||||
|
|
||||||
|
def links(self, endpoint, header, page, total):
|
||||||
|
if header is None: return False
|
||||||
|
last_page = max(1, (total + 49) // 50)
|
||||||
|
if page > last_page + 1:
|
||||||
|
raise ReadbackError('pagination page beyond declared total')
|
||||||
|
seen = set()
|
||||||
|
for part in header.split(','):
|
||||||
|
match = re.fullmatch(r'\s*<([^<>\s]+)>;\s*rel=(?:"(next|prev|first|last)"|(next|prev|first|last))\s*', part)
|
||||||
|
relation = match[2] or match[3] if match is not None else None
|
||||||
|
if match is None or relation in seen:
|
||||||
|
raise ReadbackError('malformed pagination Link')
|
||||||
|
seen.add(relation)
|
||||||
|
url = match[1]
|
||||||
|
if url.startswith(ORIGIN + '/'):
|
||||||
|
url = url[len(ORIGIN):]
|
||||||
|
elif url.startswith('/api/v1/'):
|
||||||
|
url = url[len('/api/v1'):]
|
||||||
|
parsed = urlsplit(url)
|
||||||
|
query = parse_qs(parsed.query, keep_blank_values=True)
|
||||||
|
if (parsed.scheme or parsed.netloc or parsed.fragment or parsed.path != endpoint
|
||||||
|
or set(query) != {'page','limit'} or query['limit'] != ['50']
|
||||||
|
or len(query['page']) != 1 or not re.fullmatch(r'[1-9][0-9]*', query['page'][0])):
|
||||||
|
raise ReadbackError('foreign or invalid pagination Link')
|
||||||
|
linked_page = int(query['page'][0])
|
||||||
|
expected = {'first':1, 'last':last_page}.get(relation)
|
||||||
|
if relation == 'prev':
|
||||||
|
if page <= 1 or linked_page != page - 1:
|
||||||
|
raise ReadbackError('pagination Link bound or sequence mismatch')
|
||||||
|
elif relation == 'next':
|
||||||
|
if linked_page != page + 1 or linked_page > last_page:
|
||||||
|
raise ReadbackError('pagination Link bound or sequence mismatch')
|
||||||
|
elif linked_page != expected:
|
||||||
|
raise ReadbackError('pagination Link bound or sequence mismatch')
|
||||||
|
return 'next' in seen
|
||||||
|
|
||||||
|
def collection(self, endpoint, *, allow_missing=False):
|
||||||
|
rows, pages, ids, expected_total = [], [], set(), None
|
||||||
|
for page in range(1, 22):
|
||||||
|
path = endpoint + '?page=' + str(page) + '&limit=50'
|
||||||
|
values, headers, status = self.request(path)
|
||||||
|
if status in (404,405) and allow_missing and page == 1:
|
||||||
|
return {'endpoint':endpoint,'rows':[], 'pages':[{'path':path,'page':1,'count':0,'total':None,'status':status,
|
||||||
|
'link':headers.get('link')}]}
|
||||||
|
if status != 200 or type(values) is not list or len(values) > 50:
|
||||||
|
raise ReadbackError('invalid collection response')
|
||||||
|
declared = headers.get('x-total-count')
|
||||||
|
if declared is None and not values and page == 1: declared = '0'
|
||||||
|
if declared is None or not re.fullmatch(r'0|[1-9][0-9]*', declared):
|
||||||
|
raise ReadbackError('missing or invalid collection total')
|
||||||
|
total = int(declared)
|
||||||
|
if total > 1000 or (expected_total is not None and expected_total != total):
|
||||||
|
raise ReadbackError('collection bound or total drift')
|
||||||
|
expected_total = total
|
||||||
|
for row in values:
|
||||||
|
ident = integer(object_value(row).get('id'), 1)
|
||||||
|
if ident in ids: raise ReadbackError('duplicate collection ID')
|
||||||
|
ids.add(ident)
|
||||||
|
rows.extend(values)
|
||||||
|
pages.append({'path':path,'page':page,'count':len(values),'total':total,'status':200})
|
||||||
|
link = headers.get('link')
|
||||||
|
next_link = self.links(endpoint, link, page, total)
|
||||||
|
pages[-1]['link'] = link
|
||||||
|
if len(rows) > total or (len(rows) == total and next_link):
|
||||||
|
raise ReadbackError('contradictory collection total or Link')
|
||||||
|
if len(rows) == total and len(values) < 50:
|
||||||
|
return {'endpoint':endpoint,'rows':rows,'pages':pages}
|
||||||
|
if len(values) < 50:
|
||||||
|
raise ReadbackError('truncated collection')
|
||||||
|
raise ReadbackError('incomplete provider collection')
|
||||||
|
|
||||||
|
def target(self, expected):
|
||||||
|
expected = object_value(expected)
|
||||||
|
target = object_value(expected.get('target'))
|
||||||
|
if set(target) != {'owner','repository','issue','pr'}:
|
||||||
|
raise ReadbackError('invalid expected target fields')
|
||||||
|
for name in ('owner','repository'):
|
||||||
|
if type(target.get(name)) is not str or not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9._-]{0,99}', target[name]):
|
||||||
|
raise ReadbackError('invalid literal target identifier')
|
||||||
|
for name in ('issue','pr'): integer(target.get(name), 1)
|
||||||
|
integer(expected.get('review'), 1)
|
||||||
|
if target['issue'] == target['pr']: raise ReadbackError('source Issue must differ from PR')
|
||||||
|
for name, length in (('head',40),('reviewed_head',40),('contract_sha256',64),('result_sha256',64),('correlation',64)):
|
||||||
|
if type(expected.get(name)) is not str or not re.fullmatch('[0-9a-f]{'+str(length)+'}', expected[name]):
|
||||||
|
raise ReadbackError('invalid literal expected digest')
|
||||||
|
free, body, marker = [expected.get(name) for name in ('marker_free_body','body','marker')]
|
||||||
|
if any(type(value) is not str for value in (free,body,marker)) or len(body.encode()) > 1_000_000:
|
||||||
|
raise ReadbackError('invalid expected body')
|
||||||
|
correlation = hashlib.sha256('\0'.join([target['owner'],target['repository'],str(target['issue']),
|
||||||
|
str(target['pr']),str(expected['review']),expected['reviewed_head'],expected['head'],
|
||||||
|
expected['contract_sha256']]).encode()).hexdigest()
|
||||||
|
wanted = ('<!-- gitea-agent:implementation-rework/v1 owner='+target['owner']+' repository='+target['repository']+
|
||||||
|
' issue='+str(target['issue'])+' pr='+str(target['pr'])+' review='+str(expected['review'])+
|
||||||
|
' reviewed-head='+expected['reviewed_head']+' head='+expected['head']+
|
||||||
|
' contract-sha256='+expected['contract_sha256']+' result-sha256='+expected['result_sha256']+
|
||||||
|
' correlation='+correlation+' -->\n')
|
||||||
|
if (body != free+marker or hashlib.sha256(free.encode()).hexdigest() != expected['result_sha256']
|
||||||
|
or expected['correlation'] != correlation or marker != wanted):
|
||||||
|
raise ReadbackError('expected renderer binding mismatch')
|
||||||
|
return dict(target, review=expected['review'], reviewed_head=expected['reviewed_head'], head=expected['head'])
|
||||||
|
|
||||||
|
def bind(self, target, raw):
|
||||||
|
prefix = ORIGIN + '/repos/' + target['owner'] + '/' + target['repository']
|
||||||
|
html = 'https://gitea.jsfamily.quest/' + target['owner'] + '/' + target['repository']
|
||||||
|
repo = object_value(raw['repository'])
|
||||||
|
repo_id = integer(repo.get('id'), 1)
|
||||||
|
if repo.get('full_name') != target['owner']+'/'+target['repository'] or repo.get('html_url') != html:
|
||||||
|
raise ReadbackError('repository binding mismatch')
|
||||||
|
source, pr, underlying = [object_value(raw[name]) for name in ('source_issue','pr','pr_issue')]
|
||||||
|
for record in (source, pr, underlying):
|
||||||
|
issue_metadata(record)
|
||||||
|
for record, number in ((source,target['issue']), (pr,target['pr']), (underlying,target['pr'])):
|
||||||
|
integer(record.get('id'), 1)
|
||||||
|
if integer(record.get('number'), 1) != number:
|
||||||
|
raise ReadbackError('Issue or PR number mismatch')
|
||||||
|
if source.get('html_url') != html+'/issues/'+str(target['issue']) or source.get('pull_request') is not None:
|
||||||
|
raise ReadbackError('source Issue kind or URL mismatch')
|
||||||
|
if (pr.get('html_url') != html+'/pulls/'+str(target['pr'])
|
||||||
|
or pr.get('url') != html+'/pulls/'+str(target['pr']) or pr.get('state') != 'open'):
|
||||||
|
raise ReadbackError('PR kind or URL mismatch')
|
||||||
|
if (pr['user']['id'] != self.actor_id or pr['user']['login'] != 'gitea-impl-agent'
|
||||||
|
or underlying['user']['id'] != self.actor_id
|
||||||
|
or underlying['user']['login'] != 'gitea-impl-agent'):
|
||||||
|
raise ReadbackError('PR author binding mismatch')
|
||||||
|
closing = re.findall(r'(?i)\b(?:closes?|fixes?|resolves?)\s+#([1-9][0-9]*)\b', pr['body'])
|
||||||
|
canonical_closing = re.findall(r'\bCloses[ \t]+#([1-9][0-9]*)\b', pr['body'])
|
||||||
|
if closing != [str(target['issue'])] or canonical_closing != closing:
|
||||||
|
raise ReadbackError('PR closing relationship mismatch')
|
||||||
|
base_branch, head_branch = branch(pr.get('base')), branch(pr.get('head'))
|
||||||
|
base = object_value(object_value(pr.get('base')).get('repo'))
|
||||||
|
if (integer(base.get('id'), 1) != repo_id or base.get('full_name') != repo['full_name']
|
||||||
|
or base_branch['repo_id'] != repo_id):
|
||||||
|
raise ReadbackError('PR base repository mismatch')
|
||||||
|
if head_branch['sha'] != target['head']:
|
||||||
|
raise ReadbackError('PR head mismatch')
|
||||||
|
# Fork heads are valid; the target repository is bound through base and endpoint.
|
||||||
|
relation = object_value(underlying.get('pull_request'))
|
||||||
|
if (underlying.get('html_url') not in (html+'/pulls/'+str(target['pr']), html+'/issues/'+str(target['pr']))
|
||||||
|
or relation.get('html_url') != html+'/pulls/'+str(target['pr'])
|
||||||
|
or ('url' in relation and relation.get('url') != html+'/pulls/'+str(target['pr']))):
|
||||||
|
raise ReadbackError('underlying Issue PR binding mismatch')
|
||||||
|
review = next((row for row in raw['reviews']['rows'] if row.get('id') == target['review']), None)
|
||||||
|
if review is None or review.get('commit_id') != target['reviewed_head']:
|
||||||
|
raise ReadbackError('source review binding mismatch')
|
||||||
|
if (review.get('dismissed') or review.get('state') == 'PENDING'
|
||||||
|
or review['user']['id'] == self.actor_id
|
||||||
|
or review['user']['login'] not in ('gitea-review-agent', 'gitea-spec-agent')):
|
||||||
|
raise ReadbackError('source review author or state mismatch')
|
||||||
|
|
||||||
|
def counts(self, raw):
|
||||||
|
inline = sum(integer(review.get('comments_count')) for review in raw['reviews']['rows'])
|
||||||
|
fallback = False
|
||||||
|
inline_ids = set()
|
||||||
|
for review in raw['reviews']['rows']:
|
||||||
|
collected = raw['inline'][str(review['id'])]
|
||||||
|
if collected['pages'][0]['status'] in (404,405):
|
||||||
|
if self.provider['inline']['mode'] != 'per_review_or_pr_count':
|
||||||
|
raise ReadbackError('inline fallback not explicitly authorized')
|
||||||
|
fallback = True
|
||||||
|
elif len(collected['rows']) != review['comments_count']:
|
||||||
|
raise ReadbackError('per-review inline count mismatch')
|
||||||
|
for row in collected['rows']:
|
||||||
|
if row['id'] in inline_ids: raise ReadbackError('duplicate cross-review inline ID')
|
||||||
|
inline_ids.add(row['id'])
|
||||||
|
computed = inline + (len(raw['reviews']['rows']) if 'reviews' in self.provider['inline']['components'] else 0)
|
||||||
|
field = self.provider['inline']['field']
|
||||||
|
if field not in raw['pr']:
|
||||||
|
if (self.provider['inline']['mode'] != 'per_review' or fallback or computed != 0
|
||||||
|
or len(inline_ids) != 0):
|
||||||
|
raise ReadbackError('missing inline provider counter')
|
||||||
|
elif integer(raw['pr'].get(field)) != computed:
|
||||||
|
raise ReadbackError('inline provider counter crosscheck failed')
|
||||||
|
if not fallback and len(inline_ids) != inline:
|
||||||
|
raise ReadbackError('inline record cardinality mismatch')
|
||||||
|
counts = {'top_level':len(raw['comments']['rows']),
|
||||||
|
'reviews':len(raw['reviews']['rows']), 'inline':inline}
|
||||||
|
for scope in ('pr','pr_issue'):
|
||||||
|
spec = self.provider['aggregate'][scope]
|
||||||
|
if integer(raw[scope].get(spec['field'])) != sum(counts[name] for name in spec['components']):
|
||||||
|
raise ReadbackError('provider aggregate count mismatch')
|
||||||
|
if integer(raw['source_issue'].get('comments')) != len(raw['source_comments']['rows']):
|
||||||
|
raise ReadbackError('source collection count mismatch')
|
||||||
|
return counts
|
||||||
|
|
||||||
|
def capture(self, target):
|
||||||
|
identity, _ = self.get('/user')
|
||||||
|
if integer(identity.get('id'), 1) != self.actor_id or identity.get('login') != 'gitea-impl-agent':
|
||||||
|
raise ReadbackError('authenticated identity mismatch')
|
||||||
|
version, _ = self.get('/version')
|
||||||
|
if version.get('version') != self.provider['version']:
|
||||||
|
raise ReadbackError('provider version mismatch')
|
||||||
|
prefix = '/repos/' + target['owner'] + '/' + target['repository']
|
||||||
|
raw = {'identity':identity,'version':version}
|
||||||
|
for name, path in {'repository':prefix,'source_issue':prefix+'/issues/'+str(target['issue']),
|
||||||
|
'pr':prefix+'/pulls/'+str(target['pr']),
|
||||||
|
'pr_issue':prefix+'/issues/'+str(target['pr'])}.items():
|
||||||
|
raw[name], _ = self.get(path)
|
||||||
|
for name, endpoint in {'source_comments':prefix+'/issues/'+str(target['issue'])+'/comments',
|
||||||
|
'comments':prefix+'/issues/'+str(target['pr'])+'/comments',
|
||||||
|
'reviews':prefix+'/pulls/'+str(target['pr'])+'/reviews'}.items():
|
||||||
|
raw[name] = self.collection(endpoint)
|
||||||
|
raw['inline'] = {str(review['id']):self.collection(prefix+'/pulls/'+str(target['pr'])+
|
||||||
|
'/reviews/'+str(review['id'])+'/comments', allow_missing=self.provider['inline']['mode'] == 'per_review_or_pr_count') for review in raw['reviews']['rows']}
|
||||||
|
self.bind(target, raw)
|
||||||
|
self.counts(raw)
|
||||||
|
return raw
|
||||||
|
|
||||||
|
def snapshot(self, expected):
|
||||||
|
target = self.target(expected)
|
||||||
|
raw = self.capture(target)
|
||||||
|
if digest(self.capture(target)) != digest(raw):
|
||||||
|
raise ReadbackError('concurrent provider drift')
|
||||||
|
counts = self.counts(raw)
|
||||||
|
snapshot = {'schema':'gitea-agent.rework-readback/v1','target':target,
|
||||||
|
'expected_sha256':digest(expected),'provider':copy.deepcopy(self.provider),
|
||||||
|
'actor_id':self.actor_id,'origin':ORIGIN,'credential_source':CREDENTIAL_SOURCE,
|
||||||
|
'raw':raw,'review_body_sha256':hashlib.sha256(text(next(row for row in raw['reviews']['rows']
|
||||||
|
if row['id'] == target['review'])['body']).encode()).hexdigest(),
|
||||||
|
'normalized_counts':counts,'fallback':'counts_only' if any(value['pages'][0]['status'] != 200 for value in raw['inline'].values()) else 'none'}
|
||||||
|
self.validate_snapshot(expected, snapshot)
|
||||||
|
return snapshot
|
||||||
|
|
||||||
|
def validate_collection(self, collected, endpoint, *, reviews=False, allow_missing=False):
|
||||||
|
collected = object_value(collected)
|
||||||
|
rows, pages = collected.get('rows'), collected.get('pages')
|
||||||
|
if (collected.get('endpoint') != endpoint or type(rows) is not list or len(rows) > 1000
|
||||||
|
or type(pages) is not list or not 1 <= len(pages) <= 21):
|
||||||
|
raise ReadbackError('collection provenance mismatch')
|
||||||
|
ids = set()
|
||||||
|
for row in rows:
|
||||||
|
row = object_value(row)
|
||||||
|
ident = integer(row.get('id'), 1)
|
||||||
|
if ident in ids: raise ReadbackError('duplicate collection ID')
|
||||||
|
ids.add(ident)
|
||||||
|
user = object_value(row.get('user'))
|
||||||
|
integer(user.get('id'), 1)
|
||||||
|
if type(user.get('login')) is not str or not user['login']:
|
||||||
|
raise ReadbackError('invalid collection author')
|
||||||
|
if reviews:
|
||||||
|
review_record(row)
|
||||||
|
else:
|
||||||
|
if type(row.get('body')) is not str: raise ReadbackError('invalid comment body')
|
||||||
|
if timestamp(row.get('updated_at')) < timestamp(row.get('created_at')):
|
||||||
|
raise ReadbackError('invalid comment timestamp order')
|
||||||
|
first = object_value(pages[0])
|
||||||
|
missing = first.get('status') in (404,405)
|
||||||
|
if missing:
|
||||||
|
if not allow_missing or rows or len(pages) != 1 or first.get('total') is not None:
|
||||||
|
raise ReadbackError('invalid inline capability fallback')
|
||||||
|
elif len(pages) != len(rows)//50+1:
|
||||||
|
raise ReadbackError('incomplete collection page records')
|
||||||
|
for index, page in enumerate(pages, 1):
|
||||||
|
page = object_value(page)
|
||||||
|
count = 0 if missing else min(50, max(0, len(rows)-(index-1)*50))
|
||||||
|
link = page.get('link')
|
||||||
|
if link is not None and type(link) is not str:
|
||||||
|
raise ReadbackError('collection page provenance mismatch')
|
||||||
|
if (integer(page.get('page'), 1) != index or integer(page.get('count')) != count
|
||||||
|
or page.get('path') != endpoint+'?page='+str(index)+'&limit=50'
|
||||||
|
or integer(page.get('status'), 100) not in ((404,405) if missing else (200,))
|
||||||
|
or (not missing and integer(page.get('total')) != len(rows))):
|
||||||
|
raise ReadbackError('collection page provenance mismatch')
|
||||||
|
if not missing:
|
||||||
|
self.links(endpoint, link, index, len(rows))
|
||||||
|
return missing
|
||||||
|
|
||||||
|
def validate_snapshot(self, expected, snapshot):
|
||||||
|
snapshot = object_value(snapshot)
|
||||||
|
target = self.target(expected)
|
||||||
|
if (snapshot.get('schema') != 'gitea-agent.rework-readback/v1'
|
||||||
|
or snapshot.get('origin') != ORIGIN or snapshot.get('credential_source') != CREDENTIAL_SOURCE
|
||||||
|
or integer(snapshot.get('actor_id'), 1) != self.actor_id
|
||||||
|
or digest(snapshot.get('target')) != digest(target)
|
||||||
|
or digest(snapshot.get('provider')) != digest(self.provider)
|
||||||
|
or snapshot.get('expected_sha256') != digest(expected)):
|
||||||
|
raise ReadbackError('snapshot binding mismatch')
|
||||||
|
raw = object_value(snapshot.get('raw'))
|
||||||
|
identity = object_value(raw.get('identity'))
|
||||||
|
if integer(identity.get('id'), 1) != self.actor_id or identity.get('login') != 'gitea-impl-agent':
|
||||||
|
raise ReadbackError('snapshot actor mismatch')
|
||||||
|
if object_value(raw.get('version')).get('version') != self.provider['version']:
|
||||||
|
raise ReadbackError('snapshot provider version mismatch')
|
||||||
|
prefix = '/repos/'+target['owner']+'/'+target['repository']
|
||||||
|
for name, endpoint in {'source_comments':prefix+'/issues/'+str(target['issue'])+'/comments',
|
||||||
|
'comments':prefix+'/issues/'+str(target['pr'])+'/comments',
|
||||||
|
'reviews':prefix+'/pulls/'+str(target['pr'])+'/reviews'}.items():
|
||||||
|
self.validate_collection(raw.get(name), endpoint, reviews=name == 'reviews')
|
||||||
|
inline = object_value(raw.get('inline'))
|
||||||
|
if set(inline) != {str(row['id']) for row in raw['reviews']['rows']}:
|
||||||
|
raise ReadbackError('incomplete review comment endpoints')
|
||||||
|
fallback = False
|
||||||
|
for rid, collected in inline.items():
|
||||||
|
fallback |= self.validate_collection(collected, prefix+'/pulls/'+str(target['pr'])+
|
||||||
|
'/reviews/'+rid+'/comments', allow_missing=self.provider['inline']['mode'] == 'per_review_or_pr_count')
|
||||||
|
self.bind(target, raw)
|
||||||
|
target_review = next(row for row in raw['reviews']['rows'] if row['id'] == target['review'])
|
||||||
|
if snapshot.get('review_body_sha256') != hashlib.sha256(target_review['body'].encode()).hexdigest():
|
||||||
|
raise ReadbackError('review body digest mismatch')
|
||||||
|
for name in ('source_issue','pr','pr_issue'):
|
||||||
|
timestamp(raw[name].get('updated_at'))
|
||||||
|
if (type(raw[name].get('body')) is not str or type(raw[name].get('title')) is not str
|
||||||
|
or raw[name].get('state') not in ('open','closed')):
|
||||||
|
raise ReadbackError('incomplete Issue or PR metadata')
|
||||||
|
counts = self.counts(raw)
|
||||||
|
if (digest(snapshot.get('normalized_counts')) != digest(counts)
|
||||||
|
or snapshot.get('fallback') != ('counts_only' if fallback else 'none')):
|
||||||
|
raise ReadbackError('snapshot count or capability mismatch')
|
||||||
|
return raw
|
||||||
|
|
||||||
|
def normalized_inputs(self, expected, baseline, after, action):
|
||||||
|
if action not in ('create','reuse'): raise ReadbackError('invalid rework action')
|
||||||
|
b, a = [self.validate_snapshot(expected, value) for value in (baseline, after)]
|
||||||
|
target = self.target(expected)
|
||||||
|
if digest(b['repository']) != digest(a['repository']): raise ReadbackError('repository drift')
|
||||||
|
for name in ('source_comments','reviews','inline'):
|
||||||
|
if digest(b[name]) != digest(a[name]): raise ReadbackError('source or review collection drift')
|
||||||
|
old = {row['id']:row for row in b['comments']['rows']}
|
||||||
|
current = {row['id']:row for row in a['comments']['rows']}
|
||||||
|
for ident, row in old.items():
|
||||||
|
if ident not in current or digest(row) != digest(current[ident]):
|
||||||
|
raise ReadbackError('existing comment changed or missing')
|
||||||
|
added = [row for ident, row in current.items() if ident not in old]
|
||||||
|
if len(added) != (1 if action == 'create' else 0): raise ReadbackError('comment delta cardinality')
|
||||||
|
matches = [row for row in current.values() if expected['correlation'] in row['body']]
|
||||||
|
if len(matches) != 1: raise ReadbackError('comment correlation cardinality')
|
||||||
|
matched = matches[0]
|
||||||
|
if (matched['body'] != expected['body'] or matched['user']['id'] != self.actor_id
|
||||||
|
or matched['user']['login'] != 'gitea-impl-agent'
|
||||||
|
or matched.get('html_url') != a['pr']['html_url']+'#issuecomment-'+str(matched['id'])):
|
||||||
|
raise ReadbackError('canonical comment binding mismatch')
|
||||||
|
if action == 'create':
|
||||||
|
if matched['id'] != added[0]['id'] or matched['updated_at'] != matched['created_at']:
|
||||||
|
raise ReadbackError('created comment causality mismatch')
|
||||||
|
for name in ('pr','pr_issue','source_issue'):
|
||||||
|
if timestamp(matched['created_at']) < timestamp(b[name]['updated_at']):
|
||||||
|
raise ReadbackError('created comment predates baseline')
|
||||||
|
for name in ('source_issue','pr','pr_issue'):
|
||||||
|
before, now = copy.deepcopy(b[name]), copy.deepcopy(a[name])
|
||||||
|
if name == 'source_issue':
|
||||||
|
if digest(before) != digest(now):
|
||||||
|
raise ReadbackError('source Issue metadata drift')
|
||||||
|
else:
|
||||||
|
field = self.provider['aggregate'][name]['field']
|
||||||
|
if integer(now.pop(field)) != integer(before.pop(field)) + (1 if action == 'create' else 0):
|
||||||
|
raise ReadbackError('PR counter delta causality mismatch')
|
||||||
|
if name != 'source_issue' and action == 'create' and now['updated_at'] != before['updated_at']:
|
||||||
|
if now['updated_at'] != matched['created_at']:
|
||||||
|
raise ReadbackError('uncausal Issue or PR timestamp')
|
||||||
|
now['updated_at'] = before['updated_at']
|
||||||
|
if digest(before) != digest(now): raise ReadbackError('unrelated Issue or PR metadata drift')
|
||||||
|
counts = self.counts(a)
|
||||||
|
pr = dict(a['pr'], object_kind='pull_request', owner=target['owner'],
|
||||||
|
repository=target['repository'], metadata_binding_verified=True)
|
||||||
|
components = self.provider['aggregate']['pr']['components']
|
||||||
|
provider_counts = dict(counts, aggregate_components=components,
|
||||||
|
aggregate_comments=sum(counts[name] for name in components))
|
||||||
|
comments = {'object_kind':'pull_request_top_level_comments','owner':target['owner'],
|
||||||
|
'repository':target['repository'],'pr':target['pr'],
|
||||||
|
'complete':True,'endpoint_binding_verified':True,
|
||||||
|
'source_issue_unchanged':True,'review_collection_unchanged':True,
|
||||||
|
'pr_metadata_unchanged':True,'comments':a['comments']['rows'],
|
||||||
|
'total_count':len(a['comments']['rows']), 'page_count':len(a['comments']['pages']),
|
||||||
|
'provider_counts':provider_counts}
|
||||||
|
identity = dict(a['identity'], role='gitea-impl-agent', authenticated=True,
|
||||||
|
stable_account_verified=True)
|
||||||
|
return {'pr':pr,'comments':comments,'identity':identity,
|
||||||
|
'baseline_count':len(b['comments']['rows'])}
|
||||||
@@ -0,0 +1,318 @@
|
|||||||
|
"""Role-local, read-only transport and CLI bridge for rework readback."""
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import secrets
|
||||||
|
import ssl
|
||||||
|
import stat
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
ORIGIN = 'https://gitea.jsfamily.quest'
|
||||||
|
|
||||||
|
|
||||||
|
def _open_parent(path):
|
||||||
|
path = Path(path)
|
||||||
|
raw = os.fspath(path)
|
||||||
|
if not raw.startswith('/') or any(part in ('', '.', '..') for part in raw.split('/')[1:]):
|
||||||
|
raise ValueError('absolute canonical path required')
|
||||||
|
flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC
|
||||||
|
fd = os.open('/', flags)
|
||||||
|
try:
|
||||||
|
for part in path.parts[1:-1]:
|
||||||
|
child = os.open(part, flags, dir_fd=fd)
|
||||||
|
os.close(fd)
|
||||||
|
fd = child
|
||||||
|
return fd, path.name
|
||||||
|
except BaseException:
|
||||||
|
os.close(fd)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def load_role_token(home):
|
||||||
|
"""Read only the caller's private profile file; never ambient token fallback."""
|
||||||
|
try:
|
||||||
|
parent, name = _open_parent(Path(home) / '.env')
|
||||||
|
try:
|
||||||
|
fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=parent)
|
||||||
|
try:
|
||||||
|
metadata = os.fstat(fd)
|
||||||
|
if (not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != os.getuid()
|
||||||
|
or stat.S_IMODE(metadata.st_mode) != 0o600 or metadata.st_nlink != 1):
|
||||||
|
raise ValueError('unsafe role credential file')
|
||||||
|
with os.fdopen(fd, 'rb', closefd=False) as stream:
|
||||||
|
raw = stream.read(65537)
|
||||||
|
if len(raw) > 65536:
|
||||||
|
raise ValueError('role credential file too large')
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
finally:
|
||||||
|
os.close(parent)
|
||||||
|
values = []
|
||||||
|
for line in raw.decode('utf-8').splitlines():
|
||||||
|
key, separator, value = line.partition('=')
|
||||||
|
if separator and key.strip() == 'GITEA_TOKEN':
|
||||||
|
value = value.strip()
|
||||||
|
if len(value) >= 2 and value[0] in ('\"', "'") and value[-1] == value[0]:
|
||||||
|
value = value[1:-1]
|
||||||
|
values.append(value)
|
||||||
|
if (len(values) != 1 or not 1 <= len(values[0]) <= 4096
|
||||||
|
or any(ord(char) < 33 or ord(char) > 126 for char in values[0])):
|
||||||
|
raise ValueError('invalid role credential')
|
||||||
|
return values[0]
|
||||||
|
except (OSError, UnicodeError, ValueError):
|
||||||
|
raise ValueError('private role credential unavailable') from None
|
||||||
|
|
||||||
|
|
||||||
|
def decode_json(raw):
|
||||||
|
def pairs(items):
|
||||||
|
result = {}
|
||||||
|
for key, value in items:
|
||||||
|
if key in result: raise ValueError('duplicate JSON key')
|
||||||
|
result[key] = value
|
||||||
|
return result
|
||||||
|
def invalid_constant(value):
|
||||||
|
raise ValueError('invalid JSON number')
|
||||||
|
try:
|
||||||
|
return json.loads(raw, object_pairs_hook=pairs, parse_constant=invalid_constant)
|
||||||
|
except (ValueError, UnicodeError):
|
||||||
|
raise ValueError('invalid readback JSON') from None
|
||||||
|
|
||||||
|
|
||||||
|
def response_headers(headers):
|
||||||
|
values, seen = {}, set()
|
||||||
|
for key, value in headers.items():
|
||||||
|
if type(key) is not str or type(value) is not str or key.lower() in seen:
|
||||||
|
raise ValueError('invalid or duplicate response header')
|
||||||
|
seen.add(key.lower())
|
||||||
|
values[key] = value
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||||
|
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class RestTransport:
|
||||||
|
"""GET-only fixed-origin transport; opener injection is a test seam."""
|
||||||
|
def __init__(self, token, *, opener=None):
|
||||||
|
if (type(token) is not str or not 1 <= len(token) <= 4096
|
||||||
|
or any(ord(c) < 33 or ord(c) > 126 for c in token)):
|
||||||
|
raise ValueError('invalid role credential')
|
||||||
|
self._credential = token
|
||||||
|
self._deadline = time.monotonic() + 120
|
||||||
|
self._opener = opener or urllib.request.build_opener(
|
||||||
|
NoRedirect(), urllib.request.HTTPSHandler(context=ssl.create_default_context()))
|
||||||
|
|
||||||
|
def __call__(self, path):
|
||||||
|
try:
|
||||||
|
parts = urllib.parse.urlsplit(path)
|
||||||
|
if (type(path) is not str or not path.startswith('/') or path.startswith('//')
|
||||||
|
or parts.scheme or parts.netloc or parts.fragment
|
||||||
|
or any(c in path for c in ('\r', '\n', '\\'))
|
||||||
|
or any(p in ('.', '..') for p in urllib.parse.unquote(parts.path).split('/'))
|
||||||
|
or self._credential in path):
|
||||||
|
raise ValueError('invalid readback endpoint')
|
||||||
|
url = ORIGIN + '/api/v1' + path
|
||||||
|
request = urllib.request.Request(url, method='GET', headers={
|
||||||
|
'Authorization': 'token ' + self._credential,
|
||||||
|
'Accept': 'application/json', 'Cache-Control': 'no-cache'})
|
||||||
|
started = time.monotonic()
|
||||||
|
remaining = self._deadline - started
|
||||||
|
if remaining <= 0:
|
||||||
|
raise ValueError('readback operation budget exceeded')
|
||||||
|
try:
|
||||||
|
response = self._opener.open(request, timeout=min(10, remaining))
|
||||||
|
except urllib.error.HTTPError as error:
|
||||||
|
try: return None, response_headers(error.headers), error.code
|
||||||
|
finally: error.close()
|
||||||
|
with response:
|
||||||
|
if response.geturl() != url:
|
||||||
|
raise ValueError('redirected readback response')
|
||||||
|
status = response.status
|
||||||
|
headers = response_headers(response.headers)
|
||||||
|
if status != 200:
|
||||||
|
return None, headers, status
|
||||||
|
content_type = response.headers.get('Content-Type', '').split(';')[0].strip().lower()
|
||||||
|
if content_type != 'application/json':
|
||||||
|
raise ValueError('non-JSON readback response')
|
||||||
|
chunks, size = [], 0
|
||||||
|
while True:
|
||||||
|
if time.monotonic() - started > 30 or time.monotonic() >= self._deadline:
|
||||||
|
raise ValueError('readback response budget exceeded')
|
||||||
|
chunk = response.read1(min(65536, 1_000_001 - size))
|
||||||
|
if not chunk: break
|
||||||
|
chunks.append(chunk)
|
||||||
|
size += len(chunk)
|
||||||
|
if size > 1_000_000:
|
||||||
|
raise ValueError('readback response too large')
|
||||||
|
raw = b''.join(chunks)
|
||||||
|
if self._credential.encode() in raw:
|
||||||
|
raise ValueError('credential echoed in readback')
|
||||||
|
data = decode_json(raw)
|
||||||
|
if self._credential in json.dumps(data, ensure_ascii=False):
|
||||||
|
raise ValueError('credential echoed in readback')
|
||||||
|
return data, headers, status
|
||||||
|
except Exception:
|
||||||
|
raise ValueError('authenticated readback transport failed') from None
|
||||||
|
|
||||||
|
|
||||||
|
def read_bound_json(path, expected_sha256=None):
|
||||||
|
if expected_sha256 is not None and not re.fullmatch(r'[0-9a-f]{64}', expected_sha256):
|
||||||
|
raise ValueError('invalid independently expected SHA-256')
|
||||||
|
parent, name = _open_parent(path)
|
||||||
|
try:
|
||||||
|
fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_CLOEXEC, dir_fd=parent)
|
||||||
|
try:
|
||||||
|
metadata = os.fstat(fd)
|
||||||
|
if (not stat.S_ISREG(metadata.st_mode) or metadata.st_nlink != 1
|
||||||
|
or metadata.st_mode & 0o022 or metadata.st_uid != os.getuid()):
|
||||||
|
raise ValueError('unsafe readback input')
|
||||||
|
with os.fdopen(fd, 'rb', closefd=False) as stream:
|
||||||
|
raw = stream.read(16_000_001)
|
||||||
|
if len(raw) > 16_000_000:
|
||||||
|
raise ValueError('readback input too large')
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
finally:
|
||||||
|
os.close(parent)
|
||||||
|
if expected_sha256 is not None and hashlib.sha256(raw).hexdigest() != expected_sha256:
|
||||||
|
raise ValueError('readback input fingerprint mismatch')
|
||||||
|
value = decode_json(raw)
|
||||||
|
if type(value) is not dict:
|
||||||
|
raise ValueError('readback object required')
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class OutputFile:
|
||||||
|
"""Pin a private output directory, and replace only after successful verification."""
|
||||||
|
def __init__(self, path):
|
||||||
|
self.path = Path(path)
|
||||||
|
self.fd, self.name = _open_parent(self.path)
|
||||||
|
try:
|
||||||
|
parent = os.fstat(self.fd)
|
||||||
|
if parent.st_uid != os.getuid() or stat.S_IMODE(parent.st_mode) != 0o700:
|
||||||
|
raise ValueError('private output directory required')
|
||||||
|
self.parent_identity = (parent.st_dev, parent.st_ino)
|
||||||
|
self.original = self.identity()
|
||||||
|
self.done = False
|
||||||
|
except BaseException:
|
||||||
|
os.close(self.fd)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def identity(self):
|
||||||
|
try: metadata = os.stat(self.name, dir_fd=self.fd, follow_symlinks=False)
|
||||||
|
except FileNotFoundError: return None
|
||||||
|
if (not stat.S_ISREG(metadata.st_mode) or metadata.st_uid != os.getuid()
|
||||||
|
or stat.S_IMODE(metadata.st_mode) != 0o600 or metadata.st_nlink != 1):
|
||||||
|
raise ValueError('unsafe output target')
|
||||||
|
return metadata.st_dev, metadata.st_ino
|
||||||
|
|
||||||
|
def check_parent(self):
|
||||||
|
fd, _ = _open_parent(self.path)
|
||||||
|
try:
|
||||||
|
metadata = os.fstat(fd)
|
||||||
|
if ((metadata.st_dev, metadata.st_ino) != self.parent_identity
|
||||||
|
or metadata.st_uid != os.getuid() or stat.S_IMODE(metadata.st_mode) != 0o700):
|
||||||
|
raise ValueError('output directory drift')
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
|
||||||
|
def write(self, value):
|
||||||
|
raw = (json.dumps(value, ensure_ascii=False, sort_keys=True, allow_nan=False) + '\n').encode()
|
||||||
|
if len(raw) > 16_000_000 or self.done:
|
||||||
|
raise ValueError('invalid output size or repeated write')
|
||||||
|
self.check_parent()
|
||||||
|
if self.identity() != self.original:
|
||||||
|
raise ValueError('output target drift')
|
||||||
|
temporary = '.readback-' + secrets.token_hex(16)
|
||||||
|
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW,
|
||||||
|
0o600, dir_fd=self.fd)
|
||||||
|
try:
|
||||||
|
os.fchmod(fd, 0o600)
|
||||||
|
with os.fdopen(fd, 'wb', closefd=False) as stream:
|
||||||
|
stream.write(raw)
|
||||||
|
stream.flush()
|
||||||
|
os.fsync(fd)
|
||||||
|
self.check_parent()
|
||||||
|
if self.identity() != self.original:
|
||||||
|
raise ValueError('output target drift')
|
||||||
|
os.replace(temporary, self.name, src_dir_fd=self.fd, dst_dir_fd=self.fd)
|
||||||
|
self.done = True
|
||||||
|
os.fsync(self.fd)
|
||||||
|
self.check_parent()
|
||||||
|
finally:
|
||||||
|
os.close(fd)
|
||||||
|
try: os.unlink(temporary, dir_fd=self.fd)
|
||||||
|
except FileNotFoundError: pass
|
||||||
|
return hashlib.sha256(raw).hexdigest()
|
||||||
|
|
||||||
|
def __enter__(self): return self
|
||||||
|
def __exit__(self, *args): os.close(self.fd)
|
||||||
|
|
||||||
|
|
||||||
|
def run_live(args, verifier):
|
||||||
|
"""Capture a baseline or feed computed fresh readbacks into the existing verifier."""
|
||||||
|
try:
|
||||||
|
input_paths = [Path(args.expected), Path(args.provider_contract)]
|
||||||
|
if args.cmd == 'verify-live': input_paths.append(Path(args.baseline))
|
||||||
|
if Path(args.output) in input_paths:
|
||||||
|
raise ValueError('output aliases protected input')
|
||||||
|
with OutputFile(args.output) as output:
|
||||||
|
expected = read_bound_json(args.expected)
|
||||||
|
contract = read_bound_json(args.provider_contract, args.provider_contract_sha256)
|
||||||
|
expected_digest = hashlib.sha256(json.dumps(
|
||||||
|
expected, sort_keys=True, ensure_ascii=False, allow_nan=False).encode()).hexdigest()
|
||||||
|
baseline = None
|
||||||
|
if args.cmd == 'verify-live':
|
||||||
|
bundle = read_bound_json(args.baseline, args.baseline_sha256)
|
||||||
|
if (bundle.get('schema') != 'gitea-agent.rework-baseline/v1'
|
||||||
|
or bundle.get('expected_sha256') != expected_digest
|
||||||
|
or bundle.get('provider_contract_sha256') != args.provider_contract_sha256
|
||||||
|
or type(bundle.get('snapshot')) is not dict):
|
||||||
|
raise ValueError('baseline binding mismatch')
|
||||||
|
baseline = bundle['snapshot']
|
||||||
|
home = os.environ.get('HERMES_HOME')
|
||||||
|
if not home:
|
||||||
|
raise ValueError('role profile not bound')
|
||||||
|
credential = load_role_token(home)
|
||||||
|
transport = RestTransport(credential)
|
||||||
|
import importlib.util
|
||||||
|
path = Path(__file__).with_name('rework_readback.py')
|
||||||
|
spec = importlib.util.spec_from_file_location('gitea_impl_readback_core', path)
|
||||||
|
if spec is None or spec.loader is None:
|
||||||
|
raise ValueError('canonical readback core unavailable')
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
import sys
|
||||||
|
sys.modules[spec.name] = module
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
adapter = module.ReworkReadbackAdapter(transport, origin=ORIGIN + '/api/v1',
|
||||||
|
credential_source='$HERMES_HOME/.env:GITEA_TOKEN',
|
||||||
|
actor_id=args.actor_id, provider_contract=contract)
|
||||||
|
after = adapter.snapshot(expected)
|
||||||
|
if args.cmd == 'snapshot':
|
||||||
|
result = {'schema': 'gitea-agent.rework-baseline/v1',
|
||||||
|
'expected_sha256': expected_digest,
|
||||||
|
'provider_contract_sha256': args.provider_contract_sha256,
|
||||||
|
'snapshot': after}
|
||||||
|
else:
|
||||||
|
inputs = adapter.normalized_inputs(expected, baseline, after, args.action)
|
||||||
|
result = verifier(expected, inputs['pr'], inputs['comments'], inputs['identity'],
|
||||||
|
action=args.action, baseline_count=inputs['baseline_count'])
|
||||||
|
result['readback_provenance'] = {'kind': 'fresh_authenticated_GET',
|
||||||
|
'origin': ORIGIN, 'baseline_sha256': args.baseline_sha256,
|
||||||
|
'provider_contract_sha256': args.provider_contract_sha256,
|
||||||
|
'snapshot_sha256': hashlib.sha256(json.dumps(after, sort_keys=True,
|
||||||
|
ensure_ascii=False, allow_nan=False).encode()).hexdigest(),
|
||||||
|
'limitation': 'state-delta/exact-timestamp admission; not proof of causal event attestation'}
|
||||||
|
if credential in json.dumps(result, ensure_ascii=False):
|
||||||
|
raise ValueError('credential material in readback artifact')
|
||||||
|
digest = output.write(result)
|
||||||
|
print(json.dumps({'output': str(args.output), 'sha256': digest, 'read_only': True}))
|
||||||
|
except Exception:
|
||||||
|
raise ValueError('live readback failed; output not verified') from None
|
||||||
@@ -29,6 +29,18 @@ reject them merely because the conventional Unix-domain path encoding exceeds
|
|||||||
foreign/replaced paths, close every client and directory descriptor, and retain
|
foreign/replaced paths, close every client and directory descriptor, and retain
|
||||||
unauthenticated-token rejection behavior.
|
unauthenticated-token rejection behavior.
|
||||||
|
|
||||||
|
Rework readback contract: the impl role's canonical `rework_comment.py snapshot`
|
||||||
|
and `verify-live` commands collect authenticated GET-only provider state through
|
||||||
|
`rework_readback.py` and `rework_readback_cli.py`. Focused coverage lives in
|
||||||
|
`harness/tests/impl/test_rework_readback.py` and
|
||||||
|
`harness/tests/impl/test_rework_readback_transport.py`; run them with the existing
|
||||||
|
`harness/tests/impl/test_rework_comment.py` regressions. They exercise real
|
||||||
|
renderer/verifier integration and a local HTTP fixture, not live Gitea access.
|
||||||
|
Provider/baseline fingerprints, exact identity/endpoints, complete pagination,
|
||||||
|
create/reuse deltas, metadata causality and failed-receipt preservation are
|
||||||
|
fail-closed. The workflow contract and canonical runtime artifact paths are in
|
||||||
|
`harness/agents/impl/skills/gitea-issue-rework/references/readback.md`.
|
||||||
|
|
||||||
It validates all `test*.py` files recursively under exactly these required
|
It validates all `test*.py` files recursively under exactly these required
|
||||||
roles: `common`, `spec`, `impl`, and `review`. `harness/tests`, every role, and
|
roles: `common`, `spec`, `impl`, and `review`. `harness/tests`, every role, and
|
||||||
every nested test package must have an `__init__.py`; every role and every
|
every nested test package must have an `__init__.py`; every role and every
|
||||||
|
|||||||
@@ -0,0 +1,477 @@
|
|||||||
|
import copy
|
||||||
|
import hashlib
|
||||||
|
import importlib.util
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from argparse import Namespace
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import parse_qs, urlsplit
|
||||||
|
|
||||||
|
DIRECTORY = Path(__file__).parents[2] / 'agents/impl/skills/gitea-issue-rework/scripts'
|
||||||
|
ORIGIN = 'https://gitea.jsfamily.quest'
|
||||||
|
T0 = '2026-01-01T00:00:00Z'
|
||||||
|
T1 = '2026-01-02T00:00:00Z'
|
||||||
|
|
||||||
|
|
||||||
|
class ReworkReadbackTests(unittest.TestCase):
|
||||||
|
def load(self, name):
|
||||||
|
path = DIRECTORY / (name + '.py')
|
||||||
|
self.assertTrue(path.is_file(), 'canonical readback core missing')
|
||||||
|
spec = importlib.util.spec_from_file_location(name, path)
|
||||||
|
assert spec is not None and spec.loader is not None
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.legacy = self.load('rework_comment')
|
||||||
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
|
self.addCleanup(self.temp.cleanup)
|
||||||
|
root = Path(self.temp.name)
|
||||||
|
data = {'owner':'acme','repository':'demo','issue':7,'pr':9,'review':301,
|
||||||
|
'reviewed_head':'a'*40,'final_head':'b'*40,'contract_sha256':'c'*64,
|
||||||
|
'result':'partial','findings':[{'id':'F-1','status':'resolved','classification':'valid_fix',
|
||||||
|
'changes':'bounded fix','verification':'local checks pass','reason':'regression'}],
|
||||||
|
'local_checks':'pass','runtime_browser':'not applicable',
|
||||||
|
'commit_status':'success','actions_runs':'없음'}
|
||||||
|
(root/'input.json').write_text(json.dumps(data))
|
||||||
|
self.legacy.do_render(Namespace(input=str(root/'input.json'), no_delegated_evidence=True,
|
||||||
|
delegation_adoption=None, comment=str(root/'comment.md'), expected=str(root/'expected.json')))
|
||||||
|
self.expected = json.loads((root/'expected.json').read_text())
|
||||||
|
self.contract = {'schema':'gitea-agent.rework-provider/v1','version':'1.24.6',
|
||||||
|
'aggregate':{'pr':{'field':'comments','components':['top_level','reviews','inline']},
|
||||||
|
'pr_issue':{'field':'comments','components':['top_level','reviews','inline']}},
|
||||||
|
'inline':{'mode':'per_review','field':'review_comments','components':['reviews','inline']}}
|
||||||
|
self.repo = {'id':3,'full_name':'acme/demo','html_url':ORIGIN+'/acme/demo'}
|
||||||
|
self.source = {'id':707,'number':7,'html_url':ORIGIN+'/acme/demo/issues/7',
|
||||||
|
'body':'source contract','title':'Source','state':'open','comments':0,'updated_at':T0}
|
||||||
|
self.pr = {'id':99,'number':9,'html_url':ORIGIN+'/acme/demo/pulls/9',
|
||||||
|
'url':ORIGIN+'/acme/demo/pulls/9','state':'open',
|
||||||
|
'head':{'sha':'b'*40,'repo':{'id':12,'full_name':'contributor/demo'}},
|
||||||
|
'base':{'repo':copy.deepcopy(self.repo)},'title':'PR','body':'Closes #7',
|
||||||
|
'comments':1,'review_comments':1,'updated_at':T0}
|
||||||
|
self.underlying = {'id':99,'number':9,'html_url':self.pr['html_url'],
|
||||||
|
'pull_request':{'html_url':self.pr['html_url'],'merged':False},
|
||||||
|
'title':'PR','body':'Closes #7','state':'open','comments':1,'updated_at':T0}
|
||||||
|
self.reviews = [{'id':301,'commit_id':'a'*40,'comments_count':0,'state':'REQUEST_CHANGES',
|
||||||
|
'user':{'id':33,'login':'gitea-review-agent'}}]
|
||||||
|
self.comments = []
|
||||||
|
self.source_comments = []
|
||||||
|
self.inline = []
|
||||||
|
self.overrides = {}
|
||||||
|
self.calls = []
|
||||||
|
for record in (self.source, self.pr, self.underlying):
|
||||||
|
record.update(created_at=T0, labels=[], assignees=[], milestone=None,
|
||||||
|
user={'id':22,'login':'genichin'} if record is self.source
|
||||||
|
else {'id':42,'login':'gitea-impl-agent'})
|
||||||
|
self.pr['base'].update(ref='main', label='main', sha='d'*40, repo_id=3)
|
||||||
|
self.pr['head'].update(ref='fix/issue-7', label='contributor:fix/issue-7', repo_id=12)
|
||||||
|
self.reviews[0].update(body='Fix F-1', submitted_at=T0, updated_at=T0,
|
||||||
|
stale=True, dismissed=False, html_url=self.pr['html_url']+'#pullreview-301',
|
||||||
|
pull_request_url=self.pr['html_url'])
|
||||||
|
|
||||||
|
def request(self, path):
|
||||||
|
self.calls.append(path)
|
||||||
|
if path in self.overrides: return copy.deepcopy(self.overrides[path])
|
||||||
|
parsed = urlsplit(path)
|
||||||
|
objects = {'/user':{'id':42,'login':'gitea-impl-agent'}, '/version':{'version':'1.24.6'},
|
||||||
|
'/repos/acme/demo':self.repo, '/repos/acme/demo/issues/7':self.source,
|
||||||
|
'/repos/acme/demo/pulls/9':self.pr, '/repos/acme/demo/issues/9':self.underlying}
|
||||||
|
if parsed.path in objects: return copy.deepcopy(objects[parsed.path]), {}, 200
|
||||||
|
collections = {'/repos/acme/demo/issues/7/comments':self.source_comments,
|
||||||
|
'/repos/acme/demo/issues/9/comments':self.comments,
|
||||||
|
'/repos/acme/demo/pulls/9/reviews':self.reviews,
|
||||||
|
'/repos/acme/demo/pulls/9/reviews/301/comments':self.inline}
|
||||||
|
if parsed.path not in collections: raise AssertionError('unexpected GET endpoint')
|
||||||
|
rows = collections[parsed.path]
|
||||||
|
page = int(parse_qs(parsed.query)['page'][0])
|
||||||
|
return copy.deepcopy(rows[(page-1)*50:page*50]), {'X-Total-Count':str(len(rows))}, 200
|
||||||
|
|
||||||
|
def adapter(self):
|
||||||
|
module = self.load('rework_readback')
|
||||||
|
try:
|
||||||
|
return module.ReworkReadbackAdapter(self.request, origin=ORIGIN+'/api/v1',
|
||||||
|
credential_source='$HERMES_HOME/.env:GITEA_TOKEN', actor_id=42,
|
||||||
|
provider_contract=self.contract)
|
||||||
|
except Exception as error:
|
||||||
|
self.fail('canonical CLI transport contract rejected: '+str(error))
|
||||||
|
|
||||||
|
def add_result(self):
|
||||||
|
self.comments.append({'id':501,'html_url':self.pr['html_url']+'#issuecomment-501',
|
||||||
|
'body':self.expected['body'],'created_at':T1,'updated_at':T1,
|
||||||
|
'user':{'id':42,'login':'gitea-impl-agent'}})
|
||||||
|
self.pr['comments'] += 1
|
||||||
|
self.underlying['comments'] += 1
|
||||||
|
self.pr['updated_at'] = self.underlying['updated_at'] = T1
|
||||||
|
|
||||||
|
def test_snapshot_rejects_wrong_provider_bindings(self):
|
||||||
|
cases = [('repo',['id'],True), ('repo',['full_name'],'elsewhere/demo'),
|
||||||
|
('repo',['html_url'],'https://foreign.invalid/acme/demo'),
|
||||||
|
('source',['number'],8), ('source',['id'],True),
|
||||||
|
('source',['html_url'],ORIGIN+'/elsewhere/demo/issues/7'),
|
||||||
|
('source',['pull_request'],{}), ('pr',['number'],10), ('pr',['id'],True),
|
||||||
|
('pr',['url'],'https://foreign.invalid/api/v1/repos/acme/demo/pulls/9'),
|
||||||
|
('pr',['html_url'],ORIGIN+'/elsewhere/demo/pulls/9'),
|
||||||
|
('pr',['head','sha'],'c'*40), ('pr',['base','repo','id'],4),
|
||||||
|
('pr',['state'],'closed'), ('underlying',['number'],10),
|
||||||
|
('underlying',['pull_request'],True), ('reviews',[0,'commit_id'],'c'*40)]
|
||||||
|
error = self.load('rework_readback').ReadbackError
|
||||||
|
# Each load has a distinct exception class; assert ValueError at the public boundary.
|
||||||
|
self.assertTrue(issubclass(error, ValueError))
|
||||||
|
for attribute, keys, value in cases:
|
||||||
|
with self.subTest(attribute=attribute, keys=keys):
|
||||||
|
original = copy.deepcopy(getattr(self, attribute))
|
||||||
|
item = getattr(self, attribute)
|
||||||
|
for key in keys[:-1]: item = item[key]
|
||||||
|
item[keys[-1]] = value
|
||||||
|
try:
|
||||||
|
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
|
||||||
|
finally: setattr(self, attribute, original)
|
||||||
|
|
||||||
|
def test_pagination_complete_51_and_rejects_bad_pages(self):
|
||||||
|
self.comments = [{'id':600+i,'body':'existing '+str(i),'created_at':T0,'updated_at':T0,
|
||||||
|
'user':{'id':8,'login':'other'}} for i in range(51)]
|
||||||
|
self.pr['comments'] = self.underlying['comments'] = 52
|
||||||
|
snapshot = self.adapter().snapshot(self.expected)
|
||||||
|
self.assertEqual(snapshot['normalized_counts']['top_level'], 51)
|
||||||
|
self.assertEqual(len(snapshot['raw']['comments']['pages']), 2)
|
||||||
|
first = '/repos/acme/demo/issues/9/comments?page=1&limit=50'
|
||||||
|
second = '/repos/acme/demo/issues/9/comments?page=2&limit=50'
|
||||||
|
invalid = [(second,[self.comments[0]],{'X-Total-Count':'51'},200),
|
||||||
|
(second,[self.comments[-1]],{'X-Total-Count':'52'},200),
|
||||||
|
(first,None,{},200), (first,[],{'X-Total-Count':'0','x-total-count':'0'},200),
|
||||||
|
(first,[],{'X-Total-Count':'0','Link':'<https://foreign.invalid/comments?page=2&limit=50>; rel="next"'},200),
|
||||||
|
(first,self.comments[:50],{},200),
|
||||||
|
(first,[{'id':True}],{'X-Total-Count':'1'},200),
|
||||||
|
(first,[],{'X-Total-Count':'0','Link':'not a Link'},200),
|
||||||
|
(first,[],{'X-Total-Count':'0'},True), (first,[],{},403), (first,[],{},302)]
|
||||||
|
for path, rows, headers, status in invalid:
|
||||||
|
with self.subTest(path=path, headers=headers, status=status):
|
||||||
|
self.overrides = {path:(rows,headers,status)}
|
||||||
|
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
|
||||||
|
self.overrides = {}
|
||||||
|
|
||||||
|
def test_counts_only_inline_fallback_is_explicit_and_crosschecked(self):
|
||||||
|
self.contract['inline']['mode'] = 'per_review_or_pr_count'
|
||||||
|
self.reviews[0]['comments_count'] = 3
|
||||||
|
self.pr['comments'] = self.underlying['comments'] = self.pr['review_comments'] = 4
|
||||||
|
endpoint = '/repos/acme/demo/pulls/9/reviews/301/comments?page=1&limit=50'
|
||||||
|
self.overrides = {endpoint:(None,{},404)}
|
||||||
|
try: snapshot = self.adapter().snapshot(self.expected)
|
||||||
|
except ValueError as error: self.fail('configured counts-only fallback missing: '+str(error))
|
||||||
|
self.assertEqual(snapshot['normalized_counts']['inline'], 3)
|
||||||
|
self.assertEqual(snapshot['fallback'], 'counts_only')
|
||||||
|
self.assertEqual(snapshot['raw']['inline']['301']['rows'], [])
|
||||||
|
self.pr['review_comments'] = 5
|
||||||
|
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
|
||||||
|
self.pr['review_comments'] = 4
|
||||||
|
for status, payload in ((403,None),(302,None),(200,None)):
|
||||||
|
self.overrides = {endpoint:(payload,{},status)}
|
||||||
|
with self.subTest(status=status):
|
||||||
|
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
|
||||||
|
self.contract['inline']['mode'] = 'per_review'
|
||||||
|
self.overrides = {endpoint:(None,{},404)}
|
||||||
|
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
|
||||||
|
|
||||||
|
def test_preflight_rejects_unbound_contract_and_target_before_GET(self):
|
||||||
|
module = self.load('rework_readback')
|
||||||
|
changes = [(['schema'],'unsupported'), (['version'],True),
|
||||||
|
(['aggregate','pr','components'],['inline']),
|
||||||
|
(['aggregate','pr','components'],['top_level','top_level']),
|
||||||
|
(['aggregate','pr_issue','field'],'other'),
|
||||||
|
(['inline','mode'],'automatic'), (['inline','components'],['other'])]
|
||||||
|
for keys, value in changes:
|
||||||
|
contract = copy.deepcopy(self.contract)
|
||||||
|
item = contract
|
||||||
|
for key in keys[:-1]: item = item[key]
|
||||||
|
item[keys[-1]] = value
|
||||||
|
with self.subTest(contract=keys):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
module.ReworkReadbackAdapter(self.request, origin=ORIGIN+'/api/v1',
|
||||||
|
credential_source='$HERMES_HOME/.env:GITEA_TOKEN', actor_id=42,
|
||||||
|
provider_contract=contract)
|
||||||
|
self.assertEqual(self.calls, [])
|
||||||
|
for keys, value in ((['target','owner'],'acme/../elsewhere'),
|
||||||
|
(['target','issue'],9), (['review'],True), (['head'],'B'*40),
|
||||||
|
(['body'],'forged body')):
|
||||||
|
expected = copy.deepcopy(self.expected)
|
||||||
|
item = expected
|
||||||
|
for key in keys[:-1]: item = item[key]
|
||||||
|
item[keys[-1]] = value
|
||||||
|
self.calls = []
|
||||||
|
with self.subTest(expected=keys):
|
||||||
|
with self.assertRaises(ValueError): self.adapter().snapshot(expected)
|
||||||
|
self.assertEqual(self.calls, [], 'invalid expectation reached provider')
|
||||||
|
|
||||||
|
def test_snapshot_detects_drift_during_collection(self):
|
||||||
|
original = self.request
|
||||||
|
endpoints = ['/user','/version','/repos/acme/demo','/repos/acme/demo/issues/7',
|
||||||
|
'/repos/acme/demo/pulls/9','/repos/acme/demo/issues/9',
|
||||||
|
'/repos/acme/demo/pulls/9/reviews']
|
||||||
|
for endpoint in endpoints:
|
||||||
|
counts = {}
|
||||||
|
def changing(path):
|
||||||
|
value, headers, status = original(path)
|
||||||
|
key = urlsplit(path).path
|
||||||
|
counts[key] = counts.get(key, 0) + 1
|
||||||
|
if key == endpoint and counts[key] > 1:
|
||||||
|
if type(value) is list: value[0]['state'] = 'APPROVED'
|
||||||
|
else: value['unexpected_drift'] = True
|
||||||
|
return value, headers, status
|
||||||
|
self.request = changing
|
||||||
|
try:
|
||||||
|
with self.subTest(endpoint=endpoint):
|
||||||
|
with self.assertRaisesRegex(ValueError, 'drift'):
|
||||||
|
self.adapter().snapshot(self.expected)
|
||||||
|
finally: self.request = original
|
||||||
|
|
||||||
|
def test_normalization_rejects_unrelated_delta_and_forged_pages(self):
|
||||||
|
adapter = self.adapter()
|
||||||
|
baseline = adapter.snapshot(self.expected)
|
||||||
|
self.add_result()
|
||||||
|
after = adapter.snapshot(self.expected)
|
||||||
|
# Prove this exact clean fixture is admitted before adversarial mutation.
|
||||||
|
adapter.normalized_inputs(self.expected, baseline, after, 'create')
|
||||||
|
cases = [(['schema'],'forged'), (['actor_id'],True), (['origin'],'https://foreign.invalid/api/v1'),
|
||||||
|
(['target','owner'],'other'), (['provider','version'],'different'),
|
||||||
|
(['expected_sha256'],'0'*64), (['raw','identity','login'],'other'),
|
||||||
|
(['raw','source_issue','body'],'unrelated edit'),
|
||||||
|
(['raw','reviews','rows',0,'state'],'APPROVED'),
|
||||||
|
(['raw','pr','head','sha'],'c'*40), (['raw','pr','title'],'unrelated edit'),
|
||||||
|
(['raw','pr','updated_at'],'2026-01-03T00:00:00Z'),
|
||||||
|
(['raw','comments','pages',0,'path'],'/repos/elsewhere/demo/issues/9/comments?page=1&limit=50'),
|
||||||
|
(['raw','comments','pages',0,'count'],0),
|
||||||
|
(['raw','comments','rows',0,'body'],'forged body'),
|
||||||
|
(['raw','comments','rows',0,'user','id'],8),
|
||||||
|
(['raw','comments','rows',0,'created_at'],'2025-01-01T00:00:00Z'),
|
||||||
|
(['raw','comments','rows',0,'updated_at'],'2026-01-03T00:00:00Z')]
|
||||||
|
for keys, value in cases:
|
||||||
|
changed = copy.deepcopy(after)
|
||||||
|
item = changed
|
||||||
|
for key in keys[:-1]: item = item[key]
|
||||||
|
item[keys[-1]] = value
|
||||||
|
with self.subTest(keys=keys):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
adapter.normalized_inputs(self.expected, baseline, changed, 'create')
|
||||||
|
with self.assertRaises(ValueError): adapter.normalized_inputs(self.expected, baseline, after, 'reuse')
|
||||||
|
|
||||||
|
def test_existing_comment_rows_are_immutable(self):
|
||||||
|
self.comments = [{'id':400,'body':'prior comment','created_at':T0,'updated_at':T0,
|
||||||
|
'user':{'id':8,'login':'other'}}]
|
||||||
|
self.pr['comments'] = self.underlying['comments'] = 2
|
||||||
|
adapter = self.adapter()
|
||||||
|
baseline = adapter.snapshot(self.expected)
|
||||||
|
self.add_result()
|
||||||
|
after = adapter.snapshot(self.expected)
|
||||||
|
adapter.normalized_inputs(self.expected, baseline, after, 'create')
|
||||||
|
after['raw']['comments']['rows'][0]['body'] = 'changed prior comment'
|
||||||
|
with self.assertRaisesRegex(ValueError, 'existing comment'):
|
||||||
|
adapter.normalized_inputs(self.expected, baseline, after, 'create')
|
||||||
|
|
||||||
|
def test_snapshot_validates_captured_rows_before_persisting(self):
|
||||||
|
self.add_result()
|
||||||
|
self.adapter().snapshot(self.expected)
|
||||||
|
for keys, value in ((['body'],True), (['user','id'],True),
|
||||||
|
(['created_at'],'2026-01-02T00:00:00'),
|
||||||
|
(['updated_at'],T0)):
|
||||||
|
saved = copy.deepcopy(self.comments)
|
||||||
|
item = self.comments[0]
|
||||||
|
for key in keys[:-1]: item = item[key]
|
||||||
|
item[keys[-1]] = value
|
||||||
|
with self.subTest(keys=keys):
|
||||||
|
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
|
||||||
|
self.comments = saved
|
||||||
|
self.source['updated_at'] = 'not a timestamp'
|
||||||
|
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
|
||||||
|
|
||||||
|
def test_live_bridge_round_trip_over_local_HTTP(self):
|
||||||
|
import contextlib
|
||||||
|
import http.server
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import urllib.request
|
||||||
|
from unittest.mock import patch
|
||||||
|
cli = self.load('rework_readback_cli')
|
||||||
|
root = Path(self.temp.name)
|
||||||
|
profile = root/'profile'
|
||||||
|
profile.mkdir(mode=0o700)
|
||||||
|
secret = 'synthetic-fixture-' + 'q'*40
|
||||||
|
(profile/'.env').write_text('GITEA_TOKEN='+secret+'\n')
|
||||||
|
(profile/'.env').chmod(0o600)
|
||||||
|
(root/'provider.json').write_text(json.dumps(self.contract))
|
||||||
|
contract_hash = hashlib.sha256((root/'provider.json').read_bytes()).hexdigest()
|
||||||
|
requests = []
|
||||||
|
fixture = self
|
||||||
|
class Handler(http.server.BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, format, *args): pass
|
||||||
|
def do_GET(self):
|
||||||
|
requests.append((self.command, self.headers.get('Authorization') == 'token '+secret))
|
||||||
|
data, headers, status = fixture.request(self.path[len('/api/v1'):])
|
||||||
|
self.send_response(status)
|
||||||
|
self.send_header('Content-Type','application/json')
|
||||||
|
for key, value in headers.items(): self.send_header(key,value)
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(json.dumps(data).encode())
|
||||||
|
server = http.server.ThreadingHTTPServer(('127.0.0.1',0), Handler)
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
inner = urllib.request.build_opener(cli.NoRedirect())
|
||||||
|
class Proxy:
|
||||||
|
def open(self, request, timeout):
|
||||||
|
forwarded = urllib.request.Request('http://127.0.0.1:'+str(server.server_port)+request.selector,
|
||||||
|
headers=dict(request.header_items()), method=request.get_method())
|
||||||
|
response = inner.open(forwarded, timeout=timeout)
|
||||||
|
class Response:
|
||||||
|
status = response.status
|
||||||
|
headers = response.headers
|
||||||
|
def geturl(self): return request.full_url
|
||||||
|
def read1(self, n): return response.read1(n)
|
||||||
|
def __enter__(self): return self
|
||||||
|
def __exit__(self, *args): response.close()
|
||||||
|
return Response()
|
||||||
|
transport = cli.RestTransport
|
||||||
|
common = dict(expected=str(root/'expected.json'), provider_contract=str(root/'provider.json'),
|
||||||
|
provider_contract_sha256=contract_hash, actor_id=42)
|
||||||
|
baseline_path, receipt_path = root/'baseline.json', root/'receipt.json'
|
||||||
|
try:
|
||||||
|
with patch.dict(os.environ, {'HERMES_HOME':str(profile)}), patch.object(cli,'RestTransport',
|
||||||
|
side_effect=lambda credential: transport(credential, opener=Proxy())), contextlib.redirect_stdout(io.StringIO()) as stdout:
|
||||||
|
cli.run_live(Namespace(cmd='snapshot', output=str(baseline_path), **common), self.legacy.verify_readbacks)
|
||||||
|
baseline_hash = hashlib.sha256(baseline_path.read_bytes()).hexdigest()
|
||||||
|
self.add_result()
|
||||||
|
args = Namespace(cmd='verify-live', baseline=str(baseline_path), baseline_sha256=baseline_hash,
|
||||||
|
action='create', output=str(receipt_path), **common)
|
||||||
|
cli.run_live(args, self.legacy.verify_readbacks)
|
||||||
|
receipt = json.loads(receipt_path.read_text())
|
||||||
|
self.assertEqual(receipt['status'],'verified')
|
||||||
|
self.assertEqual(receipt['comment_id'],501)
|
||||||
|
self.assertEqual(receipt['readback_provenance']['kind'],'fresh_authenticated_GET')
|
||||||
|
original = receipt_path.read_bytes()
|
||||||
|
self.source['title'] = 'unrelated provider edit'
|
||||||
|
with self.assertRaises(ValueError): cli.run_live(args, self.legacy.verify_readbacks)
|
||||||
|
self.assertEqual(receipt_path.read_bytes(), original)
|
||||||
|
self.assertNotIn(secret, stdout.getvalue())
|
||||||
|
self.assertNotIn(secret, receipt_path.read_text())
|
||||||
|
self.assertTrue(requests)
|
||||||
|
self.assertEqual(set(method for method, _ in requests), {'GET'})
|
||||||
|
self.assertTrue(all(authenticated for _, authenticated in requests))
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
thread.join(timeout=3)
|
||||||
|
|
||||||
|
def test_underlying_relation_requires_native_html_url(self):
|
||||||
|
self.adapter().snapshot(self.expected)
|
||||||
|
self.underlying['pull_request'].pop('html_url')
|
||||||
|
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
|
||||||
|
|
||||||
|
def test_non_next_Link_relations_are_coherent_and_preserved(self):
|
||||||
|
endpoint = '/repos/acme/demo/issues/7/comments'
|
||||||
|
path = endpoint+'?page=1&limit=50'
|
||||||
|
for relation, page in (('last',21), ('prev',1), ('first',2)):
|
||||||
|
self.overrides = {path:([],{'X-Total-Count':'0','Link':
|
||||||
|
'</api/v1'+endpoint+'?page='+str(page)+'&limit=50>; rel="'+relation+'"'},200)}
|
||||||
|
with self.subTest(relation=relation):
|
||||||
|
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
|
||||||
|
header = '</api/v1'+endpoint+'?page=1&limit=50>; rel="last"'
|
||||||
|
self.overrides = {path:([],{'X-Total-Count':'0','Link':header},200)}
|
||||||
|
snapshot = self.adapter().snapshot(self.expected)
|
||||||
|
self.assertEqual(snapshot['raw']['source_comments']['pages'][0].get('link'), header)
|
||||||
|
|
||||||
|
def test_pagination_link_rejects_unbalanced_relation_quotes(self):
|
||||||
|
endpoint = '/repos/acme/demo/issues/7/comments'
|
||||||
|
path = endpoint+'?page=1&limit=50'
|
||||||
|
for relation in ('"last', 'last"'):
|
||||||
|
self.overrides = {path:([],{'X-Total-Count':'0','Link':
|
||||||
|
'</api/v1'+endpoint+'?page=1&limit=50>; rel='+relation},200)}
|
||||||
|
with self.subTest(relation=relation):
|
||||||
|
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
|
||||||
|
|
||||||
|
def test_native_Gitea_PR_URL_and_omitted_proven_zero_inline_count(self):
|
||||||
|
# Pinned primary serializers: services/convert/pull.go and modules/structs/pull.go (v1.24.6).
|
||||||
|
self.pr['url'] = self.pr['html_url']
|
||||||
|
self.contract['inline']['components'] = ['inline']
|
||||||
|
self.pr.pop('review_comments')
|
||||||
|
try: snapshot = self.adapter().snapshot(self.expected)
|
||||||
|
except ValueError as error: self.fail('native Gitea wire shape rejected: '+str(error))
|
||||||
|
self.assertEqual(snapshot['normalized_counts']['inline'], 0)
|
||||||
|
self.assertEqual(snapshot['fallback'], 'none')
|
||||||
|
self.contract['inline']['mode'] = 'per_review_or_pr_count'
|
||||||
|
self.overrides = {'/repos/acme/demo/pulls/9/reviews/301/comments?page=1&limit=50':(None,{},404)}
|
||||||
|
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
|
||||||
|
|
||||||
|
def test_boolean_actor_is_rejected_before_other_GETs(self):
|
||||||
|
module = self.load('rework_readback')
|
||||||
|
self.overrides = {'/user':({'id':True,'login':'gitea-impl-agent'}, {}, 200)}
|
||||||
|
adapter = module.ReworkReadbackAdapter(self.request, origin=ORIGIN+'/api/v1',
|
||||||
|
credential_source='$HERMES_HOME/.env:GITEA_TOKEN', actor_id=1, provider_contract=self.contract)
|
||||||
|
with self.assertRaises(ValueError): adapter.snapshot(self.expected)
|
||||||
|
self.assertEqual(self.calls, ['/user'])
|
||||||
|
|
||||||
|
def test_required_native_metadata_and_PR_author_are_verified(self):
|
||||||
|
self.adapter().snapshot(self.expected)
|
||||||
|
cases = [('pr',['user'],None), ('pr',['user','id'],43),
|
||||||
|
('pr',['user','login'],'different'), ('source',['labels'],True),
|
||||||
|
('source',['assignees'],True), ('source',['milestone'],[]),
|
||||||
|
('pr',['base','ref'],None), ('pr',['head','repo_id'],99),
|
||||||
|
('reviews',[0,'state'],None), ('reviews',[0,'body'],None),
|
||||||
|
('reviews',[0,'user','login'],'unapproved'),
|
||||||
|
('reviews',[0,'submitted_at'],None), ('reviews',[0,'dismissed'],True),
|
||||||
|
('reviews',[0,'stale'],'true')]
|
||||||
|
for name, keys, value in cases:
|
||||||
|
original = copy.deepcopy(getattr(self,name))
|
||||||
|
item = getattr(self,name)
|
||||||
|
for key in keys[:-1]: item = item[key]
|
||||||
|
item[keys[-1]] = value
|
||||||
|
with self.subTest(name=name, keys=keys):
|
||||||
|
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
|
||||||
|
setattr(self,name,original)
|
||||||
|
for name, field in (('source','labels'), ('source','assignees'), ('source','milestone'),
|
||||||
|
('underlying','user'), ('reviews','submitted_at')):
|
||||||
|
original = copy.deepcopy(getattr(self,name))
|
||||||
|
record = getattr(self,name)[0] if name == 'reviews' else getattr(self,name)
|
||||||
|
record.pop(field)
|
||||||
|
with self.subTest(missing=name+'.'+field):
|
||||||
|
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
|
||||||
|
setattr(self,name,original)
|
||||||
|
|
||||||
|
def test_source_timestamp_change_is_not_admitted_as_causal_proof(self):
|
||||||
|
adapter = self.adapter()
|
||||||
|
baseline = adapter.snapshot(self.expected)
|
||||||
|
self.add_result()
|
||||||
|
after = adapter.snapshot(self.expected)
|
||||||
|
adapter.normalized_inputs(self.expected, baseline, after, 'create')
|
||||||
|
after['raw']['source_issue']['updated_at'] = T1
|
||||||
|
with self.assertRaises(ValueError): adapter.normalized_inputs(self.expected, baseline, after, 'create')
|
||||||
|
|
||||||
|
def test_native_nullable_assignees_and_milestone_without_number(self):
|
||||||
|
for record in (self.source,self.pr,self.underlying):
|
||||||
|
record['assignees'] = None
|
||||||
|
record['milestone'] = {'id':55,'title':'Native milestone','state':'open'}
|
||||||
|
try: self.adapter().snapshot(self.expected)
|
||||||
|
except ValueError as error: self.fail('native nullable-assignee/milestone shape rejected: '+str(error))
|
||||||
|
self.source.pop('assignees')
|
||||||
|
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
|
||||||
|
|
||||||
|
def test_closing_keyword_must_match_canonical_Closes(self):
|
||||||
|
self.adapter().snapshot(self.expected)
|
||||||
|
self.pr['body'] = 'Fixes #7'
|
||||||
|
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
|
||||||
|
|
||||||
|
def test_renderer_to_existing_verifier_create_and_reuse(self):
|
||||||
|
adapter = self.adapter()
|
||||||
|
try: baseline = adapter.snapshot(self.expected)
|
||||||
|
except Exception as error: self.fail('actual renderer expected rejected: '+str(error))
|
||||||
|
self.assertEqual(self.calls[0], '/user')
|
||||||
|
self.add_result()
|
||||||
|
after = adapter.snapshot(self.expected)
|
||||||
|
inputs = adapter.normalized_inputs(self.expected, baseline, after, 'create')
|
||||||
|
receipt = self.legacy.verify_readbacks(self.expected, inputs['pr'], inputs['comments'],
|
||||||
|
inputs['identity'], action='create', baseline_count=inputs['baseline_count'])
|
||||||
|
self.assertEqual(receipt['status'], 'verified')
|
||||||
|
self.assertEqual(receipt['comment_id'], 501)
|
||||||
|
reused = adapter.normalized_inputs(self.expected, after, adapter.snapshot(self.expected), 'reuse')
|
||||||
|
self.assertEqual(self.legacy.verify_readbacks(self.expected, reused['pr'], reused['comments'],
|
||||||
|
reused['identity'], action='reuse', baseline_count=reused['baseline_count'])['status'], 'verified')
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
SCRIPT = Path(__file__).parents[2] / 'agents/impl/skills/gitea-issue-rework/scripts/rework_comment.py'
|
||||||
|
|
||||||
|
|
||||||
|
class ReworkReadbackTransportTests(unittest.TestCase):
|
||||||
|
def module(self):
|
||||||
|
path = SCRIPT.with_name('rework_readback_cli.py')
|
||||||
|
self.assertTrue(path.is_file(), 'canonical live readback bridge is missing')
|
||||||
|
spec = importlib.util.spec_from_file_location('transport_under_test', path)
|
||||||
|
assert spec is not None and spec.loader is not None
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return module
|
||||||
|
|
||||||
|
def test_role_token_requires_private_profile_file(self):
|
||||||
|
module = self.module()
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
home = Path(directory)
|
||||||
|
with self.assertRaises(ValueError): module.load_role_token(home)
|
||||||
|
source = home / '.env'
|
||||||
|
source.write_text('GITEA_TOKEN=' + 's' * 48 + '\n')
|
||||||
|
source.chmod(0o644)
|
||||||
|
with self.assertRaises(ValueError): module.load_role_token(home)
|
||||||
|
source.chmod(0o600)
|
||||||
|
self.assertEqual(module.load_role_token(home), 's' * 48)
|
||||||
|
source.unlink()
|
||||||
|
external = home / 'external'
|
||||||
|
external.write_text('GITEA_TOKEN=' + 's' * 48)
|
||||||
|
external.chmod(0o600)
|
||||||
|
source.symlink_to(external)
|
||||||
|
with self.assertRaises(ValueError): module.load_role_token(home)
|
||||||
|
|
||||||
|
def test_http_transport_get_only_redirects_and_secret_echo_are_rejected(self):
|
||||||
|
module = self.module()
|
||||||
|
self.assertTrue(callable(getattr(module, 'RestTransport', None)), 'read-only HTTP transport missing')
|
||||||
|
import http.server
|
||||||
|
import threading
|
||||||
|
import urllib.request
|
||||||
|
requests = []
|
||||||
|
secret = 'z' * 48
|
||||||
|
|
||||||
|
class Handler(http.server.BaseHTTPRequestHandler):
|
||||||
|
def log_message(self, format, *args): pass
|
||||||
|
def do_GET(self):
|
||||||
|
requests.append((self.path, self.headers.get('Authorization') == 'token ' + secret))
|
||||||
|
if self.path == '/api/v1/version':
|
||||||
|
self.send_response(302)
|
||||||
|
self.send_header('Location', 'https://outside.invalid/user')
|
||||||
|
self.end_headers()
|
||||||
|
return
|
||||||
|
body = (b'{"credential":"' + secret.encode() + b'"}'
|
||||||
|
if self.path.endswith('/secret') else b'{"id":42,"login":"gitea-impl-agent"}')
|
||||||
|
self.send_response(200)
|
||||||
|
self.send_header('Content-Type', 'application/json')
|
||||||
|
if self.path.endswith('/duplicate'):
|
||||||
|
self.send_header('X-Total-Count', '1')
|
||||||
|
self.send_header('X-Total-Count', '2')
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
class NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||||
|
def redirect_request(self, *args, **kwargs): return None
|
||||||
|
|
||||||
|
server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Handler)
|
||||||
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||||
|
thread.start()
|
||||||
|
inner = urllib.request.build_opener(NoRedirect())
|
||||||
|
|
||||||
|
class Proxy:
|
||||||
|
def open(self, request, timeout):
|
||||||
|
self_request = urllib.request.Request(
|
||||||
|
'http://127.0.0.1:' + str(server.server_port) + request.selector,
|
||||||
|
headers=dict(request.header_items()), method=request.get_method())
|
||||||
|
response = inner.open(self_request, timeout=timeout)
|
||||||
|
original = request.full_url
|
||||||
|
class Response:
|
||||||
|
status = response.status
|
||||||
|
headers = response.headers
|
||||||
|
def geturl(self): return original
|
||||||
|
def read1(self, n): return response.read1(n)
|
||||||
|
def __enter__(self): return self
|
||||||
|
def __exit__(self, *args): response.close()
|
||||||
|
return Response()
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = module.RestTransport(secret, opener=Proxy())
|
||||||
|
data, _, status = client('/user')
|
||||||
|
self.assertEqual(status, 200)
|
||||||
|
self.assertEqual(data['id'], 42)
|
||||||
|
self.assertEqual(client('/version')[2], 302)
|
||||||
|
with self.assertRaises(ValueError) as raised:
|
||||||
|
client('/repos/acme/widget/secret')
|
||||||
|
self.assertNotIn(secret, str(raised.exception))
|
||||||
|
with self.assertRaises(ValueError): client('/repos/acme/widget/duplicate')
|
||||||
|
self.assertTrue(all(authenticated for _, authenticated in requests))
|
||||||
|
self.assertEqual(len(requests), 4)
|
||||||
|
finally:
|
||||||
|
server.shutdown()
|
||||||
|
server.server_close()
|
||||||
|
thread.join(timeout=3)
|
||||||
|
|
||||||
|
def test_output_admission_preserves_receipt_on_failure_and_rejects_links(self):
|
||||||
|
module = self.module()
|
||||||
|
self.assertTrue(callable(getattr(module, 'OutputFile', None)), 'pinned output writer missing')
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
receipt = Path(directory) / 'receipt.json'
|
||||||
|
receipt.write_text('original')
|
||||||
|
receipt.chmod(0o600)
|
||||||
|
with module.OutputFile(receipt): pass
|
||||||
|
self.assertEqual(receipt.read_text(), 'original')
|
||||||
|
with module.OutputFile(receipt) as output:
|
||||||
|
output.write({'status': 'verified'})
|
||||||
|
self.assertIn('verified', receipt.read_text())
|
||||||
|
self.assertEqual(receipt.stat().st_mode & 0o777, 0o600)
|
||||||
|
foreign = Path(directory) / 'foreign'
|
||||||
|
foreign.write_text('preserve')
|
||||||
|
receipt.unlink()
|
||||||
|
receipt.symlink_to(foreign)
|
||||||
|
with self.assertRaises(ValueError): module.OutputFile(receipt)
|
||||||
|
self.assertEqual(foreign.read_text(), 'preserve')
|
||||||
|
|
||||||
|
def test_http_operation_budget_stops_before_next_get(self):
|
||||||
|
module = self.module()
|
||||||
|
from unittest.mock import patch
|
||||||
|
clock = [0]
|
||||||
|
calls = []
|
||||||
|
class Opener:
|
||||||
|
def open(self, request, timeout):
|
||||||
|
calls.append(request.full_url)
|
||||||
|
class Response:
|
||||||
|
status = 200
|
||||||
|
headers = {'Content-Type': 'application/json'}
|
||||||
|
emitted = False
|
||||||
|
def geturl(self): return request.full_url
|
||||||
|
def read1(self, n):
|
||||||
|
if self.emitted: return b''
|
||||||
|
self.emitted = True
|
||||||
|
return b'{"id":42}'
|
||||||
|
def __enter__(self): return self
|
||||||
|
def __exit__(self, *args): pass
|
||||||
|
return Response()
|
||||||
|
with patch.object(module.time, 'monotonic', side_effect=lambda: clock[0]):
|
||||||
|
client = module.RestTransport('b' * 48, opener=Opener())
|
||||||
|
self.assertEqual(client('/user')[2], 200)
|
||||||
|
clock[0] = 121
|
||||||
|
with self.assertRaises(ValueError): client('/version')
|
||||||
|
self.assertEqual(len(calls), 1)
|
||||||
|
|
||||||
|
def test_live_bridge_rejects_unbound_provider_before_transport(self):
|
||||||
|
module = self.module()
|
||||||
|
self.assertTrue(callable(getattr(module, 'run_live', None)), 'live verifier bridge missing')
|
||||||
|
import hashlib
|
||||||
|
from types import SimpleNamespace
|
||||||
|
from unittest.mock import patch
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
root = Path(directory)
|
||||||
|
expected = root / 'expected.json'
|
||||||
|
expected.write_text('{}')
|
||||||
|
contract = root / 'provider.json'
|
||||||
|
contract.write_text('{"schema":"test-contract"}')
|
||||||
|
trusted = hashlib.sha256(contract.read_bytes()).hexdigest()
|
||||||
|
self.assertEqual(module.read_bound_json(contract, trusted)['schema'], 'test-contract')
|
||||||
|
receipt = root / 'receipt.json'
|
||||||
|
receipt.write_text('preserve')
|
||||||
|
receipt.chmod(0o600)
|
||||||
|
args = SimpleNamespace(cmd='snapshot', expected=str(expected),
|
||||||
|
provider_contract=str(contract), provider_contract_sha256='0' * 64,
|
||||||
|
actor_id=42, output=str(receipt))
|
||||||
|
with patch.object(module, 'RestTransport') as transport:
|
||||||
|
with patch.object(module, 'read_bound_json', wraps=module.read_bound_json) as reader:
|
||||||
|
with self.assertRaises(ValueError): module.run_live(args, lambda *a, **k: None)
|
||||||
|
self.assertGreaterEqual(reader.call_count, 2)
|
||||||
|
transport.assert_not_called()
|
||||||
|
self.assertEqual(receipt.read_text(), 'preserve')
|
||||||
|
|
||||||
|
def test_existing_command_exposes_readonly_snapshot(self):
|
||||||
|
result = subprocess.run([sys.executable, '-B', str(SCRIPT), 'snapshot', '--help'],
|
||||||
|
capture_output=True, text=True, timeout=10)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertIn('--provider-contract', result.stdout)
|
||||||
|
self.assertIn('--actor-id', result.stdout)
|
||||||
|
|
||||||
|
def test_existing_command_exposes_live_verification(self):
|
||||||
|
result = subprocess.run([sys.executable, '-B', str(SCRIPT), 'verify-live', '--help'],
|
||||||
|
capture_output=True, text=True, timeout=10)
|
||||||
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
|
self.assertIn('--baseline-sha256', result.stdout)
|
||||||
Reference in New Issue
Block a user