Compare commits
171
Commits
+5
-7
@@ -1,12 +1,10 @@
|
||||
title = "AIOS secret scan policy"
|
||||
title = "AIOS repository secret scan policy"
|
||||
|
||||
[extend]
|
||||
useDefault = true
|
||||
|
||||
[[allowlists]]
|
||||
description = "Known non-secret idempotency key used twice by the shadow RBAC test"
|
||||
targetRules = ["generic-api-key"]
|
||||
[allowlist]
|
||||
description = "Exact non-secret Task Admission test idempotency fixture in the historical PR commit"
|
||||
condition = "AND"
|
||||
regexTarget = "line"
|
||||
paths = ['''tests/task_plane/public_interface/test_shadow_client_rbac\.py$''']
|
||||
regexes = ['''idempotency_key="shadow-rbac-idempotent-v1"''']
|
||||
paths = ['''tests/test_task_admission_bootstrap_http\.py$''']
|
||||
regexes = ['''idempotency-1''']
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
# Exact historical fingerprint for the removed test-only scanner self-reference.
|
||||
a42f16d4f73b11fd23448da2711d49d5e9f49d9e:tests/fastlane/test_fastlane_assets_contract.py:generic-api-key:135
|
||||
@@ -0,0 +1,486 @@
|
||||
# Agent Production Deployment Requirements
|
||||
|
||||
## Status entering deployment
|
||||
|
||||
```yaml
|
||||
code_development: COMPLETED
|
||||
repository_commit: NOT_CREATED
|
||||
production_deployment: NOT_STARTED
|
||||
reason_repository_commit_missing:
|
||||
current ChatGPT GitOps runtime has AIOS_GITOPS_WRITES_ENABLED=false
|
||||
```
|
||||
|
||||
The code bundle is an input artifact. The Agent must place it on a non-main
|
||||
integration branch, review it, run acceptance tests, merge it through protected
|
||||
main and deploy the API adapter. The Agent must not regenerate or materially
|
||||
rewrite the supplied implementation without recording the new diff and tests.
|
||||
|
||||
## Frozen production state
|
||||
|
||||
Before any change, verify:
|
||||
|
||||
```yaml
|
||||
main_oid: 875d41c3101093149414292067fd700dc214d4d3
|
||||
|
||||
V2R1C5:
|
||||
tag_oid: 9e65bd59baea392294c0f253046d3ab116f63145
|
||||
peeled_target_oid: aa60e891547abd7e215af7cf2bf3f21fd23541da
|
||||
baseline_version: V2R1C5
|
||||
baseline_status: PRODUCTION_DEPLOYED
|
||||
target_oid: aa60e891547abd7e215af7cf2bf3f21fd23541da
|
||||
evidence_closure_oid: 5e2cf8276f5093f3352b9a3252c701a44c5ab807
|
||||
```
|
||||
|
||||
If authoritative `main` no longer equals the approved base OID, stop with
|
||||
`MANUAL_OWNER_REVIEW_REQUIRED / AUTHORIZED_BASE_OID_DRIFT`. Do not silently
|
||||
rebase the bundle onto a different base.
|
||||
|
||||
## Bootstrap sequence — no additional Owner interaction
|
||||
|
||||
Use the following order so that the approved V2R1C6 base OID remains valid and
|
||||
the bridge does not create its own authorization deadlock:
|
||||
|
||||
1. Verify the supplied archive/patch SHA-256.
|
||||
2. Install the bundle into a temporary root-owned staging directory outside the
|
||||
Git worktrees.
|
||||
3. Start a localhost-only temporary instance of
|
||||
`aios_authorized_task_writer_api.py` on a dedicated port or Unix socket.
|
||||
4. Bind it to the existing authenticated Owner registry and the exact
|
||||
`writer:provision` scope. Do not expose it through the public gateway.
|
||||
5. Execute the live V2R1C6 provisioning request while `main` still equals
|
||||
`875d41c3101093149414292067fd700dc214d4d3`.
|
||||
6. Verify `READY_FOR_CODE_AUTHORING`, the exact branch OID, writer scope and
|
||||
negative policy tests.
|
||||
7. Use the newly created scoped writer to commit this reviewed bundle onto:
|
||||
`agent/v2r1c6-mainline-reconciliation-20260805`.
|
||||
8. Stop and remove the temporary bootstrap service and revoke its temporary
|
||||
runtime credential. Preserve its signed grant receipt and audit logs.
|
||||
9. Open a protected PR from the V2R1C6 branch. Deploy the permanent bridge only
|
||||
from the reviewed merge commit.
|
||||
|
||||
The temporary service is a control-plane bootstrap, not a production release.
|
||||
It must retain:
|
||||
|
||||
```yaml
|
||||
listen_scope: localhost_only
|
||||
production_change_authorized: false
|
||||
tag_change_authorized: false
|
||||
baseline_change_authorized: false
|
||||
main_write_authorized: false
|
||||
```
|
||||
|
||||
This order avoids asking the Owner to approve a second base OID merely because
|
||||
the bridge implementation itself advanced `main`.
|
||||
|
||||
### Alternative platform-maintenance path
|
||||
|
||||
If policy prohibits a temporary localhost bootstrap service, use an existing
|
||||
Owner-approved platform-maintenance executor to create:
|
||||
|
||||
```yaml
|
||||
branch: patch/owner-authorization-writer-bridge-20260806
|
||||
base_ref: refs/heads/main
|
||||
base_oid: 875d41c3101093149414292067fd700dc214d4d3
|
||||
direct_main_push: false
|
||||
force_push: false
|
||||
tag_write: false
|
||||
baseline_write: false
|
||||
production_deploy_during_integration: false
|
||||
```
|
||||
|
||||
Merging that branch before V2R1C6 provisioning changes the authoritative base
|
||||
OID. In that alternative only, a new exact Owner base-OID amendment is required.
|
||||
The Agent must never substitute the newer `main` OID automatically.
|
||||
|
||||
## Files to install on the branch
|
||||
|
||||
Copy with the same relative paths:
|
||||
|
||||
```text
|
||||
active-runtime/app/authorized_task_writer_provisioning.py
|
||||
active-runtime/app/aios_authorized_task_writer_api.py
|
||||
docs/gitops/authorized-task-writer-provisioning.md
|
||||
docs/gitops/openapi-authorized-task-writer-fragment.yaml
|
||||
deploy/systemd/aios-gitops-model-api-authorized-writer-bridge.conf.example
|
||||
scripts/smoke_authorized_task_writer_provisioning.py
|
||||
tests/test_authorized_task_writer_provisioning.py
|
||||
```
|
||||
|
||||
Merge the OpenAPI fragment into:
|
||||
|
||||
```text
|
||||
docs/gitops/openapi-chatgpt-actions.yaml
|
||||
```
|
||||
|
||||
The final schema must expose:
|
||||
|
||||
```yaml
|
||||
operationId: provisionAuthorizedTaskWriter
|
||||
path: POST /api/task-writer/provision-authorized
|
||||
x-openai-isConsequential: true
|
||||
```
|
||||
|
||||
Do not deploy the fragment as a second standalone OpenAPI document.
|
||||
|
||||
## Required review of existing API integration
|
||||
|
||||
The adapter expects the existing API handler to provide:
|
||||
|
||||
```python
|
||||
handler.need(scope)
|
||||
handler.body()
|
||||
handler.sendj(value, status=200)
|
||||
api.H.do_POST
|
||||
api.main()
|
||||
```
|
||||
|
||||
Confirm these contracts against the branch before merge. If the live API has a
|
||||
different authentication object shape, make the smallest adapter-only change
|
||||
and rerun every test. The core provisioning policy must remain unchanged.
|
||||
|
||||
The API authorization registry must grant `writer:provision` only to the
|
||||
authenticated Owner role. Agents and ordinary writer tokens must not receive
|
||||
this scope.
|
||||
|
||||
## Privilege boundary
|
||||
|
||||
The API service must remain non-root.
|
||||
|
||||
Install a root-owned sudoers rule that allows the API service identity to run
|
||||
only these exact commands with `sudo -n --`:
|
||||
|
||||
```text
|
||||
/usr/local/libexec/aios-grant-signer-adapter
|
||||
/usr/local/libexec/aios-grant-validator
|
||||
/usr/local/libexec/aios-task-writer-provisioner
|
||||
```
|
||||
|
||||
Do not allow:
|
||||
|
||||
```text
|
||||
/bin/sh
|
||||
/bin/bash
|
||||
/usr/bin/env
|
||||
/usr/bin/git
|
||||
wildcard executable paths
|
||||
SETENV
|
||||
arbitrary environment preservation
|
||||
```
|
||||
|
||||
The governed tools themselves must validate their `--grant-dir` and
|
||||
`--result-json` paths. Their binaries must be root-owned, non-symlink, regular,
|
||||
executable and not group/world writable.
|
||||
|
||||
The signer must access its private signing material only through a runtime
|
||||
secret reference. No signing key or token may enter Git, API output, the
|
||||
provisioning receipt or systemd environment text.
|
||||
|
||||
## Runtime paths and permissions
|
||||
|
||||
Create:
|
||||
|
||||
```text
|
||||
/var/lib/aios-gitops/authorized-writer-bridge
|
||||
/var/lib/aios-gitops/authorized-writer-bridge/receipts
|
||||
/var/lib/aios-gitops/authorized-writer-bridge/locks
|
||||
```
|
||||
|
||||
Required properties:
|
||||
|
||||
```yaml
|
||||
owner: API service identity
|
||||
mode: "0700"
|
||||
symlink: false
|
||||
persistent: true
|
||||
```
|
||||
|
||||
The directory contains redacted receipts only. Temporary signed grant
|
||||
directories are deleted after success or failure.
|
||||
|
||||
## Required staging environment
|
||||
|
||||
Configure the API service:
|
||||
|
||||
```text
|
||||
AIOS_GITOPS_REPOSITORY_IDENTITY=aios/aios
|
||||
AIOS_GITOPS_AUTHORITY_GIT_DIR=/data/aios-gitops/repo.git
|
||||
AIOS_GITOPS_AUTHORIZED_WRITER_STATE_ROOT=/var/lib/aios-gitops/authorized-writer-bridge
|
||||
AIOS_GITOPS_GRANT_SIGNER_ADAPTER=/usr/local/libexec/aios-grant-signer-adapter
|
||||
AIOS_GITOPS_GRANT_VALIDATOR=/usr/local/libexec/aios-grant-validator
|
||||
AIOS_GITOPS_TASK_WRITER_PROVISIONER=/usr/local/libexec/aios-task-writer-provisioner
|
||||
AIOS_GITOPS_OWNER_PROVISION_SCOPE=writer:provision
|
||||
AIOS_GITOPS_GOVERNED_USE_SUDO=true
|
||||
AIOS_GITOPS_SUDO_PATH=/usr/bin/sudo
|
||||
```
|
||||
|
||||
Use `aios_authorized_task_writer_api.py` as the API entrypoint. It delegates
|
||||
all pre-existing routes and adds only the new endpoint.
|
||||
|
||||
## Pre-merge tests
|
||||
|
||||
Run from the repository root:
|
||||
|
||||
```bash
|
||||
python3 -m unittest -v tests/test_authorized_task_writer_provisioning.py
|
||||
python3 scripts/smoke_authorized_task_writer_provisioning.py
|
||||
python3 -m py_compile \
|
||||
active-runtime/app/authorized_task_writer_provisioning.py \
|
||||
active-runtime/app/aios_authorized_task_writer_api.py
|
||||
```
|
||||
|
||||
Required result:
|
||||
|
||||
```yaml
|
||||
unit_tests:
|
||||
total: 9
|
||||
failures: 0
|
||||
errors: 0
|
||||
smoke: PASS
|
||||
py_compile: PASS
|
||||
```
|
||||
|
||||
Also run all existing GitOps API and isolated writer tests affected by:
|
||||
|
||||
```text
|
||||
aios_chatgpt_actions_api
|
||||
aios_isolated_writer_api
|
||||
isolated_writer_authz
|
||||
isolated_writer_token_registry
|
||||
single_owner_governance
|
||||
OpenAPI schema validation
|
||||
```
|
||||
|
||||
Any regression blocks merge.
|
||||
|
||||
## Staging API acceptance
|
||||
|
||||
### Negative authentication
|
||||
|
||||
An Agent or non-Owner credential calling the endpoint must receive:
|
||||
|
||||
```yaml
|
||||
http_status: 403
|
||||
error: OWNER_AUTHORIZATION_REQUIRED
|
||||
```
|
||||
|
||||
### Negative scope tests
|
||||
|
||||
Requests enabling any of the following must be denied before signer execution:
|
||||
|
||||
```text
|
||||
production_change_authorized=true
|
||||
tag_change_authorized=true
|
||||
baseline_change_authorized=true
|
||||
main_write_authorized=true
|
||||
allowed_operations containing tag/main/baseline/admin operations
|
||||
missing required denied operation
|
||||
one_time_use=false
|
||||
expiry over 1800 seconds
|
||||
```
|
||||
|
||||
### Drift and conflict tests
|
||||
|
||||
Verify:
|
||||
|
||||
```yaml
|
||||
wrong_base_oid:
|
||||
http_status: 409
|
||||
error: AUTHORIZED_BASE_OID_DRIFT
|
||||
|
||||
existing_unreceipted_target_branch:
|
||||
http_status: 409
|
||||
error: EXISTING_BRANCH_OID_CONFLICT
|
||||
```
|
||||
|
||||
### Governed tool failures
|
||||
|
||||
Independently inject and verify fail-closed behavior for:
|
||||
|
||||
```text
|
||||
signer unavailable
|
||||
signer signature absent
|
||||
validator signature_valid=false
|
||||
validator signer_trusted=false
|
||||
validator plan expired
|
||||
provisioner grant_consumed=false
|
||||
provisioner writer scope enables main/tag/baseline/force push
|
||||
any required negative policy test false
|
||||
```
|
||||
|
||||
None may create or modify a Git ref.
|
||||
|
||||
## Live V2R1C6 provisioning request
|
||||
|
||||
After protected merge and API deployment, authenticate as the Owner and submit
|
||||
exactly:
|
||||
|
||||
```json
|
||||
{
|
||||
"task_id": "V2R1C6_TASK_SCOPED_WRITER_PROVISIONING",
|
||||
"repository": "aios/aios",
|
||||
"base_ref": "refs/heads/main",
|
||||
"base_oid": "875d41c3101093149414292067fd700dc214d4d3",
|
||||
"target_branch": "agent/v2r1c6-mainline-reconciliation-20260805",
|
||||
"owner_authorization_reference": "chat-owner-authorization:v2r1c6:20260806",
|
||||
"one_time_use": true,
|
||||
"expires_in_seconds": 900,
|
||||
"production_change_authorized": false,
|
||||
"tag_change_authorized": false,
|
||||
"baseline_change_authorized": false,
|
||||
"main_write_authorized": false,
|
||||
"allowed_operations": [
|
||||
"create_worktree",
|
||||
"create_target_branch_if_absent",
|
||||
"commit_to_target_branch",
|
||||
"push_target_branch"
|
||||
],
|
||||
"denied_operations": [
|
||||
"direct_main_push",
|
||||
"force_push",
|
||||
"tag_create",
|
||||
"tag_update",
|
||||
"tag_delete",
|
||||
"branch_delete",
|
||||
"repository_admin",
|
||||
"baseline_write",
|
||||
"production_deploy",
|
||||
"production_marker_write",
|
||||
"history_rewrite"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
This request must be executed by the localhost-only bootstrap instance before
|
||||
the bridge code advances `main`. The endpoint must never infer “current main”.
|
||||
|
||||
If the Agent chooses the alternative merge-first path, it must stop after the
|
||||
merge and obtain an exact Owner amendment for the new merge OID. It must not
|
||||
silently replace the approved OID.
|
||||
|
||||
## Success response
|
||||
|
||||
Require:
|
||||
|
||||
```yaml
|
||||
status: READY_FOR_CODE_AUTHORING
|
||||
grant_consumed: true
|
||||
branch: agent/v2r1c6-mainline-reconciliation-20260805
|
||||
branch_oid: <exact amended authorized base OID>
|
||||
base_oid: <same exact OID>
|
||||
main_unchanged: true
|
||||
|
||||
writer_scope:
|
||||
workspace_role: task-scoped-isolated-writer
|
||||
writes_enabled: true
|
||||
allowed_branch: agent/v2r1c6-mainline-reconciliation-20260805
|
||||
allowed_base_oid: <exact amended authorized base OID>
|
||||
allowed_routes:
|
||||
- POST /api/commit-file
|
||||
allow_main_write: false
|
||||
allow_tag_write: false
|
||||
allow_baseline_write: false
|
||||
allow_force_push: false
|
||||
```
|
||||
|
||||
All seven negative policy results must be true.
|
||||
|
||||
Submit the identical request a second time. It must return the same grant and
|
||||
branch attestation with:
|
||||
|
||||
```yaml
|
||||
idempotent: true
|
||||
```
|
||||
|
||||
It must not run the provisioner again.
|
||||
|
||||
## Production postchecks
|
||||
|
||||
After API deployment and writer provisioning, independently verify:
|
||||
|
||||
```yaml
|
||||
existing_routes_regression: PASS
|
||||
new_endpoint: PASS
|
||||
main_ref: expected reviewed merge commit
|
||||
V2R1C5_tag_oid: 9e65bd59baea392294c0f253046d3ab116f63145
|
||||
V2R1C5_peeled_target: aa60e891547abd7e215af7cf2bf3f21fd23541da
|
||||
V2R1C5_baseline_unchanged: true
|
||||
new_release_tag_created: false
|
||||
production_baseline_changed: false
|
||||
```
|
||||
|
||||
The deployment of this control-plane bridge does not authorize a V2R1C6
|
||||
production release. It only enables scoped code authoring.
|
||||
|
||||
## Rollback
|
||||
|
||||
If the new API route fails:
|
||||
|
||||
1. Stop accepting new provisioning calls.
|
||||
2. Restore the previous API entrypoint.
|
||||
3. Restart and verify all existing routes.
|
||||
4. Preserve receipts and logs.
|
||||
5. Revoke any task writer capability already minted.
|
||||
6. Do not delete a branch or rewrite a tag.
|
||||
7. Revert the protected-main bridge commit through an ordinary reviewed revert
|
||||
PR if source rollback is required.
|
||||
|
||||
## Required final Agent report
|
||||
|
||||
```yaml
|
||||
STATUS: COMPLETED | PARTIALLY_COMPLETED | BLOCKED | FAILED
|
||||
|
||||
INTEGRATION:
|
||||
branch:
|
||||
commit:
|
||||
protected_pr:
|
||||
merge_commit:
|
||||
files_changed:
|
||||
|
||||
TESTS:
|
||||
unit:
|
||||
smoke:
|
||||
py_compile:
|
||||
regression:
|
||||
fault_injection:
|
||||
|
||||
DEPLOYMENT:
|
||||
service:
|
||||
entrypoint:
|
||||
runtime_status:
|
||||
endpoint_status:
|
||||
rollback_verified:
|
||||
|
||||
PROVISIONING:
|
||||
owner_authorization_reference:
|
||||
approval_plan_id:
|
||||
grant_id:
|
||||
grant_digest_sha256:
|
||||
signing_key_identity:
|
||||
branch:
|
||||
branch_oid:
|
||||
workspace_path:
|
||||
grant_consumed:
|
||||
idempotent_replay:
|
||||
|
||||
CONSISTENCY:
|
||||
main_oid:
|
||||
V2R1C5_tag_oid:
|
||||
V2R1C5_peeled_target_oid:
|
||||
baseline_version:
|
||||
baseline_changed:
|
||||
tags_changed:
|
||||
|
||||
SECRETS:
|
||||
runtime_secret_reference_used:
|
||||
plaintext_secret_logged:
|
||||
plaintext_secret_committed:
|
||||
|
||||
BLOCKERS:
|
||||
|
||||
GOVERNANCE:
|
||||
force_push_used: false
|
||||
tag_rewritten: false
|
||||
direct_main_push: false
|
||||
secrets_committed: false
|
||||
owner_approval_respected: true
|
||||
```
|
||||
@@ -0,0 +1,401 @@
|
||||
# Agent Deployment Requirements: Owner Authorization Broker V2
|
||||
|
||||
## Objective
|
||||
|
||||
Deploy the audited Owner Authorization Broker that closes the bootstrap gap
|
||||
without issuing a new general-purpose Owner credential and without modifying
|
||||
`/data/aios-gitops/runtime/secrets/gitops.tokens`.
|
||||
|
||||
The broker reuses one existing, non-wildcard Owner credential only to
|
||||
authenticate the Owner identity through one pre-existing exact scope. It then
|
||||
records an immutable task authorization event and directly invokes the
|
||||
existing signed grant -> validator -> one-shot provisioner chain.
|
||||
|
||||
Production deployment of V2R1C6 is **not** authorized by this task.
|
||||
|
||||
## Fixed bindings
|
||||
|
||||
```yaml
|
||||
task_id: V2R1C6_TASK_SCOPED_WRITER_PROVISIONING
|
||||
repository: aios/aios
|
||||
base_ref: refs/heads/main
|
||||
base_oid: 875d41c3101093149414292067fd700dc214d4d3
|
||||
target_branch: agent/v2r1c6-mainline-reconciliation-20260805
|
||||
production_change_authorized: false
|
||||
tag_change_authorized: false
|
||||
baseline_change_authorized: false
|
||||
main_write_authorized: false
|
||||
```
|
||||
|
||||
The existing V2R1C5 objects are frozen:
|
||||
|
||||
```yaml
|
||||
tag_oid: 9e65bd59baea392294c0f253046d3ab116f63145
|
||||
peeled_target_oid: aa60e891547abd7e215af7cf2bf3f21fd23541da
|
||||
baseline_version: V2R1C5
|
||||
baseline_status: PRODUCTION_DEPLOYED
|
||||
```
|
||||
|
||||
## 1. Package verification
|
||||
|
||||
Verify `MANIFEST.sha256` before installation. Reject any mismatch.
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
python3 -m unittest -v \
|
||||
tests/test_authorized_task_writer_provisioning.py \
|
||||
tests/test_owner_authorization_broker.py
|
||||
|
||||
python3 scripts/smoke_authorized_task_writer_provisioning.py
|
||||
python3 scripts/smoke_owner_authorization_broker.py
|
||||
python3 -m py_compile \
|
||||
active-runtime/app/authorized_task_writer_provisioning.py \
|
||||
active-runtime/app/aios_authorized_task_writer_api.py \
|
||||
active-runtime/app/owner_authorization_broker.py \
|
||||
active-runtime/app/aios_owner_authorization_broker_api.py
|
||||
```
|
||||
|
||||
All checks must pass.
|
||||
|
||||
## 2. Existing Owner credential selection
|
||||
|
||||
Read only the non-secret registry metadata. Select one existing credential
|
||||
that satisfies all of the following:
|
||||
|
||||
```yaml
|
||||
role: owner
|
||||
wildcard_scope: false
|
||||
account: exact known Owner identity
|
||||
contains_exact_intake_scope: true
|
||||
```
|
||||
|
||||
Use one existing exact scope such as `commit:create` or `file:write`. Prefer
|
||||
the narrowest scope already assigned to the intended Owner.
|
||||
|
||||
Do not:
|
||||
|
||||
- use the historical wildcard Owner credential;
|
||||
- add `writer:provision` to any bearer token;
|
||||
- create or modify a bearer credential;
|
||||
- print, copy or commit bearer-token material.
|
||||
|
||||
Record only:
|
||||
|
||||
```yaml
|
||||
owner_account: <non-secret account>
|
||||
credential_id: <non-secret ID, when available>
|
||||
intake_scope: <one exact existing scope>
|
||||
scope_readback_available: true|false
|
||||
```
|
||||
|
||||
If `handler.need()` returns the exact scope set, configure the Owner account
|
||||
allowlist and leave the credential ID allowlist empty.
|
||||
|
||||
If scope metadata is not returned, configure the exact non-secret credential
|
||||
ID allowlist. If neither exact scope metadata nor a stable non-secret
|
||||
credential ID can be proven, stop:
|
||||
|
||||
```yaml
|
||||
STATUS: BLOCKED
|
||||
REASON: OWNER_CREDENTIAL_SCOPE_NOT_PROVABLE
|
||||
```
|
||||
|
||||
## 3. Staging installation
|
||||
|
||||
Install the four application modules in a root-owned staging directory:
|
||||
|
||||
```text
|
||||
authorized_task_writer_provisioning.py
|
||||
aios_authorized_task_writer_api.py
|
||||
owner_authorization_broker.py
|
||||
aios_owner_authorization_broker_api.py
|
||||
```
|
||||
|
||||
The directory and files must not be group/world writable.
|
||||
|
||||
Prepare environment values:
|
||||
|
||||
```yaml
|
||||
AIOS_GITOPS_OWNER_AUTHORIZATION_BROKER_ENABLED: "true"
|
||||
AIOS_GITOPS_OWNER_AUTHORIZATION_INTAKE_SCOPES: <one exact existing scope>
|
||||
AIOS_GITOPS_OWNER_AUTHORIZATION_ALLOWED_IDENTITIES: <exact owner account>
|
||||
AIOS_GITOPS_OWNER_AUTHORIZATION_ALLOWED_CREDENTIAL_IDS: <exact ID or empty>
|
||||
AIOS_GITOPS_OWNER_AUTHORIZATION_MAX_EXPIRY_SECONDS: "900"
|
||||
AIOS_GITOPS_OWNER_AUTHORIZATION_STATE_ROOT:
|
||||
/var/lib/aios-gitops/owner-authorization-broker
|
||||
```
|
||||
|
||||
Use the existing signer, validator and provisioner paths. Do not change their
|
||||
root governance configuration or disable signature checks.
|
||||
|
||||
Start a localhost-only staging instance on a temporary port. Do not replace
|
||||
the production service yet.
|
||||
|
||||
## 4. Authentication fail-closed tests
|
||||
|
||||
The staging endpoint is:
|
||||
|
||||
```text
|
||||
POST /api/governance/owner-authorizations
|
||||
```
|
||||
|
||||
Required tests:
|
||||
|
||||
```yaml
|
||||
unauthenticated:
|
||||
expected: 401
|
||||
|
||||
wrong_role:
|
||||
expected: 403
|
||||
|
||||
wrong_scope:
|
||||
expected: 403
|
||||
|
||||
wildcard_owner:
|
||||
expected: 403
|
||||
error: WILDCARD_OWNER_CREDENTIAL_REJECTED
|
||||
|
||||
unallowlisted_owner:
|
||||
expected: 403
|
||||
error: OWNER_IDENTITY_NOT_ALLOWED
|
||||
|
||||
production_capability_true:
|
||||
expected: 403
|
||||
error: PRODUCTION_CAPABILITY_REJECTED
|
||||
```
|
||||
|
||||
A connection failure, 404 or 500 is not an authorization-policy pass.
|
||||
|
||||
## 5. Formal provisioning request
|
||||
|
||||
Use the selected existing non-wildcard Owner credential through its runtime
|
||||
secret reference. Never place the bearer token in the request body, command
|
||||
line, shell history, output report or Git.
|
||||
|
||||
Request:
|
||||
|
||||
```json
|
||||
{
|
||||
"action": "AUTHORIZE_TASK_WRITER_PROVISIONING",
|
||||
"task_id": "V2R1C6_TASK_SCOPED_WRITER_PROVISIONING",
|
||||
"repository": "aios/aios",
|
||||
"base_ref": "refs/heads/main",
|
||||
"base_oid": "875d41c3101093149414292067fd700dc214d4d3",
|
||||
"target_branch": "agent/v2r1c6-mainline-reconciliation-20260805",
|
||||
"one_time_use": true,
|
||||
"expires_in_seconds": 900,
|
||||
"production_change_authorized": false,
|
||||
"tag_change_authorized": false,
|
||||
"baseline_change_authorized": false,
|
||||
"main_write_authorized": false,
|
||||
"allowed_operations": [
|
||||
"create_worktree",
|
||||
"create_target_branch_if_absent",
|
||||
"commit_to_target_branch",
|
||||
"push_target_branch"
|
||||
],
|
||||
"denied_operations": [
|
||||
"direct_main_push",
|
||||
"force_push",
|
||||
"tag_create",
|
||||
"tag_update",
|
||||
"tag_delete",
|
||||
"branch_delete",
|
||||
"repository_admin",
|
||||
"baseline_write",
|
||||
"production_deploy",
|
||||
"production_marker_write",
|
||||
"history_rewrite"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
The Owner authorization reference is generated by the broker. The caller must
|
||||
not submit or invent one.
|
||||
|
||||
Expected result:
|
||||
|
||||
```yaml
|
||||
status: READY_FOR_CODE_AUTHORING
|
||||
owner_authorization_event_id: generated
|
||||
owner_authorization_reference: generated
|
||||
owner_authorization_digest_sha256: generated
|
||||
owner_authorization_consumed: true
|
||||
approval_plan_id: generated
|
||||
grant_digest_sha256: generated
|
||||
grant_consumed: true
|
||||
|
||||
branch_oid: 875d41c3101093149414292067fd700dc214d4d3
|
||||
|
||||
writer_scope:
|
||||
workspace_role: task-scoped-isolated-writer
|
||||
writes_enabled: true
|
||||
allowed_branch: agent/v2r1c6-mainline-reconciliation-20260805
|
||||
allowed_base_oid: 875d41c3101093149414292067fd700dc214d4d3
|
||||
allow_main_write: false
|
||||
allow_tag_write: false
|
||||
allow_baseline_write: false
|
||||
allow_force_push: false
|
||||
```
|
||||
|
||||
## 6. Independent post-provisioning verification
|
||||
|
||||
Verify the Authority ref:
|
||||
|
||||
```bash
|
||||
git --git-dir=/data/aios-gitops/repo.git rev-parse \
|
||||
refs/heads/agent/v2r1c6-mainline-reconciliation-20260805
|
||||
```
|
||||
|
||||
It must equal:
|
||||
|
||||
```text
|
||||
875d41c3101093149414292067fd700dc214d4d3
|
||||
```
|
||||
|
||||
Verify `main` remains unchanged, V2R1C5 tag object and peeled target remain
|
||||
unchanged, and the Production Baseline remains V2R1C5.
|
||||
|
||||
Verify the broker audit event under:
|
||||
|
||||
```text
|
||||
/var/lib/aios-gitops/owner-authorization-broker/events/
|
||||
```
|
||||
|
||||
Requirements:
|
||||
|
||||
```yaml
|
||||
status: CONSUMED
|
||||
wildcard_scope_used: false
|
||||
grant_consumed: true
|
||||
permissions:
|
||||
directory: "0700"
|
||||
json_files: "0600"
|
||||
```
|
||||
|
||||
Do not return bearer-token material in the report.
|
||||
|
||||
## 7. Commit the broker through the new writer
|
||||
|
||||
After `READY_FOR_CODE_AUTHORING`, use only the writer's
|
||||
`POST /api/commit-file` route to put this code bundle on:
|
||||
|
||||
```text
|
||||
agent/v2r1c6-mainline-reconciliation-20260805
|
||||
```
|
||||
|
||||
Commit the bridge and broker as separate intentional commits when practical:
|
||||
|
||||
1. Owner-authorized task-writer bridge.
|
||||
2. Audited Owner authorization broker and deployment docs.
|
||||
|
||||
Do not merge to `main` directly. Create a protected PR after branch commits
|
||||
and tests are complete.
|
||||
|
||||
## 8. Permanent service deployment
|
||||
|
||||
Only after protected review and merge:
|
||||
|
||||
1. Install the reviewed files from the merge commit.
|
||||
2. Apply a reviewed systemd drop-in based on the provided example.
|
||||
3. Start the wrapper entrypoint:
|
||||
`aios_owner_authorization_broker_api.py`.
|
||||
4. Verify all existing API routes still work.
|
||||
5. Verify the broker remains localhost/reverse-proxy constrained as intended.
|
||||
6. Re-run authentication and policy tests.
|
||||
7. Remove the temporary staging instance.
|
||||
|
||||
This permanent bridge deployment does not authorize a V2R1C6 production tag,
|
||||
Baseline update or application deployment.
|
||||
|
||||
## 9. Fail-closed conditions
|
||||
|
||||
Stop with `BLOCKED` for any of the following:
|
||||
|
||||
- Authority `main` no longer equals the fixed base OID.
|
||||
- Target branch exists at a different OID.
|
||||
- Only a wildcard Owner credential can authenticate.
|
||||
- Exact credential scope cannot be proven.
|
||||
- Owner identity is not explicitly allow-listed.
|
||||
- Signer, validator or provisioner self-test fails.
|
||||
- Grant is not marked one-time and consumed.
|
||||
- Writer allows main, tag, Baseline, force-push or branch-delete operations.
|
||||
- Audit event contains sensitive material.
|
||||
- V2R1C5 tag or Baseline changes.
|
||||
|
||||
## 10. Required Agent result
|
||||
|
||||
```yaml
|
||||
STATUS: READY_FOR_CODE_AUTHORING | BLOCKED | FAILED
|
||||
|
||||
PACKAGE:
|
||||
manifest_verified:
|
||||
unit_tests:
|
||||
smoke_tests:
|
||||
py_compile:
|
||||
|
||||
OWNER_CREDENTIAL:
|
||||
account:
|
||||
credential_id:
|
||||
intake_scope:
|
||||
exact_scope_proven:
|
||||
wildcard_used: false
|
||||
token_logged: false
|
||||
|
||||
BROKER:
|
||||
staging_listener:
|
||||
endpoint_status:
|
||||
event_id:
|
||||
authorization_reference:
|
||||
authorization_digest_sha256:
|
||||
event_consumed:
|
||||
audit_permissions_valid:
|
||||
|
||||
GRANT:
|
||||
approval_plan_id:
|
||||
grant_digest_sha256:
|
||||
signer_identity:
|
||||
signature_valid:
|
||||
one_time_use:
|
||||
consumed:
|
||||
|
||||
BRANCH:
|
||||
name:
|
||||
oid:
|
||||
base_oid:
|
||||
ref_exists:
|
||||
worktree_path:
|
||||
worktree_clean:
|
||||
|
||||
WRITER:
|
||||
status:
|
||||
writes_enabled:
|
||||
allowed_branch:
|
||||
allowed_base_oid:
|
||||
allowed_routes:
|
||||
main_write_denied:
|
||||
tag_write_denied:
|
||||
baseline_write_denied:
|
||||
force_push_denied:
|
||||
|
||||
POSTCHECK:
|
||||
main_unchanged:
|
||||
V2R1C5_tag_unchanged:
|
||||
baseline_unchanged:
|
||||
|
||||
REPOSITORY_INTEGRATION:
|
||||
commit_oids:
|
||||
protected_pr:
|
||||
merged:
|
||||
permanent_bridge_deployed:
|
||||
|
||||
BLOCKERS:
|
||||
|
||||
GOVERNANCE:
|
||||
force_push_used: false
|
||||
tag_rewritten: false
|
||||
direct_main_push: false
|
||||
secrets_committed: false
|
||||
owner_approval_respected: true
|
||||
```
|
||||
@@ -0,0 +1,562 @@
|
||||
# Agent execution requirements: V2R1C6 task identity overlay
|
||||
|
||||
## Required result
|
||||
|
||||
The Agent must return one of:
|
||||
|
||||
- `READY_FOR_CODE_AUTHORING`
|
||||
- `BLOCKED`
|
||||
- `FAILED`
|
||||
|
||||
The Agent may not report success until the task branch, isolated worktree and
|
||||
task writer are independently verified.
|
||||
|
||||
This stage is **not** a production deployment. It must not modify `main`,
|
||||
V2R1C5, tags, Registry, Production Baseline or production services.
|
||||
|
||||
## Fixed authority
|
||||
|
||||
```yaml
|
||||
task_id: V2R1C6_TASK_SCOPED_WRITER_PROVISIONING
|
||||
repository: aios/aios
|
||||
base_ref: refs/heads/main
|
||||
base_oid: 875d41c3101093149414292067fd700dc214d4d3
|
||||
target_branch: agent/v2r1c6-mainline-reconciliation-20260805
|
||||
owner_identity: aiman
|
||||
one_time_use: true
|
||||
maximum_ttl_seconds: 900
|
||||
production_change_authorized: false
|
||||
tag_change_authorized: false
|
||||
baseline_change_authorized: false
|
||||
main_write_authorized: false
|
||||
```
|
||||
|
||||
## 1. Verify package
|
||||
|
||||
Verify the externally supplied package against the published SHA-256 values and
|
||||
then verify every entry in `MANIFEST.sha256`.
|
||||
|
||||
Do not synthesize a missing archive. Use either the verified ZIP or TAR.GZ.
|
||||
|
||||
Required checks:
|
||||
|
||||
```yaml
|
||||
package_hash_match: true
|
||||
manifest_entries_match: true
|
||||
unit_tests_pass: true
|
||||
smoke_tests_pass: true
|
||||
py_compile_pass: true
|
||||
```
|
||||
|
||||
## 2. Create root-owned staging
|
||||
|
||||
Extract to a fresh directory such as:
|
||||
|
||||
```text
|
||||
/var/lib/aios-gitops/staging/v2r1c6-task-identity-overlay-<nonce>
|
||||
```
|
||||
|
||||
Requirements:
|
||||
|
||||
```yaml
|
||||
owner: root
|
||||
group: root
|
||||
directory_mode: "0700"
|
||||
files_not_world_writable: true
|
||||
symlinks_rejected: true
|
||||
```
|
||||
|
||||
Do not overwrite the active runtime tree.
|
||||
|
||||
## 3. Re-run target-host tests
|
||||
|
||||
From the staging root:
|
||||
|
||||
```bash
|
||||
python3 -m unittest discover -s tests -v
|
||||
python3 scripts/smoke_authorized_task_writer_provisioning.py
|
||||
python3 scripts/smoke_owner_authorization_broker.py
|
||||
python3 scripts/smoke_task_identity_overlay.py
|
||||
python3 -m py_compile \
|
||||
active-runtime/app/authorized_task_writer_provisioning.py \
|
||||
active-runtime/app/owner_authorization_broker.py \
|
||||
active-runtime/app/task_identity_overlay.py \
|
||||
active-runtime/app/aios_authorized_task_writer_api.py \
|
||||
active-runtime/app/aios_owner_authorization_broker_api.py \
|
||||
active-runtime/app/aios_task_identity_overlay_api.py \
|
||||
active-runtime/app/aios_task_identity_overlay_cli.py \
|
||||
scripts/call_task_identity_overlay.py
|
||||
```
|
||||
|
||||
Any failure is `BLOCKED`; do not proceed.
|
||||
|
||||
## 4. Prestart Git and production freeze checks
|
||||
|
||||
Read from the authority repository:
|
||||
|
||||
```bash
|
||||
git --git-dir=/data/aios-gitops/repo.git rev-parse refs/heads/main
|
||||
git --git-dir=/data/aios-gitops/repo.git \
|
||||
show-ref --verify --quiet \
|
||||
refs/heads/agent/v2r1c6-mainline-reconciliation-20260805
|
||||
git --git-dir=/data/aios-gitops/repo.git rev-parse refs/tags/V2R1C5
|
||||
git --git-dir=/data/aios-gitops/repo.git rev-parse 'refs/tags/V2R1C5^{}'
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```yaml
|
||||
main_oid: 875d41c3101093149414292067fd700dc214d4d3
|
||||
target_branch_exists: false
|
||||
V2R1C5_tag_oid: 9e65bd59baea392294c0f253046d3ab116f63145
|
||||
V2R1C5_peeled_target: aa60e891547abd7e215af7cf2bf3f21fd23541da
|
||||
```
|
||||
|
||||
Read Baseline and require:
|
||||
|
||||
```yaml
|
||||
version: V2R1C5
|
||||
status: PRODUCTION_DEPLOYED
|
||||
target_oid: aa60e891547abd7e215af7cf2bf3f21fd23541da
|
||||
evidence_closure_oid: 5e2cf8276f5093f3352b9a3252c701a44c5ab807
|
||||
```
|
||||
|
||||
If `main` has drifted, return:
|
||||
|
||||
```yaml
|
||||
STATUS: MANUAL_OWNER_REVIEW_REQUIRED
|
||||
REASON: AUTHORIZED_BASE_OID_DRIFT
|
||||
```
|
||||
|
||||
If the target branch already exists at another OID, return:
|
||||
|
||||
```yaml
|
||||
STATUS: MANUAL_OWNER_REVIEW_REQUIRED
|
||||
REASON: EXISTING_BRANCH_OID_CONFLICT
|
||||
```
|
||||
|
||||
Do not delete, reset or force-push the branch.
|
||||
|
||||
## 5. Discover active secret references
|
||||
|
||||
The overlay does not read or modify `gitops.tokens`, but its issuer must compare
|
||||
the new random secret against all active bearer secret references.
|
||||
|
||||
Discover references without printing secret contents. At minimum include the
|
||||
currently active ChatGPT/GitOps bearer secret reference and any other bearer
|
||||
files consumed by the active API.
|
||||
|
||||
Pass them as a comma-separated list in:
|
||||
|
||||
```text
|
||||
AIOS_GITOPS_IDENTITY_OVERLAY_COLLISION_SECRET_REFS
|
||||
```
|
||||
|
||||
Evidence must contain only paths and SHA-256 match counts, not token values.
|
||||
|
||||
If active secret references cannot be enumerated, return:
|
||||
|
||||
```yaml
|
||||
STATUS: BLOCKED
|
||||
REASON: ACTIVE_SECRET_REFERENCE_SET_UNRESOLVED
|
||||
```
|
||||
|
||||
## 6. Install the transient systemd service
|
||||
|
||||
Use the provided template:
|
||||
|
||||
```text
|
||||
deploy/systemd/aios-v2r1c6-task-identity-overlay.service.example
|
||||
```
|
||||
|
||||
Substitute only:
|
||||
|
||||
- verified root-owned staging directory;
|
||||
- verified active secret-ref path list.
|
||||
|
||||
Do not change task ID, repository, base OID, target branch or denied
|
||||
capabilities.
|
||||
|
||||
The service must listen only on:
|
||||
|
||||
```text
|
||||
127.0.0.1:19864
|
||||
```
|
||||
|
||||
Before start, run:
|
||||
|
||||
```bash
|
||||
systemd-analyze verify \
|
||||
/etc/systemd/system/aios-v2r1c6-task-identity-overlay.service
|
||||
```
|
||||
|
||||
Then:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl start aios-v2r1c6-task-identity-overlay.service
|
||||
```
|
||||
|
||||
Confirm:
|
||||
|
||||
```bash
|
||||
curl --fail --silent http://127.0.0.1:19864/healthz
|
||||
```
|
||||
|
||||
Expected:
|
||||
|
||||
```yaml
|
||||
service: aios-task-identity-overlay
|
||||
status: active
|
||||
global_token_registry_used: false
|
||||
loopback_only: true
|
||||
```
|
||||
|
||||
Do not expose the port externally.
|
||||
|
||||
## 7. Issue the one-time overlay
|
||||
|
||||
Use the root-only CLI. The authorization assertion is an audit label for this
|
||||
already approved Owner task; it must not be described as an external signed
|
||||
event.
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
sudo env \
|
||||
PYTHONPATH=<staging>/active-runtime/app \
|
||||
AIOS_GITOPS_IDENTITY_OVERLAY_STATE_ROOT=/var/lib/aios-gitops/task-identity-overlays \
|
||||
AIOS_GITOPS_IDENTITY_OVERLAY_SECRET_ROOT=/run/aios-gitops/task-identity-overlays \
|
||||
AIOS_GITOPS_IDENTITY_OVERLAY_TASK_ID=V2R1C6_TASK_SCOPED_WRITER_PROVISIONING \
|
||||
AIOS_GITOPS_IDENTITY_OVERLAY_OWNER_IDENTITY=aiman \
|
||||
AIOS_GITOPS_IDENTITY_OVERLAY_BASE_REF=refs/heads/main \
|
||||
AIOS_GITOPS_IDENTITY_OVERLAY_BASE_OID=875d41c3101093149414292067fd700dc214d4d3 \
|
||||
AIOS_GITOPS_IDENTITY_OVERLAY_TARGET_BRANCH=agent/v2r1c6-mainline-reconciliation-20260805 \
|
||||
AIOS_GITOPS_IDENTITY_OVERLAY_COLLISION_SECRET_REFS=<verified-secret-ref-list> \
|
||||
python3 <staging>/active-runtime/app/aios_task_identity_overlay_cli.py \
|
||||
issue \
|
||||
--ttl-seconds 900 \
|
||||
--authorization-assertion-ref owner-session:V2R1C6:approved-20260806 \
|
||||
--operator-identity <audited-agent-identity> \
|
||||
--result-json /run/aios-gitops/v2r1c6-overlay-issued.json
|
||||
```
|
||||
|
||||
The result must contain:
|
||||
|
||||
```yaml
|
||||
status: OVERLAY_ACTIVE
|
||||
idempotent: false
|
||||
overlay_id: present
|
||||
secret_ref: under /run/aios-gitops/task-identity-overlays
|
||||
expires_at: within 900 seconds
|
||||
```
|
||||
|
||||
It must not contain a token.
|
||||
|
||||
Verify:
|
||||
|
||||
```yaml
|
||||
secret_file_mode: "0600"
|
||||
metadata_contains_raw_token: false
|
||||
token_hash_unique_across_configured_secret_refs: true
|
||||
```
|
||||
|
||||
## 8. Negative tests
|
||||
|
||||
Before the valid request:
|
||||
|
||||
1. No Authorization header must return HTTP 401.
|
||||
2. A random wrong bearer must return HTTP 401.
|
||||
3. A request with `production_change_authorized=true` must be rejected.
|
||||
4. A request with another base OID or branch must be rejected.
|
||||
5. The valid overlay must remain `ACTIVE` after pre-auth request-validation
|
||||
failures.
|
||||
|
||||
Do not use the global Owner or ChatGPT bearer credential for this endpoint.
|
||||
|
||||
## 9. Send the valid request
|
||||
|
||||
Use:
|
||||
|
||||
```text
|
||||
deploy/v2r1c6-task-writer-authorization-request.json
|
||||
```
|
||||
|
||||
Use the provided client so the bearer is not placed in argv, shell history or
|
||||
logs:
|
||||
|
||||
```bash
|
||||
sudo python3 <staging>/scripts/call_task_identity_overlay.py \
|
||||
--secret-ref <secret_ref_from_issue_result> \
|
||||
--request-json <staging>/deploy/v2r1c6-task-writer-authorization-request.json \
|
||||
--url http://127.0.0.1:19864/api/governance/owner-authorizations \
|
||||
--result-json /var/lib/aios-gitops/v2r1c6-overlay-provision-result.json
|
||||
```
|
||||
|
||||
Required result:
|
||||
|
||||
```yaml
|
||||
http_status: 200
|
||||
status: READY_FOR_CODE_AUTHORING
|
||||
owner_authorization_consumed: true
|
||||
grant_consumed: true
|
||||
identity_overlay_consumed: true
|
||||
identity_overlay_secret_removed: true
|
||||
branch: agent/v2r1c6-mainline-reconciliation-20260805
|
||||
branch_oid: 875d41c3101093149414292067fd700dc214d4d3
|
||||
```
|
||||
|
||||
The result must not include:
|
||||
|
||||
- bearer token;
|
||||
- private key;
|
||||
- signer secret;
|
||||
- raw signature;
|
||||
- credential value.
|
||||
|
||||
## 10. Independent acceptance
|
||||
|
||||
Verify the branch:
|
||||
|
||||
```bash
|
||||
git --git-dir=/data/aios-gitops/repo.git \
|
||||
rev-parse refs/heads/agent/v2r1c6-mainline-reconciliation-20260805
|
||||
```
|
||||
|
||||
It must equal:
|
||||
|
||||
```text
|
||||
875d41c3101093149414292067fd700dc214d4d3
|
||||
```
|
||||
|
||||
Verify worktree evidence returned by the provisioner:
|
||||
|
||||
```yaml
|
||||
branch: agent/v2r1c6-mainline-reconciliation-20260805
|
||||
head_oid: 875d41c3101093149414292067fd700dc214d4d3
|
||||
detached_head: false
|
||||
worktree_clean: true
|
||||
writer_status: active
|
||||
writes_enabled: true
|
||||
allowed_routes:
|
||||
- POST /api/commit-file
|
||||
allow_main_write: false
|
||||
allow_tag_write: false
|
||||
allow_baseline_write: false
|
||||
allow_force_push: false
|
||||
```
|
||||
|
||||
Run policy negative tests against the task writer and require explicit policy
|
||||
denials for:
|
||||
|
||||
- main write;
|
||||
- another branch;
|
||||
- tag create/update;
|
||||
- Baseline write;
|
||||
- force push;
|
||||
- branch delete.
|
||||
|
||||
Network errors, 404, 500 and stopped-service failures do not count as policy
|
||||
denials.
|
||||
|
||||
## 11. Stop and clean up the overlay
|
||||
|
||||
After `READY_FOR_CODE_AUTHORING`:
|
||||
|
||||
```bash
|
||||
sudo systemctl stop aios-v2r1c6-task-identity-overlay.service
|
||||
sudo systemctl disable aios-v2r1c6-task-identity-overlay.service || true
|
||||
sudo rm -f /etc/systemd/system/aios-v2r1c6-task-identity-overlay.service
|
||||
sudo systemctl daemon-reload
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```yaml
|
||||
overlay_listener_stopped: true
|
||||
overlay_secret_removed: true
|
||||
global_token_registry_unchanged: true
|
||||
disabled_owner_record_unchanged: true
|
||||
active_chatgpt_bot_record_unchanged: true
|
||||
audit_metadata_retained: true
|
||||
```
|
||||
|
||||
The root-owned staging directory may be retained until the code is committed,
|
||||
then removed after checksum-confirmed repository integration.
|
||||
|
||||
## 12. Commit the developed code through the task writer
|
||||
|
||||
Only after writer acceptance, use `POST /api/commit-file` to commit the
|
||||
verified package contents to:
|
||||
|
||||
```text
|
||||
agent/v2r1c6-mainline-reconciliation-20260805
|
||||
```
|
||||
|
||||
Commit in reviewable groups:
|
||||
|
||||
1. authorized task-writer bridge;
|
||||
2. Owner authorization broker;
|
||||
3. task identity overlay;
|
||||
4. tests, docs, OpenAPI and deployment templates.
|
||||
|
||||
Do not include:
|
||||
|
||||
- package archives;
|
||||
- runtime tokens;
|
||||
- `/run` or `/var/lib` state;
|
||||
- test result files containing host paths;
|
||||
- secret references containing values;
|
||||
- generated credentials.
|
||||
|
||||
Run the complete 40-test suite from the branch after commits.
|
||||
|
||||
## 13. Protected PR
|
||||
|
||||
Create a protected PR from:
|
||||
|
||||
```text
|
||||
agent/v2r1c6-mainline-reconciliation-20260805
|
||||
```
|
||||
|
||||
to:
|
||||
|
||||
```text
|
||||
main
|
||||
```
|
||||
|
||||
The PR must state:
|
||||
|
||||
- this change closes Owner authorization bootstrap gaps;
|
||||
- the overlay is localhost-only and one-time;
|
||||
- the global token registry is not modified;
|
||||
- no production release is authorized;
|
||||
- V2R1C5 tag and Baseline remain unchanged.
|
||||
|
||||
The Agent must not merge without the repository's normal protected review.
|
||||
|
||||
## 14. Required Agent report
|
||||
|
||||
```yaml
|
||||
STATUS:
|
||||
READY_FOR_CODE_AUTHORING | BLOCKED | FAILED
|
||||
|
||||
PACKAGE:
|
||||
zip_sha256:
|
||||
tar_gz_sha256:
|
||||
manifest_entries:
|
||||
manifest_matched:
|
||||
|
||||
TESTS:
|
||||
unit_total:
|
||||
unit_passed:
|
||||
smoke_bridge:
|
||||
smoke_broker:
|
||||
smoke_overlay:
|
||||
py_compile:
|
||||
|
||||
PRESTART:
|
||||
main_oid:
|
||||
target_branch_previously_absent:
|
||||
V2R1C5_tag_oid:
|
||||
V2R1C5_peeled_target:
|
||||
baseline_unchanged:
|
||||
|
||||
OVERLAY:
|
||||
overlay_id:
|
||||
status:
|
||||
secret_ref:
|
||||
secret_file_mode:
|
||||
token_hash_unique:
|
||||
global_registry_used: false
|
||||
listener:
|
||||
expires_at:
|
||||
consumed:
|
||||
secret_removed:
|
||||
|
||||
NEGATIVE_TESTS:
|
||||
unauthenticated_401:
|
||||
wrong_token_401:
|
||||
production_capability_denied:
|
||||
wrong_base_denied:
|
||||
wrong_branch_denied:
|
||||
|
||||
OWNER_AUTHORIZATION:
|
||||
event_id:
|
||||
reference:
|
||||
consumed:
|
||||
|
||||
GRANT:
|
||||
approval_plan_id:
|
||||
grant_id:
|
||||
grant_digest_sha256:
|
||||
signature_valid:
|
||||
consumed:
|
||||
|
||||
BRANCH:
|
||||
name:
|
||||
oid:
|
||||
base_oid:
|
||||
ref_exists:
|
||||
worktree_path:
|
||||
worktree_clean:
|
||||
|
||||
WRITER:
|
||||
status:
|
||||
writes_enabled:
|
||||
allowed_branch:
|
||||
allowed_base_oid:
|
||||
allowed_routes:
|
||||
main_write_denied:
|
||||
tag_write_denied:
|
||||
baseline_write_denied:
|
||||
force_push_denied:
|
||||
|
||||
REPOSITORY_INTEGRATION:
|
||||
commits:
|
||||
tests_after_commit:
|
||||
protected_pr:
|
||||
merged: false
|
||||
|
||||
POSTCHECK:
|
||||
main_unchanged:
|
||||
V2R1C5_tag_unchanged:
|
||||
baseline_unchanged:
|
||||
global_token_registry_unchanged:
|
||||
overlay_listener_stopped:
|
||||
overlay_secret_removed:
|
||||
|
||||
SECRETS:
|
||||
plaintext_logged: false
|
||||
plaintext_committed: false
|
||||
|
||||
BLOCKERS:
|
||||
|
||||
GOVERNANCE:
|
||||
force_push_used: false
|
||||
tag_rewritten: false
|
||||
direct_main_push: false
|
||||
secrets_committed: false
|
||||
owner_approval_respected: true
|
||||
```
|
||||
|
||||
## Fail-closed conditions
|
||||
|
||||
Return `BLOCKED` and stop on any of:
|
||||
|
||||
- authoritative main OID drift;
|
||||
- target branch conflict;
|
||||
- inability to enumerate active secret references;
|
||||
- secret hash collision;
|
||||
- overlay not bound to the exact task/base/branch;
|
||||
- non-loopback listener;
|
||||
- raw secret in result or logs;
|
||||
- Owner Authorization Broker event not created;
|
||||
- signer/validator/provisioner failure;
|
||||
- grant not consumed;
|
||||
- branch OID mismatch;
|
||||
- writer exposes main/tag/Baseline/force-push permissions;
|
||||
- V2R1C5 tag or Baseline changes.
|
||||
|
||||
Never fall back to the shared `gitops-write-bot.token`, a wildcard credential,
|
||||
manual `gitops.tokens` edits or a direct Git ref write.
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"schema": "aios.v2r1c7.engineering-build-receipt.v1",
|
||||
"version": "V2R1C7",
|
||||
"base_oid": "b356dec2ac5bd2a5f721aa73f8fd1902f9c65632",
|
||||
"production_deployed": false,
|
||||
"tag_created": false,
|
||||
"baseline_changed": false,
|
||||
"deployable": true
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
bec7da1adb75bf5feaa63c60ec6d03adf15d7508742a486207033d000f72d4ff AGENT_DEPLOYMENT_REQUIREMENTS.md
|
||||
251b46e51ce09aa2fd843bee48ef24a5608f31d24c599841f086ceb25c1a1715 README.md
|
||||
783f68ec4f00a11d43ceed536196bfedccaf6614f6db9805f7b36d24e5fa7bc2 TEST_RESULTS.json
|
||||
9208325e5b2e6f5ba6b64e000bc21d14d9f2f214e3ad33dd79857e85218fd6c2 VERSION
|
||||
c4b24b65195077716983acdc8611f705324413a74453858994ba51541957afd4 active-runtime/app/aios_authorized_task_writer_api.py
|
||||
8a1935fa098d47ac02ce51bbd9fd5fad7cf9ab9374ea3de5f630679b96681d84 active-runtime/app/authorized_task_writer_provisioning.py
|
||||
0997ab8332985c7124f334c4e49735d8833326578acc61a61a389bf792424de3 active-runtime/app/deployment_state.py
|
||||
dd3f8b1cab88e005a291f077807873c381e8ca92fe863dc05817925f0dff9613 active-runtime/app/v2r1c6_privacy_tools.py
|
||||
9e45025c41068cafdea2f360edfc4146a5459b0a6bcfbf1403206fc0161e7bac active-runtime/app/v2r1c7_health_service.py
|
||||
c96ba560d69f6c2eb700b9f2b42a32e394dadd00dffb23bbefdb9fb3e1571c0d deploy/aios-v2/systemd/aios-v2r1c7-production.service
|
||||
a05d4e8038fdb866ce17d4fb8a54798a268a1e8d44ff2cbc559069fa6ab2a667 deploy/systemd/aios-gitops-model-api-authorized-writer-bridge.conf.example
|
||||
a60dac51802bff67c2cfff61acdeb0a3a05d74a4f6cb47e3f78295ceb5c6acd9 docs/gitops/authorized-task-writer-provisioning.md
|
||||
2690f76452bceddda90c728278c600bcebd533db24776e2966423c5a8655e4b5 docs/gitops/openapi-authorized-task-writer-fragment.yaml
|
||||
560e5d9af8a8e2ec60ec31cf8659a7982b93ea09b7adb486b738ea2fba460d70 release-manifests/V2R1C7.json
|
||||
81a423c7814f81cb888b60a538c153e549b2be9099c881c968d55ae84efddc57 scripts/build_v2r1c7_release_artifact.py
|
||||
64c78dabffb05c8851f87b2a27c43bd1f21728dca586397d03a6bc22906a906c scripts/probe_v2r1c7_health.py
|
||||
5419dc7de0f50908ad0ef1dac74172fce811bb7b45f314a6e1c1bb9ce618324d scripts/rollback_v2r1c7_release.sh
|
||||
54e2845d2c3c026323ad45515e082d661e3b461feb3bfefedf5adc52db2d7b78 scripts/smoke_authorized_task_writer_provisioning.py
|
||||
32a860e28d5afc105f413d878a13fb8600fa74874dbd3bc4c031ef3efa17896a scripts/verify_v2r1c7_release.py
|
||||
3cce2b744326fe12b89bcad3cd352c77738950964bf8519e72cb5e9c7a5dfbfc tests/test_authorized_task_writer_provisioning.py
|
||||
8f3153c625241f60b65109eeac045e8aed7661d95f3a10f0b32ba3e0e9ac0a85 versions/V2R1C7.json
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"git_branch_created": false,
|
||||
"git_commit_created": false,
|
||||
"production_mutation": false,
|
||||
"py_compile": {
|
||||
"files": [
|
||||
"active-runtime/app/authorized_task_writer_provisioning.py",
|
||||
"active-runtime/app/aios_authorized_task_writer_api.py"
|
||||
],
|
||||
"status": "PASS"
|
||||
},
|
||||
"schema": "aios.authorized-task-writer-bridge-test-report.v1",
|
||||
"secrets_included": false,
|
||||
"smoke": {
|
||||
"command": "python3 scripts/smoke_authorized_task_writer_provisioning.py",
|
||||
"status": "PASS"
|
||||
},
|
||||
"status": "PASS",
|
||||
"unit_tests": {
|
||||
"command": "python3 -m unittest -v tests/test_authorized_task_writer_provisioning.py",
|
||||
"errors": 0,
|
||||
"failures": 0,
|
||||
"total": 9
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
"""Fail-closed Agent Pull Runner primitives for AIOS autonomy v1.
|
||||
|
||||
The runner owns task discovery and reporting only. It never accepts shell
|
||||
strings: executors receive argv, an allowlisted cwd and an allowlisted env.
|
||||
Durable state is intentionally small so restart recovery and duplicate
|
||||
suppression can be verified without coupling the runner to one database.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Mapping, Protocol, Sequence
|
||||
|
||||
|
||||
class RunnerError(RuntimeError):
|
||||
"""Raised when a runner contract would be violated."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RunnerConfig:
|
||||
worker_id: str
|
||||
capabilities: tuple[str, ...] = ()
|
||||
long_poll_seconds: int = 30
|
||||
fallback_seconds: int = 60
|
||||
jitter_seconds: int = 5
|
||||
max_backoff_seconds: int = 300
|
||||
executable_allowlist: tuple[str, ...] = ()
|
||||
working_directory_allowlist: tuple[str, ...] = ()
|
||||
environment_allowlist: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass
|
||||
class RunnerState:
|
||||
inflight: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
terminal: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
|
||||
|
||||
class RunnerTransport(Protocol):
|
||||
def register_worker(self, worker_id: str, capabilities: Sequence[str]) -> Mapping[str, Any]: ...
|
||||
def heartbeat(self, worker_id: str) -> Mapping[str, Any]: ...
|
||||
def long_poll(self, worker_id: str, timeout_seconds: int) -> Mapping[str, Any] | None: ...
|
||||
def poll(self, worker_id: str) -> Mapping[str, Any] | None: ...
|
||||
def claim(self, worker_id: str, task: Mapping[str, Any]) -> Mapping[str, Any]: ...
|
||||
def renew(self, worker_id: str, task_id: str) -> Mapping[str, Any]: ...
|
||||
def report_progress(self, worker_id: str, task_id: str, payload: Mapping[str, Any]) -> Mapping[str, Any]: ...
|
||||
def report_result(self, worker_id: str, task_id: str, payload: Mapping[str, Any]) -> Mapping[str, Any]: ...
|
||||
def report_evidence(self, worker_id: str, task_id: str, payload: Mapping[str, Any]) -> Mapping[str, Any]: ...
|
||||
|
||||
|
||||
class JsonStateStore:
|
||||
"""Atomic JSON state store for restart recovery."""
|
||||
|
||||
def __init__(self, path: str | os.PathLike[str]) -> None:
|
||||
self.path = Path(path)
|
||||
|
||||
def load(self) -> RunnerState:
|
||||
if not self.path.exists():
|
||||
return RunnerState()
|
||||
value = json.loads(self.path.read_text(encoding="utf-8"))
|
||||
if not isinstance(value, dict):
|
||||
raise RunnerError("runner state must be an object")
|
||||
return RunnerState(
|
||||
inflight=dict(value.get("inflight", {})),
|
||||
terminal=dict(value.get("terminal", {})),
|
||||
)
|
||||
|
||||
def save(self, state: RunnerState) -> None:
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
fd, name = tempfile.mkstemp(prefix=self.path.name + ".", dir=self.path.parent)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump({"inflight": state.inflight, "terminal": state.terminal}, handle, sort_keys=True)
|
||||
handle.write("\n")
|
||||
os.chmod(name, 0o600)
|
||||
os.replace(name, self.path)
|
||||
finally:
|
||||
if os.path.exists(name):
|
||||
os.unlink(name)
|
||||
|
||||
|
||||
class AgentPullRunner:
|
||||
"""Pull, claim, execute and report one task at a time."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: RunnerConfig,
|
||||
transport: RunnerTransport,
|
||||
state_store: JsonStateStore | None = None,
|
||||
*,
|
||||
random_fn: Callable[[], float] = random.random,
|
||||
) -> None:
|
||||
if not config.worker_id.strip():
|
||||
raise RunnerError("worker_id is required")
|
||||
self.config = config
|
||||
self.transport = transport
|
||||
self.state_store = state_store
|
||||
self.random_fn = random_fn
|
||||
self.state = state_store.load() if state_store else RunnerState()
|
||||
|
||||
def _save(self) -> None:
|
||||
if self.state_store:
|
||||
self.state_store.save(self.state)
|
||||
|
||||
def register(self) -> Mapping[str, Any]:
|
||||
return self.transport.register_worker(self.config.worker_id, self.config.capabilities)
|
||||
|
||||
def heartbeat(self) -> Mapping[str, Any]:
|
||||
return self.transport.heartbeat(self.config.worker_id)
|
||||
|
||||
def discovery_delay(self, failures: int) -> float:
|
||||
failures = max(0, int(failures))
|
||||
base = min(self.config.max_backoff_seconds, self.config.fallback_seconds * (2**failures))
|
||||
jitter = self.random_fn() * max(0, self.config.jitter_seconds)
|
||||
return min(self.config.max_backoff_seconds, base + jitter)
|
||||
|
||||
def discover_once(self) -> Mapping[str, Any] | None:
|
||||
try:
|
||||
task = self.transport.long_poll(self.config.worker_id, self.config.long_poll_seconds)
|
||||
except Exception:
|
||||
task = None
|
||||
if task is None:
|
||||
task = self.transport.poll(self.config.worker_id)
|
||||
if not task:
|
||||
return None
|
||||
task_id = str(task.get("task_id", ""))
|
||||
if not task_id:
|
||||
raise RunnerError("task_id is required")
|
||||
if task_id in self.state.terminal:
|
||||
return {"status": "duplicate_suppressed", "task_id": task_id}
|
||||
claimed = dict(self.transport.claim(self.config.worker_id, task))
|
||||
self.state.inflight[task_id] = claimed
|
||||
self._save()
|
||||
return claimed
|
||||
|
||||
def renew(self, task_id: str) -> Mapping[str, Any]:
|
||||
if task_id not in self.state.inflight:
|
||||
raise RunnerError("task is not inflight")
|
||||
return self.transport.renew(self.config.worker_id, task_id)
|
||||
|
||||
def report_progress(self, task_id: str, payload: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
if task_id not in self.state.inflight:
|
||||
raise RunnerError("task is not inflight")
|
||||
return self.transport.report_progress(self.config.worker_id, task_id, payload)
|
||||
|
||||
def report_evidence(self, task_id: str, payload: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
if task_id not in self.state.inflight:
|
||||
raise RunnerError("task is not inflight")
|
||||
return self.transport.report_evidence(self.config.worker_id, task_id, payload)
|
||||
|
||||
def report_result(self, task_id: str, payload: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
if task_id in self.state.terminal:
|
||||
return {"status": "duplicate_terminal_suppressed", "task_id": task_id}
|
||||
if task_id not in self.state.inflight:
|
||||
raise RunnerError("task is not inflight")
|
||||
result = dict(self.transport.report_result(self.config.worker_id, task_id, payload))
|
||||
self.state.terminal[task_id] = {"result": result, "reported_at": time.time()}
|
||||
self.state.inflight.pop(task_id, None)
|
||||
self._save()
|
||||
return result
|
||||
|
||||
def recover_after_restart(self) -> list[Mapping[str, Any]]:
|
||||
recovered: list[Mapping[str, Any]] = []
|
||||
for task_id in tuple(self.state.inflight):
|
||||
recovered.append(self.renew(task_id))
|
||||
return recovered
|
||||
|
||||
def validate_execution(self, argv: Sequence[str], cwd: str | os.PathLike[str], env: Mapping[str, str]) -> None:
|
||||
if not argv or any(not isinstance(item, str) or not item for item in argv):
|
||||
raise RunnerError("argv_only_execution_required")
|
||||
if any(ch in argv[0] for ch in (";", "|", "&", "`", "\n")):
|
||||
raise RunnerError("shell_syntax_denied")
|
||||
executable = shutil.which(argv[0])
|
||||
if not executable:
|
||||
raise RunnerError("executable_not_found")
|
||||
if self.config.executable_allowlist and not any(
|
||||
os.path.realpath(executable).startswith(os.path.realpath(prefix).rstrip(os.sep) + os.sep)
|
||||
or os.path.realpath(executable) == os.path.realpath(prefix)
|
||||
for prefix in self.config.executable_allowlist
|
||||
):
|
||||
raise RunnerError("executable_not_allowlisted")
|
||||
real_cwd = os.path.realpath(os.fspath(cwd))
|
||||
if self.config.working_directory_allowlist and not any(
|
||||
real_cwd == os.path.realpath(prefix)
|
||||
or real_cwd.startswith(os.path.realpath(prefix).rstrip(os.sep) + os.sep)
|
||||
for prefix in self.config.working_directory_allowlist
|
||||
):
|
||||
raise RunnerError("working_directory_not_allowlisted")
|
||||
if self.config.environment_allowlist and any(key not in self.config.environment_allowlist for key in env):
|
||||
raise RunnerError("environment_key_not_allowlisted")
|
||||
|
||||
def execute_argv(self, argv: Sequence[str], cwd: str | os.PathLike[str], env: Mapping[str, str]) -> Mapping[str, Any]:
|
||||
self.validate_execution(argv, cwd, env)
|
||||
completed = subprocess.run(list(argv), cwd=os.fspath(cwd), env=dict(env), shell=False, text=True, capture_output=True, check=False)
|
||||
return {"returncode": completed.returncode, "stdout": completed.stdout, "stderr": completed.stderr}
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Small operational CLI for the Agent Pull Runner contract."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
|
||||
from agent_pull_runner import AgentPullRunner, RunnerConfig
|
||||
|
||||
|
||||
class _NoopTransport:
|
||||
def register_worker(self, worker_id, capabilities): return {"status": "registered", "worker_id": worker_id, "capabilities": list(capabilities)}
|
||||
def heartbeat(self, worker_id): return {"status": "alive", "worker_id": worker_id}
|
||||
def long_poll(self, worker_id, timeout_seconds): return None
|
||||
def poll(self, worker_id): return None
|
||||
def claim(self, worker_id, task): return task
|
||||
def renew(self, worker_id, task_id): return {"status": "renewed", "task_id": task_id}
|
||||
def report_progress(self, worker_id, task_id, payload): return {"status": "progress_recorded", "task_id": task_id}
|
||||
def report_result(self, worker_id, task_id, payload): return {"status": "result_recorded", "task_id": task_id}
|
||||
def report_evidence(self, worker_id, task_id, payload): return {"status": "evidence_recorded", "task_id": task_id}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="AIOS Agent Pull Runner")
|
||||
parser.add_argument("--worker-id", default="self-test-worker")
|
||||
parser.add_argument("--self-test", action="store_true")
|
||||
args = parser.parse_args()
|
||||
runner = AgentPullRunner(RunnerConfig(worker_id=args.worker_id, fallback_seconds=60), _NoopTransport())
|
||||
if args.self_test:
|
||||
print(json.dumps({"status": "PASS", "registration": runner.register(), "fallback_seconds": runner.config.fallback_seconds}, sort_keys=True))
|
||||
return 0
|
||||
print(json.dumps({"status": "READY", "worker_id": args.worker_id}, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Staging HTTP overlay for the exact Task lineage supersession capability."""
|
||||
from __future__ import annotations
|
||||
|
||||
from http.server import ThreadingHTTPServer
|
||||
from typing import Any, Callable, Mapping
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from aios_task_lineage_supersession import (
|
||||
DECISION_SCHEMA,
|
||||
TaskLineageSupersessionController,
|
||||
TaskLineageSupersessionError,
|
||||
)
|
||||
|
||||
SUPERSESSION_ROUTE = "/v1/control/task-lineage-supersession"
|
||||
|
||||
|
||||
def _status(error: TaskLineageSupersessionError) -> int:
|
||||
return error.http_status
|
||||
|
||||
|
||||
class LineageSupersessionEndpoint:
|
||||
"""Framework-neutral endpoint; authentication must precede all parsing/mutation."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
db_path,
|
||||
verifier,
|
||||
authenticate_control: Callable[[Mapping[str, str]], Any],
|
||||
*,
|
||||
clock=None,
|
||||
identity_digest=None,
|
||||
) -> None:
|
||||
self.authenticate_control = authenticate_control
|
||||
self.controller = TaskLineageSupersessionController(
|
||||
db_path, verifier, clock=clock, identity_digest=identity_digest
|
||||
)
|
||||
|
||||
def post(self, headers: Mapping[str, str], body: Mapping[str, Any]) -> tuple[int, dict[str, Any]]:
|
||||
self.authenticate_control(headers)
|
||||
try:
|
||||
result = self.controller.supersede(body)
|
||||
except TaskLineageSupersessionError as exc:
|
||||
return _status(exc), {"error": exc.code}
|
||||
return (200 if result.get("idempotent") else 201), result
|
||||
|
||||
|
||||
def build_runtime():
|
||||
"""Bind to the already-active recovery API and its formal Owner verifier lazily."""
|
||||
try:
|
||||
from aios_agent_task_api import APIConfig
|
||||
from aios_agent_task_api_p0_failed_lease_recovery import (
|
||||
P0FailedLeaseRecoveryHandler,
|
||||
P0FailedLeaseRecoveryTaskApplication,
|
||||
)
|
||||
from aios_agent_task_protocol import TaskProtocolError
|
||||
from aios_owner_approval_verifier import (
|
||||
GrantRootOwnerApprovalVerifier,
|
||||
OwnerApprovalVerifierError,
|
||||
)
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("LINEAGE_SUPERSESSION_RUNTIME_OVERLAY_UNAVAILABLE") from exc
|
||||
|
||||
# The active entrypoint layers failed- and active-lease recovery on top of
|
||||
# expired recovery. Inherit that current layer so adding lineage does not
|
||||
# silently remove either already-deployed recovery route.
|
||||
class Application(P0FailedLeaseRecoveryTaskApplication):
|
||||
def __init__(self, config: Any) -> None:
|
||||
super().__init__(config)
|
||||
try:
|
||||
verifier = GrantRootOwnerApprovalVerifier()
|
||||
except OwnerApprovalVerifierError as exc:
|
||||
raise TaskProtocolError(str(exc), 503) from exc
|
||||
self.lineage_supersession = LineageSupersessionEndpoint(
|
||||
config.db_path, verifier, self.authenticate_control
|
||||
)
|
||||
|
||||
def health(self) -> dict[str, Any]:
|
||||
value = dict(super().health())
|
||||
value.update({
|
||||
"task_lineage_supersession": True,
|
||||
"task_lineage_supersession_schema": DECISION_SCHEMA,
|
||||
"task_lineage_supersession_exact_scope": True,
|
||||
"task_lineage_supersession_exact_cas": True,
|
||||
"task_lineage_supersession_append_only": True,
|
||||
"task_lineage_supersession_caller_payload": False,
|
||||
})
|
||||
return value
|
||||
|
||||
class Handler(P0FailedLeaseRecoveryHandler):
|
||||
app: Application | None = None
|
||||
|
||||
def do_POST(self) -> None:
|
||||
path = urlparse(self.path).path.rstrip("/") or "/"
|
||||
if path != SUPERSESSION_ROUTE:
|
||||
super().do_POST()
|
||||
return
|
||||
|
||||
def action() -> None:
|
||||
if self.app is None:
|
||||
raise TaskProtocolError("SERVICE_UNAVAILABLE", 503)
|
||||
status, result = self.app.lineage_supersession.post(
|
||||
self._headers(), self._body()
|
||||
)
|
||||
if "error" in result:
|
||||
raise TaskProtocolError(str(result["error"]), status)
|
||||
self._json(result, status)
|
||||
|
||||
self._run(action)
|
||||
|
||||
return APIConfig, Application, Handler
|
||||
|
||||
|
||||
def serve(config=None) -> None:
|
||||
APIConfig, Application, Handler = build_runtime()
|
||||
config = config or APIConfig.from_env()
|
||||
application = Application(config)
|
||||
Handler.app = application
|
||||
ThreadingHTTPServer((config.host, config.port), Handler).serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
serve()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,811 @@
|
||||
"""Canonical Task projection intent and same-identity recovery primitives.
|
||||
|
||||
This module is intentionally Task-Plane-owned. It does not open a network
|
||||
listener and it does not authenticate callers; existing control-plane services
|
||||
must enforce their normal service-side ``agent-task:control`` boundary before
|
||||
calling it.
|
||||
|
||||
The recovery primitive has one purpose: converge a durable, complete
|
||||
ProjectionIntent to exactly one canonical Task row. It never fabricates task
|
||||
semantics from a Bridge binding and it never generates a replacement task id.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sqlite3
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
|
||||
TASK_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{2,179}$")
|
||||
JOB_ID_RE = TASK_ID_RE
|
||||
SHA40_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
SHA64_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
BRANCH_RE = re.compile(r"^agent/[A-Za-z0-9._/-]{1,220}$")
|
||||
SCHEMA = "aios.canonical-task-projection-intent.v1"
|
||||
RECEIPT_SCHEMA = "aios.canonical-task-projection-receipt.v1"
|
||||
|
||||
|
||||
class ProjectionRecoveryError(RuntimeError):
|
||||
def __init__(self, code: str, status: int = 409) -> None:
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.status = status
|
||||
|
||||
|
||||
class ProjectionState(str, Enum):
|
||||
PENDING = "PENDING"
|
||||
CONVERGED = "CONVERGED"
|
||||
DRIFT = "DRIFT"
|
||||
FAILED = "FAILED"
|
||||
BLOCKED = "BLOCKED"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
BACKFILL_REQUIRED = "BACKFILL_REQUIRED"
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _canonical(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def sha256_json(value: object) -> str:
|
||||
return hashlib.sha256(_canonical(value)).hexdigest()
|
||||
|
||||
|
||||
def _string(value: object, code: str, pattern: re.Pattern[str] | None = None) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise ProjectionRecoveryError(code, 400)
|
||||
result = value.strip()
|
||||
if pattern is not None and pattern.fullmatch(result) is None:
|
||||
raise ProjectionRecoveryError(code, 400)
|
||||
return result
|
||||
|
||||
|
||||
def _integer(value: object, code: str, *, minimum: int = 0) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
|
||||
raise ProjectionRecoveryError(code, 400)
|
||||
return value
|
||||
|
||||
|
||||
def _plain_mapping(value: object, code: str) -> dict[str, Any]:
|
||||
if not isinstance(value, Mapping):
|
||||
raise ProjectionRecoveryError(code, 400)
|
||||
return {str(k): v for k, v in value.items()}
|
||||
|
||||
|
||||
def _plain_list(value: object, code: str) -> list[Any]:
|
||||
if not isinstance(value, list):
|
||||
raise ProjectionRecoveryError(code, 400)
|
||||
return list(value)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CanonicalTaskPayload:
|
||||
task_id: str
|
||||
goal: str
|
||||
instructions: str
|
||||
success_criteria: tuple[Mapping[str, Any], ...]
|
||||
constraints: Mapping[str, Any]
|
||||
required_capabilities: tuple[str, ...]
|
||||
metadata: Mapping[str, Any]
|
||||
priority: int = 100
|
||||
max_attempts: int = 20
|
||||
deadline: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> "CanonicalTaskPayload":
|
||||
allowed = {
|
||||
"task_id",
|
||||
"goal",
|
||||
"instructions",
|
||||
"success_criteria",
|
||||
"constraints",
|
||||
"required_capabilities",
|
||||
"metadata",
|
||||
"priority",
|
||||
"max_attempts",
|
||||
"deadline",
|
||||
}
|
||||
if set(value) - allowed:
|
||||
raise ProjectionRecoveryError("TASK_PAYLOAD_UNKNOWN_FIELDS", 400)
|
||||
|
||||
task_id = _string(value.get("task_id"), "TASK_ID_INVALID", TASK_ID_RE)
|
||||
goal = _string(value.get("goal"), "TASK_GOAL_INVALID")
|
||||
instructions = _string(value.get("instructions"), "TASK_INSTRUCTIONS_INVALID")
|
||||
if len(goal) > 8192 or len(instructions) > 65536:
|
||||
raise ProjectionRecoveryError("TASK_TEXT_TOO_LARGE", 413)
|
||||
|
||||
criteria_raw = _plain_list(value.get("success_criteria"), "TASK_SUCCESS_CRITERIA_INVALID")
|
||||
criteria: list[Mapping[str, Any]] = []
|
||||
for item in criteria_raw:
|
||||
if not isinstance(item, Mapping):
|
||||
raise ProjectionRecoveryError("TASK_SUCCESS_CRITERIA_INVALID", 400)
|
||||
criteria.append(dict(item))
|
||||
if not criteria:
|
||||
raise ProjectionRecoveryError("TASK_SUCCESS_CRITERIA_REQUIRED", 400)
|
||||
|
||||
constraints = _plain_mapping(value.get("constraints"), "TASK_CONSTRAINTS_INVALID")
|
||||
capabilities_raw = _plain_list(
|
||||
value.get("required_capabilities", []),
|
||||
"TASK_CAPABILITIES_INVALID",
|
||||
)
|
||||
capabilities: list[str] = []
|
||||
for item in capabilities_raw:
|
||||
capabilities.append(_string(item, "TASK_CAPABILITY_INVALID"))
|
||||
|
||||
# Recovery recreates the pristine canonical row. Initial execution
|
||||
# projection remains the sole owner of runtime capabilities.
|
||||
if capabilities:
|
||||
raise ProjectionRecoveryError(
|
||||
"TASK_RECOVERY_REQUIRES_PRISTINE_CAPABILITIES",
|
||||
409,
|
||||
)
|
||||
|
||||
metadata = _plain_mapping(value.get("metadata", {}), "TASK_METADATA_INVALID")
|
||||
if "execution" in metadata or "execution_projection" in metadata:
|
||||
raise ProjectionRecoveryError(
|
||||
"TASK_RECOVERY_REQUIRES_PRISTINE_METADATA",
|
||||
409,
|
||||
)
|
||||
|
||||
priority = _integer(value.get("priority", 100), "TASK_PRIORITY_INVALID", minimum=0)
|
||||
max_attempts = _integer(
|
||||
value.get("max_attempts", 20),
|
||||
"TASK_MAX_ATTEMPTS_INVALID",
|
||||
minimum=1,
|
||||
)
|
||||
deadline = value.get("deadline")
|
||||
if deadline is not None:
|
||||
deadline = _string(deadline, "TASK_DEADLINE_INVALID")
|
||||
|
||||
return cls(
|
||||
task_id=task_id,
|
||||
goal=goal,
|
||||
instructions=instructions,
|
||||
success_criteria=tuple(criteria),
|
||||
constraints=constraints,
|
||||
required_capabilities=tuple(capabilities),
|
||||
metadata=metadata,
|
||||
priority=priority,
|
||||
max_attempts=max_attempts,
|
||||
deadline=deadline,
|
||||
)
|
||||
|
||||
def public(self) -> dict[str, Any]:
|
||||
return {
|
||||
"task_id": self.task_id,
|
||||
"goal": self.goal,
|
||||
"instructions": self.instructions,
|
||||
"success_criteria": [dict(x) for x in self.success_criteria],
|
||||
"constraints": dict(self.constraints),
|
||||
"required_capabilities": list(self.required_capabilities),
|
||||
"metadata": dict(self.metadata),
|
||||
"priority": self.priority,
|
||||
"max_attempts": self.max_attempts,
|
||||
"deadline": self.deadline,
|
||||
}
|
||||
|
||||
@property
|
||||
def payload_sha256(self) -> str:
|
||||
return sha256_json(self.public())
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GovernedProjectionBinding:
|
||||
governed_task_id: str
|
||||
bridge_job_id: str
|
||||
repository: str
|
||||
branch: str
|
||||
generation: int
|
||||
base_oid: str
|
||||
expected_branch_oid: str
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> "GovernedProjectionBinding":
|
||||
allowed = {
|
||||
"governed_task_id",
|
||||
"bridge_job_id",
|
||||
"repository",
|
||||
"branch",
|
||||
"generation",
|
||||
"base_oid",
|
||||
"expected_branch_oid",
|
||||
}
|
||||
if set(value) != allowed:
|
||||
raise ProjectionRecoveryError("GOVERNED_BINDING_FIELDS_INVALID", 400)
|
||||
repository = _string(value["repository"], "REPOSITORY_INVALID")
|
||||
if repository != "aios/aios":
|
||||
raise ProjectionRecoveryError("REPOSITORY_MISMATCH", 409)
|
||||
return cls(
|
||||
governed_task_id=_string(
|
||||
value["governed_task_id"],
|
||||
"GOVERNED_TASK_ID_INVALID",
|
||||
TASK_ID_RE,
|
||||
),
|
||||
bridge_job_id=_string(
|
||||
value["bridge_job_id"],
|
||||
"BRIDGE_JOB_ID_INVALID",
|
||||
JOB_ID_RE,
|
||||
),
|
||||
repository=repository,
|
||||
branch=_string(value["branch"], "BRANCH_INVALID", BRANCH_RE),
|
||||
generation=_integer(value["generation"], "GENERATION_INVALID", minimum=1),
|
||||
base_oid=_string(value["base_oid"], "BASE_OID_INVALID", SHA40_RE),
|
||||
expected_branch_oid=_string(
|
||||
value["expected_branch_oid"],
|
||||
"EXPECTED_OID_INVALID",
|
||||
SHA40_RE,
|
||||
),
|
||||
)
|
||||
|
||||
def public(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProjectionSourceArtifact:
|
||||
ref: str
|
||||
sha256: str
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> "ProjectionSourceArtifact":
|
||||
if set(value) != {"ref", "sha256"}:
|
||||
raise ProjectionRecoveryError("SOURCE_ARTIFACT_FIELDS_INVALID", 400)
|
||||
return cls(
|
||||
ref=_string(value["ref"], "SOURCE_ARTIFACT_REF_INVALID"),
|
||||
sha256=_string(value["sha256"], "SOURCE_ARTIFACT_SHA_INVALID", SHA64_RE),
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CanonicalTaskProjectionIntent:
|
||||
projection_id: str
|
||||
projection_generation: int
|
||||
idempotency_key: str
|
||||
task: CanonicalTaskPayload
|
||||
binding: GovernedProjectionBinding
|
||||
source_artifact: ProjectionSourceArtifact
|
||||
schema: str = SCHEMA
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> "CanonicalTaskProjectionIntent":
|
||||
allowed = {
|
||||
"schema",
|
||||
"projection_id",
|
||||
"projection_generation",
|
||||
"idempotency_key",
|
||||
"task",
|
||||
"governed_binding",
|
||||
"source_artifact",
|
||||
"task_payload_sha256",
|
||||
}
|
||||
if set(value) != allowed:
|
||||
raise ProjectionRecoveryError("PROJECTION_INTENT_FIELDS_INVALID", 400)
|
||||
if value["schema"] != SCHEMA:
|
||||
raise ProjectionRecoveryError("PROJECTION_INTENT_SCHEMA_INVALID", 400)
|
||||
|
||||
task = CanonicalTaskPayload.from_mapping(
|
||||
_plain_mapping(value["task"], "TASK_PAYLOAD_INVALID")
|
||||
)
|
||||
binding = GovernedProjectionBinding.from_mapping(
|
||||
_plain_mapping(value["governed_binding"], "GOVERNED_BINDING_INVALID")
|
||||
)
|
||||
source = ProjectionSourceArtifact.from_mapping(
|
||||
_plain_mapping(value["source_artifact"], "SOURCE_ARTIFACT_INVALID")
|
||||
)
|
||||
if binding.governed_task_id != task.task_id:
|
||||
raise ProjectionRecoveryError("PROJECTION_TASK_IDENTITY_MISMATCH", 409)
|
||||
|
||||
expected_hash = _string(
|
||||
value["task_payload_sha256"],
|
||||
"TASK_PAYLOAD_SHA_INVALID",
|
||||
SHA64_RE,
|
||||
)
|
||||
if task.payload_sha256 != expected_hash:
|
||||
raise ProjectionRecoveryError("TASK_PAYLOAD_SHA_MISMATCH", 409)
|
||||
|
||||
projection_id = _string(
|
||||
value["projection_id"],
|
||||
"PROJECTION_ID_INVALID",
|
||||
TASK_ID_RE,
|
||||
)
|
||||
idempotency_key = _string(
|
||||
value["idempotency_key"],
|
||||
"IDEMPOTENCY_KEY_INVALID",
|
||||
TASK_ID_RE,
|
||||
)
|
||||
generation = _integer(
|
||||
value["projection_generation"],
|
||||
"PROJECTION_GENERATION_INVALID",
|
||||
minimum=1,
|
||||
)
|
||||
return cls(
|
||||
projection_id=projection_id,
|
||||
projection_generation=generation,
|
||||
idempotency_key=idempotency_key,
|
||||
task=task,
|
||||
binding=binding,
|
||||
source_artifact=source,
|
||||
)
|
||||
|
||||
def public(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": self.schema,
|
||||
"projection_id": self.projection_id,
|
||||
"projection_generation": self.projection_generation,
|
||||
"idempotency_key": self.idempotency_key,
|
||||
"task": self.task.public(),
|
||||
"governed_binding": self.binding.public(),
|
||||
"source_artifact": asdict(self.source_artifact),
|
||||
"task_payload_sha256": self.task.payload_sha256,
|
||||
}
|
||||
|
||||
@property
|
||||
def request_sha256(self) -> str:
|
||||
return sha256_json(self.public())
|
||||
|
||||
|
||||
class CanonicalTaskProjectionStore:
|
||||
"""Task-Plane-owned intent/recovery store.
|
||||
|
||||
The caller must already be inside the Task Plane authorization boundary.
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: Path | str) -> None:
|
||||
self.db_path = Path(db_path)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self.db_path, timeout=30.0)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
connection.execute("PRAGMA busy_timeout=30000")
|
||||
return connection
|
||||
|
||||
@staticmethod
|
||||
def _columns(connection: sqlite3.Connection, table: str) -> dict[str, sqlite3.Row]:
|
||||
return {
|
||||
str(row["name"]): row
|
||||
for row in connection.execute(f'PRAGMA table_info("{table}")')
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _table_names(connection: sqlite3.Connection) -> set[str]:
|
||||
return {
|
||||
str(row[0])
|
||||
for row in connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
)
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _event_table(
|
||||
cls,
|
||||
connection: sqlite3.Connection,
|
||||
) -> tuple[str, dict[str, sqlite3.Row]]:
|
||||
tables = cls._table_names(connection)
|
||||
for name in ("task_events", "events"):
|
||||
if name in tables:
|
||||
columns = cls._columns(connection, name)
|
||||
if "task_id" in columns and "event_type" in columns:
|
||||
return name, columns
|
||||
raise ProjectionRecoveryError("TASK_EVENT_TABLE_UNSUPPORTED", 503)
|
||||
|
||||
@staticmethod
|
||||
def _ensure_control_tables(connection: sqlite3.Connection) -> None:
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS task_projection_intents (
|
||||
projection_id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
bridge_job_id TEXT NOT NULL,
|
||||
projection_generation INTEGER NOT NULL,
|
||||
idempotency_key TEXT NOT NULL,
|
||||
request_sha256 TEXT NOT NULL,
|
||||
task_payload_sha256 TEXT NOT NULL,
|
||||
intent_json TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
last_error TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(task_id, projection_generation),
|
||||
UNIQUE(task_id, idempotency_key)
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS task_projection_receipts (
|
||||
projection_id TEXT PRIMARY KEY,
|
||||
task_id TEXT NOT NULL,
|
||||
bridge_job_id TEXT NOT NULL,
|
||||
projection_generation INTEGER NOT NULL,
|
||||
task_payload_sha256 TEXT NOT NULL,
|
||||
canonical_revision INTEGER NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
created_new_row INTEGER NOT NULL,
|
||||
observed_at TEXT NOT NULL,
|
||||
receipt_json TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
def persist_intent(
|
||||
self,
|
||||
intent: CanonicalTaskProjectionIntent,
|
||||
) -> dict[str, Any]:
|
||||
now = _now()
|
||||
request_sha = intent.request_sha256
|
||||
intent_text = _canonical(intent.public()).decode("utf-8")
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
self._ensure_control_tables(connection)
|
||||
existing = connection.execute(
|
||||
"""
|
||||
SELECT * FROM task_projection_intents
|
||||
WHERE task_id=? AND projection_generation=?
|
||||
""",
|
||||
(intent.task.task_id, intent.projection_generation),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
if (
|
||||
str(existing["projection_id"]) != intent.projection_id
|
||||
or str(existing["idempotency_key"]) != intent.idempotency_key
|
||||
or str(existing["request_sha256"]) != request_sha
|
||||
):
|
||||
raise ProjectionRecoveryError(
|
||||
"PROJECTION_INTENT_IDEMPOTENCY_CONFLICT",
|
||||
409,
|
||||
)
|
||||
connection.commit()
|
||||
return {
|
||||
"projection_id": intent.projection_id,
|
||||
"task_id": intent.task.task_id,
|
||||
"state": str(existing["state"]),
|
||||
"idempotent": True,
|
||||
}
|
||||
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO task_projection_intents(
|
||||
projection_id,task_id,bridge_job_id,projection_generation,
|
||||
idempotency_key,request_sha256,task_payload_sha256,intent_json,
|
||||
state,last_error,created_at,updated_at
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
""",
|
||||
(
|
||||
intent.projection_id,
|
||||
intent.task.task_id,
|
||||
intent.binding.bridge_job_id,
|
||||
intent.projection_generation,
|
||||
intent.idempotency_key,
|
||||
request_sha,
|
||||
intent.task.payload_sha256,
|
||||
intent_text,
|
||||
ProjectionState.PENDING.value,
|
||||
None,
|
||||
now,
|
||||
now,
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
return {
|
||||
"projection_id": intent.projection_id,
|
||||
"task_id": intent.task.task_id,
|
||||
"state": ProjectionState.PENDING.value,
|
||||
"idempotent": False,
|
||||
}
|
||||
|
||||
def _task_row_payload(
|
||||
self,
|
||||
row: sqlite3.Row,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
success = json.loads(row["success_criteria_json"])
|
||||
constraints = json.loads(row["constraints_json"])
|
||||
capabilities = json.loads(row["required_capabilities_json"])
|
||||
metadata = json.loads(row["metadata_json"])
|
||||
except (TypeError, json.JSONDecodeError) as exc:
|
||||
raise ProjectionRecoveryError("CANONICAL_TASK_JSON_INVALID", 409) from exc
|
||||
|
||||
if (
|
||||
not isinstance(success, list)
|
||||
or not isinstance(constraints, Mapping)
|
||||
or not isinstance(capabilities, list)
|
||||
or not isinstance(metadata, Mapping)
|
||||
):
|
||||
raise ProjectionRecoveryError("CANONICAL_TASK_SHAPE_INVALID", 409)
|
||||
|
||||
return {
|
||||
"task_id": str(row["task_id"]),
|
||||
"goal": str(row["goal"]),
|
||||
"instructions": str(row["instructions"]),
|
||||
"success_criteria": success,
|
||||
"constraints": dict(constraints),
|
||||
"required_capabilities": capabilities,
|
||||
"metadata": dict(metadata),
|
||||
"priority": int(row["priority"]),
|
||||
"max_attempts": int(row["max_attempts"]),
|
||||
"deadline": row["deadline"],
|
||||
}
|
||||
|
||||
def _verify_existing_task(
|
||||
self,
|
||||
row: sqlite3.Row,
|
||||
intent: CanonicalTaskProjectionIntent,
|
||||
) -> int:
|
||||
observed = self._task_row_payload(row)
|
||||
observed_hash = sha256_json(observed)
|
||||
if observed_hash != intent.task.payload_sha256:
|
||||
raise ProjectionRecoveryError(
|
||||
"CANONICAL_TASK_PROJECTION_IDENTITY_CONFLICT",
|
||||
409,
|
||||
)
|
||||
return int(row["revision"])
|
||||
|
||||
@staticmethod
|
||||
def _required_task_columns() -> set[str]:
|
||||
return {
|
||||
"task_id",
|
||||
"goal",
|
||||
"instructions",
|
||||
"success_criteria_json",
|
||||
"required_capabilities_json",
|
||||
"constraints_json",
|
||||
"metadata_json",
|
||||
"priority",
|
||||
"max_attempts",
|
||||
"attempt",
|
||||
"status",
|
||||
"claimed_by",
|
||||
"lease_token_hash",
|
||||
"lease_expires_at",
|
||||
"result_json",
|
||||
"verification_json",
|
||||
"revision",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
}
|
||||
|
||||
def _insert_task(
|
||||
self,
|
||||
connection: sqlite3.Connection,
|
||||
intent: CanonicalTaskProjectionIntent,
|
||||
) -> int:
|
||||
columns = self._columns(connection, "tasks")
|
||||
if not self._required_task_columns().issubset(columns):
|
||||
raise ProjectionRecoveryError("TASK_SCHEMA_RECOVERY_UNSUPPORTED", 503)
|
||||
|
||||
now = _now()
|
||||
values: dict[str, Any] = {
|
||||
"task_id": intent.task.task_id,
|
||||
"goal": intent.task.goal,
|
||||
"instructions": intent.task.instructions,
|
||||
"success_criteria_json": _canonical(
|
||||
[dict(x) for x in intent.task.success_criteria]
|
||||
).decode("utf-8"),
|
||||
"required_capabilities_json": "[]",
|
||||
"constraints_json": _canonical(dict(intent.task.constraints)).decode("utf-8"),
|
||||
"metadata_json": _canonical(dict(intent.task.metadata)).decode("utf-8"),
|
||||
"deadline": intent.task.deadline,
|
||||
"priority": intent.task.priority,
|
||||
"max_attempts": intent.task.max_attempts,
|
||||
"attempt": 0,
|
||||
"status": "QUEUED",
|
||||
"claimed_by": None,
|
||||
"lease_token_hash": None,
|
||||
"lease_expires_at": None,
|
||||
"result_json": None,
|
||||
"verification_json": None,
|
||||
"revision": 1,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
}
|
||||
for optional in (
|
||||
"result_digest",
|
||||
"result_idempotency_key",
|
||||
):
|
||||
if optional in columns:
|
||||
values[optional] = None
|
||||
|
||||
names = [name for name in values if name in columns]
|
||||
placeholders = ",".join("?" for _ in names)
|
||||
connection.execute(
|
||||
f'INSERT INTO tasks ({",".join(chr(34)+n+chr(34) for n in names)}) '
|
||||
f"VALUES ({placeholders})",
|
||||
tuple(values[name] for name in names),
|
||||
)
|
||||
self._insert_task_created_event(connection, intent)
|
||||
return 1
|
||||
|
||||
def _insert_task_created_event(
|
||||
self,
|
||||
connection: sqlite3.Connection,
|
||||
intent: CanonicalTaskProjectionIntent,
|
||||
) -> None:
|
||||
table, columns = self._event_table(connection)
|
||||
event = {
|
||||
"schema": "aios.task-projection-recovery.event.v1",
|
||||
"task_id": intent.task.task_id,
|
||||
"bridge_job_id": intent.binding.bridge_job_id,
|
||||
"projection_id": intent.projection_id,
|
||||
"projection_generation": intent.projection_generation,
|
||||
"task_payload_sha256": intent.task.payload_sha256,
|
||||
"source_artifact_ref": intent.source_artifact.ref,
|
||||
"source_artifact_sha256": intent.source_artifact.sha256,
|
||||
"recovery": True,
|
||||
}
|
||||
values: dict[str, Any] = {
|
||||
"task_id": intent.task.task_id,
|
||||
"event_type": "TASK_CREATED",
|
||||
}
|
||||
payload_text = _canonical(event).decode("utf-8")
|
||||
if "payload_json" in columns:
|
||||
values["payload_json"] = payload_text
|
||||
elif "payload" in columns:
|
||||
values["payload"] = payload_text
|
||||
else:
|
||||
raise ProjectionRecoveryError("TASK_EVENT_PAYLOAD_UNSUPPORTED", 503)
|
||||
if "created_at" in columns:
|
||||
values["created_at"] = _now()
|
||||
if "agent_id" in columns:
|
||||
values["agent_id"] = "control-plane-projection-recovery"
|
||||
if "actor_id" in columns:
|
||||
values["actor_id"] = "control-plane-projection-recovery"
|
||||
|
||||
names = list(values)
|
||||
connection.execute(
|
||||
f'INSERT INTO "{table}" ({",".join(chr(34)+n+chr(34) for n in names)}) '
|
||||
f'VALUES ({",".join("?" for _ in names)})',
|
||||
tuple(values[name] for name in names),
|
||||
)
|
||||
|
||||
def reconcile(
|
||||
self,
|
||||
intent: CanonicalTaskProjectionIntent,
|
||||
) -> dict[str, Any]:
|
||||
# Persist first so a crash before Task creation leaves a durable PENDING
|
||||
# intent for startup/supervisor reconciliation.
|
||||
self.persist_intent(intent)
|
||||
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
self._ensure_control_tables(connection)
|
||||
existing_receipt = connection.execute(
|
||||
"SELECT * FROM task_projection_receipts WHERE projection_id=?",
|
||||
(intent.projection_id,),
|
||||
).fetchone()
|
||||
if existing_receipt is not None:
|
||||
if (
|
||||
str(existing_receipt["task_id"]) != intent.task.task_id
|
||||
or str(existing_receipt["bridge_job_id"]) != intent.binding.bridge_job_id
|
||||
or str(existing_receipt["task_payload_sha256"])
|
||||
!= intent.task.payload_sha256
|
||||
):
|
||||
raise ProjectionRecoveryError(
|
||||
"PROJECTION_RECEIPT_IDENTITY_CONFLICT",
|
||||
409,
|
||||
)
|
||||
connection.commit()
|
||||
receipt = json.loads(existing_receipt["receipt_json"])
|
||||
receipt["idempotent"] = True
|
||||
return receipt
|
||||
|
||||
row = connection.execute(
|
||||
"SELECT * FROM tasks WHERE task_id=?",
|
||||
(intent.task.task_id,),
|
||||
).fetchone()
|
||||
created_new = False
|
||||
if row is None:
|
||||
revision = self._insert_task(connection, intent)
|
||||
created_new = True
|
||||
row = connection.execute(
|
||||
"SELECT * FROM tasks WHERE task_id=?",
|
||||
(intent.task.task_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise ProjectionRecoveryError(
|
||||
"TASK_RECOVERY_OBSERVATION_FAILED",
|
||||
503,
|
||||
)
|
||||
else:
|
||||
revision = self._verify_existing_task(row, intent)
|
||||
|
||||
# Observation after mutation is mandatory.
|
||||
revision = self._verify_existing_task(row, intent)
|
||||
observed_at = _now()
|
||||
receipt = {
|
||||
"schema": RECEIPT_SCHEMA,
|
||||
"projection_id": intent.projection_id,
|
||||
"governed_task_id": intent.binding.governed_task_id,
|
||||
"canonical_task_id": intent.task.task_id,
|
||||
"bridge_job_id": intent.binding.bridge_job_id,
|
||||
"projection_generation": intent.projection_generation,
|
||||
"task_payload_sha256": intent.task.payload_sha256,
|
||||
"canonical_revision": revision,
|
||||
"state": ProjectionState.CONVERGED.value,
|
||||
"created_new_row": created_new,
|
||||
"source_artifact": asdict(intent.source_artifact),
|
||||
"observed_at": observed_at,
|
||||
"idempotent": False,
|
||||
}
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO task_projection_receipts(
|
||||
projection_id,task_id,bridge_job_id,projection_generation,
|
||||
task_payload_sha256,canonical_revision,state,created_new_row,
|
||||
observed_at,receipt_json
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?)
|
||||
""",
|
||||
(
|
||||
intent.projection_id,
|
||||
intent.task.task_id,
|
||||
intent.binding.bridge_job_id,
|
||||
intent.projection_generation,
|
||||
intent.task.payload_sha256,
|
||||
revision,
|
||||
ProjectionState.CONVERGED.value,
|
||||
1 if created_new else 0,
|
||||
observed_at,
|
||||
_canonical(receipt).decode("utf-8"),
|
||||
),
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE task_projection_intents
|
||||
SET state=?, last_error=NULL, updated_at=?
|
||||
WHERE projection_id=?
|
||||
""",
|
||||
(
|
||||
ProjectionState.CONVERGED.value,
|
||||
observed_at,
|
||||
intent.projection_id,
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
return receipt
|
||||
|
||||
def get_projection(
|
||||
self,
|
||||
task_id: str,
|
||||
) -> dict[str, Any] | None:
|
||||
task_id = _string(task_id, "TASK_ID_INVALID", TASK_ID_RE)
|
||||
with self._connect() as connection:
|
||||
self._ensure_control_tables(connection)
|
||||
intent = connection.execute(
|
||||
"""
|
||||
SELECT * FROM task_projection_intents
|
||||
WHERE task_id=?
|
||||
ORDER BY projection_generation DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
if intent is None:
|
||||
return None
|
||||
receipt = connection.execute(
|
||||
"SELECT receipt_json FROM task_projection_receipts WHERE projection_id=?",
|
||||
(intent["projection_id"],),
|
||||
).fetchone()
|
||||
return {
|
||||
"projection_id": intent["projection_id"],
|
||||
"task_id": task_id,
|
||||
"bridge_job_id": intent["bridge_job_id"],
|
||||
"projection_generation": int(intent["projection_generation"]),
|
||||
"state": intent["state"],
|
||||
"last_error": intent["last_error"],
|
||||
"task_payload_sha256": intent["task_payload_sha256"],
|
||||
"receipt": json.loads(receipt[0]) if receipt else None,
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
"""HTTP integration helpers for canonical Task projection recovery.
|
||||
|
||||
This module intentionally contains no HTTP server. Existing Task Plane /
|
||||
projection-owner handlers should call ``recover_from_json`` only after their
|
||||
normal service-side ``agent-task:control`` authorization succeeds.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_agent_task_projection_recovery import (
|
||||
CanonicalTaskProjectionIntent,
|
||||
CanonicalTaskProjectionStore,
|
||||
ProjectionRecoveryError,
|
||||
)
|
||||
|
||||
|
||||
def recover_from_json(
|
||||
db_path: Path | str,
|
||||
task_id: str,
|
||||
body: Mapping[str, Any],
|
||||
) -> tuple[int, dict[str, Any]]:
|
||||
try:
|
||||
intent = CanonicalTaskProjectionIntent.from_mapping(body)
|
||||
if intent.task.task_id != task_id:
|
||||
raise ProjectionRecoveryError("PROJECTION_TASK_IDENTITY_MISMATCH", 409)
|
||||
result = CanonicalTaskProjectionStore(db_path).reconcile(intent)
|
||||
return (200 if result.get("idempotent") else 201), result
|
||||
except ProjectionRecoveryError as exc:
|
||||
return exc.status, {"error": exc.code}
|
||||
|
||||
|
||||
def get_projection(
|
||||
db_path: Path | str,
|
||||
task_id: str,
|
||||
) -> tuple[int, dict[str, Any]]:
|
||||
try:
|
||||
result = CanonicalTaskProjectionStore(db_path).get_projection(task_id)
|
||||
except ProjectionRecoveryError as exc:
|
||||
return exc.status, {"error": exc.code}
|
||||
if result is None:
|
||||
return 404, {"error": "TASK_PROJECTION_NOT_FOUND"}
|
||||
return 200, result
|
||||
@@ -0,0 +1,192 @@
|
||||
"""Fail-closed Authority Git side-effect boundary.
|
||||
|
||||
Only this module may issue the narrowly scoped ref CAS primitives used by the
|
||||
durable executor. It never accepts force updates, arbitrary refs, tags, or
|
||||
direct production mutations.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import tempfile
|
||||
from typing import Mapping, Sequence
|
||||
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
TASK_BRANCH_RE = re.compile(r"^refs/heads/agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$")
|
||||
|
||||
|
||||
class AuthorityGitError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _oid(value: str, field: str) -> str:
|
||||
if not isinstance(value, str) or not OID_RE.fullmatch(value):
|
||||
raise AuthorityGitError(f"{field}_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AuthorityGitExecutor:
|
||||
git_dir: Path
|
||||
worktree_root: Path = Path("/var/lib/aios-gitops/worktrees")
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.git_dir.is_absolute() or self.git_dir.is_symlink():
|
||||
raise AuthorityGitError("AUTHORITY_GIT_DIR_INVALID")
|
||||
if not self.worktree_root.is_absolute() or self.worktree_root.is_symlink():
|
||||
raise AuthorityGitError("WORKTREE_ROOT_INVALID")
|
||||
|
||||
def _git(self, args: Sequence[str], *, check: bool = True) -> str:
|
||||
result = subprocess.run(
|
||||
["git", f"--git-dir={self.git_dir}", *args],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if check and result.returncode != 0:
|
||||
raise AuthorityGitError("AUTHORITY_GIT_COMMAND_FAILED")
|
||||
return result.stdout.strip()
|
||||
|
||||
def read_ref(self, ref: str) -> str | None:
|
||||
if not ref.startswith("refs/") or ref.startswith(("refs/tags/", "refs/remotes/")):
|
||||
raise AuthorityGitError("REF_READ_SCOPE_DENIED")
|
||||
value = self._git(["rev-parse", "--verify", ref], check=False)
|
||||
return value if value and OID_RE.fullmatch(value) else None
|
||||
|
||||
def commit_exists(self, oid: str) -> bool:
|
||||
_oid(oid, "commit_oid")
|
||||
result = subprocess.run(["git", f"--git-dir={self.git_dir}", "cat-file", "-e", oid + "^{commit}"], capture_output=True, check=False)
|
||||
return result.returncode == 0
|
||||
|
||||
def ancestry_check(self, older_oid: str, newer_oid: str) -> bool:
|
||||
_oid(older_oid, "older_oid")
|
||||
_oid(newer_oid, "newer_oid")
|
||||
result = subprocess.run(
|
||||
["git", f"--git-dir={self.git_dir}", "merge-base", "--is-ancestor", older_oid, newer_oid],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode not in (0, 1):
|
||||
raise AuthorityGitError("ANCESTRY_CHECK_FAILED")
|
||||
return result.returncode == 0
|
||||
|
||||
def create_detached_worktree(
|
||||
self,
|
||||
base_oid: str,
|
||||
worktree_name: str,
|
||||
root: Path | None = None,
|
||||
) -> Path:
|
||||
_oid(base_oid, "base_oid")
|
||||
if not worktree_name or "/" in worktree_name or "\\" in worktree_name or worktree_name in {".", ".."}:
|
||||
raise AuthorityGitError("WORKTREE_NAME_INVALID")
|
||||
parent = root or self.worktree_root
|
||||
if not parent.is_absolute() or parent.is_symlink():
|
||||
raise AuthorityGitError("WORKTREE_ROOT_INVALID")
|
||||
path = parent / worktree_name
|
||||
if path.exists() or path.is_symlink():
|
||||
raise AuthorityGitError("WORKTREE_ALREADY_EXISTS")
|
||||
parent.mkdir(parents=True, exist_ok=True)
|
||||
self._git(["worktree", "add", "--detach", str(path), base_oid])
|
||||
return path
|
||||
|
||||
def create_task_branch(self, branch: str, base_oid: str) -> str:
|
||||
"""Create an isolated agent branch once, using an empty-ref CAS."""
|
||||
ref = branch if branch.startswith("refs/heads/") else "refs/heads/" + branch
|
||||
if not TASK_BRANCH_RE.fullmatch(ref):
|
||||
raise AuthorityGitError("TASK_BRANCH_SCOPE_DENIED")
|
||||
_oid(base_oid, "base_oid")
|
||||
if not self.commit_exists(base_oid):
|
||||
raise AuthorityGitError("BASE_COMMIT_NOT_FOUND")
|
||||
current = self.read_ref(ref)
|
||||
if current is not None:
|
||||
if current == base_oid:
|
||||
return current
|
||||
raise AuthorityGitError("AUTHORITY_BRANCH_EXPECTATION_MISMATCH")
|
||||
self._git(["update-ref", ref, base_oid, "0" * 40])
|
||||
readback = self.read_ref(ref)
|
||||
if readback != base_oid:
|
||||
raise AuthorityGitError("AUTHORITY_CAS_READBACK_MISMATCH")
|
||||
return readback
|
||||
|
||||
def advance_task_branch_CAS(self, branch: str, expected_oid: str, new_oid: str) -> str:
|
||||
if not TASK_BRANCH_RE.fullmatch(branch):
|
||||
raise AuthorityGitError("TASK_BRANCH_SCOPE_DENIED")
|
||||
_oid(expected_oid, "expected_oid")
|
||||
_oid(new_oid, "new_oid")
|
||||
current = self.read_ref(branch)
|
||||
if current != expected_oid:
|
||||
if current == new_oid:
|
||||
return new_oid
|
||||
raise AuthorityGitError("AUTHORITY_BRANCH_EXPECTATION_MISMATCH")
|
||||
if not self.ancestry_check(expected_oid, new_oid):
|
||||
raise AuthorityGitError("NEW_OID_NOT_DESCENDANT")
|
||||
self._git(["update-ref", branch, new_oid, expected_oid])
|
||||
readback = self.read_ref(branch)
|
||||
if readback != new_oid:
|
||||
raise AuthorityGitError("AUTHORITY_CAS_READBACK_MISMATCH")
|
||||
return readback
|
||||
|
||||
def ff_CAS_main_after_verified_protected_merge(self, expected_oid: str, new_oid: str) -> str:
|
||||
_oid(expected_oid, "expected_oid")
|
||||
_oid(new_oid, "new_oid")
|
||||
if not self.ancestry_check(expected_oid, new_oid):
|
||||
raise AuthorityGitError("MAIN_FF_REQUIRED")
|
||||
current = self.read_ref("refs/heads/main")
|
||||
if current != expected_oid:
|
||||
if current == new_oid:
|
||||
return new_oid
|
||||
raise AuthorityGitError("AUTHORITY_MAIN_EXPECTATION_MISMATCH")
|
||||
self._git(["update-ref", "refs/heads/main", new_oid, expected_oid])
|
||||
readback = self.read_ref("refs/heads/main")
|
||||
if readback != new_oid:
|
||||
raise AuthorityGitError("AUTHORITY_MAIN_CAS_READBACK_MISMATCH")
|
||||
return readback
|
||||
|
||||
def commit_file_in_isolated_worktree(
|
||||
self,
|
||||
*,
|
||||
base_oid: str,
|
||||
files: Mapping[str, bytes],
|
||||
message: str,
|
||||
worktree_name: str,
|
||||
) -> str:
|
||||
_oid(base_oid, "base_oid")
|
||||
if not worktree_name or "/" in worktree_name or "\\" in worktree_name or worktree_name in {".", ".."}:
|
||||
raise AuthorityGitError("WORKTREE_NAME_INVALID")
|
||||
root = self.worktree_root / worktree_name
|
||||
if root.exists() or root.is_symlink():
|
||||
raise AuthorityGitError("WORKTREE_ALREADY_EXISTS")
|
||||
self.worktree_root.mkdir(parents=True, exist_ok=True)
|
||||
self._git(["worktree", "add", "--detach", str(root), base_oid])
|
||||
try:
|
||||
for relative, content in files.items():
|
||||
path = Path(relative)
|
||||
if path.is_absolute() or ".." in path.parts or not path.parts or path.parts[0] == ".git":
|
||||
raise AuthorityGitError("FILE_PATH_INVALID")
|
||||
target = root / path
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(content)
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(root), "add", "--", *files.keys()],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise AuthorityGitError("WORKTREE_ADD_FAILED")
|
||||
result = subprocess.run(
|
||||
["git", "-C", str(root), "-c", "user.name=aios-durable-job", "-c", "user.email=aios-durable-job@localhost", "commit", "--no-verify", "-m", message],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise AuthorityGitError("WORKTREE_COMMIT_FAILED")
|
||||
return subprocess.check_output(["git", "-C", str(root), "rev-parse", "HEAD"], text=True).strip()
|
||||
finally:
|
||||
subprocess.run(["git", f"--git-dir={self.git_dir}", "worktree", "remove", "--force", str(root)], capture_output=True, check=False)
|
||||
@@ -0,0 +1,73 @@
|
||||
"""HTTP adapter for Owner-authorized task writer provisioning.
|
||||
|
||||
Run this module instead of aios_chatgpt_actions_api.py. All existing routes are
|
||||
delegated unchanged; only POST /api/task-writer/provision-authorized is added.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import urllib.parse
|
||||
|
||||
import aios_chatgpt_actions_api as api
|
||||
from authorized_task_writer_provisioning import (
|
||||
AuthorizedTaskWriterProvisioner,
|
||||
OwnerContext,
|
||||
ProvisioningError,
|
||||
)
|
||||
|
||||
|
||||
_original_do_post = api.H.do_POST
|
||||
_ENDPOINT = "/api/task-writer/provision-authorized"
|
||||
|
||||
|
||||
def _owner_authorization(handler):
|
||||
scope = os.getenv(
|
||||
"AIOS_GITOPS_OWNER_PROVISION_SCOPE", "writer:provision"
|
||||
)
|
||||
auth = handler.need(scope)
|
||||
if not auth:
|
||||
return None
|
||||
if auth.get("role") != "owner":
|
||||
handler.sendj({"error": "OWNER_AUTHORIZATION_REQUIRED"}, 403)
|
||||
return None
|
||||
identity = str(auth.get("account") or "").strip()
|
||||
if not identity:
|
||||
handler.sendj({"error": "OWNER_IDENTITY_REQUIRED"}, 403)
|
||||
return None
|
||||
return auth
|
||||
|
||||
|
||||
def _authorized_do_post(self):
|
||||
path = urllib.parse.urlparse(self.path).path.rstrip("/") or "/"
|
||||
if path != _ENDPOINT:
|
||||
return _original_do_post(self)
|
||||
auth = _owner_authorization(self)
|
||||
if not auth:
|
||||
return
|
||||
data = self.body()
|
||||
owner_reference = str(data.get("owner_authorization_reference") or "")
|
||||
try:
|
||||
result = AuthorizedTaskWriterProvisioner().provision(
|
||||
data,
|
||||
OwnerContext(
|
||||
identity=str(auth["account"]),
|
||||
role=str(auth["role"]),
|
||||
authorization_reference=owner_reference,
|
||||
),
|
||||
)
|
||||
return self.sendj(result)
|
||||
except ProvisioningError as exc:
|
||||
body = {"error": exc.code}
|
||||
if exc.details:
|
||||
body["details"] = exc.details
|
||||
return self.sendj(body, exc.status)
|
||||
except Exception:
|
||||
# Do not expose tool output, paths containing credentials, or traceback data.
|
||||
return self.sendj({"error": "AUTHORIZED_WRITER_BRIDGE_INTERNAL_ERROR"}, 500)
|
||||
|
||||
|
||||
api.H.do_POST = _authorized_do_post
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
api.main()
|
||||
@@ -1,379 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Task-scoped continuous authorization controller for the AIOS Candidate writer."""
|
||||
from __future__ import annotations
|
||||
import base64, fcntl, hashlib, json, os, pwd, grp, re, socket, sqlite3, stat, subprocess, sys, tempfile, time, urllib.error, urllib.parse, urllib.request
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||
from typing import Any
|
||||
|
||||
TASK='AIOS-AUTONOMOUS-RELEASE-FULL-IMPLEMENTATION-20260726'
|
||||
AUTH_ID='authz-aios-autonomous-release-20260726'
|
||||
BRANCH='patch/chatgpt-autonomous-completion-contract-20260726'
|
||||
BASE='875d41c3101093149414292067fd700dc214d4d3'
|
||||
REPO='aios/aios'
|
||||
WRITER_AUDIENCE='chatgpt-autonomous-release-writer'
|
||||
ACTOR='chatgpt-gitops-bot'
|
||||
WRITER_INSTANCE='writer-6e32f321-1240-46f8-9693-0b0631d9c77b'
|
||||
CHILD_OPERATION_MAX_USES=20
|
||||
ROOT=Path('/data/aios-gitops')
|
||||
WORKTREE=ROOT/'worktrees/chatgpt-autonomous-completion-contract-20260726'
|
||||
BARE=ROOT/'repo.git'
|
||||
TASK_ROOT=ROOT/'runtime/tasks'/TASK
|
||||
AUTH_DIR=Path('/etc/aios-gitops/task-authorizations')
|
||||
PARENT=AUTH_DIR/'authz-aios-autonomous-release-20260726.json'
|
||||
STATE_DIR=Path('/var/lib/aios-autonomous-release-authorization')
|
||||
STATE=STATE_DIR/'state.json'
|
||||
LOCK=STATE_DIR/'controller.lock'
|
||||
AUDIT=TASK_ROOT/'continuous-authorization.audit.jsonl'
|
||||
SOCKET_DIR=Path('/run/aios-autonomous-release-authorization')
|
||||
SOCKET_PATH=SOCKET_DIR/'controller.sock'
|
||||
PROJECTION=Path('/etc/aios-gitops/credentials/chatgpt-autonomous-release-writer')
|
||||
REGISTRY=Path('/var/lib/aios-chatgpt-autonomous-release-writer/token-registry.sqlite3')
|
||||
WRITER_LEDGER=Path('/var/lib/aios-chatgpt-autonomous-release-writer/operation-ledger.sqlite3')
|
||||
ASKPASS=Path('/usr/libexec/aios-autonomous-release-authorization/gitea_askpass.py')
|
||||
PATH_POLICY=Path('/etc/aios-gitops/policies-chatgpt-autonomous-release/autonomous-release-path-policy.json')
|
||||
ROUTE_BINDING=Path('/etc/aios-gitops/action-route-bindings/chatgpt-autonomous-release-20260726.json')
|
||||
OID_RE=re.compile(r'^[0-9a-f]{40}$')
|
||||
OP_RE=re.compile(r'^[A-Za-z0-9][A-Za-z0-9._:-]{7,127}$')
|
||||
TERMINAL={'COMPLETED','CANCELLED','FAILED_FINAL','REVOKED','REVOKED_IDLE_TIMEOUT','REVOKED_WALL_CLOCK','RECONCILIATION_REQUIRED'}
|
||||
|
||||
sys.path.insert(0,'/usr/libexec/aios-chatgpt-autonomous-release-writer-fullprofile-8ac2f90f')
|
||||
from isolated_writer_token_registry import TokenRegistry
|
||||
|
||||
def now() -> datetime: return datetime.now(timezone.utc)
|
||||
def stamp() -> str: return now().replace(microsecond=0).isoformat()
|
||||
def canonical(value: Any) -> bytes: return json.dumps(value,sort_keys=True,separators=(',',':'),ensure_ascii=False).encode('utf-8')
|
||||
def fail(code: str, status: int=503, **details: Any) -> None: raise ControlError(code,status,details)
|
||||
class ControlError(RuntimeError):
|
||||
def __init__(self,code:str,status:int=503,details:dict[str,Any]|None=None): self.code,self.status,self.details=code,status,details or {}; super().__init__(code)
|
||||
|
||||
def owned_regular(path:Path, mode:int) -> os.stat_result:
|
||||
try: info=os.lstat(path)
|
||||
except FileNotFoundError: fail('authorization_config_missing')
|
||||
if not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode) or info.st_uid!=0 or stat.S_IMODE(info.st_mode)&~mode: fail('authorization_config_permissions_rejected')
|
||||
return info
|
||||
|
||||
def load_json(path:Path,mode:int=0o600) -> dict[str,Any]:
|
||||
owned_regular(path,mode)
|
||||
try: value=json.loads(path.read_text(encoding='utf-8'))
|
||||
except Exception: fail('authorization_config_json_rejected')
|
||||
if not isinstance(value,dict): fail('authorization_config_schema_rejected')
|
||||
return value
|
||||
|
||||
def atomic_json(path:Path,value:dict[str,Any],mode:int=0o600) -> None:
|
||||
path.parent.mkdir(parents=True,exist_ok=True)
|
||||
raw=canonical(value)+b'\n'
|
||||
fd,tmp=tempfile.mkstemp(prefix='.new-',dir=path.parent)
|
||||
try:
|
||||
os.fchmod(fd,mode); os.write(fd,raw); os.fsync(fd); os.close(fd); os.chown(tmp,0,0); os.replace(tmp,path)
|
||||
os.chmod(path,mode)
|
||||
finally:
|
||||
try: os.close(fd)
|
||||
except OSError: pass
|
||||
if os.path.exists(tmp): os.unlink(tmp)
|
||||
|
||||
def append_audit(event:dict[str,Any]) -> None:
|
||||
TASK_ROOT.mkdir(parents=True,exist_ok=True)
|
||||
flags=os.O_WRONLY|os.O_CREAT|os.O_APPEND|getattr(os,'O_NOFOLLOW',0)
|
||||
fd=os.open(AUDIT,flags,0o600)
|
||||
try: os.write(fd,canonical({'at':stamp(),'task_id':TASK,'secrets_included':False,**event})+b'\n'); os.fsync(fd)
|
||||
finally: os.close(fd)
|
||||
|
||||
def parent() -> dict[str,Any]:
|
||||
p=load_json(PARENT)
|
||||
required={'schema_version','development_authorization_id','authorization_type','task_id','actor_identity','audience','repository','exact_branch','approved_base_oid','allowed_path_roots','allowed_exact_paths','allowed_operations','created_at','valid_until','maximum_wall_clock_seconds','idle_timeout_seconds','manual_reapproval_per_commit','maximum_concurrent_writes','path_policy_sha256','writer_instance_id'}
|
||||
if set(p)!=required or p.get('schema_version')!=1: fail('parent_authorization_schema_rejected')
|
||||
fixed={'development_authorization_id':AUTH_ID,'authorization_type':'TASK_SCOPED_CONTINUOUS_DEVELOPMENT','task_id':TASK,'actor_identity':ACTOR,'audience':WRITER_AUDIENCE,'repository':REPO,'exact_branch':BRANCH,'approved_base_oid':BASE,'manual_reapproval_per_commit':False,'maximum_concurrent_writes':1,'writer_instance_id':WRITER_INSTANCE}
|
||||
if any(p.get(k)!=v for k,v in fixed.items()): fail('parent_authorization_binding_rejected')
|
||||
if p.get('maximum_wall_clock_seconds')!=86400 or p.get('idle_timeout_seconds')!=2700: fail('parent_authorization_lifetime_rejected')
|
||||
if not isinstance(p.get('allowed_path_roots'),list) or not isinstance(p.get('allowed_exact_paths'),list) or not isinstance(p.get('allowed_operations'),list): fail('parent_authorization_scope_rejected')
|
||||
raw=PATH_POLICY.read_bytes()
|
||||
if hashlib.sha256(raw).hexdigest()!=p.get('path_policy_sha256'): fail('parent_authorization_path_policy_changed')
|
||||
return p
|
||||
|
||||
def parse_time(v:Any) -> datetime:
|
||||
try:
|
||||
d=datetime.fromisoformat(str(v).replace('Z','+00:00'))
|
||||
return d.astimezone(timezone.utc)
|
||||
except Exception: fail('authorization_time_rejected')
|
||||
|
||||
def components(path:str)->tuple[str,...]:
|
||||
if not isinstance(path,str) or not path or path!=path.strip() or '\\' in path or '\x00' in path: fail('authorization_path_rejected',403)
|
||||
pp=PurePosixPath(path); wp=PureWindowsPath(path)
|
||||
parts=tuple(path.split('/'))
|
||||
if pp.is_absolute() or wp.is_absolute() or bool(wp.drive) or any(x in {'','.','..'} for x in parts): fail('authorization_path_rejected',403)
|
||||
return parts
|
||||
|
||||
def path_allowed(p:dict[str,Any],path:str)->bool:
|
||||
item=components(path)
|
||||
for root in p['allowed_path_roots']:
|
||||
r=components(str(root).rstrip('/'))
|
||||
if item[:len(r)]==r: return True
|
||||
return any(item==components(x) for x in p['allowed_exact_paths'])
|
||||
|
||||
def git(*args:str,cwd:Path|None=None,env:dict[str,str]|None=None)->str:
|
||||
e=os.environ.copy(); e.update(env or {}); e['GIT_CONFIG_COUNT']='1'; e['GIT_CONFIG_KEY_0']='safe.directory'; e['GIT_CONFIG_VALUE_0']=str(WORKTREE)
|
||||
r=subprocess.run(['git',*args],cwd=cwd,text=True,capture_output=True,env=e)
|
||||
if r.returncode: fail('authorization_git_probe_failed',503)
|
||||
return r.stdout.strip()
|
||||
|
||||
def credential_lines()->list[str]:
|
||||
cp=Path(os.environ.get('CREDENTIALS_DIRECTORY',''))/'gitea_git_http'
|
||||
try:
|
||||
info=os.lstat(cp); lines=cp.read_text(encoding='utf-8').splitlines()
|
||||
except OSError: fail('authorization_gitea_credential_unavailable')
|
||||
if not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode) or len(lines)!=2 or lines[0]!='gitops-bot' or not lines[1]: fail('authorization_gitea_credential_rejected')
|
||||
return lines
|
||||
|
||||
def remote_oid()->str:
|
||||
credential_lines()
|
||||
e={'GIT_ASKPASS':str(ASKPASS),'GIT_TERMINAL_PROMPT':'0'}
|
||||
out=git('ls-remote','--heads','http://127.0.0.1:3000/aios/aios.git',f'refs/heads/{BRANCH}',cwd=WORKTREE,env=e)
|
||||
fields=out.split()
|
||||
if len(fields)!=2 or not OID_RE.fullmatch(fields[0]): fail('authorization_git_ls_remote_rejected')
|
||||
return fields[0]
|
||||
|
||||
def api_oid()->str:
|
||||
user,password=credential_lines(); branch=urllib.parse.quote(BRANCH,safe='')
|
||||
encoded=base64.b64encode((user+':'+password).encode()).decode()
|
||||
request=urllib.request.Request('http://127.0.0.1:3000/api/v1/repos/aios/aios/branches/'+branch,headers={'Authorization':'Basic '+encoded,'Host':'g.cnnetmkt.top'})
|
||||
try:
|
||||
with urllib.request.urlopen(request,timeout=10) as response: value=json.loads(response.read())
|
||||
except Exception: fail('authorization_gitea_api_unavailable')
|
||||
oid=str(value.get('commit',{}).get('id',''))
|
||||
if not OID_RE.fullmatch(oid): fail('authorization_gitea_api_oid_rejected')
|
||||
return oid
|
||||
|
||||
def four_way(expected:str)->None:
|
||||
local=git('rev-parse','HEAD',cwd=WORKTREE)
|
||||
bare=git('--git-dir',str(BARE),'rev-parse',f'refs/heads/{BRANCH}')
|
||||
remote=remote_oid(); api=api_oid()
|
||||
if any(x!=expected for x in (local,bare,remote,api)): fail('RECONCILIATION_REQUIRED',409,current_head_oid=local)
|
||||
if git('status','--porcelain',cwd=WORKTREE)!='': fail('authorization_worktree_dirty',409)
|
||||
|
||||
def state()->dict[str,Any]:
|
||||
s=load_json(STATE)
|
||||
required={'schema_version','development_authorization_id','task_id','status','current_head_oid','last_activity_at','writer_child','test_child','inflight','updated_at'}
|
||||
if set(s)!=required or s.get('schema_version')!=1 or s.get('development_authorization_id')!=AUTH_ID or s.get('task_id')!=TASK or not OID_RE.fullmatch(str(s.get('current_head_oid',''))): fail('authorization_state_schema_rejected')
|
||||
return s
|
||||
|
||||
def save_state(s:dict[str,Any])->None: s['updated_at']=stamp(); atomic_json(STATE,s)
|
||||
|
||||
def lock():
|
||||
STATE_DIR.mkdir(parents=True,exist_ok=True); os.chmod(STATE_DIR,0o700)
|
||||
f=open(LOCK,'a+b'); os.chmod(LOCK,0o600); fcntl.flock(f.fileno(),fcntl.LOCK_EX); return f
|
||||
|
||||
def route_binding_valid(p:dict[str,Any])->None:
|
||||
b=load_json(ROUTE_BINDING,0o640)
|
||||
identities=b.get('action_identities')
|
||||
if b.get('task_id')!=TASK or b.get('writer_instance_id')!=WRITER_INSTANCE or b.get('repository')!=REPO or b.get('branch')!=BRANCH or b.get('approved_base_oid')!=BASE or b.get('path_policy_sha256')!=p.get('path_policy_sha256') or not isinstance(identities,list) or len(identities)!=1 or identities[0].get('account')!=ACTOR:
|
||||
fail('authorization_route_binding_changed',409)
|
||||
|
||||
def task_live(p:dict[str,Any],s:dict[str,Any],*,touch:bool)->None:
|
||||
route_binding_valid(p)
|
||||
if s['status'] in TERMINAL: fail('authorization_task_terminal',409)
|
||||
if now()>=parse_time(p['valid_until']):
|
||||
s['status']='REVOKED_WALL_CLOCK'; save_state(s); cleanup(s,'wall_clock'); fail('authorization_task_terminal',409)
|
||||
if (now()-parse_time(s['last_activity_at'])).total_seconds()>p['idle_timeout_seconds']:
|
||||
s['status']='REVOKED_IDLE_TIMEOUT'; save_state(s); cleanup(s,'idle_timeout'); fail('authorization_task_terminal',409)
|
||||
if touch: s['last_activity_at']=stamp(); save_state(s)
|
||||
|
||||
def projection_token()->str|None:
|
||||
if not PROJECTION.exists(): return None
|
||||
try:
|
||||
info=os.lstat(PROJECTION)
|
||||
if not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode) or info.st_uid!=0 or stat.S_IMODE(info.st_mode)!=0o600: fail('authorization_projection_rejected')
|
||||
token=PROJECTION.read_text(encoding='utf-8').strip()
|
||||
return token if len(token.encode())>=32 else None
|
||||
except OSError: fail('authorization_projection_rejected')
|
||||
|
||||
def write_projection(token:str)->None:
|
||||
PROJECTION.parent.mkdir(parents=True,exist_ok=True)
|
||||
fd,tmp=tempfile.mkstemp(prefix='.writer-',dir=PROJECTION.parent)
|
||||
try:
|
||||
os.fchmod(fd,0o600); os.write(fd,(token+'\n').encode()); os.fsync(fd); os.close(fd); os.chown(tmp,0,0); os.replace(tmp,PROJECTION); os.chmod(PROJECTION,0o600)
|
||||
finally:
|
||||
try: os.close(fd)
|
||||
except OSError: pass
|
||||
if os.path.exists(tmp): os.unlink(tmp)
|
||||
|
||||
def post_revoke_401(token:str)->None:
|
||||
req=urllib.request.Request('http://127.0.0.1:19893/api/runtime-status',headers={'Authorization':'Bearer '+token})
|
||||
try: urllib.request.urlopen(req,timeout=5)
|
||||
except urllib.error.HTTPError as e:
|
||||
if e.code==401:return
|
||||
except Exception: pass
|
||||
fail('authorization_old_credential_revocation_unverified')
|
||||
|
||||
def rotate_child(p:dict[str,Any],s:dict[str,Any],reason:str)->str:
|
||||
old=projection_token(); registry=TokenRegistry(REGISTRY)
|
||||
old_record=registry.find(old) if old else None
|
||||
if old_record and (old_record.get('task_id')!=TASK or old_record.get('development_authorization_id')!=AUTH_ID):
|
||||
if parse_time(old_record.get('expires_at'))>=now(): fail('authorization_existing_projection_binding_rejected')
|
||||
if not registry.revoke(str(old_record['token_id'])): fail('authorization_historical_child_revoke_failed')
|
||||
post_revoke_401(old)
|
||||
append_audit({'event':'historical_expired_child_revoked','old_credential_http_status':401})
|
||||
old_record=None
|
||||
issued=now().replace(microsecond=0); expires=issued+timedelta(minutes=20)
|
||||
prefixes=[str(x).rstrip('/')+'/' for x in p['allowed_path_roots']]
|
||||
grant={'token_id':'continuous-child-'+issued.strftime('%Y%m%dT%H%M%SZ')+'-'+os.urandom(3).hex(),'credential_id':'credential-'+issued.strftime('%Y%m%dT%H%M%SZ')+'-'+os.urandom(3).hex(),'development_authorization_id':AUTH_ID,'audience':WRITER_AUDIENCE,'task_id':TASK,'actor_identity':ACTOR,'writer_instance_id':WRITER_INSTANCE,'repository_identity':REPO,'source_cidrs':['127.0.0.1/32'],'allowed_branch':BRANCH,'current_expected_head_oid':s['current_head_oid'],'approved_base_oid':BASE,'allowed_operations':p['allowed_operations'],'allowed_paths':p['allowed_exact_paths'],'allowed_path_prefixes':prefixes,'not_before':issued.isoformat(),'expires_at':expires.isoformat(),'renewable':False,'max_uses':{operation:CHILD_OPERATION_MAX_USES for operation in p['allowed_operations']},'issued_by':'task_authorization_controller'}
|
||||
token,record=registry.issue(grant)
|
||||
try:
|
||||
write_projection(token)
|
||||
if old_record:
|
||||
if not registry.revoke(str(old_record['token_id'])): fail('authorization_old_child_revoke_failed')
|
||||
post_revoke_401(old)
|
||||
except Exception:
|
||||
registry.revoke(str(record['token_id']))
|
||||
try: PROJECTION.unlink()
|
||||
except FileNotFoundError: pass
|
||||
raise
|
||||
s['writer_child']={'credential_id':record['credential_id'],'issued_at':record['not_before'],'expires_at':record['expires_at'],'current_expected_head_oid':s['current_head_oid'],'rotation_reason':reason}
|
||||
save_state(s); append_audit({'event':'child_grant_rotated','reason':reason,'current_head_oid':s['current_head_oid'],'old_grant_revoked':bool(old_record),'old_credential_http_status':401 if old_record else None})
|
||||
return token
|
||||
|
||||
def child_operation_quota_exhausted(rec:dict[str,Any],registry:TokenRegistry)->bool:
|
||||
token_id=str(rec.get('token_id',''))
|
||||
allowed=rec.get('allowed_operations')
|
||||
limits=rec.get('max_uses')
|
||||
if not token_id or not isinstance(allowed,list) or not isinstance(limits,dict): fail('authorization_child_quota_schema_rejected')
|
||||
for operation in allowed:
|
||||
maximum=limits.get(operation)
|
||||
if not isinstance(operation,str) or not isinstance(maximum,int) or maximum<1: fail('authorization_child_quota_schema_rejected')
|
||||
if registry.usage(token_id,operation)>=maximum: return True
|
||||
return False
|
||||
|
||||
def ensure_child(p:dict[str,Any],s:dict[str,Any],reason:str)->str:
|
||||
token=projection_token(); meta=s.get('writer_child') or {}
|
||||
expired=True
|
||||
try: expired=(parse_time(meta.get('expires_at'))-now()).total_seconds()<=300
|
||||
except Exception: pass
|
||||
if not token or expired or meta.get('current_expected_head_oid')!=s['current_head_oid']:
|
||||
return rotate_child(p,s,reason)
|
||||
registry=TokenRegistry(REGISTRY)
|
||||
rec=registry.find(token)
|
||||
if not rec or rec.get('revoked') or rec.get('development_authorization_id')!=AUTH_ID or rec.get('current_expected_head_oid')!=s['current_head_oid']:
|
||||
return rotate_child(p,s,reason)
|
||||
if child_operation_quota_exhausted(rec,registry):
|
||||
return rotate_child(p,s,'operation_quota_exhausted')
|
||||
return token
|
||||
|
||||
def ledger_verified(operation_id:str,commit_oid:str)->None:
|
||||
with sqlite3.connect(WRITER_LEDGER) as db:
|
||||
row=db.execute('select operation_id,branch,commit_oid,state,verification_status from writer_operations where operation_id=?',(operation_id,)).fetchone()
|
||||
if not row or row[1]!=BRANCH or row[2]!=commit_oid or row[3]!='COMPLETED' or row[4]!='bare+gitea': fail('authorization_writer_ledger_rejected',409)
|
||||
|
||||
def reconcile(p:dict[str,Any],s:dict[str,Any],previous:str,new:str,operation_id:str)->dict[str,Any]:
|
||||
inflight=s.get('inflight')
|
||||
if not isinstance(inflight,dict) or inflight.get('previous_head_oid')!=previous or inflight.get('operation_id')!=operation_id: fail('authorization_inflight_rejected',409)
|
||||
if previous!=s['current_head_oid'] or not OID_RE.fullmatch(new): fail('authorization_head_transition_rejected',409)
|
||||
ledger_verified(operation_id,new); four_way(new)
|
||||
s['current_head_oid']=new; s['inflight']=None; s['last_activity_at']=stamp(); save_state(s)
|
||||
ensure_child(p,s,'head_advance')
|
||||
append_audit({'event':'authorization_head_advanced','previous_head_oid':previous,'new_commit_oid':new,'four_way_ref_status':True,'worktree_clean':True})
|
||||
return {'authorization_current_head_oid':new,'automatic_head_advance':True,'four_way_ref_status':True}
|
||||
|
||||
def prepare(p:dict[str,Any],s:dict[str,Any],data:dict[str,Any])->dict[str,Any]:
|
||||
required={'expected_current_head_oid','operation_id','idempotency_key','exact_path','content_digest','commit_message_digest','branch','path','message'}
|
||||
if set(data)!=required: fail('authorization_request_schema_rejected',403)
|
||||
if data['branch']!=BRANCH or data['path']!=data['exact_path'] or not path_allowed(p,str(data['path'])): fail('authorization_path_rejected',403)
|
||||
if data['expected_current_head_oid']!=s['current_head_oid'] or not OID_RE.fullmatch(str(data['expected_current_head_oid'])): fail('BRANCH_HEAD_CHANGED',409,current_head_oid=s['current_head_oid'])
|
||||
if not OP_RE.fullmatch(str(data['operation_id'])) or not OP_RE.fullmatch(str(data['idempotency_key'])): fail('authorization_operation_id_rejected',403)
|
||||
if not all(isinstance(data[k],str) and re.fullmatch(r'[0-9a-f]{64}',data[k]) for k in ('content_digest','commit_message_digest')): fail('authorization_digest_rejected',403)
|
||||
if hashlib.sha256(data['message'].encode()).hexdigest()!=data['commit_message_digest']: fail('authorization_commit_message_digest_rejected',403)
|
||||
inflight=s.get('inflight')
|
||||
if inflight:
|
||||
if inflight.get('operation_id')==data['operation_id'] and inflight.get('idempotency_key')==data['idempotency_key']: return {'expected_head_oid':s['current_head_oid'],'idempotency_key':data['idempotency_key']}
|
||||
fail('BRANCH_HEAD_CHANGED',409,current_head_oid=s['current_head_oid'])
|
||||
four_way(s['current_head_oid'])
|
||||
s['inflight']={'operation_id':data['operation_id'],'idempotency_key':data['idempotency_key'],'previous_head_oid':s['current_head_oid'],'path':data['path'],'created_at':stamp()}; s['last_activity_at']=stamp(); save_state(s)
|
||||
append_audit({'event':'write_prepared','operation_id':data['operation_id'],'path':data['path'],'expected_head_oid':s['current_head_oid']})
|
||||
return {'expected_head_oid':s['current_head_oid'],'idempotency_key':data['idempotency_key']}
|
||||
|
||||
def test_authorize(p:dict[str,Any],s:dict[str,Any],operation:str)->dict[str,Any]:
|
||||
allowed={'pytest_dormant','pytest_autonomous_release','compileall_autonomous_release','git_diff_check'}
|
||||
if operation not in allowed: fail('candidate_test_executor_operation_rejected',403)
|
||||
four_way(s['current_head_oid'])
|
||||
child=s.get('test_child') or {}; refresh=True
|
||||
try: refresh=(parse_time(child.get('expires_at'))-now()).total_seconds()<=300
|
||||
except Exception: pass
|
||||
if refresh:
|
||||
issued=now().replace(microsecond=0); child={'test_authorization_id':'test-authz-aios-autonomous-release-20260726','grant_id':'test-child-'+issued.strftime('%Y%m%dT%H%M%SZ'),'issued_at':issued.isoformat(),'expires_at':(issued+timedelta(minutes=20)).isoformat()}; s['test_child']=child
|
||||
s['last_activity_at']=stamp(); save_state(s); append_audit({'event':'test_authorized','operation':operation})
|
||||
return {'test_authorization_id':child['test_authorization_id'],'operation':operation,'expires_at':child['expires_at']}
|
||||
|
||||
def cleanup(s:dict[str,Any],reason:str)->None:
|
||||
old=projection_token()
|
||||
if old:
|
||||
reg=TokenRegistry(REGISTRY); rec=reg.find(old)
|
||||
if rec: reg.revoke(str(rec['token_id']))
|
||||
try: PROJECTION.unlink()
|
||||
except FileNotFoundError: pass
|
||||
try: post_revoke_401(old)
|
||||
except ControlError: pass
|
||||
for unit in ('aios-chatgpt-autonomous-release-route.service','aios-chatgpt-autonomous-release-writer.service'):
|
||||
subprocess.run(['/usr/bin/systemctl','stop',unit],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=20)
|
||||
subprocess.run(['/usr/bin/systemctl','stop','aios-autonomous-release-dormant-test-executor@*.service'],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL,timeout=20)
|
||||
try: ROUTE_BINDING.unlink()
|
||||
except FileNotFoundError: pass
|
||||
try: SOCKET_PATH.unlink()
|
||||
except FileNotFoundError: pass
|
||||
append_audit({'event':'authorization_cleanup','reason':reason,'projection_removed':not PROJECTION.exists(),'route_overlay_removed':not ROUTE_BINDING.exists(),'task_services_stop_requested':True})
|
||||
|
||||
def request(data:dict[str,Any])->dict[str,Any]:
|
||||
op=data.get('op'); f=lock()
|
||||
try:
|
||||
p=parent(); s=state(); task_live(p,s,touch=False)
|
||||
if op=='describe_authorization':
|
||||
four_way(s['current_head_oid'])
|
||||
return {'development_authorization_id':AUTH_ID,'task_id':TASK,'actor_identity':ACTOR,'audience':WRITER_AUDIENCE,'repository':REPO,'branch':BRANCH,'approved_base_oid':BASE,'authorization_status':s['status'],'current_head_oid':s['current_head_oid']}
|
||||
if op=='get_writer_token':
|
||||
four_way(s['current_head_oid']); token=ensure_child(p,s,'scheduled_or_requested_rotation'); return {'token':token,'current_head_oid':s['current_head_oid']}
|
||||
if op=='prepare_write':
|
||||
ensure_child(p,s,'pre_write_quota_check')
|
||||
return prepare(p,s,data.get('request') if isinstance(data.get('request'),dict) else {})
|
||||
if op=='reconcile_commit':
|
||||
r=data.get('result') if isinstance(data.get('result'),dict) else {}; return reconcile(p,s,str(r.get('previous_head_oid','')),str(r.get('new_commit_oid','')),str(r.get('operation_id','')))
|
||||
if op=='fail_write':
|
||||
r=data.get('result') if isinstance(data.get('result'),dict) else {}; inflight=s.get('inflight')
|
||||
if isinstance(inflight,dict) and inflight.get('operation_id')==r.get('operation_id'): s['inflight']=None; save_state(s)
|
||||
return {'cleared':True}
|
||||
if op=='authorize_test': return test_authorize(p,s,str(data.get('operation','')))
|
||||
fail('authorization_operation_rejected',403)
|
||||
finally:
|
||||
fcntl.flock(f.fileno(),fcntl.LOCK_UN); f.close()
|
||||
|
||||
def peer_allowed(conn:socket.socket)->bool:
|
||||
try:
|
||||
import struct
|
||||
_pid,uid,gid=struct.unpack('3i',conn.getsockopt(socket.SOL_SOCKET,socket.SO_PEERCRED,12))
|
||||
return uid==pwd.getpwnam('aiman').pw_uid and gid==grp.getgrnam('gitops').gr_gid
|
||||
except Exception: return False
|
||||
|
||||
def serve()->None:
|
||||
SOCKET_DIR.mkdir(parents=True,exist_ok=True); os.chown(SOCKET_DIR,0,grp.getgrnam('gitops').gr_gid); os.chmod(SOCKET_DIR,0o750)
|
||||
try: SOCKET_PATH.unlink()
|
||||
except FileNotFoundError: pass
|
||||
with socket.socket(socket.AF_UNIX,socket.SOCK_STREAM) as server:
|
||||
server.bind(str(SOCKET_PATH)); os.chown(SOCKET_PATH,0,grp.getgrnam('gitops').gr_gid); os.chmod(SOCKET_PATH,0o660); server.listen(32); server.settimeout(1.0)
|
||||
last=0.0
|
||||
while True:
|
||||
if time.monotonic()-last>30:
|
||||
last=time.monotonic()
|
||||
try:
|
||||
f=lock(); p=parent(); s=state(); task_live(p,s,touch=False); four_way(s['current_head_oid']); ensure_child(p,s,'scheduled_rotation'); fcntl.flock(f.fileno(),fcntl.LOCK_UN); f.close()
|
||||
except ControlError as e:
|
||||
append_audit({'event':'scheduled_check_blocked','error':e.code})
|
||||
if e.code=='authorization_task_terminal': return
|
||||
except Exception: append_audit({'event':'scheduled_check_blocked','error':'authorization_controller_internal_error'})
|
||||
try: conn,_=server.accept()
|
||||
except socket.timeout: continue
|
||||
with conn:
|
||||
if not peer_allowed(conn): conn.sendall(canonical({'ok':False,'status':403,'error':'authorization_peer_rejected'})+b'\n'); continue
|
||||
try:
|
||||
raw=conn.recv(131072); data=json.loads(raw.decode()); result=request(data); conn.sendall(canonical({'ok':True,'result':result})+b'\n')
|
||||
except ControlError as e: conn.sendall(canonical({'ok':False,'status':e.status,'error':e.code,**e.details})+b'\n')
|
||||
except Exception as exc:
|
||||
append_audit({'event':'controller_internal_error','error_type':type(exc).__name__})
|
||||
conn.sendall(canonical({'ok':False,'status':503,'error':'authorization_controller_internal_error'})+b'\n')
|
||||
if __name__=='__main__': serve()
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Implementation Baseline 1.1 integrations for existing AIOS authorities."""
|
||||
BASELINE_VERSION = '1.1'
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,281 @@
|
||||
"""Executable validation profile and thin Action client.
|
||||
|
||||
This supplied ZIP does not establish the online Task authority. Consequently
|
||||
the runnable profile creates an explicitly isolated validation authority. Online
|
||||
activation requires the authority mapping gates documented in this package.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import re
|
||||
import signal
|
||||
import stat
|
||||
import threading
|
||||
import time
|
||||
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey,Ed25519PublicKey
|
||||
from task_plane.store import TaskPlaneStore
|
||||
from aios_gitops_job_controller import JobStore
|
||||
from .authorization import AuthorizationService,sign_owner_delegation_grant
|
||||
from .host import SystemdHost
|
||||
from .lineage import LineageLedger
|
||||
from .operations import OperationRegistry
|
||||
from .runtime_contract import validate_contract
|
||||
from .service import BaselineService,Identity
|
||||
from .worker_policy import WorkerProfile
|
||||
|
||||
|
||||
def _unique_object(pairs):
|
||||
result={}
|
||||
for key,value in pairs:
|
||||
if key in result: raise ValueError('DUPLICATE_JSON_KEY')
|
||||
result[key]=value
|
||||
return result
|
||||
|
||||
|
||||
def read_json(path):
|
||||
return json.loads(_safe_read(Path(path).absolute()).decode('utf-8'),object_pairs_hook=_unique_object,
|
||||
parse_constant=lambda _value: (_ for _ in ()).throw(ValueError('NON_FINITE_JSON')))
|
||||
|
||||
|
||||
def _path(value):
|
||||
if not isinstance(value,str): raise ValueError('PATH_INVALID')
|
||||
prefix='${CREDENTIALS_DIRECTORY}/'
|
||||
if value.startswith(prefix):
|
||||
directory=os.environ.get('CREDENTIALS_DIRECTORY')
|
||||
name=value[len(prefix):]
|
||||
if not directory or '/' in name or name in ('','.','..'): raise ValueError('CREDENTIAL_PATH_INVALID')
|
||||
value=str(Path(directory)/name)
|
||||
if '$' in value or not Path(value).is_absolute() or '..' in Path(value).parts:
|
||||
raise ValueError('PATH_MUST_BE_EXPLICIT_ABSOLUTE')
|
||||
return Path(value)
|
||||
|
||||
|
||||
def _safe_read(path,*,private=False):
|
||||
path=_path(str(path))
|
||||
fd=os.open(path,os.O_RDONLY|os.O_NOFOLLOW|os.O_CLOEXEC)
|
||||
try:
|
||||
info=os.fstat(fd)
|
||||
forbidden=0o077 if private else 0o022
|
||||
if not stat.S_ISREG(info.st_mode) or info.st_uid not in {0,os.geteuid()} or info.st_mode & forbidden or info.st_size>65536:
|
||||
raise ValueError('CONFIG_OR_SECRET_FILE_PERMISSIONS_INVALID')
|
||||
with os.fdopen(fd,'rb',closefd=False) as stream:
|
||||
data=stream.read(65537)
|
||||
if len(data)>65536: raise ValueError('CONFIG_OR_SECRET_FILE_TOO_LARGE')
|
||||
return data
|
||||
finally: os.close(fd)
|
||||
|
||||
|
||||
def secret(path):
|
||||
return _safe_read(path,private=True)
|
||||
|
||||
|
||||
def load_configuration(path):
|
||||
config=read_json(path)
|
||||
required={'schema','profile','state_dir','listen','machine_key_id','machine_key_file',
|
||||
'owner_public_key_files','owner_key_scopes','identities','workers','contracts','operations','host_bindings'}
|
||||
if type(config) is not dict or set(config)-{'receipt_public_key_files'}!=required or config['schema']!='aios.baseline-server-config.v1':
|
||||
raise ValueError('SERVER_CONFIG_FIELDS_INVALID')
|
||||
if config['profile']!='isolated-validation': raise ValueError('PROFILE_REQUIRES_VERIFIED_AUTHORITY_ADAPTER')
|
||||
listen=config['listen']
|
||||
if type(listen) is not dict or set(listen)!={'host','port'} or listen['host']!='127.0.0.1' or type(listen['port']) is not int or not 1024<=listen['port']<=65535:
|
||||
raise ValueError('LOOPBACK_LISTENER_REQUIRED')
|
||||
state=_path(config['state_dir'])
|
||||
if state.is_symlink() or state==Path('/') or state.name in {'aios','active-runtime','app'}:
|
||||
raise ValueError('ISOLATED_STATE_PATH_REQUIRED')
|
||||
if not isinstance(config['machine_key_id'],str) or not config['machine_key_id']: raise ValueError('MACHINE_ID_REQUIRED')
|
||||
machine=serialization.load_pem_private_key(secret(config['machine_key_file']),password=None)
|
||||
if not isinstance(machine,Ed25519PrivateKey): raise ValueError('ED25519_MACHINE_KEY_REQUIRED')
|
||||
keys={}
|
||||
for key_id,filename in config['owner_public_key_files'].items():
|
||||
public=serialization.load_pem_public_key(_safe_read(filename))
|
||||
if not isinstance(public,Ed25519PublicKey): raise ValueError('ED25519_OWNER_KEY_REQUIRED')
|
||||
keys[key_id]=public
|
||||
if not keys: raise ValueError('OWNER_TRUST_ROOT_REQUIRED')
|
||||
receipt_keys={}
|
||||
for key_id,filename in config.get('receipt_public_key_files',{}).items():
|
||||
public=serialization.load_pem_public_key(_safe_read(filename))
|
||||
if not isinstance(public,Ed25519PublicKey): raise ValueError('ED25519_RECEIPT_KEY_REQUIRED')
|
||||
receipt_keys[key_id]=public
|
||||
tokens={}
|
||||
for entry in config['identities']:
|
||||
if type(entry) is not dict or set(entry)-{'token_file','tenant_id','subject_id','role','worker_id'}:
|
||||
raise ValueError('IDENTITY_FIELDS_INVALID')
|
||||
token=secret(entry['token_file']).decode().strip()
|
||||
if len(token)<32 or len(token)>512 or any(x.isspace() for x in token) or token in tokens: raise ValueError('TOKEN_INVALID_OR_DUPLICATE')
|
||||
identity=Identity(**{k:v for k,v in entry.items() if k!='token_file'})
|
||||
if identity.role not in {'user','owner','worker'} or any(not isinstance(v,str) or not v for v in (identity.tenant_id,identity.subject_id)):
|
||||
raise ValueError('IDENTITY_INVALID')
|
||||
if (identity.role=='worker') != bool(identity.worker_id): raise ValueError('WORKER_IDENTITY_INVALID')
|
||||
tokens[token]=identity
|
||||
workers={}
|
||||
for entry in config['workers']:
|
||||
worker=WorkerProfile.from_dict(entry)
|
||||
if worker.worker_id in workers: raise ValueError('WORKER_DUPLICATE')
|
||||
workers[worker.worker_id]=worker
|
||||
for identity in tokens.values():
|
||||
if identity.role=='worker':
|
||||
worker=workers.get(identity.worker_id)
|
||||
if not worker or worker.identity!=identity.subject_id or identity.tenant_id not in worker.tenant_scope:
|
||||
raise ValueError('WORKER_IDENTITY_SCOPE_MISMATCH')
|
||||
contracts=[validate_contract(c) for c in config['contracts']]
|
||||
operations=OperationRegistry(config['operations'])
|
||||
host=SystemdHost(config['host_bindings'])
|
||||
config['_resolved']={'state':state,'machine':machine,'owner_keys':keys,'workers':workers,
|
||||
'contracts':contracts,'operations':operations,'host':host,'tokens':tokens,'receipt_keys':receipt_keys}
|
||||
return config,tokens
|
||||
|
||||
|
||||
def build_service(config):
|
||||
resolved=config['_resolved']
|
||||
state=resolved['state']
|
||||
marker=state/'ISOLATED_VALIDATION_AUTHORITY'
|
||||
if state.exists() and not marker.is_file() and any(state.iterdir()):
|
||||
raise ValueError('EXISTING_STATE_AUTHORITY_NOT_IDENTIFIED')
|
||||
if marker.exists() and _safe_read(marker)!=b'aios.baseline.isolated-validation.v1\n':
|
||||
raise ValueError('STATE_AUTHORITY_MARKER_INVALID')
|
||||
state.mkdir(parents=True,exist_ok=True,mode=0o700)
|
||||
if state.stat().st_mode & 0o077: raise ValueError('STATE_DIRECTORY_PERMISSIONS_INVALID')
|
||||
if not marker.exists(): marker.write_text('aios.baseline.isolated-validation.v1\n')
|
||||
auth=AuthorizationService(state/'authorization.sqlite3',resolved['owner_keys'],resolved['machine'],config['machine_key_id'],
|
||||
owner_key_scopes=config['owner_key_scopes'],
|
||||
control_context_verifier=lambda ctx:isinstance(ctx,Identity) and ctx.role=='owner' and any(ctx is known for known in resolved['tokens'].values()))
|
||||
store=TaskPlaneStore(str(state/'tasks.sqlite3'),high_assurance=True)
|
||||
return BaselineService(store,state/'jobs',auth,resolved['contracts'],resolved['operations'],resolved['workers'],
|
||||
resolved['host'],LineageLedger(state/'lineage.sqlite3',store,JobStore(state/'jobs').read),resolved['machine'],
|
||||
machine_key_id=config['machine_key_id'],trusted_receipt_keys=resolved['receipt_keys'])
|
||||
|
||||
|
||||
def _write_json(path,value,mode=0o600):
|
||||
data=(json.dumps(value,ensure_ascii=False,sort_keys=True,indent=2,allow_nan=False)+'\n').encode()
|
||||
fd=os.open(path,os.O_WRONLY|os.O_CREAT|os.O_EXCL,mode)
|
||||
with os.fdopen(fd,'wb') as out: out.write(data)
|
||||
|
||||
|
||||
def initialize_validation(directory,*,unit):
|
||||
"""Create explicit local-only example identities, not production trust roots."""
|
||||
directory=Path(directory).absolute()
|
||||
directory.mkdir(mode=0o700) # Never overwrite an existing installation.
|
||||
# Validate unit syntax before writing any identity material.
|
||||
SystemdHost({'validation:staging:sample':unit})
|
||||
owner=Ed25519PrivateKey.generate()
|
||||
machine=Ed25519PrivateKey.generate()
|
||||
for name,key in [('owner-private.pem',owner),('machine-private.pem',machine)]:
|
||||
path=directory/name
|
||||
fd=os.open(path,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
|
||||
with os.fdopen(fd,'wb') as out:
|
||||
out.write(key.private_bytes(serialization.Encoding.PEM,serialization.PrivateFormat.PKCS8,serialization.NoEncryption()))
|
||||
(directory/'owner-public.pem').write_bytes(owner.public_key().public_bytes(serialization.Encoding.PEM,serialization.PublicFormat.SubjectPublicKeyInfo))
|
||||
for name in ('user.token','worker.token'):
|
||||
fd=os.open(directory/name,os.O_WRONLY|os.O_CREAT|os.O_EXCL,0o600)
|
||||
with os.fdopen(fd,'w') as out: out.write(secrets.token_urlsafe(48)+'\n')
|
||||
operation={'operation_id':'service-status','version':1,'effect_class':'READ_ONLY','handler_ref':'host.service_status',
|
||||
'input_schema':'aios.operation.service-status.input.v1','required_permissions':['service-status'],'timeout_seconds':10,
|
||||
'readback':{'mode':'NONE','required':False},'retry':{'mode':'SAFE','max_attempts':2},'reconcile':{'mode':'NONE'},
|
||||
'rollback':{'mode':'NONE'},'owner_boundary':{'mode':'WITHIN_GRANT'}}
|
||||
contract={'contract_schema':'aios.runtime-contract.v1','contract_revision':1,'component_id':'sample','environment':'staging',
|
||||
'tenant_scope':'validation','lifecycle':'active','entrypoints':{'status':{'component_id':'sample','environment':'staging','resolver_ref':'service://sample/status'}},
|
||||
'bindings':{},'resource_refs':{},'credential_refs':{},'operations':['service-status']}
|
||||
config={'schema':'aios.baseline-server-config.v1','profile':'isolated-validation','state_dir':str(directory/'state'),
|
||||
'listen':{'host':'127.0.0.1','port':8769},'machine_key_id':'validation-machine','machine_key_file':str(directory/'machine-private.pem'),
|
||||
'owner_public_key_files':{'validation-owner':str(directory/'owner-public.pem')},
|
||||
'owner_key_scopes':{'validation-owner':{'tenant_ids':['validation'],'subject_ids':['owner']}},
|
||||
'identities':[{'token_file':str(directory/'user.token'),'tenant_id':'validation','subject_id':'owner','role':'owner'},
|
||||
{'token_file':str(directory/'worker.token'),'tenant_id':'validation','subject_id':'machine-worker','role':'worker','worker_id':'diagnostic-worker'}],
|
||||
'workers':[{'worker_id':'diagnostic-worker','identity':'machine-worker','tenant_scope':['validation'],'subject_scope':['owner'],
|
||||
'capabilities':['service-status'],'priority':10,'fallback_executor':False,'heartbeat_seconds':15,'max_concurrency':1}],
|
||||
'contracts':[contract],'operations':[operation],'host_bindings':{'validation:staging:sample':unit}}
|
||||
_write_json(directory/'server.json',config)
|
||||
_write_json(directory/'worker.json',{'schema':'aios.baseline-worker-config.v1','base_url':'http://127.0.0.1:8769',
|
||||
'token_file':str(directory/'worker.token'),'poll_seconds':2})
|
||||
grant=sign_owner_delegation_grant(owner,'validation-owner',grant_id='validation-grant',generation=1,
|
||||
tenant_id='validation',subject_id='owner',mission_ids=('validation-mission',),task_ids=('*',),operations=('service-status',),
|
||||
component_ids=('sample',),environments=('staging',),issued_at=int(time.time()),validity_mode='UNTIL_REVOKED',max_effects=20)
|
||||
_write_json(directory/'grant.json',grant)
|
||||
_write_json(directory/'goal.json',{'mission_id':'validation-mission','grant_id':'validation-grant','operation':'service-status',
|
||||
'component_id':'sample','environment':'staging','inputs':{},'intent':'检查已绑定 staging 服务并生成诊断结果'})
|
||||
return directory
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser=argparse.ArgumentParser(description=__doc__)
|
||||
sub=parser.add_subparsers(dest='command',required=True)
|
||||
init=sub.add_parser('init-validation');init.add_argument('--directory',required=True);init.add_argument('--service-unit',required=True)
|
||||
for name in ('preflight','server','grant-import','grant-revoke'):
|
||||
item=sub.add_parser(name);item.add_argument('--config',required=True)
|
||||
if name=='grant-import': item.add_argument('--grant',required=True)
|
||||
if name=='grant-revoke':
|
||||
item.add_argument('--grant',required=True);item.add_argument('--owner-token-file',required=True)
|
||||
worker=sub.add_parser('worker');worker.add_argument('--config',required=True);worker.add_argument('--once',action='store_true')
|
||||
for name in ('goal-submit','goal-explain','task-get','task-cancel'):
|
||||
item=sub.add_parser(name);item.add_argument('--base-url',default='http://127.0.0.1:8769');item.add_argument('--token-file',required=True)
|
||||
if name=='goal-submit': item.add_argument('--request',required=True);item.add_argument('--idempotency-key',required=True)
|
||||
elif name=='goal-explain': item.add_argument('--mission-id',required=True)
|
||||
else: item.add_argument('--task-id',required=True)
|
||||
args=parser.parse_args(argv)
|
||||
try:
|
||||
if args.command=='init-validation':
|
||||
print(json.dumps({'profile':'isolated-validation','directory':str(initialize_validation(args.directory,unit=args.service_unit))}));return 0
|
||||
if args.command in {'preflight','server','grant-import','grant-revoke'}:
|
||||
config,tokens=load_configuration(args.config)
|
||||
if args.command=='preflight':
|
||||
print(json.dumps({'status':'CONFIG_VALID','profile':config['profile'],'online_authority':'NOT_VERIFIED',
|
||||
'production_activation':'BLOCKED','registered_operations':len(config['operations'])}));return 0
|
||||
service=build_service(config)
|
||||
if args.command=='grant-import':
|
||||
grant=service.authorization.register_grant(read_json(args.grant))
|
||||
print(json.dumps({'grant_id':grant['payload']['grant_id'],'registered':True}));return 0
|
||||
if args.command=='grant-revoke':
|
||||
context=tokens.get(secret(args.owner_token_file).decode().strip())
|
||||
grant=read_json(args.grant)
|
||||
payload=grant['payload']
|
||||
if context is None or context.role!='owner' or payload['tenant_id']!=context.tenant_id or payload['subject_id']!=context.subject_id:
|
||||
raise ValueError('OWNER_SCOPE_DENIED')
|
||||
# Register verifies signature and ID before a target can be revoked.
|
||||
service.authorization.register_grant(grant)
|
||||
generation=service.authorization.revoke_grant(payload['grant_id'],control_context=context)
|
||||
print(json.dumps({'grant_id':payload['grant_id'],'revocation_generation':generation}));return 0
|
||||
from .transport import serve
|
||||
server=serve(service,tokens,**config['listen'])
|
||||
stop=threading.Event()
|
||||
def recover_loop():
|
||||
while not stop.wait(5):
|
||||
try: service.recover()
|
||||
except Exception: service.storage_blocked=True
|
||||
recovery=threading.Thread(target=recover_loop,daemon=True);recovery.start()
|
||||
def shutdown(_signal,_frame):
|
||||
stop.set();threading.Thread(target=server.shutdown,daemon=True).start()
|
||||
signal.signal(signal.SIGTERM,shutdown);signal.signal(signal.SIGINT,shutdown)
|
||||
print(json.dumps({'status':'LISTENING','host':config['listen']['host'],'port':config['listen']['port'],'profile':config['profile']}),flush=True)
|
||||
try: server.serve_forever(poll_interval=0.5)
|
||||
finally: stop.set();server.server_close()
|
||||
return 0
|
||||
from .transport import HttpTransport
|
||||
if args.command=='worker':
|
||||
from .worker import OperationWorker
|
||||
config=read_json(args.config)
|
||||
if type(config) is not dict or set(config)!={'schema','base_url','token_file','poll_seconds'} or config['schema']!='aios.baseline-worker-config.v1': raise ValueError('WORKER_CONFIG_INVALID')
|
||||
transport=HttpTransport(config['base_url'],secret(config['token_file']).decode().strip(),timeout_seconds=20)
|
||||
worker=OperationWorker(transport,poll_seconds=config['poll_seconds'])
|
||||
if args.once: print(json.dumps(worker.run_once(),ensure_ascii=False));return 0
|
||||
worker.run_forever();return 0
|
||||
transport=HttpTransport(args.base_url,secret(args.token_file).decode().strip(),timeout_seconds=20)
|
||||
if args.command=='goal-submit': result=transport.submit_goal(read_json(args.request),idempotency_key=args.idempotency_key)
|
||||
elif args.command=='goal-explain': result=transport.explain(args.mission_id)
|
||||
elif args.command=='task-get': result=transport.get_task(args.task_id)
|
||||
else: result=transport.cancel_task(args.task_id)
|
||||
print(json.dumps(result,ensure_ascii=False,indent=2,allow_nan=False));return 0
|
||||
except (Exception,KeyboardInterrupt) as exc:
|
||||
# Avoid dumping request bodies, credential material or Python tracebacks.
|
||||
text=str(exc)
|
||||
code=text if re.fullmatch(r'[A-Z][A-Z0-9_]{2,100}',text) else getattr(exc,'code',type(exc).__name__)
|
||||
print(json.dumps({'status':'ERROR','error_type':type(exc).__name__,'code':code,'message':'See local configuration and acceptance runbook; no action was reported successful.'}))
|
||||
return 1
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Canonical, domain-separated machine receipts, distinct from Owner grants."""
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
|
||||
|
||||
def canonical(value):
|
||||
return json.dumps(value,sort_keys=True,separators=(',',':'),ensure_ascii=False,allow_nan=False).encode()
|
||||
|
||||
|
||||
def digest(value):
|
||||
return hashlib.sha256(canonical(value)).hexdigest()
|
||||
|
||||
|
||||
class ReceiptSigner:
|
||||
def __init__(self, private_key, *, key_id='baseline-machine', trusted_public_keys=None):
|
||||
self.private_key=private_key
|
||||
self.public_key=private_key.public_key()
|
||||
self.key_id=key_id
|
||||
self.trusted_public_keys=dict(trusted_public_keys or {})
|
||||
self.trusted_public_keys[key_id]=self.public_key
|
||||
|
||||
def sign(self,payload):
|
||||
raw=canonical({'domain':'aios.operation-receipt.v1','key_id':self.key_id,'payload':payload})
|
||||
return {'schema':'aios.operation-receipt.v1','key_id':self.key_id,'payload':payload,
|
||||
'signature':base64.b64encode(self.private_key.sign(raw)).decode()}
|
||||
|
||||
def verify(self,envelope):
|
||||
try:
|
||||
if type(envelope) is not dict or set(envelope)!={'schema','key_id','payload','signature'} or envelope['schema']!='aios.operation-receipt.v1' or type(envelope['payload']) is not dict:
|
||||
return False
|
||||
public=self.trusted_public_keys.get(envelope['key_id'])
|
||||
if public is None: return False
|
||||
raw=canonical({'domain':envelope['schema'],'key_id':envelope['key_id'],'payload':envelope['payload']})
|
||||
public.verify(base64.b64decode(envelope['signature'],validate=True),raw)
|
||||
return True
|
||||
except (ValueError,TypeError,InvalidSignature,KeyError):
|
||||
return False
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Small registered host diagnostics; unit bindings come from trusted configuration."""
|
||||
from __future__ import annotations
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import signal
|
||||
import selectors
|
||||
import stat
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
|
||||
class HostError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class SystemdHost:
|
||||
PROPERTIES = ('Id', 'ActiveState', 'SubState', 'MainPID', 'InvocationID')
|
||||
|
||||
def __init__(self, bindings: dict[str, str], *, executable: str = '/usr/bin/systemctl',
|
||||
timeout_seconds: float = 10):
|
||||
if type(timeout_seconds) not in (int,float) or not 0 < timeout_seconds <= 60:
|
||||
raise HostError('HOST_TIMEOUT_INVALID')
|
||||
self.bindings = dict(bindings)
|
||||
for binding,unit in self.bindings.items():
|
||||
if not isinstance(binding,str) or len(binding.split(':'))!=3 or any(not re.fullmatch(r'[A-Za-z0-9_.-]+',part) for part in binding.split(':')):
|
||||
raise HostError('HOST_TENANT_BINDING_INVALID')
|
||||
if not isinstance(unit, str) or not re.fullmatch(r'[A-Za-z0-9_.@:-]+\.service', unit):
|
||||
raise HostError('HOST_UNIT_INVALID')
|
||||
if not Path(executable).is_absolute() or not Path(executable).is_file() or Path(executable).is_symlink():
|
||||
raise HostError('HOST_EXECUTABLE_UNAVAILABLE')
|
||||
info=Path(executable).stat()
|
||||
if info.st_uid not in {0,os.geteuid()} or info.st_mode & 0o022:
|
||||
raise HostError('HOST_EXECUTABLE_UNTRUSTED')
|
||||
self._executable_identity=(info.st_dev,info.st_ino,info.st_size,info.st_mtime_ns)
|
||||
self.executable = executable
|
||||
self.timeout_seconds = timeout_seconds
|
||||
|
||||
def status(self, component_id: str, environment: str, *, tenant_id: str, deadline=None) -> dict:
|
||||
if any(not isinstance(part,str) or not re.fullmatch(r'[A-Za-z0-9_.-]+',part) for part in (tenant_id,environment,component_id)):
|
||||
raise HostError('HOST_SCOPE_INVALID')
|
||||
unit = self.bindings.get(f'{tenant_id}:{environment}:{component_id}')
|
||||
if not unit:
|
||||
raise HostError('HOST_BINDING_NOT_FOUND')
|
||||
remaining=self.timeout_seconds if deadline is None else min(self.timeout_seconds,deadline-time.time())
|
||||
if remaining<=0: raise HostError('HOST_OPERATION_TIMEOUT')
|
||||
stop_at=time.monotonic()+remaining
|
||||
executable_fd=os.open(self.executable,os.O_RDONLY|os.O_NOFOLLOW|os.O_CLOEXEC)
|
||||
try:
|
||||
info=os.fstat(executable_fd)
|
||||
if not stat.S_ISREG(info.st_mode) or (info.st_dev,info.st_ino,info.st_size,info.st_mtime_ns)!=self._executable_identity:
|
||||
raise HostError('HOST_EXECUTABLE_IDENTITY_CHANGED')
|
||||
executable_path=f'/proc/self/fd/{executable_fd}'
|
||||
if not Path(executable_path).exists(): raise HostError('HOST_EXECUTOR_PLATFORM_UNSUPPORTED')
|
||||
argv=[executable_path,'show','--no-pager','--property='+','.join(self.PROPERTIES),'--',unit]
|
||||
process=subprocess.Popen(argv,stdin=subprocess.DEVNULL,stdout=subprocess.PIPE,stderr=subprocess.PIPE,
|
||||
env={'PATH':'/usr/bin:/bin','LANG':'C'},start_new_session=True,pass_fds=(executable_fd,))
|
||||
finally: os.close(executable_fd)
|
||||
output=bytearray()
|
||||
byte_count=0
|
||||
try:
|
||||
with selectors.DefaultSelector() as selector:
|
||||
selector.register(process.stdout,selectors.EVENT_READ,'stdout')
|
||||
selector.register(process.stderr,selectors.EVENT_READ,'stderr')
|
||||
while selector.get_map():
|
||||
remaining=stop_at-time.monotonic()
|
||||
if remaining<=0: raise HostError('HOST_OPERATION_TIMEOUT')
|
||||
for event,_mask in selector.select(remaining):
|
||||
chunk=os.read(event.fileobj.fileno(),4096)
|
||||
if not chunk:
|
||||
selector.unregister(event.fileobj)
|
||||
continue
|
||||
byte_count+=len(chunk)
|
||||
if byte_count>65536: raise HostError('HOST_RESPONSE_TOO_LARGE')
|
||||
if event.data=='stdout': output.extend(chunk)
|
||||
process.wait(timeout=max(0.001,stop_at-time.monotonic()))
|
||||
except (HostError,subprocess.TimeoutExpired) as exc:
|
||||
try: os.killpg(process.pid,signal.SIGKILL)
|
||||
except ProcessLookupError: pass
|
||||
process.wait()
|
||||
if isinstance(exc,HostError): raise
|
||||
raise HostError('HOST_OPERATION_TIMEOUT') from exc
|
||||
finally:
|
||||
process.stdout.close()
|
||||
process.stderr.close()
|
||||
if process.returncode != 0:
|
||||
raise HostError('HOST_STATUS_UNAVAILABLE')
|
||||
try: out=output.decode('utf-8')
|
||||
except UnicodeDecodeError as exc: raise HostError('HOST_OBSERVATION_INVALID') from exc
|
||||
properties = {}
|
||||
for line in out.splitlines():
|
||||
key, sep, value = line.partition('=')
|
||||
if sep and key in self.PROPERTIES:
|
||||
if key in properties:
|
||||
raise HostError('HOST_DUPLICATE_PROPERTY')
|
||||
properties[key] = value
|
||||
if set(properties) != set(self.PROPERTIES) or properties['Id'] != unit:
|
||||
raise HostError('HOST_OBSERVATION_INCOMPLETE')
|
||||
if not properties['MainPID'].isdigit():
|
||||
raise HostError('HOST_OBSERVATION_INVALID')
|
||||
return {'operation':'service-status', 'tenant_id':tenant_id, 'component_id':component_id, 'environment':environment,
|
||||
'unit':unit, 'healthy':properties['ActiveState'] == 'active',
|
||||
'properties':properties, 'observed_at':time.time(), 'source':'systemd.show'}
|
||||
@@ -0,0 +1,650 @@
|
||||
"""Persistent correlation relationships for the AIOS baseline.
|
||||
|
||||
This is deliberately a relationship authority. It does not create or advance
|
||||
Tasks, Jobs, Releases, or Deployments. In particular, a Task is admitted only
|
||||
after the configured Task authority returns the real Task and its authenticated
|
||||
tenant/subject binding has been checked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, is_dataclass
|
||||
from datetime import datetime, timezone
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
|
||||
class LineageError(RuntimeError):
|
||||
"""Base class for persistent lineage failures."""
|
||||
|
||||
|
||||
class LineageConflict(LineageError):
|
||||
"""An opaque identity was already bound to different lineage."""
|
||||
|
||||
|
||||
class LineageVerificationError(LineageError):
|
||||
"""An external authority did not verify the requested relationship."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VerifiedAcceptanceEvidence:
|
||||
"""Typed output of a configured acceptance evidence verifier."""
|
||||
|
||||
acceptance_id: str
|
||||
tenant_id: str
|
||||
subject_id: str
|
||||
component_id: str
|
||||
deployment_id: str
|
||||
release_id: str
|
||||
artifact: Any
|
||||
accepted: bool
|
||||
evidence_id: str
|
||||
observed: Mapping[str, Any] | None = None
|
||||
observed_at: str | None = None
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _required(value: Any, name: str) -> str:
|
||||
text = str(value or "")
|
||||
if not text or len(text) > 512 or any(ord(char) < 32 for char in text):
|
||||
raise ValueError(f"invalid {name}")
|
||||
return text
|
||||
|
||||
|
||||
def _json(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
||||
|
||||
|
||||
_SELF_HASH_FIELDS = {
|
||||
"manifest_sha256",
|
||||
"manifest_hash",
|
||||
"outer_manifest_hash",
|
||||
"outer_manifest_sha256",
|
||||
"self_hash",
|
||||
"self_sha256",
|
||||
}
|
||||
|
||||
|
||||
def canonical_artifact(artifact: Any) -> str:
|
||||
"""Return an exact artifact identity without permitting outer self-hashes."""
|
||||
|
||||
if not isinstance(artifact, (str, Mapping)):
|
||||
raise ValueError("artifact must be a string or object")
|
||||
if isinstance(artifact, str):
|
||||
_required(artifact, "artifact")
|
||||
else:
|
||||
if not artifact:
|
||||
raise ValueError("artifact must not be empty")
|
||||
if any(not isinstance(key,str) for key in artifact):
|
||||
raise ValueError("artifact object keys must be strings")
|
||||
if _SELF_HASH_FIELDS.intersection(key.lower() for key in artifact):
|
||||
raise ValueError("outer artifact manifest must not contain a self hash")
|
||||
try:
|
||||
return _json(artifact)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("artifact must be canonical JSON") from exc
|
||||
|
||||
|
||||
def decode_artifact(value: str) -> Any:
|
||||
return json.loads(value)
|
||||
|
||||
|
||||
def _mapping(value: Any) -> Mapping[str, Any]:
|
||||
if isinstance(value, Mapping):
|
||||
return value
|
||||
if is_dataclass(value):
|
||||
return asdict(value)
|
||||
converter = getattr(value, "to_dict", None)
|
||||
if not callable(converter):
|
||||
converter = getattr(value, "as_dict", None)
|
||||
if callable(converter):
|
||||
try:
|
||||
converted = converter(redact_payload=False)
|
||||
except TypeError:
|
||||
converted = converter()
|
||||
if isinstance(converted, Mapping):
|
||||
return converted
|
||||
raise LineageVerificationError("task_authority returned an invalid Task")
|
||||
|
||||
|
||||
def _task_plan(task: Any) -> tuple[Mapping[str, Any], Mapping[str, Any]]:
|
||||
raw = _mapping(task)
|
||||
plan: Any = raw.get("plan") or raw.get("task_plan")
|
||||
if plan is None and hasattr(task, "task_plan"):
|
||||
plan = getattr(task, "task_plan")
|
||||
if plan is None:
|
||||
plan = raw
|
||||
plan_map = _mapping(plan)
|
||||
return raw, plan_map
|
||||
|
||||
|
||||
class LineageLedger:
|
||||
"""SQLite-backed authority for cross-domain identity relationships."""
|
||||
|
||||
def __init__(self, path: str | Path, task_authority: Any, job_authority: Any):
|
||||
if task_authority is None:
|
||||
raise ValueError("task_authority is required")
|
||||
if job_authority is None:
|
||||
raise ValueError("job_authority is required")
|
||||
self.path = str(path)
|
||||
self.task_authority = task_authority
|
||||
self.job_authority = job_authority
|
||||
Path(self.path).parent.mkdir(parents=True, exist_ok=True)
|
||||
self._initialize()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self.path, timeout=30)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
connection.execute("PRAGMA busy_timeout=30000")
|
||||
return connection
|
||||
|
||||
def _initialize(self) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
tables={row[0] for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
||||
if "lineage_tasks" in tables and "lineage_schema_meta" not in tables:
|
||||
raise LineageConflict("unversioned candidate lineage schema requires explicit migration")
|
||||
if "lineage_schema_meta" in tables:
|
||||
version=connection.execute("SELECT version FROM lineage_schema_meta WHERE domain='lineage'").fetchone()
|
||||
if version is None or version[0] != 2:
|
||||
raise LineageConflict("unsupported lineage schema version")
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS lineage_schema_meta (
|
||||
domain TEXT PRIMARY KEY,
|
||||
version INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS lineage_tasks (
|
||||
tenant_id TEXT NOT NULL,
|
||||
task_id TEXT NOT NULL,
|
||||
subject_id TEXT NOT NULL,
|
||||
mission_id TEXT NOT NULL,
|
||||
component_id TEXT NOT NULL,
|
||||
grant_id TEXT NOT NULL,
|
||||
source_task_id TEXT,
|
||||
metadata_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (tenant_id, task_id),
|
||||
UNIQUE (tenant_id, task_id, subject_id, mission_id, component_id, grant_id),
|
||||
FOREIGN KEY (tenant_id, source_task_id)
|
||||
REFERENCES lineage_tasks(tenant_id, task_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS lineage_effects (
|
||||
tenant_id TEXT NOT NULL,
|
||||
effect_id TEXT NOT NULL,
|
||||
subject_id TEXT NOT NULL,
|
||||
mission_id TEXT NOT NULL,
|
||||
component_id TEXT NOT NULL,
|
||||
grant_id TEXT NOT NULL,
|
||||
task_id TEXT NOT NULL,
|
||||
job_id TEXT NOT NULL,
|
||||
source_effect_id TEXT,
|
||||
metadata_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (tenant_id, effect_id),
|
||||
UNIQUE (tenant_id, job_id),
|
||||
UNIQUE (tenant_id, effect_id, subject_id, mission_id, component_id),
|
||||
UNIQUE (tenant_id, effect_id, subject_id, mission_id, component_id, grant_id),
|
||||
FOREIGN KEY (tenant_id, task_id, subject_id, mission_id, component_id, grant_id)
|
||||
REFERENCES lineage_tasks(tenant_id, task_id, subject_id, mission_id, component_id, grant_id),
|
||||
FOREIGN KEY (tenant_id, source_effect_id, subject_id, mission_id, component_id)
|
||||
REFERENCES lineage_effects(tenant_id, effect_id, subject_id, mission_id, component_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS lineage_releases (
|
||||
tenant_id TEXT NOT NULL,
|
||||
release_id TEXT NOT NULL,
|
||||
subject_id TEXT NOT NULL,
|
||||
mission_id TEXT NOT NULL,
|
||||
component_id TEXT NOT NULL,
|
||||
grant_id TEXT NOT NULL,
|
||||
effect_id TEXT NOT NULL,
|
||||
source_task_id TEXT NOT NULL,
|
||||
source_effect_id TEXT NOT NULL,
|
||||
artifact_json TEXT NOT NULL,
|
||||
metadata_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (tenant_id, release_id),
|
||||
UNIQUE (tenant_id, effect_id),
|
||||
UNIQUE (tenant_id, release_id, subject_id, mission_id, component_id, artifact_json),
|
||||
FOREIGN KEY (tenant_id, effect_id, subject_id, mission_id, component_id, grant_id)
|
||||
REFERENCES lineage_effects(tenant_id, effect_id, subject_id, mission_id, component_id, grant_id),
|
||||
FOREIGN KEY (tenant_id, source_task_id, subject_id, mission_id, component_id, grant_id)
|
||||
REFERENCES lineage_tasks(tenant_id, task_id, subject_id, mission_id, component_id, grant_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS lineage_deployments (
|
||||
tenant_id TEXT NOT NULL,
|
||||
deployment_id TEXT NOT NULL,
|
||||
subject_id TEXT NOT NULL,
|
||||
mission_id TEXT NOT NULL,
|
||||
component_id TEXT NOT NULL,
|
||||
release_id TEXT NOT NULL,
|
||||
environment TEXT NOT NULL,
|
||||
target_revision INTEGER NOT NULL,
|
||||
artifact_json TEXT NOT NULL,
|
||||
metadata_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (tenant_id, deployment_id),
|
||||
UNIQUE (tenant_id, deployment_id, subject_id, mission_id, component_id, release_id, artifact_json),
|
||||
FOREIGN KEY (tenant_id, release_id, subject_id, mission_id, component_id, artifact_json)
|
||||
REFERENCES lineage_releases(tenant_id, release_id, subject_id, mission_id, component_id, artifact_json)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS lineage_acceptances (
|
||||
tenant_id TEXT NOT NULL,
|
||||
acceptance_id TEXT NOT NULL,
|
||||
subject_id TEXT NOT NULL,
|
||||
mission_id TEXT NOT NULL,
|
||||
component_id TEXT NOT NULL,
|
||||
deployment_id TEXT NOT NULL,
|
||||
release_id TEXT NOT NULL,
|
||||
artifact_json TEXT NOT NULL,
|
||||
accepted INTEGER NOT NULL CHECK (accepted IN (0,1)),
|
||||
evidence_id TEXT NOT NULL,
|
||||
evidence_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY (tenant_id, acceptance_id),
|
||||
FOREIGN KEY (tenant_id, deployment_id, subject_id, mission_id, component_id, release_id, artifact_json)
|
||||
REFERENCES lineage_deployments(
|
||||
tenant_id, deployment_id, subject_id, mission_id, component_id, release_id, artifact_json
|
||||
)
|
||||
);
|
||||
"""
|
||||
)
|
||||
connection.execute("INSERT OR IGNORE INTO lineage_schema_meta(domain,version) VALUES('lineage',2)")
|
||||
|
||||
def close(self) -> None:
|
||||
"""Compatibility hook; connections are transaction-scoped."""
|
||||
|
||||
def _authorized_task(self, tenant_id: str, subject_id: str, task_id: str) -> Any:
|
||||
authority = self.task_authority
|
||||
try:
|
||||
if hasattr(authority, "get_task"):
|
||||
task = authority.get_task(task_id)
|
||||
elif callable(authority):
|
||||
try:
|
||||
task = authority(tenant_id, subject_id, task_id)
|
||||
except TypeError:
|
||||
task = authority(task_id)
|
||||
else:
|
||||
raise TypeError("authority has no task lookup")
|
||||
except Exception as exc:
|
||||
raise LineageVerificationError("task_authority did not confirm task") from exc
|
||||
raw, plan = _task_plan(task)
|
||||
returned_id = raw.get("task_id") or plan.get("task_id") or getattr(task, "task_id", None)
|
||||
if str(returned_id or "") != task_id:
|
||||
raise LineageVerificationError("task_authority returned wrong task_id")
|
||||
issuer = plan.get("issuer") if isinstance(plan.get("issuer"), Mapping) else {}
|
||||
metadata = plan.get("metadata") if isinstance(plan.get("metadata"), Mapping) else {}
|
||||
baseline = metadata.get("baseline") if isinstance(metadata.get("baseline"), Mapping) else {}
|
||||
tenant_values = [value for value in (issuer.get("tenant_id"), metadata.get("tenant_id"), baseline.get("tenant_id")) if value is not None]
|
||||
subject_values = [
|
||||
value for value in (issuer.get("subject_id"), metadata.get("subject_id"), baseline.get("subject_id")) if value is not None
|
||||
]
|
||||
# issuer.id is accepted as subject identity only when no explicit subject_id exists.
|
||||
if not subject_values and issuer.get("id") is not None:
|
||||
subject_values.append(issuer["id"])
|
||||
if not tenant_values or any(str(value) != tenant_id for value in tenant_values):
|
||||
raise LineageVerificationError("task_authority tenant mismatch")
|
||||
if not subject_values or any(str(value) != subject_id for value in subject_values):
|
||||
raise LineageVerificationError("task_authority subject mismatch")
|
||||
return task
|
||||
|
||||
def _authorized_job(
|
||||
self, tenant_id: str, subject_id: str, mission_id: str, grant_id: str,
|
||||
task_id: str, effect_id: str, job_id: str,
|
||||
) -> None:
|
||||
authority=self.job_authority
|
||||
try:
|
||||
if hasattr(authority,"get_job"):
|
||||
job=authority.get_job(job_id)
|
||||
elif hasattr(authority,"read"):
|
||||
job=authority.read(job_id)
|
||||
elif callable(authority):
|
||||
job=authority(job_id)
|
||||
else:
|
||||
raise TypeError("authority has no Job lookup")
|
||||
except Exception as exc:
|
||||
raise LineageVerificationError("job_authority did not confirm Job") from exc
|
||||
raw=_mapping(job)
|
||||
request=raw.get("request") or raw.get("binding")
|
||||
if request is None and isinstance(raw.get("request_json"),str):
|
||||
try:
|
||||
request=json.loads(raw["request_json"])
|
||||
except json.JSONDecodeError as exc:
|
||||
raise LineageVerificationError("job_authority returned invalid request") from exc
|
||||
if not isinstance(request,Mapping):
|
||||
request=raw
|
||||
if isinstance(request.get("binding"),Mapping):
|
||||
request=request["binding"]
|
||||
bindings={
|
||||
"job_id":raw.get("job_id") or request.get("job_id"),
|
||||
"tenant_id":request.get("tenant_id"),"subject_id":request.get("subject_id"),
|
||||
"mission_id":request.get("mission_id"),"grant_id":request.get("grant_id"),
|
||||
"task_id":request.get("task_id"),"effect_id":request.get("effect_id"),
|
||||
}
|
||||
expected={"job_id":job_id,"tenant_id":tenant_id,"subject_id":subject_id,
|
||||
"mission_id":mission_id,"grant_id":grant_id,"task_id":task_id,"effect_id":effect_id}
|
||||
if any(str(bindings[key] or "") != value for key,value in expected.items()):
|
||||
raise LineageVerificationError("job_authority binding mismatch")
|
||||
|
||||
@staticmethod
|
||||
def _check_task_context(task: Any, mission_id: str, grant_id: str) -> None:
|
||||
_, plan = _task_plan(task)
|
||||
metadata = plan.get("metadata") if isinstance(plan.get("metadata"), Mapping) else {}
|
||||
baseline = metadata.get("baseline") if isinstance(metadata.get("baseline"), Mapping) else {}
|
||||
mission_values = [value for value in (metadata.get("mission_id"), baseline.get("mission_id")) if value is not None]
|
||||
grant_values = [value for value in (metadata.get("grant_id"), baseline.get("grant_id")) if value is not None]
|
||||
if any(str(value) != mission_id for value in mission_values):
|
||||
raise LineageVerificationError("task_authority mission mismatch")
|
||||
if any(str(value) != grant_id for value in grant_values):
|
||||
raise LineageVerificationError("task_authority grant mismatch")
|
||||
|
||||
@staticmethod
|
||||
def _task_component(task: Any, requested: str | None) -> str:
|
||||
_, plan = _task_plan(task)
|
||||
metadata = plan.get("metadata") if isinstance(plan.get("metadata"), Mapping) else {}
|
||||
baseline = metadata.get("baseline") if isinstance(metadata.get("baseline"), Mapping) else {}
|
||||
values=[value for value in (metadata.get("component_id"),baseline.get("component_id")) if value is not None]
|
||||
if requested is not None:
|
||||
requested=_required(requested,"component_id")
|
||||
if any(str(value) != requested for value in values):
|
||||
raise LineageVerificationError("task_authority component mismatch")
|
||||
return requested
|
||||
if not values or any(str(value) != str(values[0]) for value in values):
|
||||
raise LineageVerificationError("task_authority component missing or inconsistent")
|
||||
return _required(values[0],"component_id")
|
||||
|
||||
def link_task(
|
||||
self,
|
||||
tenant_id: str,
|
||||
subject_id: str,
|
||||
mission_id: str,
|
||||
grant_id: str,
|
||||
task_id: str,
|
||||
*,
|
||||
component_id: str | None = None,
|
||||
source_task_id: str | None = None,
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
tenant_id, subject_id, mission_id, grant_id, task_id = (
|
||||
_required(tenant_id, "tenant_id"), _required(subject_id, "subject_id"),
|
||||
_required(mission_id, "mission_id"), _required(grant_id, "grant_id"),
|
||||
_required(task_id, "task_id"),
|
||||
)
|
||||
task = self._authorized_task(tenant_id, subject_id, task_id)
|
||||
self._check_task_context(task, mission_id, grant_id)
|
||||
component_id=self._task_component(task,component_id)
|
||||
source_task_id = _required(source_task_id, "source_task_id") if source_task_id else None
|
||||
raw_task, task_plan = _task_plan(task)
|
||||
authoritative_source = task_plan.get("parent_task_id") or raw_task.get("parent_task_id")
|
||||
if authoritative_source is not None:
|
||||
authoritative_source = _required(authoritative_source, "authoritative source_task_id")
|
||||
if source_task_id is not None and source_task_id != authoritative_source:
|
||||
raise LineageVerificationError("source_task_id does not match Task authority")
|
||||
source_task_id = authoritative_source
|
||||
metadata_json = _json(dict(metadata or {}))
|
||||
values = (subject_id, mission_id, component_id, grant_id, source_task_id)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
existing = connection.execute(
|
||||
"SELECT * FROM lineage_tasks WHERE tenant_id=? AND task_id=?", (tenant_id, task_id)
|
||||
).fetchone()
|
||||
if existing:
|
||||
if tuple(existing[key] for key in ("subject_id", "mission_id", "component_id", "grant_id", "source_task_id")) != values:
|
||||
raise LineageConflict("task_id already has different lineage")
|
||||
return self._task_row(existing)
|
||||
if source_task_id:
|
||||
self._require_row(connection, "lineage_tasks", "task_id", tenant_id, source_task_id, subject_id, mission_id, component_id)
|
||||
connection.execute(
|
||||
"INSERT INTO lineage_tasks VALUES(?,?,?,?,?,?,?,?,?)",
|
||||
(tenant_id, task_id, subject_id, mission_id, component_id, grant_id, source_task_id, metadata_json, _now()),
|
||||
)
|
||||
row = connection.execute(
|
||||
"SELECT * FROM lineage_tasks WHERE tenant_id=? AND task_id=?", (tenant_id, task_id)
|
||||
).fetchone()
|
||||
return self._task_row(row)
|
||||
|
||||
@staticmethod
|
||||
def _require_row(connection, table, id_column, tenant_id, object_id, subject_id, mission_id, component_id=None):
|
||||
row = connection.execute(
|
||||
f"SELECT * FROM {table} WHERE tenant_id=? AND {id_column}=?", (tenant_id, object_id)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise LineageConflict(f"{id_column} has no linked authority record")
|
||||
if row["subject_id"] != subject_id or row["mission_id"] != mission_id:
|
||||
raise LineageConflict(f"{id_column} belongs to different subject or mission")
|
||||
if component_id is not None and row["component_id"] != component_id:
|
||||
raise LineageConflict(f"{id_column} belongs to different component")
|
||||
return row
|
||||
|
||||
def link_effect(
|
||||
self, tenant_id: str, subject_id: str, mission_id: str, task_id: str,
|
||||
effect_id: str, job_id: str, *, source_effect_id: str | None = None,
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
tenant_id, subject_id, mission_id, task_id, effect_id, job_id = map(
|
||||
lambda pair: _required(pair[0], pair[1]),
|
||||
((tenant_id,"tenant_id"),(subject_id,"subject_id"),(mission_id,"mission_id"),
|
||||
(task_id,"task_id"),(effect_id,"effect_id"),(job_id,"job_id")),
|
||||
)
|
||||
source_effect_id = _required(source_effect_id, "source_effect_id") if source_effect_id else None
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
task=self._require_row(connection, "lineage_tasks", "task_id", tenant_id, task_id, subject_id, mission_id)
|
||||
component_id=task["component_id"]
|
||||
grant_id=task["grant_id"]
|
||||
self._authorized_job(tenant_id,subject_id,mission_id,grant_id,task_id,effect_id,job_id)
|
||||
existing = connection.execute(
|
||||
"SELECT * FROM lineage_effects WHERE tenant_id=? AND effect_id=?", (tenant_id, effect_id)
|
||||
).fetchone()
|
||||
expected = (subject_id, mission_id, component_id, grant_id, task_id, job_id, source_effect_id)
|
||||
if existing:
|
||||
actual = tuple(existing[key] for key in ("subject_id","mission_id","component_id","grant_id","task_id","job_id","source_effect_id"))
|
||||
if actual != expected:
|
||||
raise LineageConflict("effect_id already has different task/job identity")
|
||||
return self._effect_row(existing)
|
||||
job = connection.execute(
|
||||
"SELECT effect_id,task_id,subject_id FROM lineage_effects WHERE tenant_id=? AND job_id=?",
|
||||
(tenant_id, job_id),
|
||||
).fetchone()
|
||||
if job:
|
||||
raise LineageConflict("job_id already belongs to exactly one Task/effect")
|
||||
if source_effect_id:
|
||||
self._require_row(connection, "lineage_effects", "effect_id", tenant_id, source_effect_id, subject_id, mission_id, component_id)
|
||||
connection.execute(
|
||||
"INSERT INTO lineage_effects VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(tenant_id,effect_id,subject_id,mission_id,component_id,grant_id,task_id,job_id,source_effect_id,_json(dict(metadata or {})),_now()),
|
||||
)
|
||||
row = connection.execute("SELECT * FROM lineage_effects WHERE tenant_id=? AND effect_id=?",(tenant_id,effect_id)).fetchone()
|
||||
return self._effect_row(row)
|
||||
|
||||
def link_release(
|
||||
self, tenant_id: str, subject_id: str, mission_id: str, effect_id: str,
|
||||
release_id: str, artifact: Any, *, metadata: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
tenant_id, subject_id, mission_id, effect_id, release_id = map(
|
||||
lambda pair: _required(pair[0],pair[1]),
|
||||
((tenant_id,"tenant_id"),(subject_id,"subject_id"),(mission_id,"mission_id"),
|
||||
(effect_id,"effect_id"),(release_id,"release_id")),
|
||||
)
|
||||
artifact_json = canonical_artifact(artifact)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
effect = self._require_row(connection,"lineage_effects","effect_id",tenant_id,effect_id,subject_id,mission_id)
|
||||
existing = connection.execute("SELECT * FROM lineage_releases WHERE tenant_id=? AND release_id=?",(tenant_id,release_id)).fetchone()
|
||||
expected = (subject_id,mission_id,effect["component_id"],effect["grant_id"],effect_id,effect["task_id"],effect_id,artifact_json)
|
||||
if existing:
|
||||
actual = tuple(existing[key] for key in ("subject_id","mission_id","component_id","grant_id","effect_id","source_task_id","source_effect_id","artifact_json"))
|
||||
if actual != expected:
|
||||
raise LineageConflict("release_id already has different source lineage")
|
||||
return self._release_row(existing)
|
||||
producer = connection.execute("SELECT release_id FROM lineage_releases WHERE tenant_id=? AND effect_id=?",(tenant_id,effect_id)).fetchone()
|
||||
if producer:
|
||||
raise LineageConflict("release-producing effect already points to a unique release")
|
||||
connection.execute(
|
||||
"INSERT INTO lineage_releases VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(tenant_id,release_id,subject_id,mission_id,effect["component_id"],effect["grant_id"],effect_id,effect["task_id"],effect_id,artifact_json,_json(dict(metadata or {})),_now()),
|
||||
)
|
||||
row=connection.execute("SELECT * FROM lineage_releases WHERE tenant_id=? AND release_id=?",(tenant_id,release_id)).fetchone()
|
||||
return self._release_row(row)
|
||||
|
||||
def link_deployment(
|
||||
self, tenant_id: str, subject_id: str, mission_id: str, release_id: str,
|
||||
deployment_id: str, component_id: str, environment: str, target_revision: int, artifact: Any,
|
||||
*, metadata: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
tenant_id,subject_id,mission_id,release_id,deployment_id,component_id,environment=map(
|
||||
lambda pair:_required(pair[0],pair[1]),
|
||||
((tenant_id,"tenant_id"),(subject_id,"subject_id"),(mission_id,"mission_id"),
|
||||
(release_id,"release_id"),(deployment_id,"deployment_id"),(component_id,"component_id"),(environment,"environment")),
|
||||
)
|
||||
if not isinstance(target_revision,int) or isinstance(target_revision,bool) or target_revision < 1:
|
||||
raise ValueError("invalid target_revision")
|
||||
artifact_json=canonical_artifact(artifact)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
release=self._require_row(connection,"lineage_releases","release_id",tenant_id,release_id,subject_id,mission_id,component_id)
|
||||
if release["artifact_json"] != artifact_json:
|
||||
raise LineageConflict("deployment artifact does not match release")
|
||||
existing=connection.execute("SELECT * FROM lineage_deployments WHERE tenant_id=? AND deployment_id=?",(tenant_id,deployment_id)).fetchone()
|
||||
expected=(subject_id,mission_id,component_id,release_id,environment,target_revision,artifact_json)
|
||||
if existing:
|
||||
actual=tuple(existing[key] for key in ("subject_id","mission_id","component_id","release_id","environment","target_revision","artifact_json"))
|
||||
if actual != expected:
|
||||
raise LineageConflict("deployment_id already has different release identity")
|
||||
return self._deployment_row(existing)
|
||||
connection.execute(
|
||||
"INSERT INTO lineage_deployments VALUES(?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(tenant_id,deployment_id,subject_id,mission_id,component_id,release_id,environment,target_revision,artifact_json,_json(dict(metadata or {})),_now()),
|
||||
)
|
||||
row=connection.execute("SELECT * FROM lineage_deployments WHERE tenant_id=? AND deployment_id=?",(tenant_id,deployment_id)).fetchone()
|
||||
return self._deployment_row(row)
|
||||
|
||||
def record_acceptance(
|
||||
self, tenant_id: str, subject_id: str, mission_id: str, component_id: str, acceptance_id: str,
|
||||
deployment_id: str, release_id: str, artifact: Any, accepted: bool, *,
|
||||
evidence: Any, verifier: Callable[[Any, Mapping[str, Any]], VerifiedAcceptanceEvidence],
|
||||
) -> dict[str, Any]:
|
||||
if not callable(verifier):
|
||||
raise LineageVerificationError("acceptance verifier is required")
|
||||
tenant_id,subject_id,mission_id,component_id,acceptance_id,deployment_id,release_id=map(
|
||||
lambda pair:_required(pair[0],pair[1]),
|
||||
((tenant_id,"tenant_id"),(subject_id,"subject_id"),(mission_id,"mission_id"),(component_id,"component_id"),
|
||||
(acceptance_id,"acceptance_id"),(deployment_id,"deployment_id"),(release_id,"release_id")),
|
||||
)
|
||||
if type(accepted) is not bool:
|
||||
raise ValueError("accepted must be boolean")
|
||||
artifact_json=canonical_artifact(artifact)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
deployment=self._require_row(connection,"lineage_deployments","deployment_id",tenant_id,deployment_id,subject_id,mission_id,component_id)
|
||||
if deployment["release_id"] != release_id:
|
||||
raise LineageConflict("acceptance release_id does not match deployment")
|
||||
if deployment["artifact_json"] != artifact_json:
|
||||
raise LineageConflict("acceptance artifact does not match deployment")
|
||||
expected={"acceptance_id":acceptance_id,"tenant_id":tenant_id,"subject_id":subject_id,"component_id":component_id,
|
||||
"deployment_id":deployment_id,"release_id":release_id,"artifact":decode_artifact(artifact_json),
|
||||
"accepted":accepted}
|
||||
try:
|
||||
verified=verifier(evidence,expected)
|
||||
except Exception as exc:
|
||||
raise LineageVerificationError("acceptance evidence was not verified") from exc
|
||||
if not isinstance(verified,VerifiedAcceptanceEvidence):
|
||||
raise LineageVerificationError("verifier must return VerifiedAcceptanceEvidence")
|
||||
bindings=(verified.acceptance_id,verified.tenant_id,verified.subject_id,verified.component_id,verified.deployment_id,verified.release_id,canonical_artifact(verified.artifact),verified.accepted)
|
||||
if bindings != (acceptance_id,tenant_id,subject_id,component_id,deployment_id,release_id,artifact_json,accepted):
|
||||
raise LineageVerificationError("verified acceptance evidence binding mismatch")
|
||||
evidence_id=_required(verified.evidence_id,"evidence_id")
|
||||
evidence_json=_json({
|
||||
"evidence_id":evidence_id,
|
||||
"observed":dict(verified.observed or {}),
|
||||
"observed_at":verified.observed_at,
|
||||
"signed_envelope":dict(evidence) if isinstance(evidence,Mapping) else None,
|
||||
})
|
||||
existing=connection.execute("SELECT * FROM lineage_acceptances WHERE tenant_id=? AND acceptance_id=?",(tenant_id,acceptance_id)).fetchone()
|
||||
expected_row=(subject_id,mission_id,component_id,deployment_id,release_id,artifact_json,int(accepted),evidence_id,evidence_json)
|
||||
if existing:
|
||||
actual=tuple(existing[key] for key in ("subject_id","mission_id","component_id","deployment_id","release_id","artifact_json","accepted","evidence_id","evidence_json"))
|
||||
if actual != expected_row:
|
||||
raise LineageConflict("acceptance_id already has different binding")
|
||||
return self._acceptance_row(existing)
|
||||
connection.execute(
|
||||
"INSERT INTO lineage_acceptances VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(tenant_id,acceptance_id,subject_id,mission_id,component_id,deployment_id,release_id,artifact_json,int(accepted),evidence_id,evidence_json,_now()),
|
||||
)
|
||||
row=connection.execute("SELECT * FROM lineage_acceptances WHERE tenant_id=? AND acceptance_id=?",(tenant_id,acceptance_id)).fetchone()
|
||||
return self._acceptance_row(row)
|
||||
|
||||
def explain(self, tenant_id: str, subject_id: str, mission_id: str) -> dict[str, Any]:
|
||||
tenant_id,subject_id,mission_id=(_required(tenant_id,"tenant_id"),_required(subject_id,"subject_id"),_required(mission_id,"mission_id"))
|
||||
specs=(("tasks","lineage_tasks",self._task_row),("effects","lineage_effects",self._effect_row),
|
||||
("releases","lineage_releases",self._release_row),("deployments","lineage_deployments",self._deployment_row),
|
||||
("acceptances","lineage_acceptances",self._acceptance_row))
|
||||
result={"tenant_id":tenant_id,"subject_id":subject_id,"mission_id":mission_id}
|
||||
with self._connect() as connection:
|
||||
for key,table,converter in specs:
|
||||
rows=connection.execute(f"SELECT * FROM {table} WHERE tenant_id=? AND subject_id=? AND mission_id=? ORDER BY created_at",(tenant_id,subject_id,mission_id)).fetchall()
|
||||
result[key]=[converter(row) for row in rows]
|
||||
return result
|
||||
|
||||
def get_release(self, tenant_id: str, subject_id: str, release_id: str) -> dict[str, Any]:
|
||||
"""Resolve an exact tenant/subject release without disclosing other scopes."""
|
||||
|
||||
tenant_id, subject_id, release_id = (
|
||||
_required(tenant_id, "tenant_id"), _required(subject_id, "subject_id"),
|
||||
_required(release_id, "release_id"),
|
||||
)
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM lineage_releases WHERE tenant_id=? AND release_id=? AND subject_id=?",
|
||||
(tenant_id, release_id, subject_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise KeyError("release lineage not found")
|
||||
return self._release_row(row)
|
||||
|
||||
def get_release_for_component(self, tenant_id: str, component_id: str, release_id: str) -> dict[str, Any]:
|
||||
tenant_id,component_id,release_id=(_required(tenant_id,"tenant_id"),_required(component_id,"component_id"),_required(release_id,"release_id"))
|
||||
with self._connect() as connection:
|
||||
row=connection.execute(
|
||||
"SELECT * FROM lineage_releases WHERE tenant_id=? AND release_id=? AND component_id=?",
|
||||
(tenant_id,release_id,component_id),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise KeyError("release component lineage not found")
|
||||
return self._release_row(row)
|
||||
|
||||
@staticmethod
|
||||
def _common(row):
|
||||
return {key:row[key] for key in row.keys() if not key.endswith("_json")}
|
||||
|
||||
@classmethod
|
||||
def _task_row(cls,row):
|
||||
value=cls._common(row); value["metadata"]=json.loads(row["metadata_json"]); return value
|
||||
|
||||
@classmethod
|
||||
def _effect_row(cls,row):
|
||||
value=cls._common(row); value["metadata"]=json.loads(row["metadata_json"]); return value
|
||||
|
||||
@classmethod
|
||||
def _release_row(cls,row):
|
||||
value=cls._common(row); value["artifact"]=decode_artifact(row["artifact_json"]); value["metadata"]=json.loads(row["metadata_json"]); return value
|
||||
|
||||
@classmethod
|
||||
def _deployment_row(cls,row):
|
||||
value=cls._common(row); value["artifact"]=decode_artifact(row["artifact_json"]); value["metadata"]=json.loads(row["metadata_json"]); return value
|
||||
|
||||
@classmethod
|
||||
def _acceptance_row(cls,row):
|
||||
value=cls._common(row); value["artifact"]=decode_artifact(row["artifact_json"]); value["accepted"]=bool(row["accepted"]); value["evidence"]=json.loads(row["evidence_json"]); return value
|
||||
|
||||
|
||||
__all__ = [
|
||||
"LineageConflict", "LineageError", "LineageLedger", "LineageVerificationError",
|
||||
"VerifiedAcceptanceEvidence", "canonical_artifact", "decode_artifact",
|
||||
]
|
||||
@@ -0,0 +1,258 @@
|
||||
"""Declarative, non-executing operation registry and timeout safety rules."""
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import math
|
||||
import re
|
||||
from typing import Any, Iterable, Mapping, Sequence
|
||||
|
||||
|
||||
EFFECT_CLASSES = frozenset(
|
||||
{"READ_ONLY", "IDEMPOTENT", "CONDITIONAL", "RECONCILABLE", "IRREVERSIBLE"}
|
||||
)
|
||||
_FORBIDDEN_EXECUTION_FIELDS = frozenset({"command", "shell", "argv", "executable", "script"})
|
||||
_HANDLER_REF = re.compile(r"^[A-Za-z][A-Za-z0-9_.:-]{0,126}$")
|
||||
|
||||
|
||||
def _invalid(code: str, field: str = "") -> ValueError:
|
||||
return ValueError(f"{code}:{field}" if field else code)
|
||||
|
||||
|
||||
def _text(value: object, field: str) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise _invalid("field_invalid", field)
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _policy(value: object, field: str, allowed_modes: set[str]) -> dict[str, Any]:
|
||||
if not isinstance(value, Mapping):
|
||||
raise _invalid("policy_invalid", field)
|
||||
result = deepcopy(dict(value))
|
||||
mode = _text(result.get("mode"), f"{field}.mode").upper()
|
||||
if mode not in allowed_modes:
|
||||
raise _invalid("policy_mode_invalid", f"{field}.mode")
|
||||
result["mode"] = mode
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OperationDefinition:
|
||||
operation_id: str
|
||||
version: int | str
|
||||
effect_class: str
|
||||
handler_ref: str
|
||||
input_schema: str | Mapping[str, Any]
|
||||
required_permissions: tuple[str, ...]
|
||||
timeout_seconds: float
|
||||
readback: Mapping[str, Any]
|
||||
retry: Mapping[str, Any]
|
||||
reconcile: Mapping[str, Any]
|
||||
rollback: Mapping[str, Any]
|
||||
owner_boundary: Mapping[str, Any]
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Mapping[str, Any]) -> "OperationDefinition":
|
||||
if not isinstance(data, Mapping):
|
||||
raise _invalid("operation_definition_invalid")
|
||||
forbidden = sorted(_FORBIDDEN_EXECUTION_FIELDS.intersection(data))
|
||||
if forbidden:
|
||||
raise _invalid("arbitrary_execution_forbidden", forbidden[0])
|
||||
operation_id = _text(data.get("operation_id"), "operation_id")
|
||||
version = data.get("version")
|
||||
if (
|
||||
isinstance(version, bool)
|
||||
or not isinstance(version, (int, str))
|
||||
or (isinstance(version, int) and version < 1)
|
||||
or (isinstance(version, str) and not version.strip())
|
||||
):
|
||||
raise _invalid("field_invalid", "version")
|
||||
effect_class = _text(data.get("effect_class"), "effect_class").upper()
|
||||
if effect_class not in EFFECT_CLASSES:
|
||||
raise _invalid("effect_class_invalid", "effect_class")
|
||||
handler_ref = _text(data.get("handler_ref", data.get("handler")), "handler_ref")
|
||||
if not _HANDLER_REF.fullmatch(handler_ref):
|
||||
raise _invalid("handler_ref_invalid", "handler_ref")
|
||||
input_schema = data.get("input_schema")
|
||||
if not isinstance(input_schema, (str, Mapping)) or not input_schema:
|
||||
raise _invalid("field_invalid", "input_schema")
|
||||
input_schema = deepcopy(input_schema)
|
||||
permissions = data.get("required_permissions", data.get("permissions"))
|
||||
if not isinstance(permissions, Sequence) or isinstance(permissions, (str, bytes, bytearray)):
|
||||
raise _invalid("field_invalid", "required_permissions")
|
||||
required_permissions = tuple(
|
||||
_text(permission, f"required_permissions[{index}]")
|
||||
for index, permission in enumerate(permissions)
|
||||
)
|
||||
if len(set(required_permissions)) != len(required_permissions):
|
||||
raise _invalid("duplicate_permission", "required_permissions")
|
||||
timeout_seconds = data.get("timeout_seconds")
|
||||
if (
|
||||
isinstance(timeout_seconds, bool)
|
||||
or not isinstance(timeout_seconds, (int, float))
|
||||
or not math.isfinite(timeout_seconds)
|
||||
or timeout_seconds <= 0
|
||||
):
|
||||
raise _invalid("field_invalid", "timeout_seconds")
|
||||
|
||||
readback = _policy(data.get("readback"), "readback", {"NONE", "STATE", "EXACT"})
|
||||
if "required" in readback and not isinstance(readback["required"], bool):
|
||||
raise _invalid("field_invalid", "readback.required")
|
||||
retry = _policy(
|
||||
data.get("retry"),
|
||||
"retry",
|
||||
{"NEVER", "SAFE", "AFTER_READBACK", "RECONCILE_FIRST"},
|
||||
)
|
||||
attempts = retry.get("max_attempts", 1)
|
||||
if isinstance(attempts, bool) or not isinstance(attempts, int) or attempts < 1:
|
||||
raise _invalid("field_invalid", "retry.max_attempts")
|
||||
retry["max_attempts"] = attempts
|
||||
reconcile = _policy(
|
||||
data.get("reconcile"), "reconcile", {"NONE", "EXACT_READBACK", "CUSTOM", "REQUIRED"}
|
||||
)
|
||||
rollback = _policy(
|
||||
data.get("rollback"), "rollback", {"NONE", "OPERATION", "COMPENSATE"}
|
||||
)
|
||||
if rollback["mode"] == "OPERATION":
|
||||
_text(rollback.get("operation_id"), "rollback.operation_id")
|
||||
owner_boundary = _policy(
|
||||
data.get("owner_boundary"),
|
||||
"owner_boundary",
|
||||
{"NONE", "WITHIN_GRANT", "EXPLICIT_AUTHORIZATION", "CROSS_BOUNDARY_DENIED"},
|
||||
)
|
||||
|
||||
restart_operation = (
|
||||
"service.restart" in operation_id
|
||||
or "service-restart" in operation_id
|
||||
or handler_ref.endswith("service_restart")
|
||||
)
|
||||
if restart_operation and (
|
||||
readback["mode"] != "EXACT"
|
||||
or not readback.get("required", False)
|
||||
or reconcile["mode"] == "NONE"
|
||||
):
|
||||
raise _invalid("service_restart_exact_readback_required")
|
||||
|
||||
return cls(
|
||||
operation_id=operation_id,
|
||||
version=version,
|
||||
effect_class=effect_class,
|
||||
handler_ref=handler_ref,
|
||||
input_schema=input_schema,
|
||||
required_permissions=required_permissions,
|
||||
timeout_seconds=float(timeout_seconds),
|
||||
readback=readback,
|
||||
retry=retry,
|
||||
reconcile=reconcile,
|
||||
rollback=rollback,
|
||||
owner_boundary=owner_boundary,
|
||||
)
|
||||
|
||||
def timeout_action(self, outcome: str, attempts: int = 1) -> str:
|
||||
return operation_timeout_action(self, outcome=outcome, attempts=attempts)
|
||||
|
||||
@property
|
||||
def permissions(self) -> tuple[str, ...]:
|
||||
return self.required_permissions
|
||||
|
||||
|
||||
class OperationRegistry:
|
||||
"""Lookup-only registry; it deliberately has no execute or shell interface."""
|
||||
|
||||
def __init__(self, definitions: Iterable[OperationDefinition | Mapping[str, Any]]) -> None:
|
||||
self._definitions: dict[str, OperationDefinition] = {}
|
||||
for raw in definitions:
|
||||
definition = raw if isinstance(raw, OperationDefinition) else OperationDefinition.from_dict(raw)
|
||||
if definition.operation_id in self._definitions:
|
||||
raise _invalid("duplicate_operation_id", definition.operation_id)
|
||||
self._definitions[definition.operation_id] = definition
|
||||
|
||||
def get(self, operation_id: str) -> OperationDefinition:
|
||||
return self._definitions[_text(operation_id, "operation_id")]
|
||||
|
||||
@property
|
||||
def definitions(self) -> tuple[OperationDefinition, ...]:
|
||||
return tuple(self._definitions.values())
|
||||
|
||||
|
||||
def operation_timeout_action(
|
||||
definition: OperationDefinition,
|
||||
outcome: str = "AMBIGUOUS",
|
||||
attempts: int = 1,
|
||||
) -> str:
|
||||
"""Return ``RETRY``, ``RECONCILE`` or ``STOP`` after a timeout.
|
||||
|
||||
An unknown response from a mutating sink never authorizes a blind repeat.
|
||||
Exact readback/reconciliation must first establish whether the effect happened.
|
||||
"""
|
||||
|
||||
if not isinstance(definition, OperationDefinition):
|
||||
raise _invalid("operation_definition_invalid")
|
||||
normalized_outcome = _text(outcome, "outcome").upper()
|
||||
if isinstance(attempts, bool) or not isinstance(attempts, int) or attempts < 1:
|
||||
raise _invalid("field_invalid", "attempts")
|
||||
if definition.effect_class == "IRREVERSIBLE":
|
||||
return "STOP"
|
||||
if definition.effect_class != "READ_ONLY" and normalized_outcome in {
|
||||
"AMBIGUOUS",
|
||||
"UNKNOWN",
|
||||
"MAY_HAVE_SUCCEEDED",
|
||||
}:
|
||||
if definition.reconcile["mode"] != "NONE" or definition.readback.get("required", False):
|
||||
return "RECONCILE"
|
||||
return "STOP"
|
||||
retry_mode = definition.retry["mode"]
|
||||
if attempts >= definition.retry["max_attempts"] or retry_mode == "NEVER":
|
||||
return "STOP"
|
||||
if retry_mode in {"AFTER_READBACK", "RECONCILE_FIRST"} and normalized_outcome not in {
|
||||
"NOT_OBSERVED",
|
||||
"PROVEN_ABSENT",
|
||||
}:
|
||||
return "RECONCILE" if definition.reconcile["mode"] != "NONE" else "STOP"
|
||||
return "RETRY"
|
||||
|
||||
|
||||
def _timestamp(value: object) -> datetime | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
||||
return None
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def verify_service_restart(
|
||||
before: Mapping[str, Any], after: Mapping[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Verify a restart with instance identity and start-time evidence.
|
||||
|
||||
Merely observing an active unit proves current activity, not that the requested
|
||||
restart occurred.
|
||||
"""
|
||||
|
||||
if not isinstance(before, Mapping) or not isinstance(after, Mapping):
|
||||
return {"status": "UNKNOWN", "reasons": ["restart_observation_invalid"]}
|
||||
if not isinstance(after.get("active"), bool):
|
||||
return {"status": "UNKNOWN", "reasons": ["service_active_invalid"]}
|
||||
if after["active"] is False:
|
||||
return {"status": "DRIFT", "reasons": ["service_not_active"]}
|
||||
before_id = before.get("instance_id")
|
||||
after_id = after.get("instance_id")
|
||||
before_started = _timestamp(before.get("started_at"))
|
||||
after_started = _timestamp(after.get("started_at"))
|
||||
if (
|
||||
not isinstance(before_id, str)
|
||||
or not before_id
|
||||
or not isinstance(after_id, str)
|
||||
or not after_id
|
||||
or before_started is None
|
||||
or after_started is None
|
||||
):
|
||||
return {"status": "UNKNOWN", "reasons": ["restart_identity_missing"]}
|
||||
if before_id == after_id or after_started <= before_started:
|
||||
return {"status": "DRIFT", "reasons": ["restart_not_observed"]}
|
||||
return {"status": "VERIFIED", "reasons": []}
|
||||
@@ -0,0 +1,866 @@
|
||||
"""Bounded, task-only optimization proposals for Baseline 1.1.
|
||||
|
||||
This module deliberately has no configuration or policy write adapter. Its only
|
||||
effect boundary is ``submit_task``, supplied by the authenticated root service.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sqlite3
|
||||
import time
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
|
||||
ENVELOPE_SCHEMA = "aios.optimization-envelope.v1"
|
||||
PROPOSAL_SCHEMA = "aios.optimization-proposal.v1"
|
||||
TASK_REQUEST_SCHEMA = "aios.optimization-task-request.v1"
|
||||
|
||||
_IDENTIFIER = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}")
|
||||
_DIGEST = re.compile(r"sha256:[0-9a-f]{64}")
|
||||
|
||||
# An envelope may narrow these limits but cannot introduce another path or
|
||||
# expand a range. Every entry describes a low-risk operational tuning knob.
|
||||
_HARD_PARAMETER_LIMITS: dict[str, tuple[float, float, bool]] = {
|
||||
"retry.max_attempts": (1, 10, True),
|
||||
"retry.backoff_seconds": (0, 60, False),
|
||||
"backoff.initial_seconds": (0, 60, False),
|
||||
"backoff.maximum_seconds": (0.1, 600, False),
|
||||
"routing.candidate_weight_percent": (0, 25, False),
|
||||
"routing.weight_percent": (0, 25, False),
|
||||
"evidence_reuse.max_age_seconds": (0, 3600, True),
|
||||
"evidence_reuse.ttl_seconds": (0, 3600, True),
|
||||
"timeout.operation_seconds": (0.1, 300, False),
|
||||
"timeout.seconds": (0.1, 300, False),
|
||||
"poll.interval_seconds": (0.1, 300, False),
|
||||
"notification_dedup.window_seconds": (0, 3600, True),
|
||||
"test_selection.optional_sample_percent": (1, 100, False),
|
||||
"test_selection.optional_percentage": (1, 100, False),
|
||||
}
|
||||
_NON_PRODUCTION_ENVIRONMENTS = frozenset({"development", "test", "staging", "canary"})
|
||||
|
||||
|
||||
class OptimizationError(RuntimeError):
|
||||
"""A fail-closed optimization boundary error with a stable code."""
|
||||
|
||||
def __init__(self, code: str):
|
||||
self.code = code
|
||||
super().__init__(code)
|
||||
|
||||
|
||||
def canonical_json_bytes(value: Any) -> bytes:
|
||||
try:
|
||||
return json.dumps(
|
||||
value,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise OptimizationError("OPTIMIZATION_DOCUMENT_NOT_CANONICAL") from exc
|
||||
|
||||
|
||||
def _digest(value: Any) -> str:
|
||||
return "sha256:" + hashlib.sha256(canonical_json_bytes(value)).hexdigest()
|
||||
|
||||
|
||||
def _number(value: Any, code: str, *, minimum: float | None = None,
|
||||
maximum: float | None = None) -> int | float:
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)):
|
||||
raise OptimizationError(code)
|
||||
if not math.isfinite(float(value)):
|
||||
raise OptimizationError(code)
|
||||
if minimum is not None and value < minimum:
|
||||
raise OptimizationError(code)
|
||||
if maximum is not None and value > maximum:
|
||||
raise OptimizationError(code)
|
||||
return value
|
||||
|
||||
|
||||
def _identifier(value: Any, code: str) -> str:
|
||||
if not isinstance(value, str) or _IDENTIFIER.fullmatch(value) is None:
|
||||
raise OptimizationError(code)
|
||||
return value
|
||||
|
||||
|
||||
def _unique_identifiers(value: Any, code: str, *, maximum: int = 256) -> tuple[str, ...]:
|
||||
if not isinstance(value, (list, tuple)) or len(value) > maximum:
|
||||
raise OptimizationError(code)
|
||||
items = tuple(_identifier(item, code) for item in value)
|
||||
if len(set(items)) != len(items):
|
||||
raise OptimizationError(code)
|
||||
return items
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParameterRange:
|
||||
minimum: int | float
|
||||
maximum: int | float
|
||||
integer: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class QualityFloor:
|
||||
direction: str
|
||||
threshold: int | float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PromotionRules:
|
||||
objective_metric: str
|
||||
minimum_improvement: int | float
|
||||
mandatory_tests: tuple[str, ...]
|
||||
mandatory_quality_gates: tuple[str, ...]
|
||||
required_receipt_schema: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OptimizationEnvelope:
|
||||
schema: str
|
||||
envelope_id: str
|
||||
tenant_id: str
|
||||
allowed_parameters: Mapping[str, ParameterRange]
|
||||
allowed_components: tuple[str, ...]
|
||||
allowed_environments: tuple[str, ...]
|
||||
max_change_size: int
|
||||
max_compute_budget: int | float
|
||||
quality_floor: Mapping[str, QualityFloor]
|
||||
rollback_requirement: bool
|
||||
promotion_rules: PromotionRules
|
||||
source_config_digest: str
|
||||
key_id: str
|
||||
signature: str
|
||||
|
||||
@classmethod
|
||||
def from_dict(
|
||||
cls,
|
||||
data: Mapping[str, Any],
|
||||
*,
|
||||
verify_envelope: Callable[[str, bytes, str], bool] | None,
|
||||
) -> "OptimizationEnvelope":
|
||||
"""Parse a trusted-config envelope after authenticating its exact bytes.
|
||||
|
||||
``verify_envelope`` is intentionally mandatory. The caller cannot mark
|
||||
a document trusted with a boolean or construct an unrestricted envelope.
|
||||
"""
|
||||
if verify_envelope is None or not callable(verify_envelope):
|
||||
raise OptimizationError("OPTIMIZATION_ENVELOPE_VERIFIER_REQUIRED")
|
||||
if not isinstance(data, Mapping):
|
||||
raise OptimizationError("OPTIMIZATION_ENVELOPE_INVALID")
|
||||
required = {
|
||||
"schema", "envelope_id", "tenant_id", "allowed_parameters",
|
||||
"allowed_components", "allowed_environments", "max_change_size",
|
||||
"max_compute_budget", "quality_floor", "rollback_requirement",
|
||||
"promotion_rules", "source_config_digest", "key_id", "signature",
|
||||
}
|
||||
if set(data) != required:
|
||||
raise OptimizationError("OPTIMIZATION_ENVELOPE_FIELDS_INVALID")
|
||||
unsigned = dict(data)
|
||||
signature = unsigned.pop("signature")
|
||||
key_id = data.get("key_id")
|
||||
if not isinstance(signature, str) or not signature or not isinstance(key_id, str):
|
||||
raise OptimizationError("OPTIMIZATION_ENVELOPE_SIGNATURE_INVALID")
|
||||
try:
|
||||
verified = verify_envelope(key_id, canonical_json_bytes(unsigned), signature)
|
||||
except Exception as exc:
|
||||
raise OptimizationError("OPTIMIZATION_ENVELOPE_SIGNATURE_INVALID") from exc
|
||||
if verified is not True:
|
||||
raise OptimizationError("OPTIMIZATION_ENVELOPE_SIGNATURE_INVALID")
|
||||
|
||||
if data["schema"] != ENVELOPE_SCHEMA:
|
||||
raise OptimizationError("OPTIMIZATION_ENVELOPE_SCHEMA_INVALID")
|
||||
envelope_id = _identifier(data["envelope_id"], "OPTIMIZATION_ENVELOPE_ID_INVALID")
|
||||
tenant_id = _identifier(data["tenant_id"], "OPTIMIZATION_TENANT_INVALID")
|
||||
if not _DIGEST.fullmatch(str(data["source_config_digest"])):
|
||||
raise OptimizationError("OPTIMIZATION_SOURCE_CONFIG_DIGEST_INVALID")
|
||||
key_id = _identifier(key_id, "OPTIMIZATION_ENVELOPE_KEY_ID_INVALID")
|
||||
|
||||
raw_parameters = data["allowed_parameters"]
|
||||
if not isinstance(raw_parameters, Mapping) or not raw_parameters or len(raw_parameters) > 16:
|
||||
raise OptimizationError("OPTIMIZATION_ALLOWED_PARAMETERS_INVALID")
|
||||
parameters: dict[str, ParameterRange] = {}
|
||||
for path, raw_rule in raw_parameters.items():
|
||||
if path not in _HARD_PARAMETER_LIMITS:
|
||||
raise OptimizationError("OPTIMIZATION_PARAMETER_PATH_FORBIDDEN")
|
||||
if not isinstance(raw_rule, Mapping) or not {"minimum", "maximum"} <= set(raw_rule):
|
||||
raise OptimizationError("OPTIMIZATION_PARAMETER_RANGE_INVALID")
|
||||
if set(raw_rule) - {"minimum", "maximum", "integer"}:
|
||||
raise OptimizationError("OPTIMIZATION_PARAMETER_RANGE_INVALID")
|
||||
hard_min, hard_max, hard_integer = _HARD_PARAMETER_LIMITS[path]
|
||||
minimum = _number(raw_rule["minimum"], "OPTIMIZATION_PARAMETER_RANGE_INVALID")
|
||||
maximum = _number(raw_rule["maximum"], "OPTIMIZATION_PARAMETER_RANGE_INVALID")
|
||||
integer = raw_rule.get("integer", hard_integer)
|
||||
if type(integer) is not bool or minimum > maximum:
|
||||
raise OptimizationError("OPTIMIZATION_PARAMETER_RANGE_INVALID")
|
||||
if minimum < hard_min or maximum > hard_max or (hard_integer and not integer):
|
||||
raise OptimizationError("OPTIMIZATION_PARAMETER_RANGE_TOO_BROAD")
|
||||
if integer and (type(minimum) is not int or type(maximum) is not int):
|
||||
raise OptimizationError("OPTIMIZATION_PARAMETER_RANGE_INVALID")
|
||||
parameters[path] = ParameterRange(minimum, maximum, integer)
|
||||
|
||||
components = _unique_identifiers(
|
||||
data["allowed_components"], "OPTIMIZATION_COMPONENTS_INVALID", maximum=32
|
||||
)
|
||||
environments = _unique_identifiers(
|
||||
data["allowed_environments"], "OPTIMIZATION_ENVIRONMENTS_INVALID", maximum=4
|
||||
)
|
||||
if not components or not environments or not set(environments) <= _NON_PRODUCTION_ENVIRONMENTS:
|
||||
raise OptimizationError("OPTIMIZATION_ENVIRONMENTS_INVALID")
|
||||
max_change_size = _number(
|
||||
data["max_change_size"], "OPTIMIZATION_CHANGE_SIZE_INVALID", minimum=1, maximum=16
|
||||
)
|
||||
if type(max_change_size) is not int:
|
||||
raise OptimizationError("OPTIMIZATION_CHANGE_SIZE_INVALID")
|
||||
max_compute_budget = _number(
|
||||
data["max_compute_budget"],
|
||||
"OPTIMIZATION_COMPUTE_BUDGET_INVALID",
|
||||
minimum=0.001,
|
||||
maximum=10_000,
|
||||
)
|
||||
|
||||
raw_quality = data["quality_floor"]
|
||||
if not isinstance(raw_quality, Mapping) or not raw_quality or len(raw_quality) > 16:
|
||||
raise OptimizationError("OPTIMIZATION_QUALITY_FLOOR_INVALID")
|
||||
quality: dict[str, QualityFloor] = {}
|
||||
for metric, raw_rule in raw_quality.items():
|
||||
metric = _identifier(metric, "OPTIMIZATION_QUALITY_METRIC_INVALID")
|
||||
if not isinstance(raw_rule, Mapping) or set(raw_rule) != {"direction", "threshold"}:
|
||||
raise OptimizationError("OPTIMIZATION_QUALITY_FLOOR_INVALID")
|
||||
if raw_rule["direction"] not in {"higher", "lower"}:
|
||||
raise OptimizationError("OPTIMIZATION_QUALITY_DIRECTION_INVALID")
|
||||
threshold = _number(
|
||||
raw_rule["threshold"], "OPTIMIZATION_QUALITY_FLOOR_INVALID",
|
||||
minimum=-1e12, maximum=1e12,
|
||||
)
|
||||
quality[metric] = QualityFloor(raw_rule["direction"], threshold)
|
||||
|
||||
if type(data["rollback_requirement"]) is not bool:
|
||||
raise OptimizationError("OPTIMIZATION_ROLLBACK_REQUIREMENT_INVALID")
|
||||
raw_promotion = data["promotion_rules"]
|
||||
promotion_fields = {
|
||||
"objective_metric", "minimum_improvement", "mandatory_tests",
|
||||
"mandatory_quality_gates", "required_receipt_schema",
|
||||
}
|
||||
if not isinstance(raw_promotion, Mapping) or set(raw_promotion) != promotion_fields:
|
||||
raise OptimizationError("OPTIMIZATION_PROMOTION_RULES_INVALID")
|
||||
objective = _identifier(
|
||||
raw_promotion["objective_metric"], "OPTIMIZATION_OBJECTIVE_METRIC_INVALID"
|
||||
)
|
||||
if objective not in quality:
|
||||
raise OptimizationError("OPTIMIZATION_OBJECTIVE_METRIC_INVALID")
|
||||
minimum_improvement = _number(
|
||||
raw_promotion["minimum_improvement"],
|
||||
"OPTIMIZATION_MINIMUM_IMPROVEMENT_INVALID",
|
||||
minimum=0,
|
||||
maximum=1e12,
|
||||
)
|
||||
mandatory_tests = _unique_identifiers(
|
||||
raw_promotion["mandatory_tests"], "OPTIMIZATION_MANDATORY_TESTS_INVALID"
|
||||
)
|
||||
mandatory_gates = _unique_identifiers(
|
||||
raw_promotion["mandatory_quality_gates"], "OPTIMIZATION_MANDATORY_GATES_INVALID"
|
||||
)
|
||||
if not mandatory_tests or not mandatory_gates:
|
||||
raise OptimizationError("OPTIMIZATION_PROMOTION_RULES_INVALID")
|
||||
receipt_schema = _identifier(
|
||||
raw_promotion["required_receipt_schema"], "OPTIMIZATION_RECEIPT_SCHEMA_INVALID"
|
||||
)
|
||||
if not receipt_schema.startswith("aios."):
|
||||
raise OptimizationError("OPTIMIZATION_RECEIPT_SCHEMA_INVALID")
|
||||
promotion = PromotionRules(
|
||||
objective, minimum_improvement, mandatory_tests, mandatory_gates, receipt_schema
|
||||
)
|
||||
return cls(
|
||||
ENVELOPE_SCHEMA, envelope_id, tenant_id, parameters, components,
|
||||
environments, max_change_size, max_compute_budget, quality,
|
||||
data["rollback_requirement"], promotion, data["source_config_digest"],
|
||||
key_id, signature,
|
||||
)
|
||||
|
||||
|
||||
_PROPOSAL_REQUIRED = {
|
||||
"schema", "proposal_id", "envelope_id", "tenant_id", "component",
|
||||
"environment", "changes", "compute_budget", "tests", "quality_gates",
|
||||
"baseline_metrics", "rollback_plan",
|
||||
}
|
||||
|
||||
|
||||
def validate_proposal(envelope: OptimizationEnvelope, proposal: Mapping[str, Any]) -> dict:
|
||||
"""Return a canonical candidate constrained by ``envelope``."""
|
||||
if not isinstance(envelope, OptimizationEnvelope):
|
||||
raise OptimizationError("OPTIMIZATION_ENVELOPE_REQUIRED")
|
||||
if not isinstance(proposal, Mapping):
|
||||
raise OptimizationError("OPTIMIZATION_PROPOSAL_INVALID")
|
||||
allowed_fields = _PROPOSAL_REQUIRED | {"evidence_reuse"}
|
||||
if set(proposal) - allowed_fields or not _PROPOSAL_REQUIRED <= set(proposal):
|
||||
raise OptimizationError("OPTIMIZATION_PROPOSAL_FIELDS_INVALID")
|
||||
if proposal["schema"] != PROPOSAL_SCHEMA:
|
||||
raise OptimizationError("OPTIMIZATION_PROPOSAL_SCHEMA_INVALID")
|
||||
proposal_id = _identifier(proposal["proposal_id"], "OPTIMIZATION_PROPOSAL_ID_INVALID")
|
||||
if proposal["envelope_id"] != envelope.envelope_id:
|
||||
raise OptimizationError("OPTIMIZATION_ENVELOPE_BINDING_MISMATCH")
|
||||
if proposal["tenant_id"] != envelope.tenant_id:
|
||||
raise OptimizationError("OPTIMIZATION_PROPOSAL_TENANT_MISMATCH")
|
||||
component = _identifier(proposal["component"], "OPTIMIZATION_COMPONENT_INVALID")
|
||||
if component not in envelope.allowed_components:
|
||||
raise OptimizationError("OPTIMIZATION_COMPONENT_FORBIDDEN")
|
||||
environment = _identifier(proposal["environment"], "OPTIMIZATION_ENVIRONMENT_INVALID")
|
||||
if environment not in envelope.allowed_environments:
|
||||
raise OptimizationError("OPTIMIZATION_ENVIRONMENT_FORBIDDEN")
|
||||
|
||||
raw_changes = proposal["changes"]
|
||||
if not isinstance(raw_changes, list) or not raw_changes or len(raw_changes) > envelope.max_change_size:
|
||||
raise OptimizationError("OPTIMIZATION_CHANGE_SIZE_EXCEEDED")
|
||||
changes: list[dict[str, Any]] = []
|
||||
paths: set[str] = set()
|
||||
for change in raw_changes:
|
||||
if not isinstance(change, Mapping) or set(change) != {"path", "value"}:
|
||||
raise OptimizationError("OPTIMIZATION_CHANGE_INVALID")
|
||||
path = change["path"]
|
||||
if not isinstance(path, str) or path not in envelope.allowed_parameters:
|
||||
raise OptimizationError("OPTIMIZATION_PARAMETER_PATH_FORBIDDEN")
|
||||
if path in paths:
|
||||
raise OptimizationError("OPTIMIZATION_PARAMETER_DUPLICATE")
|
||||
paths.add(path)
|
||||
rule = envelope.allowed_parameters[path]
|
||||
value = _number(change["value"], "OPTIMIZATION_PARAMETER_VALUE_INVALID")
|
||||
if rule.integer and type(value) is not int:
|
||||
raise OptimizationError("OPTIMIZATION_PARAMETER_VALUE_INVALID")
|
||||
if value < rule.minimum or value > rule.maximum:
|
||||
raise OptimizationError("OPTIMIZATION_PARAMETER_OUT_OF_RANGE")
|
||||
changes.append({"path": path, "value": value})
|
||||
|
||||
compute_budget = _number(
|
||||
proposal["compute_budget"],
|
||||
"OPTIMIZATION_COMPUTE_BUDGET_EXCEEDED",
|
||||
minimum=0.001,
|
||||
maximum=float(envelope.max_compute_budget),
|
||||
)
|
||||
tests = _unique_identifiers(proposal["tests"], "OPTIMIZATION_TESTS_INVALID")
|
||||
gates = _unique_identifiers(proposal["quality_gates"], "OPTIMIZATION_QUALITY_GATES_INVALID")
|
||||
for mandatory in envelope.promotion_rules.mandatory_tests:
|
||||
if mandatory not in tests:
|
||||
raise OptimizationError("OPTIMIZATION_MANDATORY_TEST_MISSING")
|
||||
for mandatory in envelope.promotion_rules.mandatory_quality_gates:
|
||||
if mandatory not in gates:
|
||||
raise OptimizationError("OPTIMIZATION_MANDATORY_GATE_MISSING")
|
||||
|
||||
raw_baseline = proposal["baseline_metrics"]
|
||||
if not isinstance(raw_baseline, Mapping) or set(raw_baseline) != set(envelope.quality_floor):
|
||||
raise OptimizationError("OPTIMIZATION_BASELINE_METRICS_INVALID")
|
||||
baseline = {
|
||||
metric: _number(raw_baseline[metric], "OPTIMIZATION_BASELINE_METRICS_INVALID")
|
||||
for metric in sorted(raw_baseline)
|
||||
}
|
||||
|
||||
rollback_plan = proposal["rollback_plan"]
|
||||
if envelope.rollback_requirement:
|
||||
if (
|
||||
not isinstance(rollback_plan, Mapping)
|
||||
or set(rollback_plan) != {"strategy", "reference"}
|
||||
or rollback_plan.get("strategy") != "restore_previous_values"
|
||||
):
|
||||
raise OptimizationError("OPTIMIZATION_ROLLBACK_PLAN_REQUIRED")
|
||||
_identifier(rollback_plan.get("reference"), "OPTIMIZATION_ROLLBACK_PLAN_INVALID")
|
||||
normalized_rollback: dict[str, str] | None = {
|
||||
"strategy": "restore_previous_values",
|
||||
"reference": rollback_plan["reference"],
|
||||
}
|
||||
else:
|
||||
if rollback_plan is not None:
|
||||
raise OptimizationError("OPTIMIZATION_ROLLBACK_PLAN_INVALID")
|
||||
normalized_rollback = None
|
||||
|
||||
normalized_evidence = None
|
||||
if "evidence_reuse" in proposal:
|
||||
evidence = proposal["evidence_reuse"]
|
||||
if not isinstance(evidence, Mapping) or set(evidence) != {"scope", "evidence_ids"}:
|
||||
raise OptimizationError("OPTIMIZATION_EVIDENCE_REUSE_INVALID")
|
||||
expected_scope = {
|
||||
"tenant_id": envelope.tenant_id,
|
||||
"component": component,
|
||||
"environment": environment,
|
||||
}
|
||||
if evidence["scope"] != expected_scope:
|
||||
raise OptimizationError("OPTIMIZATION_EVIDENCE_SCOPE_MISMATCH")
|
||||
evidence_ids = _unique_identifiers(
|
||||
evidence["evidence_ids"], "OPTIMIZATION_EVIDENCE_REUSE_INVALID", maximum=128
|
||||
)
|
||||
normalized_evidence = {"scope": expected_scope, "evidence_ids": list(evidence_ids)}
|
||||
|
||||
normalized = {
|
||||
"schema": PROPOSAL_SCHEMA,
|
||||
"proposal_id": proposal_id,
|
||||
"envelope_id": envelope.envelope_id,
|
||||
"tenant_id": envelope.tenant_id,
|
||||
"component": component,
|
||||
"environment": environment,
|
||||
"changes": changes,
|
||||
"compute_budget": float(compute_budget),
|
||||
"tests": list(tests),
|
||||
"quality_gates": list(gates),
|
||||
"baseline_metrics": baseline,
|
||||
"rollback_plan": normalized_rollback,
|
||||
}
|
||||
if normalized_evidence is not None:
|
||||
normalized["evidence_reuse"] = normalized_evidence
|
||||
if len(canonical_json_bytes(normalized)) > 65_536:
|
||||
raise OptimizationError("OPTIMIZATION_PROPOSAL_TOO_LARGE")
|
||||
return normalized
|
||||
|
||||
|
||||
class OptimizationController:
|
||||
"""Durable proposal state machine whose only external effect is task admission."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
envelope: OptimizationEnvelope,
|
||||
submit_task: Callable[[Mapping[str, Any]], Mapping[str, Any]],
|
||||
*,
|
||||
verify_receipt: Callable[[Mapping[str, Any]], bool],
|
||||
):
|
||||
if not isinstance(envelope, OptimizationEnvelope):
|
||||
raise OptimizationError("OPTIMIZATION_ENVELOPE_REQUIRED")
|
||||
if not callable(submit_task):
|
||||
raise OptimizationError("OPTIMIZATION_TASK_SUBMITTER_REQUIRED")
|
||||
if not callable(verify_receipt):
|
||||
raise OptimizationError("OPTIMIZATION_RECEIPT_VERIFIER_REQUIRED")
|
||||
self.path = Path(path)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.envelope = envelope
|
||||
self.submit_task = submit_task
|
||||
self.verify_receipt = verify_receipt
|
||||
self._initialize()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self.path, timeout=30)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
return connection
|
||||
|
||||
def _initialize(self) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS optimization_proposals (
|
||||
proposal_id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
subject_id TEXT NOT NULL,
|
||||
grant_id TEXT NOT NULL,
|
||||
mission_id TEXT NOT NULL,
|
||||
envelope_id TEXT NOT NULL,
|
||||
proposal_digest TEXT NOT NULL,
|
||||
proposal_json TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
evaluation_task_id TEXT,
|
||||
promotion_task_id TEXT,
|
||||
baseline_json TEXT NOT NULL,
|
||||
candidate_json TEXT,
|
||||
effect_json TEXT,
|
||||
evaluation_json TEXT,
|
||||
receipt_json TEXT,
|
||||
error_code TEXT,
|
||||
created_at REAL NOT NULL,
|
||||
updated_at REAL NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
def _context(self, value: Mapping[str, Any]) -> dict[str, str]:
|
||||
if not isinstance(value, Mapping):
|
||||
raise OptimizationError("OPTIMIZATION_AUTHENTICATED_CONTEXT_REQUIRED")
|
||||
fields = ("tenant_id", "subject_id", "grant_id", "mission_id", "service_identity")
|
||||
try:
|
||||
context = {
|
||||
field: _identifier(value[field], "OPTIMIZATION_AUTHENTICATED_CONTEXT_INVALID")
|
||||
for field in fields
|
||||
}
|
||||
except KeyError as exc:
|
||||
raise OptimizationError("OPTIMIZATION_AUTHENTICATED_CONTEXT_REQUIRED") from exc
|
||||
if context["service_identity"] != "aios-root-service":
|
||||
raise OptimizationError("OPTIMIZATION_ROOT_SERVICE_REQUIRED")
|
||||
if context["tenant_id"] != self.envelope.tenant_id:
|
||||
raise OptimizationError("OPTIMIZATION_CONTEXT_TENANT_MISMATCH")
|
||||
return context
|
||||
|
||||
def _row(self, proposal_id: str) -> sqlite3.Row:
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM optimization_proposals WHERE proposal_id=?", (proposal_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise OptimizationError("OPTIMIZATION_PROPOSAL_NOT_FOUND")
|
||||
return row
|
||||
|
||||
def _authorized_row(self, proposal_id: str, context: Mapping[str, Any]) -> sqlite3.Row:
|
||||
checked = self._context(context)
|
||||
row = self._row(_identifier(proposal_id, "OPTIMIZATION_PROPOSAL_ID_INVALID"))
|
||||
if row["tenant_id"] != checked["tenant_id"]:
|
||||
raise OptimizationError("OPTIMIZATION_CONTEXT_TENANT_MISMATCH")
|
||||
if row["subject_id"] != checked["subject_id"]:
|
||||
raise OptimizationError("OPTIMIZATION_CONTEXT_SUBJECT_MISMATCH")
|
||||
if row["grant_id"] != checked["grant_id"]:
|
||||
raise OptimizationError("OPTIMIZATION_CONTEXT_GRANT_MISMATCH")
|
||||
if row["mission_id"] != checked["mission_id"]:
|
||||
raise OptimizationError("OPTIMIZATION_CONTEXT_MISSION_MISMATCH")
|
||||
return row
|
||||
|
||||
@staticmethod
|
||||
def _decode(value: str | None) -> Any:
|
||||
return None if value is None else json.loads(value)
|
||||
|
||||
def _record(self, row: sqlite3.Row) -> dict:
|
||||
proposal = self._decode(row["proposal_json"])
|
||||
result = dict(proposal)
|
||||
result.update(
|
||||
{
|
||||
"proposal_id": row["proposal_id"],
|
||||
"proposal_digest": row["proposal_digest"],
|
||||
"status": row["status"],
|
||||
"evaluation_task_id": row["evaluation_task_id"],
|
||||
"promotion_task_id": row["promotion_task_id"],
|
||||
"baseline_metrics": self._decode(row["baseline_json"]),
|
||||
"candidate_metrics": self._decode(row["candidate_json"]),
|
||||
"effect_comparison": self._decode(row["effect_json"]),
|
||||
"error_code": row["error_code"],
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
def get_proposal(self, proposal_id: str, *, authenticated_context: Mapping[str, Any]) -> dict:
|
||||
return self._record(self._authorized_row(proposal_id, authenticated_context))
|
||||
|
||||
def _persist_invalid(self, proposal: Mapping[str, Any], context: Mapping[str, str],
|
||||
error: OptimizationError) -> None:
|
||||
proposal_id = proposal.get("proposal_id")
|
||||
tenant_id = proposal.get("tenant_id")
|
||||
if (
|
||||
not isinstance(proposal_id, str)
|
||||
or _IDENTIFIER.fullmatch(proposal_id) is None
|
||||
or tenant_id != self.envelope.tenant_id
|
||||
):
|
||||
return
|
||||
try:
|
||||
encoded = canonical_json_bytes(proposal)
|
||||
except OptimizationError:
|
||||
return
|
||||
if len(encoded) > 65_536:
|
||||
return
|
||||
digest = "sha256:" + hashlib.sha256(encoded).hexdigest()
|
||||
baseline = proposal.get("baseline_metrics")
|
||||
if not isinstance(baseline, Mapping):
|
||||
baseline = {}
|
||||
now = time.time()
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO optimization_proposals
|
||||
(proposal_id,tenant_id,subject_id,grant_id,mission_id,envelope_id,
|
||||
proposal_digest,proposal_json,status,baseline_json,error_code,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
""",
|
||||
(
|
||||
proposal_id, self.envelope.tenant_id, context["subject_id"],
|
||||
context["grant_id"], context["mission_id"], self.envelope.envelope_id,
|
||||
digest, encoded.decode("utf-8"), "STOPPED_INVALID",
|
||||
canonical_json_bytes(baseline).decode("utf-8"), error.code, now, now,
|
||||
),
|
||||
)
|
||||
|
||||
def propose(self, proposal: Mapping[str, Any], *, authenticated_context: Mapping[str, Any]) -> dict:
|
||||
context = self._context(authenticated_context)
|
||||
try:
|
||||
normalized = validate_proposal(self.envelope, proposal)
|
||||
except OptimizationError as exc:
|
||||
if isinstance(proposal, Mapping):
|
||||
self._persist_invalid(proposal, context, exc)
|
||||
raise
|
||||
proposal_digest = _digest(normalized)
|
||||
proposal_id = normalized["proposal_id"]
|
||||
now = time.time()
|
||||
encoded = canonical_json_bytes(normalized).decode("utf-8")
|
||||
with self._connect() as connection:
|
||||
existing = connection.execute(
|
||||
"SELECT * FROM optimization_proposals WHERE proposal_id=?", (proposal_id,)
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
if existing["proposal_digest"] != proposal_digest:
|
||||
raise OptimizationError("OPTIMIZATION_PROPOSAL_ID_CONFLICT")
|
||||
if (
|
||||
existing["tenant_id"] != context["tenant_id"]
|
||||
or existing["subject_id"] != context["subject_id"]
|
||||
or existing["grant_id"] != context["grant_id"]
|
||||
or existing["mission_id"] != context["mission_id"]
|
||||
):
|
||||
raise OptimizationError("OPTIMIZATION_CONTEXT_BINDING_MISMATCH")
|
||||
if existing["status"] != "PROPOSAL_RECORDED":
|
||||
return self._record(existing)
|
||||
else:
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO optimization_proposals
|
||||
(proposal_id,tenant_id,subject_id,grant_id,mission_id,envelope_id,
|
||||
proposal_digest,proposal_json,status,baseline_json,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
""",
|
||||
(
|
||||
proposal_id, context["tenant_id"], context["subject_id"],
|
||||
context["grant_id"], context["mission_id"], self.envelope.envelope_id,
|
||||
proposal_digest, encoded, "PROPOSAL_RECORDED",
|
||||
canonical_json_bytes(normalized["baseline_metrics"]).decode("utf-8"),
|
||||
now, now,
|
||||
),
|
||||
)
|
||||
|
||||
requested_task_id = f"optimization-evaluate-{proposal_id}"
|
||||
task_request = {
|
||||
"schema": TASK_REQUEST_SCHEMA,
|
||||
"task_id": requested_task_id,
|
||||
"action": "evaluate",
|
||||
"tenant_id": context["tenant_id"],
|
||||
"subject_id": context["subject_id"],
|
||||
"grant_id": context["grant_id"],
|
||||
"mission_id": context["mission_id"],
|
||||
"proposal_id": proposal_id,
|
||||
"proposal_digest": proposal_digest,
|
||||
"component": normalized["component"],
|
||||
"environment": normalized["environment"],
|
||||
"compute_budget": normalized["compute_budget"],
|
||||
}
|
||||
try:
|
||||
receipt = self.submit_task(task_request)
|
||||
if not isinstance(receipt, Mapping):
|
||||
raise OptimizationError("OPTIMIZATION_TASK_SUBMISSION_INVALID")
|
||||
task_id = _identifier(
|
||||
receipt.get("task_id"), "OPTIMIZATION_TASK_SUBMISSION_INVALID"
|
||||
)
|
||||
except Exception as exc:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE optimization_proposals SET status=?,error_code=?,updated_at=? "
|
||||
"WHERE proposal_id=? AND status='PROPOSAL_RECORDED'",
|
||||
("STOPPED_SUBMISSION_FAILED", "OPTIMIZATION_TASK_SUBMISSION_FAILED", time.time(), proposal_id),
|
||||
)
|
||||
if isinstance(exc, OptimizationError):
|
||||
raise
|
||||
raise OptimizationError("OPTIMIZATION_TASK_SUBMISSION_FAILED") from exc
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE optimization_proposals SET status=?,evaluation_task_id=?,updated_at=? "
|
||||
"WHERE proposal_id=? AND status='PROPOSAL_RECORDED'",
|
||||
("EVALUATION_SUBMITTED", task_id, time.time(), proposal_id),
|
||||
)
|
||||
return self.get_proposal(proposal_id, authenticated_context=context)
|
||||
|
||||
def _trusted_receipt(self, receipt: Any) -> Mapping[str, Any]:
|
||||
if not isinstance(receipt, Mapping):
|
||||
raise OptimizationError("OPTIMIZATION_RECEIPT_INVALID")
|
||||
try:
|
||||
trusted = self.verify_receipt(receipt)
|
||||
except Exception as exc:
|
||||
raise OptimizationError("OPTIMIZATION_RECEIPT_UNTRUSTED") from exc
|
||||
if trusted is not True:
|
||||
raise OptimizationError("OPTIMIZATION_RECEIPT_UNTRUSTED")
|
||||
return receipt
|
||||
|
||||
def record_evaluation(
|
||||
self,
|
||||
proposal_id: str,
|
||||
evaluation: Mapping[str, Any],
|
||||
*,
|
||||
authenticated_context: Mapping[str, Any],
|
||||
) -> dict:
|
||||
row = self._authorized_row(proposal_id, authenticated_context)
|
||||
if row["status"] != "EVALUATION_SUBMITTED":
|
||||
raise OptimizationError("OPTIMIZATION_EVALUATION_STATE_INVALID")
|
||||
if not isinstance(evaluation, Mapping) or set(evaluation) != {
|
||||
"candidate_metrics", "compute_used", "tests_passed",
|
||||
"quality_gates_passed", "receipt",
|
||||
}:
|
||||
raise OptimizationError("OPTIMIZATION_EVALUATION_INVALID")
|
||||
result_body = {key: evaluation[key] for key in evaluation if key != "receipt"}
|
||||
receipt = self._trusted_receipt(evaluation["receipt"])
|
||||
required_receipt = {
|
||||
"schema", "task_id", "proposal_id", "proposal_digest", "action",
|
||||
"success", "result_digest", "key_id", "signature",
|
||||
}
|
||||
if set(receipt) != required_receipt:
|
||||
raise OptimizationError("OPTIMIZATION_RECEIPT_INVALID")
|
||||
if receipt["schema"] != self.envelope.promotion_rules.required_receipt_schema:
|
||||
raise OptimizationError("OPTIMIZATION_RECEIPT_SCHEMA_MISMATCH")
|
||||
if receipt["task_id"] != row["evaluation_task_id"]:
|
||||
raise OptimizationError("OPTIMIZATION_RECEIPT_TASK_MISMATCH")
|
||||
if receipt["proposal_id"] != row["proposal_id"]:
|
||||
raise OptimizationError("OPTIMIZATION_RECEIPT_PROPOSAL_MISMATCH")
|
||||
if receipt["proposal_digest"] != row["proposal_digest"]:
|
||||
raise OptimizationError("OPTIMIZATION_RECEIPT_PROPOSAL_DIGEST_MISMATCH")
|
||||
if receipt["action"] != "evaluate" or receipt["success"] is not True:
|
||||
raise OptimizationError("OPTIMIZATION_RECEIPT_RESULT_INVALID")
|
||||
if receipt["result_digest"] != _digest(result_body):
|
||||
raise OptimizationError("OPTIMIZATION_RECEIPT_RESULT_DIGEST_MISMATCH")
|
||||
|
||||
proposal = self._decode(row["proposal_json"])
|
||||
compute_used = _number(
|
||||
evaluation["compute_used"], "OPTIMIZATION_EVALUATION_BUDGET_EXCEEDED", minimum=0
|
||||
)
|
||||
if compute_used > proposal["compute_budget"] or compute_used > self.envelope.max_compute_budget:
|
||||
status = "STOPPED_BUDGET_EXCEEDED"
|
||||
error_code = "OPTIMIZATION_EVALUATION_BUDGET_EXCEEDED"
|
||||
return self._store_evaluation(row, evaluation, receipt, {}, status, error_code)
|
||||
tests_passed = _unique_identifiers(
|
||||
evaluation["tests_passed"], "OPTIMIZATION_EVALUATION_TESTS_INVALID"
|
||||
)
|
||||
gates_passed = _unique_identifiers(
|
||||
evaluation["quality_gates_passed"], "OPTIMIZATION_EVALUATION_GATES_INVALID"
|
||||
)
|
||||
if not set(self.envelope.promotion_rules.mandatory_tests) <= set(tests_passed):
|
||||
return self._store_evaluation(
|
||||
row, evaluation, receipt, {}, "STOPPED_MANDATORY_TEST_FAILED",
|
||||
"OPTIMIZATION_MANDATORY_TEST_FAILED",
|
||||
)
|
||||
if not set(self.envelope.promotion_rules.mandatory_quality_gates) <= set(gates_passed):
|
||||
return self._store_evaluation(
|
||||
row, evaluation, receipt, {}, "STOPPED_MANDATORY_GATE_FAILED",
|
||||
"OPTIMIZATION_MANDATORY_GATE_FAILED",
|
||||
)
|
||||
raw_candidate = evaluation["candidate_metrics"]
|
||||
if not isinstance(raw_candidate, Mapping) or set(raw_candidate) != set(self.envelope.quality_floor):
|
||||
raise OptimizationError("OPTIMIZATION_CANDIDATE_METRICS_INVALID")
|
||||
candidate = {
|
||||
metric: _number(raw_candidate[metric], "OPTIMIZATION_CANDIDATE_METRICS_INVALID")
|
||||
for metric in sorted(raw_candidate)
|
||||
}
|
||||
baseline = self._decode(row["baseline_json"])
|
||||
effect: dict[str, float] = {}
|
||||
quality_regression = False
|
||||
for metric in sorted(self.envelope.quality_floor):
|
||||
rule = self.envelope.quality_floor[metric]
|
||||
if rule.direction == "higher":
|
||||
delta = float(candidate[metric]) - float(baseline[metric])
|
||||
below_floor = candidate[metric] < rule.threshold
|
||||
else:
|
||||
delta = float(baseline[metric]) - float(candidate[metric])
|
||||
below_floor = candidate[metric] > rule.threshold
|
||||
effect[metric] = round(delta, 12)
|
||||
quality_regression = quality_regression or below_floor or delta < -1e-12
|
||||
objective = self.envelope.promotion_rules.objective_metric
|
||||
if quality_regression:
|
||||
status, error_code = "STOPPED_QUALITY_REGRESSION", "OPTIMIZATION_QUALITY_REGRESSION"
|
||||
elif effect[objective] + 1e-12 < self.envelope.promotion_rules.minimum_improvement:
|
||||
status, error_code = "STOPPED_NO_IMPROVEMENT", "OPTIMIZATION_NO_IMPROVEMENT"
|
||||
else:
|
||||
status, error_code = "ELIGIBLE", None
|
||||
return self._store_evaluation(row, evaluation, receipt, effect, status, error_code, candidate)
|
||||
|
||||
def _store_evaluation(
|
||||
self,
|
||||
row: sqlite3.Row,
|
||||
evaluation: Mapping[str, Any],
|
||||
receipt: Mapping[str, Any],
|
||||
effect: Mapping[str, Any],
|
||||
status: str,
|
||||
error_code: str | None,
|
||||
candidate: Mapping[str, Any] | None = None,
|
||||
) -> dict:
|
||||
candidate = dict(candidate or {})
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
UPDATE optimization_proposals
|
||||
SET status=?,candidate_json=?,effect_json=?,evaluation_json=?,receipt_json=?,
|
||||
error_code=?,updated_at=?
|
||||
WHERE proposal_id=?
|
||||
""",
|
||||
(
|
||||
status, canonical_json_bytes(candidate).decode("utf-8"),
|
||||
canonical_json_bytes(effect).decode("utf-8"),
|
||||
canonical_json_bytes({k: evaluation[k] for k in evaluation if k != "receipt"}).decode("utf-8"),
|
||||
canonical_json_bytes(receipt).decode("utf-8"), error_code, time.time(),
|
||||
row["proposal_id"],
|
||||
),
|
||||
)
|
||||
return self._record(self._row(row["proposal_id"]))
|
||||
|
||||
def promote(
|
||||
self,
|
||||
proposal_id: str,
|
||||
promotion: Mapping[str, Any],
|
||||
*,
|
||||
authenticated_context: Mapping[str, Any],
|
||||
) -> dict:
|
||||
context = self._context(authenticated_context)
|
||||
row = self._authorized_row(proposal_id, context)
|
||||
if not isinstance(promotion, Mapping) or set(promotion) != {
|
||||
"evaluation_task_id", "proposal_digest"
|
||||
}:
|
||||
raise OptimizationError("OPTIMIZATION_PROMOTION_REQUEST_INVALID")
|
||||
if promotion["evaluation_task_id"] != row["evaluation_task_id"]:
|
||||
raise OptimizationError("OPTIMIZATION_PROMOTION_TASK_MISMATCH")
|
||||
if promotion["proposal_digest"] != row["proposal_digest"]:
|
||||
raise OptimizationError("OPTIMIZATION_PROMOTION_PROPOSAL_MISMATCH")
|
||||
if row["status"] == "PROMOTION_SUBMITTED":
|
||||
return self._record(row)
|
||||
if row["status"] != "ELIGIBLE":
|
||||
raise OptimizationError("OPTIMIZATION_NOT_PROMOTABLE")
|
||||
stored_receipt = self._trusted_receipt(self._decode(row["receipt_json"]))
|
||||
if (
|
||||
stored_receipt.get("task_id") != row["evaluation_task_id"]
|
||||
or stored_receipt.get("proposal_digest") != row["proposal_digest"]
|
||||
or stored_receipt.get("action") != "evaluate"
|
||||
or stored_receipt.get("success") is not True
|
||||
):
|
||||
raise OptimizationError("OPTIMIZATION_PROMOTION_RECEIPT_BINDING_INVALID")
|
||||
evaluation = self._decode(row["evaluation_json"])
|
||||
if stored_receipt.get("result_digest") != _digest(evaluation):
|
||||
raise OptimizationError("OPTIMIZATION_PROMOTION_QUALITY_RESULT_INVALID")
|
||||
|
||||
task_request = {
|
||||
"schema": TASK_REQUEST_SCHEMA,
|
||||
"task_id": f"optimization-promote-{proposal_id}",
|
||||
"action": "promote",
|
||||
"tenant_id": context["tenant_id"],
|
||||
"subject_id": context["subject_id"],
|
||||
"grant_id": context["grant_id"],
|
||||
"mission_id": context["mission_id"],
|
||||
"proposal_id": row["proposal_id"],
|
||||
"proposal_digest": row["proposal_digest"],
|
||||
"evaluation_task_id": row["evaluation_task_id"],
|
||||
"evaluation_result_digest": stored_receipt["result_digest"],
|
||||
}
|
||||
try:
|
||||
submission = self.submit_task(task_request)
|
||||
if not isinstance(submission, Mapping):
|
||||
raise OptimizationError("OPTIMIZATION_TASK_SUBMISSION_INVALID")
|
||||
task_id = _identifier(
|
||||
submission.get("task_id"), "OPTIMIZATION_TASK_SUBMISSION_INVALID"
|
||||
)
|
||||
except Exception as exc:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE optimization_proposals SET status=?,error_code=?,updated_at=? WHERE proposal_id=?",
|
||||
("STOPPED_PROMOTION_SUBMISSION_FAILED", "OPTIMIZATION_TASK_SUBMISSION_FAILED", time.time(), proposal_id),
|
||||
)
|
||||
if isinstance(exc, OptimizationError):
|
||||
raise
|
||||
raise OptimizationError("OPTIMIZATION_TASK_SUBMISSION_FAILED") from exc
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE optimization_proposals SET status=?,promotion_task_id=?,error_code=NULL,updated_at=? "
|
||||
"WHERE proposal_id=? AND status='ELIGIBLE'",
|
||||
("PROMOTION_SUBMITTED", task_id, time.time(), proposal_id),
|
||||
)
|
||||
return self.get_proposal(proposal_id, authenticated_context=context)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"OptimizationController", "OptimizationEnvelope", "OptimizationError",
|
||||
"canonical_json_bytes", "validate_proposal",
|
||||
]
|
||||
@@ -0,0 +1,742 @@
|
||||
"""Release-owned deployment targets and a receipt-gated persistent saga.
|
||||
|
||||
The module persists intent before invoking a governed operation adapter. It
|
||||
does not contain a shell runner, production signer, or success simulator. A
|
||||
configured verifier must translate an adapter/sink receipt into a typed,
|
||||
operation-bound :class:`VerifiedReceipt` before a stage can advance.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
from typing import Any, Callable, Mapping, Sequence
|
||||
|
||||
from .lineage import (
|
||||
LineageLedger,
|
||||
VerifiedAcceptanceEvidence,
|
||||
canonical_artifact,
|
||||
decode_artifact,
|
||||
)
|
||||
|
||||
|
||||
class ReleaseError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class ReleaseConflict(ReleaseError):
|
||||
pass
|
||||
|
||||
|
||||
class ReleaseVerificationError(ReleaseError):
|
||||
pass
|
||||
|
||||
|
||||
class ReleaseAuthorizationError(ReleaseError):
|
||||
pass
|
||||
|
||||
|
||||
class ReleaseReconciliationRequired(ReleaseError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VerifiedReceipt:
|
||||
"""Trusted verifier output, fully bound to one persisted stage effect."""
|
||||
|
||||
receipt_id: str
|
||||
tenant_id: str
|
||||
subject_id: str
|
||||
component_id: str
|
||||
task_id: str
|
||||
source_task_id: str
|
||||
source_effect_id: str
|
||||
grant_id: str
|
||||
job_id: str
|
||||
effect_id: str
|
||||
operation_id: str
|
||||
deployment_id: str
|
||||
release_id: str
|
||||
artifact: Any
|
||||
observed: Mapping[str, Any]
|
||||
outcome: str = "SUCCEEDED"
|
||||
observed_at: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EnvironmentDeploymentTarget:
|
||||
tenant_id: str
|
||||
component_id: str
|
||||
environment: str
|
||||
release_id: str
|
||||
artifact: Any
|
||||
revision: int
|
||||
stage: str
|
||||
source_revision: str | None
|
||||
observed_main_revision: str | None
|
||||
previous_release_id: str | None
|
||||
previous_artifact: Any | None
|
||||
rollback_release_id: str | None
|
||||
rollback_artifact: Any | None
|
||||
rollback_compatible: bool
|
||||
updated_by_subject_id: str
|
||||
updated_at: str
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _required(value: Any, name: str) -> str:
|
||||
text = str(value or "")
|
||||
if not text or len(text) > 512 or any(ord(char) < 32 for char in text):
|
||||
raise ValueError(f"invalid {name}")
|
||||
return text
|
||||
|
||||
|
||||
def _json(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
||||
|
||||
|
||||
def _recipe_digest(stages: Sequence[Mapping[str, Any]]) -> str:
|
||||
return hashlib.sha256(_json(list(stages)).encode()).hexdigest()
|
||||
|
||||
|
||||
class ReleaseLedger:
|
||||
"""The release domain authority for dynamic targets and deployment sagas."""
|
||||
|
||||
TARGET_STAGES = {"STAGED", "ACTIVE", "ROLLBACK_STAGED"}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
path: str | Path,
|
||||
*,
|
||||
lineage: LineageLedger,
|
||||
receipt_verifier: Callable[[Any, Mapping[str, Any]], VerifiedReceipt],
|
||||
target_authorizer: Callable[[str,str,str,str,str], bool],
|
||||
):
|
||||
if not isinstance(lineage, LineageLedger):
|
||||
raise ValueError("lineage ledger is required")
|
||||
if not callable(receipt_verifier):
|
||||
raise ValueError("receipt_verifier is required")
|
||||
if not callable(target_authorizer):
|
||||
raise ValueError("target_authorizer is required")
|
||||
self.path = str(path)
|
||||
self.lineage = lineage
|
||||
self.receipt_verifier = receipt_verifier
|
||||
self.target_authorizer = target_authorizer
|
||||
Path(self.path).parent.mkdir(parents=True, exist_ok=True)
|
||||
self._initialize()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self.path, timeout=30)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
connection.execute("PRAGMA busy_timeout=30000")
|
||||
return connection
|
||||
|
||||
def _initialize(self) -> None:
|
||||
with self._connect() as connection:
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
tables={row[0] for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
||||
if "release_targets" in tables and "release_schema_meta" not in tables:
|
||||
raise ReleaseConflict("unversioned candidate release schema requires explicit migration")
|
||||
if "release_schema_meta" in tables:
|
||||
version=connection.execute("SELECT version FROM release_schema_meta WHERE domain='release'").fetchone()
|
||||
if version is None or version[0] != 2:
|
||||
raise ReleaseConflict("unsupported release schema version")
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS release_schema_meta (
|
||||
domain TEXT PRIMARY KEY,
|
||||
version INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS release_recipes (
|
||||
recipe_id TEXT PRIMARY KEY,
|
||||
recipe_digest TEXT NOT NULL,
|
||||
stages_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS release_targets (
|
||||
tenant_id TEXT NOT NULL,
|
||||
component_id TEXT NOT NULL,
|
||||
environment TEXT NOT NULL,
|
||||
release_id TEXT NOT NULL,
|
||||
artifact_json TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL,
|
||||
stage TEXT NOT NULL,
|
||||
source_revision TEXT,
|
||||
observed_main_revision TEXT,
|
||||
previous_release_id TEXT,
|
||||
previous_artifact_json TEXT,
|
||||
rollback_release_id TEXT,
|
||||
rollback_artifact_json TEXT,
|
||||
rollback_compatible INTEGER NOT NULL CHECK (rollback_compatible IN (0,1)),
|
||||
updated_by_subject_id TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (tenant_id, component_id, environment)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS release_sagas (
|
||||
tenant_id TEXT NOT NULL,
|
||||
saga_id TEXT NOT NULL,
|
||||
subject_id TEXT NOT NULL,
|
||||
mission_id TEXT NOT NULL,
|
||||
component_id TEXT NOT NULL,
|
||||
source_task_id TEXT NOT NULL,
|
||||
source_effect_id TEXT NOT NULL,
|
||||
grant_id TEXT NOT NULL,
|
||||
release_id TEXT NOT NULL,
|
||||
deployment_id TEXT NOT NULL,
|
||||
environment TEXT NOT NULL,
|
||||
artifact_json TEXT NOT NULL,
|
||||
recipe_id TEXT NOT NULL,
|
||||
recipe_digest TEXT NOT NULL,
|
||||
target_revision INTEGER NOT NULL,
|
||||
stage_index INTEGER NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
revision INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (tenant_id, saga_id),
|
||||
UNIQUE (tenant_id, deployment_id),
|
||||
FOREIGN KEY (recipe_id) REFERENCES release_recipes(recipe_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS release_effect_intents (
|
||||
tenant_id TEXT NOT NULL,
|
||||
saga_id TEXT NOT NULL,
|
||||
stage_index INTEGER NOT NULL,
|
||||
stage_id TEXT NOT NULL,
|
||||
operation_id TEXT NOT NULL,
|
||||
effect_id TEXT NOT NULL,
|
||||
job_id TEXT,
|
||||
state TEXT NOT NULL,
|
||||
receipt_id TEXT,
|
||||
receipt_json TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (tenant_id, saga_id, stage_index),
|
||||
UNIQUE (tenant_id, effect_id),
|
||||
FOREIGN KEY (tenant_id, saga_id)
|
||||
REFERENCES release_sagas(tenant_id, saga_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS release_deployment_facts (
|
||||
tenant_id TEXT NOT NULL,
|
||||
fact_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
subject_id TEXT NOT NULL,
|
||||
mission_id TEXT NOT NULL,
|
||||
component_id TEXT NOT NULL,
|
||||
task_id TEXT NOT NULL,
|
||||
grant_id TEXT NOT NULL,
|
||||
job_id TEXT NOT NULL,
|
||||
deployment_id TEXT NOT NULL,
|
||||
release_id TEXT NOT NULL,
|
||||
artifact_json TEXT NOT NULL,
|
||||
saga_id TEXT NOT NULL,
|
||||
stage_id TEXT NOT NULL,
|
||||
operation_id TEXT NOT NULL,
|
||||
effect_id TEXT NOT NULL,
|
||||
receipt_id TEXT NOT NULL,
|
||||
observed_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE (tenant_id, effect_id, receipt_id),
|
||||
FOREIGN KEY (tenant_id, saga_id)
|
||||
REFERENCES release_sagas(tenant_id, saga_id)
|
||||
);
|
||||
"""
|
||||
)
|
||||
connection.execute("INSERT OR IGNORE INTO release_schema_meta(domain,version) VALUES('release',2)")
|
||||
|
||||
def close(self) -> None:
|
||||
"""Compatibility hook; connections are transaction-scoped."""
|
||||
|
||||
def _authorize(self, tenant_id: str, subject_id: str, component_id: str, environment: str, action: str) -> None:
|
||||
try:
|
||||
authorized=self.target_authorizer(tenant_id,subject_id,component_id,environment,action)
|
||||
except Exception as exc:
|
||||
raise ReleaseAuthorizationError("deployment target authorization unavailable") from exc
|
||||
if authorized is not True:
|
||||
raise ReleaseAuthorizationError("deployment target authorization denied")
|
||||
|
||||
def register_recipe(self, recipe_id: str, stages: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
|
||||
recipe_id = _required(recipe_id, "recipe_id")
|
||||
if not isinstance(stages, Sequence) or isinstance(stages, (str, bytes)) or not stages:
|
||||
raise ValueError("recipe stages must be a non-empty sequence")
|
||||
normalized = []
|
||||
stage_ids = set()
|
||||
for item in stages:
|
||||
if not isinstance(item, Mapping) or set(item) - {"stage_id", "operation_id", "parameters"}:
|
||||
raise ValueError("invalid recipe stage")
|
||||
stage_id = _required(item.get("stage_id"), "stage_id")
|
||||
operation_id = _required(item.get("operation_id"), "operation_id")
|
||||
if stage_id in stage_ids:
|
||||
raise ValueError("duplicate recipe stage_id")
|
||||
stage_ids.add(stage_id)
|
||||
parameters = item.get("parameters", {})
|
||||
if not isinstance(parameters, Mapping):
|
||||
raise ValueError("stage parameters must be an object")
|
||||
normalized.append({"stage_id": stage_id, "operation_id": operation_id, "parameters": dict(parameters)})
|
||||
stages_json = _json(normalized)
|
||||
digest = _recipe_digest(normalized)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
existing = connection.execute("SELECT * FROM release_recipes WHERE recipe_id=?", (recipe_id,)).fetchone()
|
||||
if existing:
|
||||
if existing["recipe_digest"] != digest:
|
||||
raise ReleaseConflict("recipe_id already has different immutable stages")
|
||||
else:
|
||||
connection.execute("INSERT INTO release_recipes VALUES(?,?,?,?)", (recipe_id, digest, stages_json, _now()))
|
||||
return {"recipe_id": recipe_id, "recipe_digest": digest, "stages": normalized}
|
||||
|
||||
def set_target(
|
||||
self,
|
||||
tenant_id: str,
|
||||
subject_id: str,
|
||||
component_id: str,
|
||||
environment: str,
|
||||
release_id: str,
|
||||
artifact: Any,
|
||||
*,
|
||||
expected_revision: int | None,
|
||||
stage: str = "STAGED",
|
||||
source_revision: str | None = None,
|
||||
observed_main_revision: str | None = None,
|
||||
rollback_release_id: str | None = None,
|
||||
rollback_artifact: Any | None = None,
|
||||
rollback_compatible: bool = False,
|
||||
) -> EnvironmentDeploymentTarget:
|
||||
tenant_id, subject_id, component_id, environment, release_id = map(
|
||||
lambda pair: _required(pair[0], pair[1]),
|
||||
((tenant_id,"tenant_id"),(subject_id,"subject_id"),(component_id,"component_id"),(environment,"environment"),(release_id,"release_id")),
|
||||
)
|
||||
self._authorize(tenant_id,subject_id,component_id,environment,"write")
|
||||
if stage not in self.TARGET_STAGES:
|
||||
raise ValueError("invalid target stage")
|
||||
if expected_revision is not None and (not isinstance(expected_revision, int) or isinstance(expected_revision, bool) or expected_revision < 0):
|
||||
raise ValueError("invalid expected_revision")
|
||||
if type(rollback_compatible) is not bool:
|
||||
raise ValueError("rollback_compatible must be boolean")
|
||||
artifact_json = canonical_artifact(artifact)
|
||||
try:
|
||||
linked_release = self.lineage.get_release_for_component(tenant_id, component_id, release_id)
|
||||
except KeyError as exc:
|
||||
raise ReleaseConflict("deployment target release has no lineage") from exc
|
||||
if canonical_artifact(linked_release["artifact"]) != artifact_json:
|
||||
raise ReleaseConflict("deployment target artifact does not match release")
|
||||
rollback_artifact_json = canonical_artifact(rollback_artifact) if rollback_artifact is not None else None
|
||||
source_revision = _required(source_revision, "source_revision") if source_revision else None
|
||||
observed_main_revision = _required(observed_main_revision, "observed_main_revision") if observed_main_revision else None
|
||||
rollback_release_id = _required(rollback_release_id, "rollback_release_id") if rollback_release_id else None
|
||||
if (rollback_release_id is None) != (rollback_artifact_json is None):
|
||||
raise ValueError("rollback release and artifact must be supplied together")
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
current = connection.execute(
|
||||
"SELECT * FROM release_targets WHERE tenant_id=? AND component_id=? AND environment=?",
|
||||
(tenant_id, component_id, environment),
|
||||
).fetchone()
|
||||
if current is None:
|
||||
if expected_revision is not None:
|
||||
raise ReleaseConflict("expected revision does not match absent target")
|
||||
if stage == "ROLLBACK_STAGED":
|
||||
raise ReleaseConflict("rollback requires a current target")
|
||||
revision = 1
|
||||
previous_release_id = previous_artifact_json = None
|
||||
else:
|
||||
if expected_revision != current["revision"]:
|
||||
raise ReleaseConflict("expected revision does not match target")
|
||||
if stage == "ROLLBACK_STAGED":
|
||||
if not current["rollback_compatible"]:
|
||||
raise ReleaseConflict("current target has no compatible rollback")
|
||||
if release_id != current["rollback_release_id"] or artifact_json != current["rollback_artifact_json"]:
|
||||
raise ReleaseConflict("rollback target does not match compatible rollback binding")
|
||||
revision = current["revision"] + 1
|
||||
previous_release_id = current["release_id"]
|
||||
previous_artifact_json = current["artifact_json"]
|
||||
if rollback_compatible and rollback_release_id is None:
|
||||
rollback_release_id = previous_release_id
|
||||
rollback_artifact_json = previous_artifact_json
|
||||
if rollback_compatible and current is not None and rollback_release_id is None:
|
||||
raise ReleaseConflict("compatible rollback requires a previous target")
|
||||
now = _now()
|
||||
connection.execute(
|
||||
"""INSERT INTO release_targets VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
|
||||
ON CONFLICT(tenant_id,component_id,environment) DO UPDATE SET
|
||||
release_id=excluded.release_id, artifact_json=excluded.artifact_json,
|
||||
revision=excluded.revision, stage=excluded.stage,
|
||||
source_revision=excluded.source_revision,
|
||||
observed_main_revision=excluded.observed_main_revision,
|
||||
previous_release_id=excluded.previous_release_id,
|
||||
previous_artifact_json=excluded.previous_artifact_json,
|
||||
rollback_release_id=excluded.rollback_release_id,
|
||||
rollback_artifact_json=excluded.rollback_artifact_json,
|
||||
rollback_compatible=excluded.rollback_compatible,
|
||||
updated_by_subject_id=excluded.updated_by_subject_id,
|
||||
updated_at=excluded.updated_at""",
|
||||
(tenant_id,component_id,environment,release_id,artifact_json,revision,stage,
|
||||
source_revision,observed_main_revision,previous_release_id,previous_artifact_json,
|
||||
rollback_release_id,rollback_artifact_json,int(rollback_compatible),subject_id,now),
|
||||
)
|
||||
row = connection.execute(
|
||||
"SELECT * FROM release_targets WHERE tenant_id=? AND component_id=? AND environment=?",
|
||||
(tenant_id,component_id,environment),
|
||||
).fetchone()
|
||||
return self._target(row)
|
||||
|
||||
def get_target(self, tenant_id: str, subject_id: str, component_id: str, environment: str) -> EnvironmentDeploymentTarget:
|
||||
tenant_id,subject_id,component_id,environment=(
|
||||
_required(tenant_id,"tenant_id"),_required(subject_id,"subject_id"),
|
||||
_required(component_id,"component_id"),_required(environment,"environment"),
|
||||
)
|
||||
self._authorize(tenant_id,subject_id,component_id,environment,"read")
|
||||
with self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM release_targets WHERE tenant_id=? AND component_id=? AND environment=?",
|
||||
(tenant_id,component_id,environment),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise KeyError("deployment target not found")
|
||||
return self._target(row)
|
||||
|
||||
@staticmethod
|
||||
def _target(row: sqlite3.Row) -> EnvironmentDeploymentTarget:
|
||||
return EnvironmentDeploymentTarget(
|
||||
tenant_id=row["tenant_id"], component_id=row["component_id"], environment=row["environment"],
|
||||
release_id=row["release_id"], artifact=decode_artifact(row["artifact_json"]), revision=row["revision"],
|
||||
stage=row["stage"], source_revision=row["source_revision"], observed_main_revision=row["observed_main_revision"],
|
||||
previous_release_id=row["previous_release_id"],
|
||||
previous_artifact=decode_artifact(row["previous_artifact_json"]) if row["previous_artifact_json"] else None,
|
||||
rollback_release_id=row["rollback_release_id"],
|
||||
rollback_artifact=decode_artifact(row["rollback_artifact_json"]) if row["rollback_artifact_json"] else None,
|
||||
rollback_compatible=bool(row["rollback_compatible"]),
|
||||
updated_by_subject_id=row["updated_by_subject_id"], updated_at=row["updated_at"],
|
||||
)
|
||||
|
||||
def start_saga(
|
||||
self, tenant_id: str, subject_id: str, mission_id: str, component_id: str, saga_id: str,
|
||||
release_id: str, deployment_id: str, environment: str, artifact: Any,
|
||||
recipe_id: str, *, expected_target_revision: int,
|
||||
) -> dict[str, Any]:
|
||||
tenant_id,subject_id,mission_id,component_id,saga_id,release_id,deployment_id,environment,recipe_id=map(
|
||||
lambda pair:_required(pair[0],pair[1]),
|
||||
((tenant_id,"tenant_id"),(subject_id,"subject_id"),(mission_id,"mission_id"),(component_id,"component_id"),(saga_id,"saga_id"),
|
||||
(release_id,"release_id"),(deployment_id,"deployment_id"),(environment,"environment"),(recipe_id,"recipe_id")),
|
||||
)
|
||||
if not isinstance(expected_target_revision,int) or isinstance(expected_target_revision,bool) or expected_target_revision < 1:
|
||||
raise ValueError("invalid expected_target_revision")
|
||||
self._authorize(tenant_id,subject_id,component_id,environment,"deploy")
|
||||
artifact_json=canonical_artifact(artifact)
|
||||
target=self.get_target(tenant_id,subject_id,component_id,environment)
|
||||
if target.revision != expected_target_revision:
|
||||
raise ReleaseConflict("expected target revision does not match")
|
||||
if target.stage not in {"STAGED","ROLLBACK_STAGED"}:
|
||||
raise ReleaseConflict("deployment target is not staged")
|
||||
if target.release_id != release_id or canonical_artifact(target.artifact) != artifact_json:
|
||||
raise ReleaseConflict("saga does not match deployment target")
|
||||
graph=self.lineage.explain(tenant_id,subject_id,mission_id)
|
||||
release=next((item for item in graph["releases"] if item["release_id"] == release_id),None)
|
||||
if release is None or release["component_id"] != component_id or canonical_artifact(release["artifact"]) != artifact_json:
|
||||
raise ReleaseConflict("release has no exact source lineage")
|
||||
source_task=next((item for item in graph["tasks"] if item["task_id"] == release["source_task_id"]),None)
|
||||
if source_task is None or source_task["component_id"] != component_id:
|
||||
raise ReleaseConflict("release source Task component lineage missing")
|
||||
with self._connect() as connection:
|
||||
recipe=connection.execute("SELECT * FROM release_recipes WHERE recipe_id=?",(recipe_id,)).fetchone()
|
||||
if recipe is None:
|
||||
raise ReleaseConflict("recipe not found")
|
||||
# The relationship is durable before saga admission and is idempotent on retry.
|
||||
self.lineage.link_deployment(
|
||||
tenant_id,subject_id,mission_id,release_id,deployment_id,component_id,environment,
|
||||
expected_target_revision,decode_artifact(artifact_json),
|
||||
metadata={"saga_id":saga_id,"recipe_id":recipe_id,"recipe_digest":recipe["recipe_digest"]},
|
||||
)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
existing=connection.execute("SELECT * FROM release_sagas WHERE tenant_id=? AND saga_id=?",(tenant_id,saga_id)).fetchone()
|
||||
expected=(subject_id,mission_id,component_id,release["source_task_id"],release["source_effect_id"],source_task["grant_id"],release_id,deployment_id,environment,artifact_json,recipe_id,recipe["recipe_digest"],expected_target_revision)
|
||||
if existing:
|
||||
actual=tuple(existing[key] for key in ("subject_id","mission_id","component_id","source_task_id","source_effect_id","grant_id","release_id","deployment_id","environment","artifact_json","recipe_id","recipe_digest","target_revision"))
|
||||
if actual != expected:
|
||||
raise ReleaseConflict("saga_id already has different immutable binding")
|
||||
return self._saga(connection,existing)
|
||||
collision=connection.execute("SELECT saga_id FROM release_sagas WHERE tenant_id=? AND deployment_id=?",(tenant_id,deployment_id)).fetchone()
|
||||
if collision:
|
||||
raise ReleaseConflict("deployment_id already has a different saga")
|
||||
now=_now()
|
||||
connection.execute(
|
||||
"INSERT INTO release_sagas VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(tenant_id,saga_id,subject_id,mission_id,component_id,release["source_task_id"],release["source_effect_id"],source_task["grant_id"],release_id,deployment_id,environment,artifact_json,
|
||||
recipe_id,recipe["recipe_digest"],expected_target_revision,0,"RUNNING",0,now,now),
|
||||
)
|
||||
row=connection.execute("SELECT * FROM release_sagas WHERE tenant_id=? AND saga_id=?",(tenant_id,saga_id)).fetchone()
|
||||
return self._saga(connection,row)
|
||||
|
||||
def get_saga(self, tenant_id: str, subject_id: str, saga_id: str) -> dict[str, Any]:
|
||||
with self._connect() as connection:
|
||||
row=connection.execute("SELECT * FROM release_sagas WHERE tenant_id=? AND saga_id=?",(_required(tenant_id,"tenant_id"),_required(saga_id,"saga_id"))).fetchone()
|
||||
if row is None:
|
||||
raise KeyError("release saga not found")
|
||||
if row["subject_id"] != _required(subject_id,"subject_id"):
|
||||
raise ReleaseAuthorizationError("release saga subject denied")
|
||||
return self._saga(connection,row)
|
||||
|
||||
@staticmethod
|
||||
def _stages(connection: sqlite3.Connection, recipe_id: str) -> list[dict[str, Any]]:
|
||||
row=connection.execute("SELECT stages_json FROM release_recipes WHERE recipe_id=?",(recipe_id,)).fetchone()
|
||||
if row is None:
|
||||
raise ReleaseConflict("persisted recipe missing")
|
||||
return json.loads(row["stages_json"])
|
||||
|
||||
def _saga(self, connection: sqlite3.Connection, row: sqlite3.Row) -> dict[str, Any]:
|
||||
stages=self._stages(connection,row["recipe_id"])
|
||||
index=row["stage_index"]
|
||||
return {
|
||||
"tenant_id":row["tenant_id"],"subject_id":row["subject_id"],"mission_id":row["mission_id"],
|
||||
"component_id":row["component_id"],"source_task_id":row["source_task_id"],
|
||||
"source_effect_id":row["source_effect_id"],"grant_id":row["grant_id"],
|
||||
"saga_id":row["saga_id"],"release_id":row["release_id"],"deployment_id":row["deployment_id"],
|
||||
"environment":row["environment"],"artifact":decode_artifact(row["artifact_json"]),
|
||||
"recipe_id":row["recipe_id"],"recipe_digest":row["recipe_digest"],
|
||||
"target_revision":row["target_revision"],"stage_index":index,
|
||||
"stage_id":stages[index]["stage_id"] if index < len(stages) else None,
|
||||
"state":row["state"],"revision":row["revision"],"created_at":row["created_at"],"updated_at":row["updated_at"],
|
||||
}
|
||||
|
||||
def advance_saga(
|
||||
self, tenant_id: str, subject_id: str, saga_id: str, expected_revision: int,
|
||||
operation_adapter: Any,
|
||||
) -> dict[str, Any]:
|
||||
tenant_id,subject_id,saga_id=(
|
||||
_required(tenant_id,"tenant_id"),_required(subject_id,"subject_id"),_required(saga_id,"saga_id")
|
||||
)
|
||||
if not isinstance(expected_revision,int) or isinstance(expected_revision,bool) or expected_revision < 0:
|
||||
raise ValueError("invalid expected_revision")
|
||||
prepare=getattr(operation_adapter,"prepare",None)
|
||||
execute=getattr(operation_adapter,"execute",None)
|
||||
if not callable(prepare) or not callable(execute):
|
||||
raise ValueError("governed operation adapter requires prepare and execute")
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
saga=connection.execute(
|
||||
"SELECT * FROM release_sagas WHERE tenant_id=? AND saga_id=?",(tenant_id,saga_id)
|
||||
).fetchone()
|
||||
if saga is None:
|
||||
raise KeyError("release saga not found")
|
||||
if saga["subject_id"] != subject_id:
|
||||
raise ReleaseAuthorizationError("saga belongs to different subject")
|
||||
self._authorize(tenant_id,subject_id,saga["component_id"],saga["environment"],"deploy")
|
||||
if saga["revision"] != expected_revision:
|
||||
raise ReleaseConflict("expected revision does not match saga")
|
||||
if saga["state"] == "COMPLETED":
|
||||
return self._saga(connection,saga)
|
||||
stages=self._stages(connection,saga["recipe_id"])
|
||||
stage=stages[saga["stage_index"]]
|
||||
effect_id=f"release:{saga_id}:{saga['stage_index']}:{stage['stage_id']}"
|
||||
intent=connection.execute(
|
||||
"SELECT * FROM release_effect_intents WHERE tenant_id=? AND saga_id=? AND stage_index=?",
|
||||
(tenant_id,saga_id,saga["stage_index"]),
|
||||
).fetchone()
|
||||
if intent is None:
|
||||
now=_now()
|
||||
connection.execute(
|
||||
"INSERT INTO release_effect_intents VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(tenant_id,saga_id,saga["stage_index"],stage["stage_id"],stage["operation_id"],effect_id,
|
||||
None,"PENDING_JOB",None,None,now,now),
|
||||
)
|
||||
elif (intent["effect_id"],intent["stage_id"],intent["operation_id"]) != (
|
||||
effect_id,stage["stage_id"],stage["operation_id"]
|
||||
):
|
||||
raise ReleaseConflict("persisted stage effect identity mismatch")
|
||||
operation={
|
||||
"tenant_id":tenant_id,"subject_id":subject_id,"mission_id":saga["mission_id"],
|
||||
"component_id":saga["component_id"],"task_id":saga["source_task_id"],
|
||||
"source_task_id":saga["source_task_id"],"source_effect_id":saga["source_effect_id"],
|
||||
"grant_id":saga["grant_id"],"saga_id":saga_id,
|
||||
"deployment_id":saga["deployment_id"],"release_id":saga["release_id"],
|
||||
"environment":saga["environment"],"artifact":decode_artifact(saga["artifact_json"]),
|
||||
"target_revision":saga["target_revision"],"stage_index":saga["stage_index"],
|
||||
"stage_id":stage["stage_id"],"operation_id":stage["operation_id"],"effect_id":effect_id,
|
||||
"parameters":stage.get("parameters",{}),
|
||||
}
|
||||
with self._connect() as connection:
|
||||
intent=connection.execute(
|
||||
"SELECT * FROM release_effect_intents WHERE tenant_id=? AND saga_id=? AND stage_index=?",
|
||||
(tenant_id,saga_id,saga["stage_index"]),
|
||||
).fetchone()
|
||||
job_id=intent["job_id"]
|
||||
if job_id is None:
|
||||
prepared=prepare(dict(operation))
|
||||
job_id=prepared.get("job_id") if isinstance(prepared,Mapping) else prepared
|
||||
job_id=_required(job_id,"prepared job_id")
|
||||
# This is the first point where the relationship graph may name a Job:
|
||||
# link_effect independently resolves the configured real Job authority.
|
||||
self.lineage.link_effect(
|
||||
tenant_id,subject_id,saga["mission_id"],saga["source_task_id"],effect_id,job_id,
|
||||
source_effect_id=saga["source_effect_id"],
|
||||
metadata={"deployment_id":saga["deployment_id"],"release_id":saga["release_id"],
|
||||
"component_id":saga["component_id"],"stage_id":stage["stage_id"]},
|
||||
)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
current=connection.execute(
|
||||
"SELECT revision,stage_index FROM release_sagas WHERE tenant_id=? AND saga_id=?",
|
||||
(tenant_id,saga_id),
|
||||
).fetchone()
|
||||
if current is None or current["revision"] != expected_revision or current["stage_index"] != saga["stage_index"]:
|
||||
raise ReleaseConflict("expected revision changed before Job binding")
|
||||
bound=connection.execute(
|
||||
"SELECT job_id FROM release_effect_intents WHERE tenant_id=? AND saga_id=? AND stage_index=?",
|
||||
(tenant_id,saga_id,saga["stage_index"]),
|
||||
).fetchone()
|
||||
if bound["job_id"] not in (None,job_id):
|
||||
raise ReleaseConflict("stage effect already bound to different Job")
|
||||
connection.execute(
|
||||
"UPDATE release_effect_intents SET job_id=?,state='JOB_LINKED',updated_at=? WHERE tenant_id=? AND saga_id=? AND stage_index=?",
|
||||
(job_id,_now(),tenant_id,saga_id,saga["stage_index"]),
|
||||
)
|
||||
operation["job_id"]=job_id
|
||||
with self._connect() as connection:
|
||||
intent=connection.execute(
|
||||
"SELECT * FROM release_effect_intents WHERE tenant_id=? AND saga_id=? AND stage_index=?",
|
||||
(tenant_id,saga_id,saga["stage_index"]),
|
||||
).fetchone()
|
||||
if intent["receipt_json"]:
|
||||
raw_receipt=json.loads(intent["receipt_json"])
|
||||
elif intent["state"] == "EFFECT_UNKNOWN":
|
||||
reconcile=getattr(operation_adapter,"reconcile",None)
|
||||
if not callable(reconcile):
|
||||
raise ReleaseReconciliationRequired("RECONCILIATION_REQUIRED: adapter readback unavailable")
|
||||
raw_receipt=reconcile(dict(operation))
|
||||
else:
|
||||
try:
|
||||
raw_receipt=execute(dict(operation))
|
||||
except Exception:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE release_effect_intents SET state='EFFECT_UNKNOWN',updated_at=? WHERE tenant_id=? AND saga_id=? AND stage_index=?",
|
||||
(_now(),tenant_id,saga_id,saga["stage_index"]),
|
||||
)
|
||||
raise
|
||||
if not isinstance(raw_receipt,Mapping):
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE release_effect_intents SET state='EFFECT_UNKNOWN',updated_at=? WHERE tenant_id=? AND saga_id=? AND stage_index=?",
|
||||
(_now(),tenant_id,saga_id,saga["stage_index"]),
|
||||
)
|
||||
raise ReleaseVerificationError("operation adapter receipt must be an object")
|
||||
try:
|
||||
unverified_receipt_json=_json(dict(raw_receipt))
|
||||
except (TypeError,ValueError) as exc:
|
||||
raise ReleaseVerificationError("operation adapter receipt must be canonical JSON") from exc
|
||||
if len(unverified_receipt_json.encode("utf-8")) > 262_144:
|
||||
raise ReleaseVerificationError("operation adapter receipt is too large")
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
current=connection.execute(
|
||||
"SELECT revision,stage_index FROM release_sagas WHERE tenant_id=? AND saga_id=?",(tenant_id,saga_id)
|
||||
).fetchone()
|
||||
if current is None or current["revision"] != expected_revision or current["stage_index"] != saga["stage_index"]:
|
||||
raise ReleaseConflict("expected revision changed before receipt persistence")
|
||||
connection.execute(
|
||||
"UPDATE release_effect_intents SET state='RECEIPT_UNVERIFIED',receipt_json=?,updated_at=? WHERE tenant_id=? AND saga_id=? AND stage_index=?",
|
||||
(unverified_receipt_json,_now(),tenant_id,saga_id,saga["stage_index"]),
|
||||
)
|
||||
expected={key:operation[key] for key in (
|
||||
"tenant_id","subject_id","component_id","task_id","source_task_id","source_effect_id","grant_id",
|
||||
"job_id","effect_id","operation_id","deployment_id","release_id","artifact"
|
||||
)}
|
||||
try:
|
||||
verified=self.receipt_verifier(raw_receipt,expected)
|
||||
except Exception as exc:
|
||||
raise ReleaseVerificationError("configured verifier rejected operation receipt") from exc
|
||||
if not isinstance(verified,VerifiedReceipt):
|
||||
raise ReleaseVerificationError("receipt_verifier must return VerifiedReceipt")
|
||||
if verified.outcome != "SUCCEEDED":
|
||||
raise ReleaseVerificationError("verified receipt outcome is not SUCCEEDED")
|
||||
fields=("tenant_id","subject_id","component_id","task_id","source_task_id","source_effect_id","grant_id",
|
||||
"job_id","effect_id","operation_id","deployment_id","release_id","artifact")
|
||||
bindings=tuple(canonical_artifact(getattr(verified,name)) if name == "artifact" else getattr(verified,name) for name in fields)
|
||||
expected_bindings=tuple(canonical_artifact(operation[name]) if name == "artifact" else operation[name] for name in fields)
|
||||
if bindings != expected_bindings:
|
||||
wrong=next(name for name,actual,wanted in zip(fields,bindings,expected_bindings) if actual != wanted)
|
||||
raise ReleaseVerificationError(f"verified receipt {wrong} binding mismatch")
|
||||
receipt_id=_required(verified.receipt_id,"receipt_id")
|
||||
if not isinstance(verified.observed,Mapping):
|
||||
raise ReleaseVerificationError("verified receipt observation must be an object")
|
||||
observed_json=_json(dict(verified.observed))
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
current=connection.execute("SELECT * FROM release_sagas WHERE tenant_id=? AND saga_id=?",(tenant_id,saga_id)).fetchone()
|
||||
if current["revision"] != expected_revision or current["stage_index"] != saga["stage_index"]:
|
||||
raise ReleaseConflict("expected revision changed before receipt commit")
|
||||
connection.execute(
|
||||
"UPDATE release_effect_intents SET state='VERIFIED',receipt_id=?,updated_at=? WHERE tenant_id=? AND saga_id=? AND stage_index=?",
|
||||
(receipt_id,_now(),tenant_id,saga_id,saga["stage_index"]),
|
||||
)
|
||||
connection.execute(
|
||||
"""INSERT OR IGNORE INTO release_deployment_facts
|
||||
(tenant_id,subject_id,mission_id,component_id,task_id,grant_id,job_id,deployment_id,release_id,artifact_json,saga_id,stage_id,operation_id,effect_id,receipt_id,observed_json,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(tenant_id,subject_id,saga["mission_id"],saga["component_id"],saga["source_task_id"],saga["grant_id"],job_id,
|
||||
saga["deployment_id"],saga["release_id"],saga["artifact_json"],saga_id,stage["stage_id"],stage["operation_id"],
|
||||
effect_id,receipt_id,observed_json,_now()),
|
||||
)
|
||||
next_index=saga["stage_index"]+1
|
||||
state="COMPLETED" if next_index == len(stages) else "RUNNING"
|
||||
updated=connection.execute(
|
||||
"UPDATE release_sagas SET stage_index=?,state=?,revision=revision+1,updated_at=? WHERE tenant_id=? AND saga_id=? AND revision=?",
|
||||
(next_index,state,_now(),tenant_id,saga_id,expected_revision),
|
||||
)
|
||||
if updated.rowcount != 1:
|
||||
raise ReleaseConflict("expected revision changed")
|
||||
row=connection.execute("SELECT * FROM release_sagas WHERE tenant_id=? AND saga_id=?",(tenant_id,saga_id)).fetchone()
|
||||
return self._saga(connection,row)
|
||||
|
||||
|
||||
def deployment_facts(self, tenant_id: str, subject_id: str, component_id: str, deployment_id: str) -> list[dict[str, Any]]:
|
||||
tenant_id,subject_id,component_id,deployment_id=(
|
||||
_required(tenant_id,"tenant_id"),_required(subject_id,"subject_id"),
|
||||
_required(component_id,"component_id"),_required(deployment_id,"deployment_id"),
|
||||
)
|
||||
with self._connect() as connection:
|
||||
saga=connection.execute(
|
||||
"SELECT environment,subject_id,component_id FROM release_sagas WHERE tenant_id=? AND deployment_id=?",
|
||||
(tenant_id,deployment_id),
|
||||
).fetchone()
|
||||
if saga is None or saga["subject_id"] != subject_id or saga["component_id"] != component_id:
|
||||
raise ReleaseAuthorizationError("deployment facts scope denied")
|
||||
self._authorize(tenant_id,subject_id,component_id,saga["environment"],"read")
|
||||
rows=connection.execute(
|
||||
"SELECT * FROM release_deployment_facts WHERE tenant_id=? AND subject_id=? AND component_id=? AND deployment_id=? ORDER BY fact_id",
|
||||
(tenant_id,subject_id,component_id,deployment_id),
|
||||
).fetchall()
|
||||
return [{**{key:row[key] for key in row.keys() if not key.endswith("_json")},
|
||||
"artifact":decode_artifact(row["artifact_json"]),"observed":json.loads(row["observed_json"])} for row in rows]
|
||||
|
||||
def record_acceptance(
|
||||
self, tenant_id: str, subject_id: str, mission_id: str, component_id: str, acceptance_id: str,
|
||||
deployment_id: str, release_id: str, artifact: Any, accepted: bool, *,
|
||||
evidence: Any,
|
||||
verifier: Callable[[Any, Mapping[str, Any]], VerifiedAcceptanceEvidence],
|
||||
) -> dict[str, Any]:
|
||||
with self._connect() as connection:
|
||||
saga=connection.execute(
|
||||
"SELECT environment,subject_id,component_id FROM release_sagas WHERE tenant_id=? AND deployment_id=?",
|
||||
(tenant_id,deployment_id),
|
||||
).fetchone()
|
||||
if saga is None or saga["subject_id"] != subject_id or saga["component_id"] != component_id:
|
||||
raise ReleaseAuthorizationError("acceptance deployment scope denied")
|
||||
self._authorize(tenant_id,subject_id,component_id,saga["environment"],"accept")
|
||||
return self.lineage.record_acceptance(
|
||||
tenant_id,subject_id,mission_id,component_id,acceptance_id,deployment_id,release_id,
|
||||
artifact,accepted,evidence=evidence,verifier=verifier,
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"EnvironmentDeploymentTarget", "ReleaseAuthorizationError", "ReleaseConflict", "ReleaseError", "ReleaseLedger",
|
||||
"ReleaseReconciliationRequired", "ReleaseVerificationError", "VerifiedReceipt",
|
||||
]
|
||||
@@ -0,0 +1,375 @@
|
||||
"""Pure validation and deployment-truth evaluation for runtime contracts.
|
||||
|
||||
Runtime contracts describe stable structure. A release-owned deployment target
|
||||
is supplied separately so that changing a release does not rewrite structural
|
||||
configuration or turn a source branch into runtime evidence.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timezone
|
||||
import re
|
||||
from pathlib import PurePosixPath
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
|
||||
CONTRACT_SCHEMA = "aios.runtime-contract.v1"
|
||||
LIFECYCLES = frozenset({"active", "candidate", "legacy", "example"})
|
||||
_DYNAMIC_FIELDS = frozenset(
|
||||
{"desired", "desired_commit", "desired_release", "desired_target", "desired_targets"}
|
||||
)
|
||||
_SECRET_FIELDS = frozenset(
|
||||
{"secret", "secret_bytes", "token", "password", "private_key", "credential_value", "value"}
|
||||
)
|
||||
_DIGEST = re.compile(r"^(?:sha256:)?[0-9a-fA-F]{64}$")
|
||||
_SAFE_SYMBOLIC_REF = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:[/A-Za-z0-9_.:@+-]+$")
|
||||
|
||||
|
||||
def _error(code: str, path: str = "") -> ValueError:
|
||||
return ValueError(f"{code}:{path}" if path else code)
|
||||
|
||||
|
||||
def _nonempty_string(value: object, path: str) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
raise _error("field_invalid", path)
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _positive_int(value: object, path: str) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value < 1:
|
||||
raise _error("field_invalid", path)
|
||||
return value
|
||||
|
||||
|
||||
def _reject_dynamic_fields(value: object, path: str = "contract") -> None:
|
||||
if isinstance(value, Mapping):
|
||||
for key, child in value.items():
|
||||
if not isinstance(key, str):
|
||||
raise _error("field_name_invalid", path)
|
||||
child_path = f"{path}.{key}"
|
||||
if key.lower() in _DYNAMIC_FIELDS:
|
||||
raise _error("dynamic_field_forbidden", child_path)
|
||||
_reject_dynamic_fields(child, child_path)
|
||||
elif isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
|
||||
for index, child in enumerate(value):
|
||||
_reject_dynamic_fields(child, f"{path}[{index}]")
|
||||
|
||||
|
||||
def _safe_path_ref(value: object, path: str) -> str:
|
||||
text = _nonempty_string(value, path)
|
||||
if "\x00" in text or "\\" in text:
|
||||
raise _error("unsafe_path_ref", path)
|
||||
candidate = PurePosixPath(text)
|
||||
if candidate.is_absolute() or any(part in {"", ".", ".."} for part in candidate.parts):
|
||||
raise _error("unsafe_path_ref", path)
|
||||
return text
|
||||
|
||||
|
||||
def _symbolic_ref(value: object, path: str) -> str:
|
||||
text = _nonempty_string(value, path)
|
||||
if "\x00" in text or ".." in text or not _SAFE_SYMBOLIC_REF.fullmatch(text):
|
||||
raise _error("resolver_ref_invalid", path)
|
||||
return text
|
||||
|
||||
|
||||
def _credential_ref(value: object, path: str) -> str:
|
||||
text = _nonempty_string(value, path)
|
||||
allowed = (
|
||||
text.startswith("credential://")
|
||||
or text.startswith("secret://")
|
||||
or text.startswith("systemd-credential:")
|
||||
)
|
||||
if not allowed or "\x00" in text or ".." in text or text.startswith("/run/credentials/"):
|
||||
raise _error("credential_ref_invalid", path)
|
||||
return text
|
||||
|
||||
|
||||
def _validate_binding_group(
|
||||
group: object,
|
||||
group_name: str,
|
||||
component_id: str,
|
||||
environment: str,
|
||||
tenant_scope: str,
|
||||
) -> dict[str, Any]:
|
||||
if not isinstance(group, Mapping):
|
||||
raise _error("binding_group_invalid", group_name)
|
||||
normalized: dict[str, Any] = {}
|
||||
for name, raw in group.items():
|
||||
item_path = f"{group_name}.{name}"
|
||||
_nonempty_string(name, f"{group_name}.name")
|
||||
if isinstance(raw, str):
|
||||
reference_field = (
|
||||
"credential_ref"
|
||||
if group_name == "credential_refs"
|
||||
else "path_ref"
|
||||
if group_name == "resource_refs" and ":" not in raw
|
||||
else "resolver_ref"
|
||||
)
|
||||
item = {
|
||||
"component_id": component_id,
|
||||
"environment": environment,
|
||||
reference_field: raw,
|
||||
}
|
||||
elif isinstance(raw, Mapping):
|
||||
item = deepcopy(dict(raw))
|
||||
else:
|
||||
raise _error("binding_invalid", item_path)
|
||||
if _nonempty_string(item.get("component_id"), f"{item_path}.component_id") != component_id:
|
||||
raise _error("binding_component_mismatch", item_path)
|
||||
if _nonempty_string(item.get("environment"), f"{item_path}.environment") != environment:
|
||||
raise _error("binding_environment_mismatch", item_path)
|
||||
if "tenant_scope" in item and (
|
||||
_nonempty_string(item["tenant_scope"], f"{item_path}.tenant_scope") != tenant_scope
|
||||
):
|
||||
raise _error("binding_tenant_mismatch", item_path)
|
||||
|
||||
reference_fields = [key for key in ("resolver_ref", "path_ref", "credential_ref") if key in item]
|
||||
if len(reference_fields) != 1:
|
||||
raise _error("binding_reference_invalid", item_path)
|
||||
reference_field = reference_fields[0]
|
||||
reference_path = f"{item_path}.{reference_field}"
|
||||
if group_name == "credential_refs" or reference_field == "credential_ref":
|
||||
for key in item:
|
||||
if key.lower() in _SECRET_FIELDS:
|
||||
raise _error("secret_material_forbidden", f"{item_path}.{key}")
|
||||
item[reference_field] = _credential_ref(item[reference_field], reference_path)
|
||||
elif reference_field == "path_ref":
|
||||
item[reference_field] = _safe_path_ref(item[reference_field], reference_path)
|
||||
else:
|
||||
item[reference_field] = _symbolic_ref(item[reference_field], reference_path)
|
||||
normalized[str(name)] = item
|
||||
return normalized
|
||||
|
||||
|
||||
def validate_contract(document: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""Validate and copy a stable ``aios.runtime-contract.v1`` document.
|
||||
|
||||
The returned mapping owns its nested containers, so callers cannot mutate the
|
||||
source after validation. Invalid contracts raise ``ValueError`` with a stable
|
||||
machine-readable code prefix.
|
||||
"""
|
||||
|
||||
if not isinstance(document, Mapping):
|
||||
raise _error("contract_invalid")
|
||||
_reject_dynamic_fields(document)
|
||||
result = deepcopy(dict(document))
|
||||
if result.get("contract_schema") != CONTRACT_SCHEMA:
|
||||
raise _error("contract_schema_invalid", "contract_schema")
|
||||
result["contract_revision"] = _positive_int(result.get("contract_revision"), "contract_revision")
|
||||
component_id = _nonempty_string(result.get("component_id"), "component_id")
|
||||
environment = _nonempty_string(result.get("environment"), "environment")
|
||||
result["component_id"] = component_id
|
||||
result["environment"] = environment
|
||||
tenant_scope = _nonempty_string(result.get("tenant_scope"), "tenant_scope")
|
||||
result["tenant_scope"] = tenant_scope
|
||||
lifecycle = _nonempty_string(result.get("lifecycle"), "lifecycle").lower()
|
||||
if lifecycle not in LIFECYCLES:
|
||||
raise _error("lifecycle_invalid", "lifecycle")
|
||||
result["lifecycle"] = lifecycle
|
||||
|
||||
if "resources" in result and "resource_refs" in result:
|
||||
raise _error("duplicate_resource_group")
|
||||
if "resources" in result:
|
||||
result["resource_refs"] = result.pop("resources")
|
||||
for group_name in ("entrypoints", "bindings", "resource_refs", "credential_refs"):
|
||||
result[group_name] = _validate_binding_group(
|
||||
result.get(group_name), group_name, component_id, environment, tenant_scope
|
||||
)
|
||||
|
||||
operations = result.get("operations")
|
||||
if (
|
||||
not isinstance(operations, Sequence)
|
||||
or isinstance(operations, (str, bytes, bytearray))
|
||||
or not operations
|
||||
):
|
||||
raise _error("operations_invalid", "operations")
|
||||
normalized_operations = [
|
||||
_nonempty_string(operation, f"operations[{index}]")
|
||||
for index, operation in enumerate(operations)
|
||||
]
|
||||
if len(set(normalized_operations)) != len(normalized_operations):
|
||||
raise _error("duplicate_operation", "operations")
|
||||
result["operations"] = normalized_operations
|
||||
return result
|
||||
|
||||
|
||||
def _unknown(*reasons: str) -> dict[str, Any]:
|
||||
return {
|
||||
"status": "UNKNOWN",
|
||||
"state": "UNKNOWN",
|
||||
"verification": "UNKNOWN",
|
||||
"converged": False,
|
||||
"reasons": list(dict.fromkeys(reasons)),
|
||||
}
|
||||
|
||||
|
||||
def _drift(reasons: list[str]) -> dict[str, Any]:
|
||||
return {
|
||||
"status": "DRIFT",
|
||||
"state": "DRIFT",
|
||||
"verification": "FAILED",
|
||||
"converged": False,
|
||||
"reasons": list(dict.fromkeys(reasons)),
|
||||
}
|
||||
|
||||
|
||||
def _parse_time(value: object) -> datetime | None:
|
||||
if isinstance(value, datetime):
|
||||
parsed = value
|
||||
elif isinstance(value, str):
|
||||
try:
|
||||
parsed = datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
else:
|
||||
return None
|
||||
if parsed.tzinfo is None or parsed.utcoffset() is None:
|
||||
return None
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _digest_value(document: Mapping[str, Any], *names: str) -> object:
|
||||
for name in names:
|
||||
if name in document:
|
||||
return document[name]
|
||||
return None
|
||||
|
||||
|
||||
def _valid_digest(value: object) -> bool:
|
||||
return isinstance(value, str) and bool(_DIGEST.fullmatch(value))
|
||||
|
||||
|
||||
def evaluate_deployment(
|
||||
contract: Mapping[str, Any],
|
||||
target: Mapping[str, Any] | None,
|
||||
observation: Mapping[str, Any] | None,
|
||||
provenance: Mapping[str, Any] | None,
|
||||
now: datetime | str,
|
||||
) -> dict[str, Any]:
|
||||
"""Evaluate one target using fresh runtime and provenance evidence.
|
||||
|
||||
Missing or malformed evidence is ``UNKNOWN``. ``DRIFT`` is reserved for a
|
||||
complete evidence set that proves an incompatible target or runtime fact.
|
||||
Repository HEAD is intentionally not an input to this decision.
|
||||
"""
|
||||
|
||||
stable = validate_contract(contract)
|
||||
missing: list[str] = []
|
||||
if target is None:
|
||||
missing.append("target_missing")
|
||||
elif not isinstance(target, Mapping):
|
||||
missing.append("target_invalid")
|
||||
if observation is None:
|
||||
missing.append("observation_missing")
|
||||
elif not isinstance(observation, Mapping):
|
||||
missing.append("observation_invalid")
|
||||
if provenance is None:
|
||||
missing.append("provenance_missing")
|
||||
elif not isinstance(provenance, Mapping):
|
||||
missing.append("provenance_invalid")
|
||||
current_time = _parse_time(now)
|
||||
if current_time is None:
|
||||
missing.append("now_invalid")
|
||||
if missing:
|
||||
return _unknown(*missing)
|
||||
assert target is not None and observation is not None and provenance is not None
|
||||
assert current_time is not None
|
||||
|
||||
target_errors: list[str] = []
|
||||
for field in ("component_id", "environment", "release_id", "deployment_id"):
|
||||
if not isinstance(target.get(field), str) or not str(target[field]).strip():
|
||||
target_errors.append(f"target_{field}_invalid")
|
||||
if "tenant_scope" in target and (
|
||||
not isinstance(target["tenant_scope"], str) or not target["tenant_scope"].strip()
|
||||
):
|
||||
target_errors.append("target_tenant_scope_invalid")
|
||||
target_artifact = target.get("artifact_digest")
|
||||
if not _valid_digest(target_artifact):
|
||||
target_errors.append("target_artifact_digest_invalid")
|
||||
if isinstance(target.get("revision"), bool) or not isinstance(target.get("revision"), int) or target.get("revision", 0) < 1:
|
||||
target_errors.append("target_revision_invalid")
|
||||
|
||||
observed_at = _parse_time(observation.get("observed_at"))
|
||||
max_age = observation.get("max_age_seconds", observation.get("max_age"))
|
||||
observed_artifact = _digest_value(
|
||||
observation, "actual_artifact_digest", "actual_artifact", "artifact_digest"
|
||||
)
|
||||
observation_errors: list[str] = []
|
||||
if observed_at is None:
|
||||
observation_errors.append("observation_observed_at_invalid")
|
||||
if isinstance(max_age, bool) or not isinstance(max_age, (int, float)) or max_age <= 0:
|
||||
observation_errors.append("observation_max_age_invalid")
|
||||
if not isinstance(observation.get("source"), str) or not observation.get("source", "").strip():
|
||||
observation_errors.append("observation_source_invalid")
|
||||
if not _valid_digest(observed_artifact):
|
||||
observation_errors.append("observation_actual_artifact_invalid")
|
||||
if not isinstance(observation.get("healthy"), bool):
|
||||
observation_errors.append("observation_healthy_invalid")
|
||||
for field in ("component_id", "environment", "tenant_scope", "deployment_id"):
|
||||
if field in observation and (
|
||||
not isinstance(observation[field], str) or not observation[field].strip()
|
||||
):
|
||||
observation_errors.append(f"observation_{field}_invalid")
|
||||
if "revision" in observation and (
|
||||
isinstance(observation["revision"], bool)
|
||||
or not isinstance(observation["revision"], int)
|
||||
or observation["revision"] < 1
|
||||
):
|
||||
observation_errors.append("observation_revision_invalid")
|
||||
|
||||
artifact_provenance = _digest_value(provenance, "artifact_digest", "artifact_hash")
|
||||
source_provenance = _digest_value(provenance, "source_digest", "source_hash")
|
||||
builder_provenance = _digest_value(provenance, "builder_digest", "builder_hash")
|
||||
provenance_errors: list[str] = []
|
||||
if not _valid_digest(artifact_provenance):
|
||||
provenance_errors.append("provenance_artifact_digest_invalid")
|
||||
if not _valid_digest(source_provenance):
|
||||
provenance_errors.append("provenance_source_digest_invalid")
|
||||
if not _valid_digest(builder_provenance):
|
||||
provenance_errors.append("provenance_builder_digest_invalid")
|
||||
|
||||
evidence_errors = target_errors + observation_errors + provenance_errors
|
||||
if evidence_errors:
|
||||
return _unknown(*evidence_errors)
|
||||
assert observed_at is not None and isinstance(max_age, (int, float))
|
||||
age = (current_time - observed_at).total_seconds()
|
||||
if age < 0:
|
||||
return _unknown("observation_from_future")
|
||||
if age > max_age:
|
||||
return _unknown("observation_stale")
|
||||
|
||||
drift: list[str] = []
|
||||
if target["component_id"] != stable["component_id"]:
|
||||
drift.append("target_component_id_mismatch")
|
||||
if target["environment"] != stable["environment"]:
|
||||
drift.append("target_environment_mismatch")
|
||||
if "tenant_scope" in target and target["tenant_scope"] != stable["tenant_scope"]:
|
||||
drift.append("target_tenant_scope_mismatch")
|
||||
if "tenant_scope" in observation and observation["tenant_scope"] != stable["tenant_scope"]:
|
||||
drift.append("observation_tenant_scope_mismatch")
|
||||
for field in ("component_id", "environment", "deployment_id", "revision"):
|
||||
if field in observation and observation[field] != target[field]:
|
||||
drift.append(f"observation_{field}_mismatch")
|
||||
if observed_artifact != target_artifact:
|
||||
drift.append("artifact_mismatch")
|
||||
if artifact_provenance != target_artifact:
|
||||
drift.append("provenance_artifact_mismatch")
|
||||
if observation["healthy"] is False:
|
||||
drift.append("observation_unhealthy")
|
||||
if drift:
|
||||
return _drift(drift)
|
||||
return {
|
||||
"status": "VERIFIED",
|
||||
"state": "CONVERGED",
|
||||
"verification": "VERIFIED",
|
||||
"converged": True,
|
||||
"reasons": [],
|
||||
"component_id": target["component_id"],
|
||||
"environment": target["environment"],
|
||||
"release_id": target["release_id"],
|
||||
"deployment_id": target["deployment_id"],
|
||||
"artifact_digest": target_artifact,
|
||||
"revision": target["revision"],
|
||||
"observed_at": observation["observed_at"],
|
||||
"observation_source": observation["source"],
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
"""Thin authenticated application service over existing TaskPlaneStore and JobStore.
|
||||
|
||||
This module owns no second Task lifecycle store. Added task runtime columns live
|
||||
in the supplied Task authority database; the lineage ledger owns only relations.
|
||||
The built-in host adapter is READ_ONLY. Mutating operations require separately
|
||||
registered governed adapters with receiver fencing/readback, never arbitrary argv.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime,timezone
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Mapping
|
||||
|
||||
from task_plane.models import TaskPlan,WorkerRegistration,ProbeEvidence,ProbeOutcome,Budget
|
||||
from task_plane.errors import TaskPlaneHTTPError
|
||||
from aios_gitops_job_controller import GitOpsJobController,JobStore,JobState,JobError
|
||||
from .evidence import ReceiptSigner,canonical,digest
|
||||
from .runtime_contract import validate_contract
|
||||
from .worker_policy import eligible
|
||||
from .authorization import AuthorizationError
|
||||
from .host import HostError
|
||||
|
||||
|
||||
class ServiceError(RuntimeError):
|
||||
def __init__(self,code,status=409):
|
||||
super().__init__(code)
|
||||
self.code=code
|
||||
self.status=status
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Identity:
|
||||
tenant_id: str
|
||||
subject_id: str
|
||||
role: str
|
||||
worker_id: str | None=None
|
||||
|
||||
|
||||
class BaselineService:
|
||||
def __init__(self,store,job_root,authorization,contracts,operations,worker_profiles,host,lineage,machine_key,
|
||||
*,machine_key_id='baseline-machine',trusted_receipt_keys=None):
|
||||
self.store=store
|
||||
self.signer=ReceiptSigner(machine_key,key_id=machine_key_id,trusted_public_keys=trusted_receipt_keys)
|
||||
self.jobs=GitOpsJobController(JobStore(Path(job_root)),generic_receipt_verifier=self.signer.verify)
|
||||
self.authorization=authorization
|
||||
self.contracts={}
|
||||
for entry in contracts:
|
||||
value=validate_contract(entry)
|
||||
key=(value['tenant_scope'],value['environment'],value['component_id'])
|
||||
if key in self.contracts: raise ServiceError('CONTRACT_DUPLICATE')
|
||||
self.contracts[key]=value
|
||||
self.operations=operations
|
||||
self.worker_profiles=dict(worker_profiles)
|
||||
self.host=host
|
||||
self.lineage=lineage
|
||||
self.storage_blocked=False
|
||||
self._execute_lock=threading.Lock()
|
||||
with self.store._transaction():
|
||||
self.store.conn.execute('''CREATE TABLE IF NOT EXISTS baseline_task_runtime(
|
||||
task_id TEXT PRIMARY KEY REFERENCES tasks(task_id),ready_at REAL,
|
||||
blocker_code TEXT,job_id TEXT,effect_id TEXT NOT NULL UNIQUE)''')
|
||||
self.store.conn.execute('''CREATE TABLE IF NOT EXISTS baseline_worker_policy(
|
||||
worker_id TEXT PRIMARY KEY,policy_json TEXT NOT NULL)''')
|
||||
self.store.probe_attestation_verifier=self._verify_probe
|
||||
|
||||
def _user(self,identity):
|
||||
if not isinstance(identity,Identity) or identity.role not in {'user','owner'}:
|
||||
raise ServiceError('USER_IDENTITY_REQUIRED',403)
|
||||
|
||||
def _worker(self,identity):
|
||||
if not isinstance(identity,Identity) or identity.role!='worker':
|
||||
raise ServiceError('WORKER_IDENTITY_REQUIRED',403)
|
||||
profile=self.worker_profiles.get(identity.worker_id)
|
||||
if not profile or profile.identity!=identity.subject_id or identity.tenant_id not in profile.tenant_scope:
|
||||
raise ServiceError('WORKER_SCOPE_DENIED',403)
|
||||
return profile
|
||||
|
||||
def _task(self,identity,task_id):
|
||||
task=self.store.get_task(task_id)
|
||||
meta=task.task_plan.metadata.get('baseline',{})
|
||||
if meta.get('tenant_id')!=identity.tenant_id or meta.get('subject_id')!=identity.subject_id:
|
||||
raise ServiceError('TASK_SCOPE_DENIED',403)
|
||||
return task
|
||||
|
||||
def _scope(self,task):
|
||||
meta=task.task_plan.metadata['baseline']
|
||||
return {**{key:meta[key] for key in ('tenant_id','subject_id','mission_id','operation','component_id','environment')},'task_id':task.task_id}
|
||||
|
||||
def _principal(self,task):
|
||||
meta=task.task_plan.metadata['baseline']
|
||||
return self.authorization.issue_principal(meta['grant_id'],ttl_seconds=60,**self._scope(task))
|
||||
|
||||
def _fresh_write(self):
|
||||
if self.storage_blocked: raise ServiceError('STORAGE_RECOVERY_REQUIRED',503)
|
||||
try:
|
||||
with self.store._transaction():
|
||||
self.store.conn.execute('UPDATE baseline_task_runtime SET blocker_code=blocker_code WHERE 0')
|
||||
except (sqlite3.Error,OSError) as exc:
|
||||
self.storage_blocked=True
|
||||
raise ServiceError('TASK_STORAGE_UNAVAILABLE',503) from exc
|
||||
|
||||
def submit_goal(self,identity,request,*,idempotency_key):
|
||||
self._user(identity)
|
||||
allowed={'mission_id','grant_id','operation','component_id','environment','inputs','intent'}
|
||||
if type(request) is not dict or set(request)!=allowed or not isinstance(idempotency_key,str) or not 1<=len(idempotency_key)<=128:
|
||||
raise ServiceError('GOAL_SCHEMA_INVALID',400)
|
||||
if any(not isinstance(request[key],str) or not request[key] or len(request[key])>2048 for key in allowed-{'inputs'}):
|
||||
raise ServiceError('GOAL_SCHEMA_INVALID',400)
|
||||
if request['inputs']!={}: raise ServiceError('OPERATION_INPUT_SCHEMA_INVALID',400)
|
||||
if not re.fullmatch(r'[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}',request['mission_id']): raise ServiceError('MISSION_ID_INVALID',400)
|
||||
operation=self.operations.get(request['operation'])
|
||||
if operation.handler_ref!='host.service_status' or operation.effect_class!='READ_ONLY' or operation.required_permissions!=(operation.operation_id,):
|
||||
raise ServiceError('REGISTERED_HANDLER_UNAVAILABLE',503)
|
||||
contract=self.contracts.get((identity.tenant_id,request['environment'],request['component_id']))
|
||||
if not contract or contract['lifecycle']!='active' or request['operation'] not in contract['operations']:
|
||||
raise ServiceError('CONTRACT_OPERATION_UNAVAILABLE',403)
|
||||
host_key=identity.tenant_id+':'+request['environment']+':'+request['component_id']
|
||||
if host_key not in self.host.bindings: raise ServiceError('HOST_BINDING_UNAVAILABLE',503)
|
||||
key=digest({'tenant':identity.tenant_id,'subject':identity.subject_id,'key':idempotency_key})
|
||||
task_id='task-'+key[:40]
|
||||
effect_id='effect-'+digest({'tenant':identity.tenant_id,'task':task_id,'request':request})[:40]
|
||||
meta={**request,'tenant_id':identity.tenant_id,'subject_id':identity.subject_id,'effect_id':effect_id,
|
||||
'operation_version':operation.version,'contract_revision':contract['contract_revision']}
|
||||
plan=TaskPlan.from_dict({'task_id':task_id,'task_type':'host.diagnostic','intent':request['intent'],
|
||||
'issuer':{'id':identity.subject_id,'tenant_id':identity.tenant_id,'subject_id':identity.subject_id},
|
||||
'required_capabilities':[request['operation']],'risk':'low','approval_policy':'governed-grant',
|
||||
'correlation_id':request['mission_id'],'metadata':{'baseline':meta},
|
||||
'budgets':{'attempts':operation.retry['max_attempts'],'time_seconds':30*operation.retry['max_attempts']},'evidence_requirements':['diagnostic']})
|
||||
# Validate business authorization before creating an executable task.
|
||||
self.authorization.issue_principal(request['grant_id'],ttl_seconds=60,tenant_id=identity.tenant_id,
|
||||
subject_id=identity.subject_id,task_id=task_id,**{key:request[key] for key in ('mission_id','operation','component_id','environment')})
|
||||
self._fresh_write()
|
||||
try:
|
||||
with self.store._transaction():
|
||||
task=self.store.create_task_with_event(plan,idempotency_key=key)
|
||||
self.store.conn.execute('INSERT OR IGNORE INTO baseline_task_runtime(task_id,ready_at,effect_id) VALUES(?,?,?)',
|
||||
(task_id,time.time(),effect_id))
|
||||
self.lineage.link_task(identity.tenant_id,identity.subject_id,request['mission_id'],request['grant_id'],task_id)
|
||||
return self.get_task(identity,task_id)
|
||||
except TaskPlaneHTTPError as exc:
|
||||
raise ServiceError('TASK_CONFLICT') from exc
|
||||
except (sqlite3.Error,OSError) as exc:
|
||||
self.storage_blocked=True
|
||||
raise ServiceError('TASK_STORAGE_UNAVAILABLE',503) from exc
|
||||
|
||||
def get_task(self,identity,task_id):
|
||||
self._user(identity)
|
||||
task=self._task(identity,task_id)
|
||||
with self.store._transaction():
|
||||
row=self.store.conn.execute('SELECT * FROM baseline_task_runtime WHERE task_id=?',(task_id,)).fetchone()
|
||||
result=task.to_dict()
|
||||
if row:
|
||||
result.update({'ready_at':row['ready_at'],'blocker_code':row['blocker_code'],'effect_id':row['effect_id'],'job_id':row['job_id']})
|
||||
if row['job_id']:
|
||||
job=self.jobs.store.read(row['job_id'])
|
||||
result['job_state']=job['state']
|
||||
result['side_effect_state']=job['side_effect_state']
|
||||
envelope=json.loads(job['receipt_json']) if job.get('receipt_json') else None
|
||||
if self._receipt_valid(task,envelope): result['receipt']=envelope['payload']
|
||||
result['remaining_budget']=self.store.get_task_budget(task_id).to_dict()
|
||||
return result
|
||||
|
||||
def explain(self,identity,mission_id):
|
||||
self._user(identity)
|
||||
result=self.lineage.explain(identity.tenant_id,identity.subject_id,mission_id)
|
||||
result['tasks_runtime']=[self.get_task(identity,t.task_id) for t in self.store.list_tasks()
|
||||
if t.task_plan.metadata.get('baseline',{}).get('mission_id')==mission_id and
|
||||
t.task_plan.issuer.get('tenant_id')==identity.tenant_id and t.task_plan.issuer.get('subject_id')==identity.subject_id]
|
||||
return result
|
||||
|
||||
def register_worker(self,identity):
|
||||
profile=self._worker(identity)
|
||||
try:
|
||||
existing=self.store.get_worker(profile.worker_id)
|
||||
except TaskPlaneHTTPError:
|
||||
existing=None
|
||||
now=time.time()
|
||||
worker=WorkerRegistration.from_dict({'worker_id':profile.worker_id,'worker_type':'baseline-operation-worker',
|
||||
'identity':profile.identity,'capabilities':list(profile.capabilities),'trust_zone':'governed-host',
|
||||
'max_concurrency':profile.max_concurrency,'heartbeat_interval_seconds':profile.heartbeat_seconds,
|
||||
'supported_protocols':['aios-baseline-worker-v1'],'last_heartbeat':now})
|
||||
self.store.register_worker(worker,actor_identity=profile.identity,is_admin=False,
|
||||
expected_revision=existing.registration_revision if existing else None)
|
||||
with self.store._transaction():
|
||||
self.store.conn.execute('INSERT INTO baseline_worker_policy VALUES(?,?) ON CONFLICT(worker_id) DO UPDATE SET policy_json=excluded.policy_json',
|
||||
(profile.worker_id,canonical(profile.__dict__).decode()))
|
||||
return {'worker_id':profile.worker_id,'registered':True,'fallback_executor':profile.fallback_executor}
|
||||
|
||||
def heartbeat(self,identity):
|
||||
profile=self._worker(identity)
|
||||
worker=self.store.get_worker(profile.worker_id)
|
||||
self.store.heartbeat_worker(profile.worker_id,actor_identity=profile.identity,expected_revision=worker.registration_revision)
|
||||
return {'worker_id':profile.worker_id,'alive':True}
|
||||
|
||||
def claim_next(self,identity):
|
||||
profile=self._worker(identity)
|
||||
worker=self.store.get_worker(profile.worker_id)
|
||||
if worker.disabled: raise ServiceError('WORKER_DISABLED',403)
|
||||
self._fresh_write()
|
||||
for task in sorted(self.store.list_tasks(),key=lambda t:(t.created_at,t.task_id)):
|
||||
meta=task.task_plan.metadata.get('baseline',{})
|
||||
if not meta or task.status not in {'CREATED','QUEUED','FAILED_RECOVERABLE'}: continue
|
||||
with self.store._transaction():
|
||||
row=self.store.conn.execute('SELECT ready_at FROM baseline_task_runtime WHERE task_id=?',(task.task_id,)).fetchone()
|
||||
if not row: continue
|
||||
now=time.time()
|
||||
task_scope={**meta,'ready_at':row['ready_at']}
|
||||
if not eligible(profile,task_scope,now=now,last_heartbeat=worker.last_heartbeat): continue
|
||||
# Priority is configuration on ordinary eligible workers; no special main actor.
|
||||
candidates=[]
|
||||
for registered in self.store.list_workers():
|
||||
peer=self.worker_profiles.get(registered.worker_id)
|
||||
if not peer or registered.disabled or not eligible(peer,task_scope,now=now,last_heartbeat=registered.last_heartbeat): continue
|
||||
with self.store._transaction():
|
||||
active=self.store.conn.execute("SELECT count(*) FROM claims WHERE worker_id=? AND status='ACTIVE' AND lease_until>?",(peer.worker_id,now)).fetchone()[0]
|
||||
if active<peer.max_concurrency: candidates.append(peer)
|
||||
if not candidates or sorted(candidates,key=lambda p:(-p.priority,p.worker_id))[0].worker_id!=profile.worker_id: continue
|
||||
try:
|
||||
self._principal(task)
|
||||
with self.store._transaction():
|
||||
self.store.consume_budget(task.task_id,Budget(attempts=1,time_seconds=30))
|
||||
claim=self.store.claim_task(task.task_id,profile.worker_id,profile.identity,lease_seconds=30,
|
||||
expected_revision=task.revision,operation_digest=task.task_plan.operation_digest())
|
||||
except TaskPlaneHTTPError:
|
||||
budget=self.store.get_task_budget(task.task_id)
|
||||
if budget.attempts==0 or budget.time_seconds==0:
|
||||
with self.store._transaction():
|
||||
self.store.conn.execute('UPDATE baseline_task_runtime SET blocker_code=? WHERE task_id=?',('TASK_BUDGET_EXHAUSTED',task.task_id))
|
||||
continue
|
||||
except AuthorizationError:
|
||||
with self.store._transaction():
|
||||
self.store.conn.execute('UPDATE baseline_task_runtime SET blocker_code=? WHERE task_id=?',('AUTHORIZATION_REQUIRED',task.task_id))
|
||||
continue
|
||||
return {'task_id':task.task_id,'claim_id':claim.claim_id,'generation':claim.attempt,
|
||||
'lease_until':claim.lease_until,'operation':meta['operation']}
|
||||
return None
|
||||
|
||||
def renew(self,identity,task_id,claim_id):
|
||||
profile=self._worker(identity)
|
||||
claim=self.store.get_claim(claim_id)
|
||||
if claim.task_id!=task_id or claim.worker_id!=profile.worker_id or claim.worker_identity!=profile.identity:
|
||||
raise ServiceError('CLAIM_IDENTITY_MISMATCH',403)
|
||||
task=self.store.get_task(task_id)
|
||||
meta=task.task_plan.metadata.get('baseline',{})
|
||||
if meta.get('tenant_id') not in profile.tenant_scope or meta.get('subject_id') not in profile.subject_scope or task.status=='CANCELLED':
|
||||
raise ServiceError('CLAIM_SCOPE_OR_STATE_DENIED',403)
|
||||
self._principal(task)
|
||||
self.store.heartbeat_claim(task_id,claim_id,30)
|
||||
return {'task_id':task_id,'claim_id':claim_id,'renewed':True}
|
||||
|
||||
def _advance(self,job_id,state,**kwargs):
|
||||
row=self.jobs.store.read(job_id)
|
||||
return self.jobs.advance(job_id,state,expected_revision=int(row['revision']),**kwargs)
|
||||
|
||||
@contextmanager
|
||||
def _execution_slot(self,deadline):
|
||||
remaining=deadline-time.time()
|
||||
if remaining<=0 or not self._execute_lock.acquire(timeout=remaining):
|
||||
raise ServiceError('EXECUTION_DEADLINE_EXPIRED',408)
|
||||
try:
|
||||
if time.time()>=deadline: raise ServiceError('EXECUTION_DEADLINE_EXPIRED',408)
|
||||
yield
|
||||
finally: self._execute_lock.release()
|
||||
|
||||
def execute_claim(self,identity,task_id,claim_id,*,deadline=None):
|
||||
profile=self._worker(identity)
|
||||
deadline=time.time()+30 if deadline is None else deadline
|
||||
with self._execution_slot(deadline):
|
||||
self._fresh_write()
|
||||
task=self.store.get_task(task_id)
|
||||
meta=task.task_plan.metadata.get('baseline',{})
|
||||
claim=self.store.get_claim(claim_id)
|
||||
if claim.task_id!=task_id or claim.worker_id!=profile.worker_id or claim.worker_identity!=profile.identity:
|
||||
raise ServiceError('CLAIM_IDENTITY_MISMATCH',403)
|
||||
if meta.get('tenant_id') not in profile.tenant_scope or meta.get('subject_id') not in profile.subject_scope:
|
||||
raise ServiceError('WORKER_SCOPE_DENIED',403)
|
||||
user=Identity(meta['tenant_id'],meta['subject_id'],'user')
|
||||
if task.status=='COMPLETED': return self.get_task(user,task_id)
|
||||
if claim.status!='ACTIVE' or claim.lease_until<=time.time() or task.status=='CANCELLED':
|
||||
raise ServiceError('CLAIM_EXPIRED_OR_CANCELLED')
|
||||
principal=self._principal(task)
|
||||
self.authorization.authorize(principal,**self._scope(task))
|
||||
operation=self.operations.get(meta['operation'])
|
||||
if operation.handler_ref!='host.service_status' or operation.effect_class!='READ_ONLY':
|
||||
raise ServiceError('HANDLER_NOT_REGISTERED',503)
|
||||
self.authorization.assert_effect_bounds(principal,operation,**self._scope(task))
|
||||
request={'schema':'aios.operation-job.v1','tenant_id':meta['tenant_id'],'subject_id':meta['subject_id'],
|
||||
'mission_id':meta['mission_id'],'grant_id':meta['grant_id'],'task_id':task_id,'effect_id':meta['effect_id'],
|
||||
'operation':meta['operation'],'operation_version':str(operation.version),'resource_kind':'systemd-service',
|
||||
'resource_id':digest({'tenant':meta['tenant_id'],'environment':meta['environment'],'component':meta['component_id']}),'input_digest':digest(meta['inputs'])}
|
||||
try:
|
||||
job=self.jobs.submit(request)
|
||||
with self.store._transaction():
|
||||
self.store.conn.execute('UPDATE baseline_task_runtime SET job_id=? WHERE task_id=?',(job.job_id,task_id))
|
||||
self.lineage.link_task(meta['tenant_id'],meta['subject_id'],meta['mission_id'],meta['grant_id'],task_id)
|
||||
self.lineage.link_effect(meta['tenant_id'],meta['subject_id'],meta['mission_id'],task_id,meta['effect_id'],job.job_id)
|
||||
row=self.jobs.store.read(job.job_id)
|
||||
if row['state']=='COMPLETED':
|
||||
self._close_task(task,claim,json.loads(row['receipt_json']))
|
||||
return self.get_task(user,task_id)
|
||||
if row['state'] not in {'REQUESTED','AUTHORIZED'}:
|
||||
raise ServiceError('JOB_RECONCILIATION_REQUIRED')
|
||||
self.jobs.assert_effect_admission(exclude_job_id=job.job_id)
|
||||
self.authorization.reserve_effect(meta['grant_id'],meta['effect_id'],principal,**self._scope(task))
|
||||
if row['state']=='REQUESTED': self._advance(job.job_id,JobState.AUTHORIZED,side_effect_state='AUTHORIZED')
|
||||
row=self.jobs.store.read(job.job_id)
|
||||
self.jobs.lease_resource_job(job.job_id,lease_id=claim_id,resource_kind=request['resource_kind'],
|
||||
resource_id=request['resource_id'],generation=None,
|
||||
lease_expires_at=datetime.fromtimestamp(min(claim.lease_until,time.time()+30,deadline),timezone.utc).isoformat(),
|
||||
expected_revision=int(row['revision']))
|
||||
effect_lease=self.jobs.get_execution_lease(job.job_id)
|
||||
fence_generation=int(effect_lease['generation'])
|
||||
self._advance(job.job_id,JobState.EXECUTING,side_effect_state='IN_PROGRESS')
|
||||
# Last authorization/claim check directly before the registered host boundary.
|
||||
self.authorization.authorize(principal,**self._scope(task))
|
||||
if self.store.get_task(task_id).status=='CANCELLED' or self.store.get_claim(claim_id).lease_until<=time.time():
|
||||
raise ServiceError('CLAIM_EXPIRED_OR_CANCELLED')
|
||||
self.jobs.assert_execution_lease(job.job_id,generation=fence_generation,lease_id=claim_id)
|
||||
if time.time()>=deadline: raise ServiceError('EXECUTION_DEADLINE_EXPIRED',408)
|
||||
observation=self.host.status(meta['component_id'],meta['environment'],tenant_id=meta['tenant_id'],deadline=deadline)
|
||||
if time.time()>=deadline or self.store.get_claim(claim_id).lease_until<=time.time():
|
||||
raise ServiceError('EXECUTION_DEADLINE_EXPIRED',408)
|
||||
envelope=self.signer.sign({'task_id':task_id,'job_id':job.job_id,'effect_id':meta['effect_id'],
|
||||
'tenant_id':meta['tenant_id'],'subject_id':meta['subject_id'],'mission_id':meta['mission_id'],
|
||||
'grant_id':meta['grant_id'],'claim_id':claim_id,'generation':fence_generation,
|
||||
'operation':meta['operation'],'operation_version':str(operation.version),
|
||||
'input_digest':request['input_digest'],'resource_kind':request['resource_kind'],
|
||||
'resource_id':request['resource_id'],'observation':observation,'verified':True})
|
||||
self._advance(job.job_id,JobState.LOCAL_VERIFIED,side_effect_state='READBACK_VERIFIED',receipt=envelope)
|
||||
self._advance(job.job_id,JobState.COMPLETED,side_effect_state='READBACK_VERIFIED',receipt=envelope)
|
||||
self._close_task(task,claim,envelope)
|
||||
return self.get_task(user,task_id)
|
||||
except (sqlite3.Error,OSError) as exc:
|
||||
self.storage_blocked=True
|
||||
raise ServiceError('STORAGE_RECOVERY_REQUIRED',503) from exc
|
||||
except HostError as exc:
|
||||
try:
|
||||
self._advance(job.job_id,JobState.RECONCILIATION_REQUIRED,side_effect_state='UNKNOWN')
|
||||
with self.store._transaction():
|
||||
self.store.conn.execute('UPDATE baseline_task_runtime SET blocker_code=? WHERE task_id=?',(str(exc),task_id))
|
||||
except (sqlite3.Error,OSError,JobError): self.storage_blocked=True
|
||||
raise ServiceError(str(exc),503) from exc
|
||||
except JobError as exc:
|
||||
if 'UNAVAILABLE' in str(exc) or 'STORAGE' in str(exc): self.storage_blocked=True
|
||||
raise ServiceError(str(exc),503 if self.storage_blocked else 409) from exc
|
||||
|
||||
def _receipt_valid(self,task,envelope):
|
||||
meta=task.task_plan.metadata['baseline']
|
||||
if not self.signer.verify(envelope): return False
|
||||
payload=envelope['payload']
|
||||
with self.store._transaction():
|
||||
runtime=self.store.conn.execute('SELECT job_id FROM baseline_task_runtime WHERE task_id=?',(task.task_id,)).fetchone()
|
||||
if not runtime or not runtime['job_id']: return False
|
||||
expected={**{k:meta[k] for k in ('effect_id','tenant_id','subject_id','mission_id','grant_id','operation')},
|
||||
'task_id':task.task_id,'job_id':runtime['job_id'],'operation_version':str(meta['operation_version']),
|
||||
'input_digest':digest(meta['inputs']),'resource_kind':'systemd-service',
|
||||
'resource_id':digest({'tenant':meta['tenant_id'],'environment':meta['environment'],'component':meta['component_id']})}
|
||||
if any(payload.get(k)!=v for k,v in expected.items()) or payload.get('verified') is not True: return False
|
||||
try:
|
||||
lease=self.jobs.get_execution_lease(runtime['job_id'])
|
||||
source_claim=self.store.get_claim(payload.get('claim_id'))
|
||||
if payload.get('claim_id')!=lease['lease_id'] or payload.get('generation')!=lease['generation']: return False
|
||||
if source_claim.task_id!=task.task_id or source_claim.operation_digest!=task.task_plan.operation_digest(): return False
|
||||
except (JobError,TaskPlaneHTTPError,TypeError): return False
|
||||
observation=payload.get('observation')
|
||||
if type(observation) is not dict: return False
|
||||
for k in ('operation','tenant_id','component_id','environment'):
|
||||
if observation.get(k)!=meta[k]: return False
|
||||
binding=meta['tenant_id']+':'+meta['environment']+':'+meta['component_id']
|
||||
if observation.get('unit')!=self.host.bindings.get(binding) or observation.get('source')!='systemd.show': return False
|
||||
return True
|
||||
|
||||
def _close_task(self,task,claim,envelope):
|
||||
if not self._receipt_valid(task,envelope): raise ServiceError('RESULT_ATTESTATION_INVALID',403)
|
||||
current=self.store.get_task(task.task_id)
|
||||
if current.status=='CANCELLED': raise ServiceError('TASK_CANCELLED')
|
||||
if current.status=='CLAIMED': self.store.update_task_status(task.task_id,current.revision,'RUNNING')
|
||||
self.store.complete_claim(task.task_id,claim.claim_id,'COMPLETED',envelope,
|
||||
evidence_entries=[('diagnostic',envelope,claim.worker_identity)])
|
||||
|
||||
def cancel_task(self,identity,task_id):
|
||||
self._user(identity)
|
||||
task=self._task(identity,task_id)
|
||||
self.store.cancel_task_with_event(task_id,task.revision,'cancelled by authenticated subject')
|
||||
return self.get_task(identity,task_id)
|
||||
|
||||
def _verify_probe(self,probe):
|
||||
try:
|
||||
self.signer.public_key.verify(base64.b64decode(probe.signature,validate=True),canonical(probe.digest_payload()))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def recover(self):
|
||||
"""Reconcile expired baseline claims from durable signed receipts; never fake a result."""
|
||||
with self._execute_lock:
|
||||
return self._recover_locked()
|
||||
|
||||
def _recover_locked(self):
|
||||
recovered=[]
|
||||
with self.store._transaction():
|
||||
rows=self.store.conn.execute("SELECT claim_id FROM claims WHERE status='ACTIVE' AND lease_until<?",(time.time(),)).fetchall()
|
||||
for row in rows:
|
||||
claim=self.store.get_claim(row['claim_id'])
|
||||
task=self.store.get_task(claim.task_id)
|
||||
meta=task.task_plan.metadata.get('baseline')
|
||||
if not meta: continue
|
||||
with self.store._transaction():
|
||||
rt=self.store.conn.execute('SELECT job_id FROM baseline_task_runtime WHERE task_id=?',(task.task_id,)).fetchone()
|
||||
outcome=ProbeOutcome.NOT_APPLIED
|
||||
ref='read-only-no-persistent-effect'
|
||||
if rt and rt['job_id']:
|
||||
job=self.jobs.store.read(rt['job_id'])
|
||||
env=json.loads(job['receipt_json']) if job.get('receipt_json') else None
|
||||
if job['state']=='COMPLETED':
|
||||
if self._receipt_valid(task,env):
|
||||
outcome=ProbeOutcome.APPLIED_VERIFIED
|
||||
ref=digest(env)
|
||||
else: outcome=ProbeOutcome.INDETERMINATE
|
||||
elif self.operations.get(meta['operation']).effect_class!='READ_ONLY':
|
||||
outcome=ProbeOutcome.INDETERMINATE
|
||||
now=time.time()
|
||||
probe=ProbeEvidence.create(task_id=task.task_id,claim_id=claim.claim_id,worker_id=claim.worker_id,
|
||||
worker_identity=claim.worker_identity,operation_digest=claim.operation_digest,outcome=outcome,
|
||||
observed_at=now,expires_at=now+30,probe_executor_identity='baseline-reconciler',attestation_ref=ref)
|
||||
from dataclasses import replace
|
||||
probe=replace(probe,signature=base64.b64encode(self.signer.private_key.sign(canonical(probe.digest_payload()))).decode())
|
||||
self.store.recover_claim_with_probe_and_event(task.task_id,claim.claim_id,probe)
|
||||
if outcome==ProbeOutcome.NOT_APPLIED and rt and rt['job_id']:
|
||||
job=self.jobs.store.read(rt['job_id'])
|
||||
if job['state']!='COMPLETED':
|
||||
self._advance(rt['job_id'],JobState.RECONCILIATION_REQUIRED,side_effect_state='UNKNOWN')
|
||||
self.jobs.reconcile(rt['job_id'],lambda _:{'state':'REQUESTED','side_effect_state':'READ_ONLY_SAFE_TO_RETRY'})
|
||||
recovered.append({'task_id':task.task_id,'outcome':outcome.value})
|
||||
# A successful writable transaction permits recovery processing, not blind effect replay.
|
||||
with self.store._transaction():
|
||||
self.store.conn.execute('UPDATE baseline_task_runtime SET blocker_code=blocker_code WHERE 0')
|
||||
try:
|
||||
self.jobs.assert_effect_admission()
|
||||
self.storage_blocked=False
|
||||
except JobError:
|
||||
self.storage_blocked=True
|
||||
return recovered
|
||||
@@ -0,0 +1,513 @@
|
||||
"""Authenticated loopback HTTP transport for the Baseline 1.1 service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
import ipaddress
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
import threading
|
||||
from typing import Any, Mapping
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import quote, unquote, urlsplit
|
||||
from urllib.request import HTTPRedirectHandler, ProxyHandler, Request, build_opener
|
||||
|
||||
from .service import Identity, ServiceError
|
||||
|
||||
|
||||
MAX_REQUEST_BYTES = 65_536
|
||||
MAX_RESPONSE_BYTES = 1_048_576
|
||||
MAX_DEADLINE_SECONDS = 30
|
||||
_IDENTIFIER = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.:-]{0,127}")
|
||||
_IDENTITY_FIELDS = frozenset({"tenant_id", "subject_id", "role", "worker_id", "identity"})
|
||||
|
||||
|
||||
class TransportError(RuntimeError):
|
||||
def __init__(self, code: str, status: int | None = None):
|
||||
self.code = code
|
||||
self.status = status
|
||||
super().__init__(code)
|
||||
|
||||
|
||||
class _RequestError(RuntimeError):
|
||||
def __init__(self, code: str, status: int):
|
||||
self.code = code
|
||||
self.status = status
|
||||
super().__init__(code)
|
||||
|
||||
|
||||
def _safe_identifier(value: Any, code: str = "HTTP_PATH_INVALID") -> str:
|
||||
if not isinstance(value, str) or _IDENTIFIER.fullmatch(value) is None:
|
||||
raise _RequestError(code, 400)
|
||||
return value
|
||||
|
||||
|
||||
def _has_identity_field(value: Any) -> bool:
|
||||
if isinstance(value, Mapping):
|
||||
if any(key in _IDENTITY_FIELDS for key in value):
|
||||
return True
|
||||
return any(_has_identity_field(item) for item in value.values())
|
||||
if isinstance(value, list):
|
||||
return any(_has_identity_field(item) for item in value)
|
||||
return False
|
||||
|
||||
|
||||
def _loopback_host(host: str, code: str) -> str:
|
||||
if not isinstance(host, str):
|
||||
raise TransportError(code)
|
||||
try:
|
||||
address = ipaddress.ip_address(host)
|
||||
except ValueError as exc:
|
||||
raise TransportError(code) from exc
|
||||
if not address.is_loopback or address.version != 4:
|
||||
raise TransportError(code)
|
||||
return str(address)
|
||||
|
||||
|
||||
class _BaselineHTTPServer(ThreadingHTTPServer):
|
||||
daemon_threads = True
|
||||
allow_reuse_address = True
|
||||
|
||||
def __init__(self, address, handler, service, identities):
|
||||
self.baseline_service = service
|
||||
self.trusted_identities = identities
|
||||
self.request_slots=threading.BoundedSemaphore(16)
|
||||
super().__init__(address, handler)
|
||||
|
||||
def get_request(self):
|
||||
connection, address = super().get_request()
|
||||
connection.settimeout(MAX_DEADLINE_SECONDS)
|
||||
return connection, address
|
||||
|
||||
def process_request(self,request,client_address):
|
||||
if not self.request_slots.acquire(blocking=False):
|
||||
try:
|
||||
body=b'{"error":{"code":"HTTP_CAPACITY_EXCEEDED"}}'
|
||||
request.sendall(b'HTTP/1.1 503 Service Unavailable\r\nContent-Type: application/json\r\nConnection: close\r\nContent-Length: '+str(len(body)).encode()+b'\r\n\r\n'+body)
|
||||
except OSError: pass
|
||||
self.shutdown_request(request)
|
||||
return
|
||||
try: super().process_request(request,client_address)
|
||||
except Exception:
|
||||
self.request_slots.release()
|
||||
raise
|
||||
|
||||
def process_request_thread(self,request,client_address):
|
||||
try: super().process_request_thread(request,client_address)
|
||||
finally: self.request_slots.release()
|
||||
|
||||
|
||||
class _Handler(BaseHTTPRequestHandler):
|
||||
protocol_version = "HTTP/1.1"
|
||||
server_version = "AIOSBaselineHTTP/1"
|
||||
sys_version = ""
|
||||
|
||||
def log_message(self, _format, *_args):
|
||||
# Request paths can contain tenant-owned identifiers; do not emit them or
|
||||
# bearer-related failure details through the default stderr logger.
|
||||
return
|
||||
|
||||
def send_error(self, code, message=None, explain=None):
|
||||
# BaseHTTPRequestHandler otherwise emits an HTML page which may include
|
||||
# parser details. Keep pre-dispatch protocol failures in the same closed
|
||||
# JSON error format.
|
||||
status = code if isinstance(code, int) and 400 <= code <= 599 else 400
|
||||
self._send(status, {"error": {"code": "HTTP_PROTOCOL_ERROR"}})
|
||||
|
||||
def _send(self, status: int, value: Any) -> None:
|
||||
try:
|
||||
body = json.dumps(
|
||||
value, sort_keys=True, separators=(",", ":"), ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
except (TypeError, ValueError):
|
||||
status = 500
|
||||
body = b'{"error":{"code":"HTTP_RESPONSE_INVALID"}}'
|
||||
if len(body) > MAX_RESPONSE_BYTES:
|
||||
status = 500
|
||||
body = b'{"error":{"code":"HTTP_RESPONSE_TOO_LARGE"}}'
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.send_header("Connection", "close")
|
||||
self.end_headers()
|
||||
try:
|
||||
self.wfile.write(body)
|
||||
except OSError:
|
||||
# A peer can disconnect or let its deadline expire while a response
|
||||
# is being written. Do not let the server print a handler traceback.
|
||||
pass
|
||||
self.close_connection = True
|
||||
|
||||
def _error(self, error: _RequestError) -> None:
|
||||
self._send(error.status, {"error": {"code": error.code}})
|
||||
|
||||
def _deadline(self) -> float:
|
||||
values = self.headers.get_all("X-AIOS-Deadline", [])
|
||||
if len(values) != 1:
|
||||
raise _RequestError("HTTP_DEADLINE_REQUIRED", 400)
|
||||
try:
|
||||
deadline = float(values[0])
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise _RequestError("HTTP_DEADLINE_INVALID", 400) from exc
|
||||
now = time.time()
|
||||
if not math.isfinite(deadline) or deadline > now + MAX_DEADLINE_SECONDS:
|
||||
raise _RequestError("HTTP_DEADLINE_INVALID", 400)
|
||||
if deadline <= now:
|
||||
raise _RequestError("HTTP_DEADLINE_EXPIRED", 408)
|
||||
self.connection.settimeout(deadline - now)
|
||||
return deadline
|
||||
|
||||
def _identity(self) -> Identity:
|
||||
values = self.headers.get_all("Authorization", [])
|
||||
if len(values) != 1 or not values[0].startswith("Bearer "):
|
||||
raise _RequestError("HTTP_UNAUTHORIZED", 401)
|
||||
token = values[0][7:]
|
||||
if not token or any(ord(char) < 33 or ord(char) > 126 for char in token):
|
||||
raise _RequestError("HTTP_UNAUTHORIZED", 401)
|
||||
identity = self.server.trusted_identities.get(token)
|
||||
if identity is None:
|
||||
raise _RequestError("HTTP_UNAUTHORIZED", 401)
|
||||
return identity
|
||||
|
||||
def _json_body(self) -> dict:
|
||||
if self.headers.get("Transfer-Encoding") is not None:
|
||||
raise _RequestError("HTTP_TRANSFER_ENCODING_FORBIDDEN", 400)
|
||||
content_types = self.headers.get_all("Content-Type", [])
|
||||
if content_types != ["application/json"]:
|
||||
raise _RequestError("HTTP_CONTENT_TYPE_INVALID", 415)
|
||||
lengths = self.headers.get_all("Content-Length", [])
|
||||
if len(lengths) != 1 or not lengths[0].isdigit():
|
||||
raise _RequestError("HTTP_CONTENT_LENGTH_INVALID", 411)
|
||||
length = int(lengths[0])
|
||||
if length > MAX_REQUEST_BYTES:
|
||||
raise _RequestError("HTTP_BODY_TOO_LARGE", 413)
|
||||
if length <= 0:
|
||||
raise _RequestError("HTTP_BODY_REQUIRED", 400)
|
||||
raw = self.rfile.read(length)
|
||||
if len(raw) != length:
|
||||
raise _RequestError("HTTP_BODY_TRUNCATED", 400)
|
||||
try:
|
||||
def unique_object(pairs):
|
||||
result = {}
|
||||
for key, item in pairs:
|
||||
if key in result:
|
||||
raise _RequestError("HTTP_JSON_DUPLICATE_KEY", 400)
|
||||
result[key] = item
|
||||
return result
|
||||
|
||||
def reject_constant(_value):
|
||||
raise _RequestError("HTTP_JSON_NON_FINITE", 400)
|
||||
|
||||
value = json.loads(
|
||||
raw.decode("utf-8"), object_pairs_hook=unique_object,
|
||||
parse_constant=reject_constant,
|
||||
)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise _RequestError("HTTP_JSON_INVALID", 400) from exc
|
||||
if type(value) is not dict:
|
||||
raise _RequestError("HTTP_JSON_OBJECT_REQUIRED", 400)
|
||||
if _has_identity_field(value):
|
||||
raise _RequestError("HTTP_IDENTITY_FIELD_FORBIDDEN", 400)
|
||||
return value
|
||||
|
||||
def _path(self) -> list[str]:
|
||||
parsed = urlsplit(self.path)
|
||||
if parsed.query or parsed.fragment or not parsed.path.startswith("/"):
|
||||
raise _RequestError("HTTP_PATH_INVALID", 400)
|
||||
raw_segments = parsed.path.split("/")[1:]
|
||||
if any(not segment for segment in raw_segments):
|
||||
raise _RequestError("HTTP_NOT_FOUND", 404)
|
||||
segments = [unquote(segment) for segment in raw_segments]
|
||||
if any("/" in segment or "\\" in segment for segment in segments):
|
||||
raise _RequestError("HTTP_PATH_INVALID", 400)
|
||||
return segments
|
||||
|
||||
@staticmethod
|
||||
def _exact(body: Mapping[str, Any], fields: set[str]) -> None:
|
||||
if set(body) != fields:
|
||||
raise _RequestError("HTTP_BODY_SCHEMA_INVALID", 400)
|
||||
|
||||
def _dispatch(self, method: str, identity: Identity, segments: list[str], body: dict | None):
|
||||
service = self.server.baseline_service
|
||||
if method == "POST" and segments == ["v1", "goals", "submit"]:
|
||||
self._exact(body, {"request", "idempotency_key"})
|
||||
if type(body["request"]) is not dict:
|
||||
raise _RequestError("HTTP_BODY_SCHEMA_INVALID", 400)
|
||||
return 201, service.submit_goal(
|
||||
identity, body["request"], idempotency_key=body["idempotency_key"]
|
||||
)
|
||||
if method == "GET" and len(segments) == 3 and segments[:2] == ["v1", "tasks"]:
|
||||
return 200, service.get_task(identity, _safe_identifier(segments[2]))
|
||||
if (
|
||||
method == "POST" and len(segments) == 4
|
||||
and segments[:2] == ["v1", "tasks"] and segments[3] == "cancel"
|
||||
):
|
||||
self._exact(body, set())
|
||||
return 200, service.cancel_task(identity, _safe_identifier(segments[2]))
|
||||
if (
|
||||
method == "GET" and len(segments) == 4
|
||||
and segments[:2] == ["v1", "missions"] and segments[3] == "explain"
|
||||
):
|
||||
return 200, service.explain(identity, _safe_identifier(segments[2]))
|
||||
if method == "POST" and segments == ["v1", "workers", "register"]:
|
||||
self._exact(body, set())
|
||||
return 200, service.register_worker(identity)
|
||||
if method == "POST" and segments == ["v1", "workers", "heartbeat"]:
|
||||
self._exact(body, set())
|
||||
return 200, service.heartbeat(identity)
|
||||
if method == "POST" and segments == ["v1", "workers", "claim"]:
|
||||
self._exact(body, set())
|
||||
return 200, service.claim_next(identity)
|
||||
if method == "POST" and segments == ["v1", "workers", "renew"]:
|
||||
self._exact(body, {"task_id", "claim_id"})
|
||||
return 200, service.renew(
|
||||
identity, _safe_identifier(body["task_id"]), _safe_identifier(body["claim_id"])
|
||||
)
|
||||
if method == "POST" and segments == ["v1", "workers", "execute"]:
|
||||
self._exact(body, {"task_id", "claim_id"})
|
||||
return 200, service.execute_claim(
|
||||
identity, _safe_identifier(body["task_id"]), _safe_identifier(body["claim_id"]),deadline=self.request_deadline
|
||||
)
|
||||
raise _RequestError("HTTP_NOT_FOUND", 404)
|
||||
|
||||
def _handle(self, method: str) -> None:
|
||||
try:
|
||||
self.request_deadline=self._deadline()
|
||||
identity = self._identity()
|
||||
segments = self._path()
|
||||
body = self._json_body() if method == "POST" else None
|
||||
if method == "GET":
|
||||
lengths = self.headers.get_all("Content-Length", [])
|
||||
if self.headers.get("Transfer-Encoding") is not None:
|
||||
raise _RequestError("HTTP_GET_BODY_FORBIDDEN", 400)
|
||||
if len(lengths) > 1 or (lengths and (not lengths[0].isdigit() or int(lengths[0]) != 0)):
|
||||
raise _RequestError("HTTP_GET_BODY_FORBIDDEN", 400)
|
||||
status, result = self._dispatch(method, identity, segments, body)
|
||||
self._send(status, result)
|
||||
except _RequestError as exc:
|
||||
self._error(exc)
|
||||
except ServiceError as exc:
|
||||
status = exc.status if isinstance(exc.status, int) and 400 <= exc.status <= 599 else 500
|
||||
self._send(status, {"error": {"code": exc.code}})
|
||||
except TimeoutError:
|
||||
self._send(408, {"error": {"code": "HTTP_DEADLINE_EXPIRED"}})
|
||||
except Exception:
|
||||
# No exception string, traceback, request headers, or token crosses
|
||||
# this boundary.
|
||||
self._send(500, {"error": {"code": "HTTP_INTERNAL_ERROR"}})
|
||||
|
||||
def do_GET(self):
|
||||
self._handle("GET")
|
||||
|
||||
def do_POST(self):
|
||||
self._handle("POST")
|
||||
|
||||
def _method_not_allowed(self):
|
||||
try:
|
||||
self._deadline()
|
||||
self._identity()
|
||||
self._send(405, {"error": {"code": "HTTP_METHOD_NOT_ALLOWED"}})
|
||||
except _RequestError as exc:
|
||||
self._error(exc)
|
||||
|
||||
do_PUT = _method_not_allowed
|
||||
do_PATCH = _method_not_allowed
|
||||
do_DELETE = _method_not_allowed
|
||||
do_OPTIONS = _method_not_allowed
|
||||
do_HEAD = _method_not_allowed
|
||||
do_TRACE = _method_not_allowed
|
||||
do_CONNECT = _method_not_allowed
|
||||
|
||||
|
||||
def serve(
|
||||
service: Any,
|
||||
tokens: Mapping[str, Identity],
|
||||
host: str = "127.0.0.1",
|
||||
port: int = 0,
|
||||
) -> ThreadingHTTPServer:
|
||||
"""Build an unstarted real HTTP server bound only to an IPv4 loopback."""
|
||||
host = _loopback_host(host, "HTTP_LOOPBACK_REQUIRED")
|
||||
if type(port) is not int or not 0 <= port <= 65_535:
|
||||
raise TransportError("HTTP_PORT_INVALID")
|
||||
if not isinstance(tokens, Mapping) or not tokens:
|
||||
raise TransportError("HTTP_TRUSTED_IDENTITIES_REQUIRED")
|
||||
identities: dict[str, Identity] = {}
|
||||
for token, identity in tokens.items():
|
||||
if (
|
||||
not isinstance(token, str) or not 32<=len(token)<=512
|
||||
or any(ord(char) < 33 or ord(char) > 126 for char in token)
|
||||
or not isinstance(identity, Identity)
|
||||
):
|
||||
raise TransportError("HTTP_TRUSTED_IDENTITY_INVALID")
|
||||
identities[token] = identity
|
||||
return _BaselineHTTPServer((host, port), _Handler, service, identities)
|
||||
|
||||
|
||||
class _NoRedirect(HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
return None
|
||||
|
||||
|
||||
class HttpTransport:
|
||||
"""Small urllib client for the loopback API; redirects and proxies are off."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
token: str,
|
||||
*,
|
||||
timeout_seconds: float = 15,
|
||||
max_response_bytes: int = MAX_RESPONSE_BYTES,
|
||||
):
|
||||
try:
|
||||
parsed = urlsplit(base_url)
|
||||
parsed_port = parsed.port
|
||||
except ValueError as exc:
|
||||
raise TransportError("TRANSPORT_BASE_URL_INVALID") from exc
|
||||
if (
|
||||
parsed.scheme != "http" or parsed.username is not None or parsed.password is not None
|
||||
or parsed.query or parsed.fragment or parsed.path not in {"", "/"}
|
||||
or parsed.hostname is None or parsed_port is None
|
||||
):
|
||||
raise TransportError("TRANSPORT_BASE_URL_INVALID")
|
||||
_loopback_host(parsed.hostname, "TRANSPORT_LOOPBACK_REQUIRED")
|
||||
if not isinstance(token, str) or not 32<=len(token)<=512 or any(
|
||||
ord(char) < 33 or ord(char) > 126 for char in token
|
||||
):
|
||||
raise TransportError("TRANSPORT_TOKEN_INVALID")
|
||||
if (
|
||||
isinstance(timeout_seconds, bool)
|
||||
or not isinstance(timeout_seconds, (int, float))
|
||||
or not math.isfinite(float(timeout_seconds))
|
||||
or not 0.1 <= timeout_seconds <= MAX_DEADLINE_SECONDS
|
||||
):
|
||||
raise TransportError("TRANSPORT_TIMEOUT_INVALID")
|
||||
if type(max_response_bytes) is not int or not 1 <= max_response_bytes <= 4 * 1024 * 1024:
|
||||
raise TransportError("TRANSPORT_RESPONSE_LIMIT_INVALID")
|
||||
self.base_url = f"http://{parsed.hostname}:{parsed_port}"
|
||||
self.token = token
|
||||
self.timeout_seconds = float(timeout_seconds)
|
||||
self.max_response_bytes = max_response_bytes
|
||||
self._opener = build_opener(ProxyHandler({}), _NoRedirect())
|
||||
|
||||
def _decode(self, response) -> Any:
|
||||
content_type = response.headers.get("Content-Type", "").split(";", 1)[0]
|
||||
if content_type != "application/json":
|
||||
raise TransportError("TRANSPORT_RESPONSE_TYPE_INVALID")
|
||||
declared = response.headers.get("Content-Length")
|
||||
if declared is None or not declared.isdigit() or int(declared) > self.max_response_bytes:
|
||||
raise TransportError("TRANSPORT_RESPONSE_TOO_LARGE")
|
||||
raw = response.read(self.max_response_bytes + 1)
|
||||
if len(raw) > self.max_response_bytes or len(raw) != int(declared):
|
||||
raise TransportError("TRANSPORT_RESPONSE_SIZE_INVALID")
|
||||
try:
|
||||
def unique_object(pairs):
|
||||
result = {}
|
||||
for key, item in pairs:
|
||||
if key in result:
|
||||
raise ValueError("duplicate")
|
||||
result[key] = item
|
||||
return result
|
||||
|
||||
return json.loads(
|
||||
raw.decode("utf-8"), object_pairs_hook=unique_object,
|
||||
parse_constant=lambda _value: (_ for _ in ()).throw(ValueError("non-finite")),
|
||||
)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc:
|
||||
raise TransportError("TRANSPORT_RESPONSE_JSON_INVALID") from exc
|
||||
|
||||
def _request(self, method: str, path: str, body: Mapping[str, Any] | None = None) -> Any:
|
||||
if not isinstance(path, str) or not path.startswith("/") or "?" in path or "#" in path:
|
||||
raise TransportError("TRANSPORT_PATH_INVALID")
|
||||
data = None
|
||||
headers = {
|
||||
"Authorization": "Bearer " + self.token,
|
||||
"Accept": "application/json",
|
||||
"X-AIOS-Deadline": str(time.time() + self.timeout_seconds),
|
||||
}
|
||||
if method == "POST":
|
||||
if not isinstance(body, Mapping):
|
||||
raise TransportError("TRANSPORT_BODY_INVALID")
|
||||
try:
|
||||
data = json.dumps(
|
||||
body, sort_keys=True, separators=(",", ":"), ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise TransportError("TRANSPORT_BODY_INVALID") from exc
|
||||
if not data or len(data) > MAX_REQUEST_BYTES:
|
||||
raise TransportError("TRANSPORT_BODY_TOO_LARGE")
|
||||
headers["Content-Type"] = "application/json"
|
||||
request = Request(self.base_url + path, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with self._opener.open(request, timeout=self.timeout_seconds) as response:
|
||||
return self._decode(response)
|
||||
except HTTPError as exc:
|
||||
if 300 <= exc.code < 400:
|
||||
exc.close()
|
||||
raise TransportError("TRANSPORT_REDIRECT_REJECTED", exc.code) from None
|
||||
try:
|
||||
payload = self._decode(exc)
|
||||
except TransportError:
|
||||
payload = None
|
||||
except (TimeoutError, OSError):
|
||||
raise TransportError("TRANSPORT_UNAVAILABLE") from None
|
||||
finally:
|
||||
exc.close()
|
||||
code = None
|
||||
if isinstance(payload, Mapping) and isinstance(payload.get("error"), Mapping):
|
||||
code = payload["error"].get("code")
|
||||
if not isinstance(code, str) or _IDENTIFIER.fullmatch(code) is None:
|
||||
code = "TRANSPORT_HTTP_ERROR"
|
||||
raise TransportError(code, exc.code) from None
|
||||
except (URLError, TimeoutError, OSError):
|
||||
raise TransportError("TRANSPORT_UNAVAILABLE") from None
|
||||
|
||||
def submit_goal(self, request: Mapping[str, Any], *, idempotency_key: str) -> dict:
|
||||
return self._request(
|
||||
"POST", "/v1/goals/submit",
|
||||
{"request": request, "idempotency_key": idempotency_key},
|
||||
)
|
||||
|
||||
def get_task(self, task_id: str) -> dict:
|
||||
return self._request("GET", "/v1/tasks/" + quote(task_id, safe=""))
|
||||
|
||||
def cancel_task(self, task_id: str) -> dict:
|
||||
return self._request("POST", "/v1/tasks/" + quote(task_id, safe="") + "/cancel", {})
|
||||
|
||||
def explain(self, mission_id: str) -> dict:
|
||||
return self._request("GET", "/v1/missions/" + quote(mission_id, safe="") + "/explain")
|
||||
|
||||
def register_worker(self) -> dict:
|
||||
return self._request("POST", "/v1/workers/register", {})
|
||||
|
||||
def heartbeat(self) -> dict:
|
||||
return self._request("POST", "/v1/workers/heartbeat", {})
|
||||
|
||||
def claim_next(self) -> dict | None:
|
||||
return self._request("POST", "/v1/workers/claim", {})
|
||||
|
||||
def renew(self, task_id: str, claim_id: str) -> dict:
|
||||
return self._request(
|
||||
"POST", "/v1/workers/renew", {"task_id": task_id, "claim_id": claim_id}
|
||||
)
|
||||
|
||||
def execute_claim(self, task_id: str, claim_id: str) -> dict:
|
||||
return self._request(
|
||||
"POST", "/v1/workers/execute", {"task_id": task_id, "claim_id": claim_id}
|
||||
)
|
||||
|
||||
# Goal-oriented names used by thin clients.
|
||||
submit = submit_goal
|
||||
query = get_task
|
||||
cancel = cancel_task
|
||||
|
||||
|
||||
Transport = HttpTransport
|
||||
|
||||
__all__ = ["HttpTransport", "Transport", "TransportError", "serve"]
|
||||
@@ -0,0 +1,204 @@
|
||||
"""Verification-only Ed25519 boundaries for release and acceptance evidence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
from types import MappingProxyType
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
from cryptography.exceptions import InvalidSignature
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
|
||||
|
||||
from .lineage import VerifiedAcceptanceEvidence, canonical_artifact
|
||||
from .release import VerifiedReceipt
|
||||
|
||||
|
||||
RELEASE_RECEIPT_DOMAIN = b"aios.release-operation-receipt.v1"
|
||||
ACCEPTANCE_EVIDENCE_DOMAIN = b"aios.release-acceptance-evidence.v1"
|
||||
|
||||
_ENVELOPE_FIELDS = {"schema", "key_id", "payload", "signature"}
|
||||
_RECEIPT_FIELDS = {
|
||||
"receipt_id", "tenant_id", "subject_id", "component_id", "task_id",
|
||||
"source_task_id", "source_effect_id", "grant_id", "job_id", "effect_id", "operation_id",
|
||||
"deployment_id", "release_id", "artifact", "outcome", "observed", "observed_at",
|
||||
}
|
||||
_ACCEPTANCE_FIELDS = {
|
||||
"acceptance_id", "tenant_id", "subject_id", "component_id", "deployment_id", "release_id",
|
||||
"artifact", "accepted", "evidence_id", "observed", "observed_at",
|
||||
}
|
||||
_B64URL = re.compile(r"[A-Za-z0-9_-]+")
|
||||
|
||||
|
||||
class SignatureVerificationError(ValueError):
|
||||
"""A configured trust root did not authenticate an exact evidence payload."""
|
||||
|
||||
|
||||
def _canonical(value: Any) -> bytes:
|
||||
try:
|
||||
return json.dumps(
|
||||
value, ensure_ascii=False, sort_keys=True, separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise SignatureVerificationError("signed payload is not canonical JSON") from exc
|
||||
|
||||
|
||||
def _decode_urlsafe(value: Any, name: str, expected_size: int) -> bytes:
|
||||
if not isinstance(value, str) or not value or not _B64URL.fullmatch(value) or "=" in value:
|
||||
raise SignatureVerificationError(f"invalid {name} encoding")
|
||||
try:
|
||||
raw = base64.urlsafe_b64decode(value + "=" * (-len(value) % 4))
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise SignatureVerificationError(f"invalid {name} encoding") from exc
|
||||
if len(raw) != expected_size:
|
||||
raise SignatureVerificationError(f"invalid {name} size")
|
||||
return raw
|
||||
|
||||
|
||||
def _public_key(value: Any) -> Ed25519PublicKey:
|
||||
if isinstance(value, Ed25519PublicKey):
|
||||
return value
|
||||
if isinstance(value, bytes):
|
||||
raw = value
|
||||
elif isinstance(value, str):
|
||||
raw = _decode_urlsafe(value, "trusted public key", 32)
|
||||
else:
|
||||
raise ValueError("trusted public key must be Ed25519PublicKey, raw bytes, or base64url")
|
||||
if len(raw) != 32:
|
||||
raise ValueError("trusted Ed25519 public key must be 32 bytes")
|
||||
return Ed25519PublicKey.from_public_bytes(raw)
|
||||
|
||||
|
||||
def _trusted_keys(values: Mapping[str, Any]) -> Mapping[str, Ed25519PublicKey]:
|
||||
if not isinstance(values, Mapping) or not values:
|
||||
raise ValueError("trusted_public_keys must be a non-empty mapping")
|
||||
keys: dict[str, Ed25519PublicKey] = {}
|
||||
for key_id, value in values.items():
|
||||
if not isinstance(key_id, str) or not key_id or len(key_id) > 128:
|
||||
raise ValueError("invalid trusted key_id")
|
||||
keys[key_id] = _public_key(value)
|
||||
return MappingProxyType(keys)
|
||||
|
||||
|
||||
def _verified_payload(
|
||||
envelope: Any,
|
||||
keys: Mapping[str, Ed25519PublicKey],
|
||||
*,
|
||||
schema: str,
|
||||
domain: bytes,
|
||||
payload_fields: set[str],
|
||||
) -> Mapping[str, Any]:
|
||||
if not isinstance(envelope, Mapping) or set(envelope) != _ENVELOPE_FIELDS:
|
||||
raise SignatureVerificationError("signed envelope fields invalid")
|
||||
if envelope.get("schema") != schema:
|
||||
raise SignatureVerificationError("signed envelope schema mismatch")
|
||||
key_id = envelope.get("key_id")
|
||||
if not isinstance(key_id, str) or key_id not in keys:
|
||||
raise SignatureVerificationError("untrusted signing key_id")
|
||||
payload = envelope.get("payload")
|
||||
if not isinstance(payload, Mapping) or set(payload) != payload_fields:
|
||||
raise SignatureVerificationError("signed payload fields invalid")
|
||||
signature = _decode_urlsafe(envelope.get("signature"), "signature", 64)
|
||||
try:
|
||||
keys[key_id].verify(signature, domain + b"\x00" + _canonical(dict(payload)))
|
||||
except InvalidSignature as exc:
|
||||
raise SignatureVerificationError("Ed25519 signature invalid") from exc
|
||||
return payload
|
||||
|
||||
|
||||
def _text(payload: Mapping[str, Any], name: str) -> str:
|
||||
value = payload.get(name)
|
||||
if not isinstance(value, str) or not value or len(value) > 512:
|
||||
raise SignatureVerificationError(f"signed {name} invalid")
|
||||
return value
|
||||
|
||||
|
||||
def _expected(payload: Mapping[str, Any], expected: Mapping[str, Any], fields: tuple[str, ...]) -> None:
|
||||
for name in fields:
|
||||
if name not in expected:
|
||||
raise SignatureVerificationError(f"verifier expected {name} missing")
|
||||
actual = canonical_artifact(payload[name]) if name == "artifact" else payload[name]
|
||||
wanted = canonical_artifact(expected[name]) if name == "artifact" else expected[name]
|
||||
if actual != wanted:
|
||||
raise SignatureVerificationError(f"signed {name} binding mismatch")
|
||||
|
||||
|
||||
def ed25519_release_receipt_verifier(
|
||||
trusted_public_keys: Mapping[str, Any],
|
||||
) -> Callable[[Any, Mapping[str, Any]], VerifiedReceipt]:
|
||||
"""Build a receipt verifier from local/configured public keys only."""
|
||||
|
||||
keys = _trusted_keys(trusted_public_keys)
|
||||
|
||||
def verify(envelope: Any, expected: Mapping[str, Any]) -> VerifiedReceipt:
|
||||
payload = _verified_payload(
|
||||
envelope, keys,
|
||||
schema="aios.release-operation-receipt-envelope.v1",
|
||||
domain=RELEASE_RECEIPT_DOMAIN,
|
||||
payload_fields=_RECEIPT_FIELDS,
|
||||
)
|
||||
_expected(
|
||||
payload, expected,
|
||||
("tenant_id", "subject_id", "component_id", "task_id", "source_task_id", "source_effect_id",
|
||||
"grant_id", "job_id", "effect_id", "operation_id", "deployment_id", "release_id", "artifact"),
|
||||
)
|
||||
if payload.get("outcome") != "SUCCEEDED":
|
||||
raise SignatureVerificationError("signed receipt outcome is not SUCCEEDED")
|
||||
if not isinstance(payload.get("observed"), Mapping):
|
||||
raise SignatureVerificationError("signed receipt observation invalid")
|
||||
return VerifiedReceipt(
|
||||
receipt_id=_text(payload, "receipt_id"), tenant_id=_text(payload, "tenant_id"),
|
||||
subject_id=_text(payload, "subject_id"), effect_id=_text(payload, "effect_id"),
|
||||
component_id=_text(payload,"component_id"), task_id=_text(payload,"task_id"),
|
||||
source_task_id=_text(payload,"source_task_id"), source_effect_id=_text(payload,"source_effect_id"),
|
||||
grant_id=_text(payload,"grant_id"), job_id=_text(payload,"job_id"),
|
||||
operation_id=_text(payload, "operation_id"), deployment_id=_text(payload, "deployment_id"),
|
||||
release_id=_text(payload, "release_id"), artifact=payload["artifact"],
|
||||
observed=dict(payload["observed"]), outcome="SUCCEEDED",
|
||||
observed_at=_text(payload, "observed_at"),
|
||||
)
|
||||
|
||||
return verify
|
||||
|
||||
|
||||
def ed25519_acceptance_evidence_verifier(
|
||||
trusted_public_keys: Mapping[str, Any],
|
||||
) -> Callable[[Any, Mapping[str, Any]], VerifiedAcceptanceEvidence]:
|
||||
"""Build an acceptance verifier in a domain separate from stage receipts."""
|
||||
|
||||
keys = _trusted_keys(trusted_public_keys)
|
||||
|
||||
def verify(envelope: Any, expected: Mapping[str, Any]) -> VerifiedAcceptanceEvidence:
|
||||
payload = _verified_payload(
|
||||
envelope, keys,
|
||||
schema="aios.release-acceptance-evidence-envelope.v1",
|
||||
domain=ACCEPTANCE_EVIDENCE_DOMAIN,
|
||||
payload_fields=_ACCEPTANCE_FIELDS,
|
||||
)
|
||||
_expected(
|
||||
payload, expected,
|
||||
("acceptance_id", "tenant_id", "subject_id", "component_id", "deployment_id", "release_id", "artifact", "accepted"),
|
||||
)
|
||||
if type(payload.get("accepted")) is not bool:
|
||||
raise SignatureVerificationError("signed accepted decision invalid")
|
||||
if not isinstance(payload.get("observed"), Mapping):
|
||||
raise SignatureVerificationError("signed acceptance observation invalid")
|
||||
return VerifiedAcceptanceEvidence(
|
||||
acceptance_id=_text(payload, "acceptance_id"), tenant_id=_text(payload, "tenant_id"),
|
||||
subject_id=_text(payload, "subject_id"), deployment_id=_text(payload, "deployment_id"),
|
||||
component_id=_text(payload,"component_id"),
|
||||
release_id=_text(payload, "release_id"), artifact=payload["artifact"],
|
||||
accepted=payload["accepted"], evidence_id=_text(payload, "evidence_id"),
|
||||
observed=dict(payload["observed"]), observed_at=_text(payload, "observed_at"),
|
||||
)
|
||||
|
||||
return verify
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ACCEPTANCE_EVIDENCE_DOMAIN", "RELEASE_RECEIPT_DOMAIN",
|
||||
"SignatureVerificationError", "ed25519_acceptance_evidence_verifier",
|
||||
"ed25519_release_receipt_verifier",
|
||||
]
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Thin resident worker loop over the authenticated HTTP transport.
|
||||
|
||||
Worker policy, operation handlers, effects, receipts, and completion remain in
|
||||
the trusted server. This process only maintains its registered transport
|
||||
identity and asks the service to run an offered claim.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from .transport import TransportError
|
||||
|
||||
|
||||
class WorkerError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class OperationWorker:
|
||||
HEARTBEAT_INTERVAL_SECONDS = 15.0
|
||||
|
||||
def __init__(self, transport: Any, poll_seconds: float = 1.0, stop_event=None):
|
||||
required = (
|
||||
"register_worker", "heartbeat", "claim_next", "renew", "execute_claim"
|
||||
)
|
||||
if transport is None or any(not callable(getattr(transport, name, None)) for name in required):
|
||||
raise WorkerError("WORKER_TRANSPORT_INVALID")
|
||||
if (
|
||||
isinstance(poll_seconds, bool)
|
||||
or not isinstance(poll_seconds, (int, float))
|
||||
or not math.isfinite(float(poll_seconds))
|
||||
or not 0.001 <= poll_seconds <= 60
|
||||
):
|
||||
raise WorkerError("WORKER_POLL_INTERVAL_INVALID")
|
||||
if stop_event is not None and (
|
||||
not callable(getattr(stop_event, "is_set", None))
|
||||
or not callable(getattr(stop_event, "wait", None))
|
||||
):
|
||||
raise WorkerError("WORKER_STOP_EVENT_INVALID")
|
||||
self.transport = transport
|
||||
self.poll_seconds = float(poll_seconds)
|
||||
self.stop_event = stop_event if stop_event is not None else threading.Event()
|
||||
self.registered = False
|
||||
self.worker_id: str | None = None
|
||||
self._last_heartbeat = 0.0
|
||||
self.last_error: str | None = None
|
||||
|
||||
def _register(self) -> None:
|
||||
result = self.transport.register_worker()
|
||||
if (
|
||||
not isinstance(result, dict) or result.get("registered") is not True
|
||||
or not isinstance(result.get("worker_id"), str) or not result["worker_id"]
|
||||
):
|
||||
raise WorkerError("WORKER_REGISTRATION_RESULT_INVALID")
|
||||
self.worker_id = result["worker_id"]
|
||||
self.registered = True
|
||||
|
||||
def _heartbeat(self, *, force: bool = False) -> None:
|
||||
now = time.monotonic()
|
||||
if not force and now < self._last_heartbeat + self.HEARTBEAT_INTERVAL_SECONDS:
|
||||
return
|
||||
result = self.transport.heartbeat()
|
||||
if (
|
||||
not isinstance(result, dict) or result.get("alive") is not True
|
||||
or result.get("worker_id") != self.worker_id
|
||||
):
|
||||
raise WorkerError("WORKER_HEARTBEAT_RESULT_INVALID")
|
||||
self._last_heartbeat = time.monotonic()
|
||||
|
||||
def run_once(self):
|
||||
"""Perform one register/heartbeat/claim/renew/execute iteration."""
|
||||
if self.stop_event.is_set():
|
||||
return None
|
||||
just_registered = False
|
||||
if not self.registered:
|
||||
self._register()
|
||||
just_registered = True
|
||||
self._heartbeat(force=just_registered)
|
||||
claim = self.transport.claim_next()
|
||||
if claim is None:
|
||||
self.last_error = None
|
||||
return None
|
||||
required = {"task_id", "claim_id", "generation", "lease_until", "operation"}
|
||||
if not isinstance(claim, dict) or set(claim) != required:
|
||||
raise WorkerError("WORKER_CLAIM_RESULT_INVALID")
|
||||
task_id = claim["task_id"]
|
||||
claim_id = claim["claim_id"]
|
||||
if not isinstance(task_id, str) or not task_id or not isinstance(claim_id, str) or not claim_id:
|
||||
raise WorkerError("WORKER_CLAIM_RESULT_INVALID")
|
||||
renewed = self.transport.renew(task_id, claim_id)
|
||||
if (
|
||||
not isinstance(renewed, dict) or renewed.get("renewed") is not True
|
||||
or renewed.get("task_id") != task_id or renewed.get("claim_id") != claim_id
|
||||
):
|
||||
raise WorkerError("WORKER_RENEW_RESULT_INVALID")
|
||||
result = self.transport.execute_claim(task_id, claim_id)
|
||||
if not isinstance(result, dict) or result.get("task_id") != task_id:
|
||||
raise WorkerError("WORKER_EXECUTION_RESULT_INVALID")
|
||||
self.last_error = None
|
||||
return result
|
||||
|
||||
def run_forever(self) -> None:
|
||||
"""Run until the supplied stop event is set, using interruptible waits."""
|
||||
while not self.stop_event.is_set():
|
||||
try:
|
||||
self.run_once()
|
||||
except TransportError as exc:
|
||||
# Transport failures are observable and retried on the next
|
||||
# bounded poll. Re-register after a server restart; programming
|
||||
# and schema errors still terminate.
|
||||
self.last_error = exc.code
|
||||
self.registered = False
|
||||
self.stop_event.wait(self.poll_seconds)
|
||||
|
||||
|
||||
Worker = OperationWorker
|
||||
|
||||
__all__ = ["OperationWorker", "Worker", "WorkerError"]
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Registered worker eligibility; fallback is deployment policy, never a special actor."""
|
||||
from dataclasses import dataclass
|
||||
import math
|
||||
from typing import Mapping
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkerProfile:
|
||||
worker_id: str
|
||||
identity: str
|
||||
tenant_scope: tuple[str, ...]
|
||||
subject_scope: tuple[str, ...]
|
||||
capabilities: tuple[str, ...]
|
||||
priority: int
|
||||
fallback_executor: bool
|
||||
heartbeat_seconds: int
|
||||
max_concurrency: int
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: Mapping):
|
||||
if set(value) != set(cls.__dataclass_fields__):
|
||||
raise ValueError('WORKER_PROFILE_FIELDS_INVALID')
|
||||
data = dict(value)
|
||||
for name in ('worker_id', 'identity'):
|
||||
if not isinstance(data[name], str) or not data[name] or len(data[name]) > 128:
|
||||
raise ValueError('WORKER_ID_INVALID')
|
||||
for name in ('tenant_scope', 'subject_scope', 'capabilities'):
|
||||
items = data[name]
|
||||
if not isinstance(items, (list, tuple)) or not items or any(not isinstance(x, str) or not x or '*' in x for x in items):
|
||||
raise ValueError('WORKER_SCOPE_INVALID')
|
||||
data[name] = tuple(sorted(set(items)))
|
||||
if type(data['fallback_executor']) is not bool:
|
||||
raise ValueError('WORKER_FALLBACK_INVALID')
|
||||
for name, lower, upper in (('priority', 0, 1000), ('heartbeat_seconds', 1, 300), ('max_concurrency', 1, 32)):
|
||||
if type(data[name]) is not int or not lower <= data[name] <= upper:
|
||||
raise ValueError('WORKER_LIMIT_INVALID')
|
||||
return cls(**data)
|
||||
|
||||
|
||||
def eligible(worker: WorkerProfile, task: Mapping, *, now: float, last_heartbeat: float,
|
||||
fallback_seconds: int = 600) -> bool:
|
||||
ready_at = task.get('ready_at')
|
||||
if any(type(value) not in (int,float) or not math.isfinite(value) for value in (ready_at,now,last_heartbeat)) or ready_at > now:
|
||||
return False
|
||||
if last_heartbeat > now or now > last_heartbeat + worker.heartbeat_seconds * 3:
|
||||
return False
|
||||
if task.get('tenant_id') not in worker.tenant_scope or task.get('subject_id') not in worker.subject_scope:
|
||||
return False
|
||||
if task.get('operation') not in worker.capabilities:
|
||||
return False
|
||||
return not worker.fallback_executor or now >= ready_at + fallback_seconds
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Governed API facade for branch-bound repair leases.
|
||||
|
||||
The facade is transport-neutral so the permanent control plane can expose it
|
||||
through its existing authenticated loopback/API seam without creating a second
|
||||
Git writer or a global credential registry.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping
|
||||
|
||||
from branch_repair_lease import LeaseError, RepairLease, RepairLeaseManager, sanitized_lease_view
|
||||
|
||||
|
||||
class BranchRepairLeaseAPI:
|
||||
"""Validate operation envelopes and return sanitized lease metadata."""
|
||||
|
||||
def __init__(self, manager: RepairLeaseManager) -> None:
|
||||
self.manager = manager
|
||||
|
||||
@staticmethod
|
||||
def _required(request: Mapping[str, Any], name: str) -> Any:
|
||||
value = request.get(name)
|
||||
if value is None or value == "":
|
||||
raise LeaseError(f"missing required field: {name}")
|
||||
return value
|
||||
|
||||
@staticmethod
|
||||
def _view(lease: RepairLease) -> dict[str, Any]:
|
||||
return {"status": "ok", "lease": sanitized_lease_view(lease)}
|
||||
|
||||
def handle(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
try:
|
||||
operation = self._required(request, "operation")
|
||||
owner = str(self._required(request, "owner_identity"))
|
||||
if operation == "issue":
|
||||
lease = self.manager.issue(
|
||||
task_id=str(self._required(request, "task_id")),
|
||||
owner_identity=owner,
|
||||
branch=str(self._required(request, "branch")),
|
||||
description=str(self._required(request, "description")),
|
||||
allowed_paths=tuple(self._required(request, "allowed_paths")),
|
||||
ttl_seconds=int(request.get("ttl_seconds", 3600)),
|
||||
)
|
||||
elif operation == "attach_existing_branch":
|
||||
lease = self.manager.attach_existing_branch(
|
||||
str(self._required(request, "lease_id")),
|
||||
owner_identity=owner,
|
||||
expected_head_oid=str(self._required(request, "expected_head_oid")),
|
||||
)
|
||||
elif operation == "authorize_commit":
|
||||
lease = self.manager.authorize_commit(
|
||||
str(self._required(request, "lease_id")),
|
||||
owner_identity=owner,
|
||||
expected_head_oid=str(self._required(request, "expected_head_oid")),
|
||||
paths=tuple(self._required(request, "paths")),
|
||||
content=request.get("content"),
|
||||
)
|
||||
elif operation == "record_commit":
|
||||
lease = self.manager.record_commit(
|
||||
str(self._required(request, "lease_id")),
|
||||
owner_identity=owner,
|
||||
expected_head_oid=str(self._required(request, "expected_head_oid")),
|
||||
new_head_oid=str(self._required(request, "new_head_oid")),
|
||||
paths=tuple(self._required(request, "paths")),
|
||||
)
|
||||
elif operation == "recover":
|
||||
lease = self.manager.recover(
|
||||
str(self._required(request, "lease_id")), owner_identity=owner
|
||||
)
|
||||
elif operation == "renew":
|
||||
lease = self.manager.renew(
|
||||
str(self._required(request, "lease_id")),
|
||||
owner_identity=owner,
|
||||
ttl_seconds=int(self._required(request, "ttl_seconds")),
|
||||
)
|
||||
elif operation == "complete":
|
||||
lease = self.manager.complete(
|
||||
str(self._required(request, "lease_id")), owner_identity=owner
|
||||
)
|
||||
elif operation == "revoke":
|
||||
lease = self.manager.revoke(
|
||||
str(self._required(request, "lease_id")), owner_identity=owner
|
||||
)
|
||||
else:
|
||||
raise LeaseError("unsupported lease operation")
|
||||
return self._view(lease)
|
||||
except (LeaseError, TypeError, ValueError):
|
||||
return {"status": "blocked", "error": "lease_request_rejected"}
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Root-only CLI for the branch-bound repair lease API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from aios_branch_repair_lease_api import BranchRepairLeaseAPI
|
||||
from branch_repair_lease import GitProbe, LeaseStore
|
||||
from branch_repair_lease import RepairLeaseManager
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="AIOS branch repair lease CLI")
|
||||
parser.add_argument("--store", required=True)
|
||||
parser.add_argument("--bare-repo", required=True)
|
||||
parser.add_argument("--request-file")
|
||||
parser.add_argument("--request-json")
|
||||
args = parser.parse_args()
|
||||
if hasattr(os, "geteuid") and os.geteuid() != 0:
|
||||
print(json.dumps({"status": "blocked", "error": "root_required"}))
|
||||
return 2
|
||||
if bool(args.request_file) == bool(args.request_json):
|
||||
print(json.dumps({"status": "blocked", "error": "one_request_source_required"}))
|
||||
return 2
|
||||
try:
|
||||
raw = (
|
||||
Path(args.request_file).read_text(encoding="utf-8")
|
||||
if args.request_file
|
||||
else str(args.request_json)
|
||||
)
|
||||
request = json.loads(raw)
|
||||
if not isinstance(request, dict):
|
||||
raise ValueError("request must be an object")
|
||||
api = BranchRepairLeaseAPI(RepairLeaseManager(LeaseStore(args.store), GitProbe(args.bare_repo)))
|
||||
response = api.handle(request)
|
||||
print(json.dumps(response, ensure_ascii=True, sort_keys=True, separators=(",", ":")))
|
||||
return 0 if response.get("status") == "ok" else 1
|
||||
except Exception:
|
||||
print(json.dumps({"status": "blocked", "error": "lease_cli_failed"}))
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Read-only validation of recovered campaign authorization evidence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import stat
|
||||
from typing import Any
|
||||
|
||||
|
||||
class RecoveryEvidenceError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _digest(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for block in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
h.update(block)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def _secure_json(path: Path) -> dict[str, Any]:
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_MISSING")
|
||||
info = path.stat(follow_symlinks=False)
|
||||
if info.st_uid != 0 or stat.S_IMODE(info.st_mode) & 0o022:
|
||||
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_PERMISSIONS")
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_INVALID") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_OBJECT_REQUIRED")
|
||||
return value
|
||||
|
||||
|
||||
def load_recovered_authorization(path: str | Path) -> dict[str, Any]:
|
||||
"""Verify the recovery receipt, sidecar, and all referenced immutable files."""
|
||||
receipt_path = Path(path)
|
||||
receipt = _secure_json(receipt_path)
|
||||
sidecar = receipt_path.with_suffix(receipt_path.suffix + ".sha256")
|
||||
if sidecar.is_symlink() or not sidecar.is_file():
|
||||
raise RecoveryEvidenceError("RECOVERY_DIGEST_SIDECAR_MISSING")
|
||||
expected = sidecar.read_text(encoding="ascii").split()[0]
|
||||
actual = _digest(receipt_path)
|
||||
if expected != actual:
|
||||
raise RecoveryEvidenceError("RECOVERY_DIGEST_MISMATCH")
|
||||
required = {
|
||||
"schema",
|
||||
"campaign_id",
|
||||
"original_event_id",
|
||||
"original_authorization_digest",
|
||||
"signed_grant_id",
|
||||
"signed_grant_digest",
|
||||
"task_id",
|
||||
"repository",
|
||||
"original_base_oid",
|
||||
"branch",
|
||||
"expected_branch_oid",
|
||||
"recovered_from_existing_evidence",
|
||||
"new_owner_interaction",
|
||||
"recovery_state",
|
||||
"production_side_effect_executed",
|
||||
"global_writes_enabled",
|
||||
"grant_consumed",
|
||||
"owner_authorization_consumed",
|
||||
"original_receipts_immutable",
|
||||
"recovered_at",
|
||||
"evidence_files",
|
||||
}
|
||||
if set(receipt) != required or receipt["schema"] != "aios.owner-authorization-recovery.v1":
|
||||
raise RecoveryEvidenceError("RECOVERY_SCHEMA_INVALID")
|
||||
if receipt["recovered_from_existing_evidence"] is not True or receipt["new_owner_interaction"] is not False:
|
||||
raise RecoveryEvidenceError("RECOVERY_NOT_APPEND_ONLY")
|
||||
if receipt["production_side_effect_executed"] is not False or receipt["global_writes_enabled"] is not False:
|
||||
raise RecoveryEvidenceError("RECOVERY_SCOPE_INVALID")
|
||||
if receipt["recovery_state"] != "RECOVERED_PENDING_CONTINUATION_LEASE":
|
||||
raise RecoveryEvidenceError("RECOVERY_STATE_INVALID")
|
||||
evidence = receipt["evidence_files"]
|
||||
if not isinstance(evidence, list) or not evidence:
|
||||
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_LIST_INVALID")
|
||||
for item in evidence:
|
||||
if not isinstance(item, dict) or set(item) != {"path", "sha256"}:
|
||||
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_ENTRY_INVALID")
|
||||
target = Path(item["path"])
|
||||
if _digest(target) != item["sha256"]:
|
||||
raise RecoveryEvidenceError("RECOVERY_EVIDENCE_DIGEST_MISMATCH")
|
||||
return {
|
||||
"schema": receipt["schema"],
|
||||
"state": receipt["recovery_state"],
|
||||
"campaign_id": receipt["campaign_id"],
|
||||
"original_event_id": receipt["original_event_id"],
|
||||
"original_authorization_digest": receipt["original_authorization_digest"],
|
||||
"signed_grant_id": receipt["signed_grant_id"],
|
||||
"task_id": receipt["task_id"],
|
||||
"repository": receipt["repository"],
|
||||
"original_base_oid": receipt["original_base_oid"],
|
||||
"branch": receipt["branch"],
|
||||
"expected_branch_oid": receipt["expected_branch_oid"],
|
||||
"digest": actual,
|
||||
"new_owner_interaction": False,
|
||||
}
|
||||
@@ -1,567 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Task-bound bridge from the legacy ChatGPT Action to an isolated Writer V2."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import socket
|
||||
import stat
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||
from typing import Any
|
||||
|
||||
|
||||
CONFIG_PATH = Path(os.environ["AIOS_ACTION_ROUTE_BINDING"])
|
||||
POLICY_PATH = Path(os.environ["AIOS_ACTION_ROUTE_PATH_POLICY"])
|
||||
KNOWN_IDENTITIES_PATH = Path(os.environ["AIOS_ACTION_ROUTE_KNOWN_IDENTITIES"])
|
||||
STATE_PATH = Path(os.environ["AIOS_ACTION_ROUTE_STATE"])
|
||||
AUDIT_PATH = Path(os.environ["AIOS_ACTION_ROUTE_AUDIT"])
|
||||
HOST = os.environ.get("AIOS_ACTION_ROUTE_HOST", "127.0.0.1")
|
||||
PORT = int(os.environ.get("AIOS_ACTION_ROUTE_PORT", "19894"))
|
||||
EXPECTED_TASK_ID = os.environ["AIOS_ACTION_ROUTE_TASK_ID"]
|
||||
EXPECTED_ROUTE_HEADER = os.environ["AIOS_ACTION_ROUTE_HEADER"]
|
||||
AUTHORIZATION_SOCKET = Path(os.environ["AIOS_CONTINUOUS_AUTHORIZATION_SOCKET"])
|
||||
OID_RE = re.compile(r"[0-9a-f]{40}")
|
||||
SECRET_RE = re.compile(
|
||||
r"(?i)(api[_-]?key|secret[_-]?access[_-]?key|secret|password|token)"
|
||||
r"\s*[:=]\s*[\"']?([A-Za-z0-9_./+=:-]{16,})"
|
||||
)
|
||||
SECRET_VALUE_RE = re.compile(
|
||||
r"(sk-[A-Za-z0-9_-]{20,}|HPUA[A-Z0-9]{10,}|SDC[A-Za-z0-9]{20,}|"
|
||||
r"-----BEGIN (?:OPENSSH |RSA |EC )?PRIVATE KEY-----)"
|
||||
)
|
||||
ALLOWED_REQUEST_FIELDS = frozenset({"base","branch","content","create_branch","message","path","task_id","development_authorization_id","actor_identity","audience","approved_base_oid","expected_current_head_oid","operation_id","idempotency_key","exact_path","content_digest","commit_message_digest"})
|
||||
|
||||
|
||||
class RouteError(RuntimeError):
|
||||
def __init__(self, code: str, status: int = 400, **details: Any):
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.status = status
|
||||
self.details = details
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
def canonical_json(value: Any) -> bytes:
|
||||
return json.dumps(
|
||||
value, ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def load_root_managed_json(
|
||||
path: Path, *, expected_mode: int = 0o640
|
||||
) -> tuple[dict[str, Any], bytes]:
|
||||
info = os.lstat(path)
|
||||
if stat.S_ISLNK(info.st_mode) or not stat.S_ISREG(info.st_mode):
|
||||
raise RouteError("writer_route_config_type_rejected", 503)
|
||||
if info.st_uid != 0 or stat.S_IMODE(info.st_mode) & ~expected_mode:
|
||||
raise RouteError("writer_route_config_permissions_rejected", 503)
|
||||
raw = path.read_bytes()
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
raise RouteError("writer_route_config_json_rejected", 503) from None
|
||||
if not isinstance(value, dict):
|
||||
raise RouteError("writer_route_config_object_required", 503)
|
||||
return value, raw
|
||||
|
||||
|
||||
def load_binding() -> dict[str, Any]:
|
||||
binding, _ = load_root_managed_json(CONFIG_PATH)
|
||||
required = {
|
||||
"schema_version",
|
||||
"task_id",
|
||||
"writer_instance_id",
|
||||
"repository",
|
||||
"branch",
|
||||
"approved_base_oid",
|
||||
"writer_url",
|
||||
"path_policy_sha256",
|
||||
"action_identities",
|
||||
}
|
||||
if set(binding) != required or binding.get("schema_version") != 1:
|
||||
raise RouteError("writer_route_binding_schema_rejected", 503)
|
||||
if binding["task_id"] != EXPECTED_TASK_ID:
|
||||
raise RouteError("writer_route_task_binding_rejected", 503)
|
||||
if binding["repository"] != "aios/aios":
|
||||
raise RouteError("writer_route_repository_rejected", 503)
|
||||
if not OID_RE.fullmatch(str(binding["approved_base_oid"])):
|
||||
raise RouteError("writer_route_base_oid_rejected", 503)
|
||||
if binding["writer_url"] != "http://127.0.0.1:19893":
|
||||
raise RouteError("writer_route_upstream_rejected", 503)
|
||||
identities = binding.get("action_identities")
|
||||
if not isinstance(identities, list) or len(identities) != 1:
|
||||
raise RouteError("writer_route_action_identity_rejected", 503)
|
||||
identity = identities[0]
|
||||
if (
|
||||
not isinstance(identity, dict)
|
||||
or set(identity) != {"account", "token_sha256"}
|
||||
or identity.get("account") != "chatgpt-gitops-bot"
|
||||
or not re.fullmatch(
|
||||
r"[0-9a-f]{64}", str(identity.get("token_sha256", ""))
|
||||
)
|
||||
):
|
||||
raise RouteError("writer_route_action_identity_rejected", 503)
|
||||
return binding
|
||||
|
||||
|
||||
def allowed_path_scope(binding: dict[str, Any]) -> tuple[set[str], tuple[str, ...]]:
|
||||
policy, raw = load_root_managed_json(POLICY_PATH)
|
||||
actual = hashlib.sha256(raw).hexdigest()
|
||||
if actual != binding["path_policy_sha256"]:
|
||||
raise RouteError("writer_route_path_policy_digest_mismatch", 503)
|
||||
if policy.get("allowed_branch") != binding["branch"]:
|
||||
raise RouteError("writer_route_path_policy_branch_mismatch", 503)
|
||||
operation = (policy.get("allowed_operations") or {}).get("commit_file.atomic")
|
||||
paths = operation.get("exact_paths") if isinstance(operation, dict) else None
|
||||
prefixes = operation.get("prefix_paths") if isinstance(operation, dict) else None
|
||||
if not isinstance(paths, list) or not paths or not all(
|
||||
isinstance(x, str) for x in paths
|
||||
):
|
||||
raise RouteError("writer_route_path_policy_rejected", 503)
|
||||
if not isinstance(prefixes, list) or not prefixes or not all(
|
||||
isinstance(x, str) and x.endswith("/") for x in prefixes
|
||||
):
|
||||
raise RouteError("writer_route_path_policy_rejected", 503)
|
||||
if any(not valid_relative_path(x[:-1]) for x in prefixes):
|
||||
raise RouteError("writer_route_path_policy_rejected", 503)
|
||||
if any(not valid_relative_path(x) for x in paths):
|
||||
raise RouteError("writer_route_path_policy_rejected", 503)
|
||||
return set(paths), tuple(prefixes)
|
||||
|
||||
|
||||
def valid_relative_path(path: str) -> bool:
|
||||
if not isinstance(path, str) or not path or path != path.strip():
|
||||
return False
|
||||
if "\x00" in path or "\\" in path:
|
||||
return False
|
||||
try:
|
||||
decoded = urllib.parse.unquote(path, errors="strict")
|
||||
except (UnicodeDecodeError, ValueError):
|
||||
return False
|
||||
if decoded != path:
|
||||
return False
|
||||
posix = PurePosixPath(path)
|
||||
windows = PureWindowsPath(path)
|
||||
return not (
|
||||
posix.is_absolute()
|
||||
or windows.is_absolute()
|
||||
or bool(windows.drive)
|
||||
or any(part in {"", ".", ".."} for part in path.split("/"))
|
||||
)
|
||||
|
||||
|
||||
def path_allowed(binding: dict[str, Any], path: str) -> bool:
|
||||
if not valid_relative_path(path):
|
||||
return False
|
||||
exact_paths, prefixes = allowed_path_scope(binding)
|
||||
return path in exact_paths or any(path.startswith(prefix) for prefix in prefixes)
|
||||
|
||||
|
||||
def bearer(headers: Any) -> str:
|
||||
value = headers.get("Authorization", "")
|
||||
if not value.startswith("Bearer "):
|
||||
raise RouteError("unauthorized", 401)
|
||||
token = value[7:].strip()
|
||||
if not token:
|
||||
raise RouteError("unauthorized", 401)
|
||||
return token
|
||||
|
||||
|
||||
def authorize_action(headers: Any, binding: dict[str, Any]) -> str:
|
||||
digest = hashlib.sha256(bearer(headers).encode("utf-8")).hexdigest()
|
||||
identity = binding["action_identities"][0]
|
||||
if not hmac.compare_digest(identity["token_sha256"], digest):
|
||||
known, _ = load_root_managed_json(KNOWN_IDENTITIES_PATH)
|
||||
identities = known.get("identities")
|
||||
if known.get("schema_version") != 1 or not isinstance(identities, list):
|
||||
raise RouteError("writer_route_identity_catalog_rejected", 503)
|
||||
observed = "unregistered"
|
||||
allowed_accounts = {
|
||||
"chatgpt-gitops-bot",
|
||||
"gitops-write-bot",
|
||||
"codex-gitops-bot",
|
||||
}
|
||||
for candidate in identities:
|
||||
if not isinstance(candidate, dict) or set(candidate) != {
|
||||
"account",
|
||||
"token_sha256",
|
||||
}:
|
||||
raise RouteError("writer_route_identity_catalog_rejected", 503)
|
||||
account = str(candidate.get("account", ""))
|
||||
token_sha256 = str(candidate.get("token_sha256", ""))
|
||||
if account not in allowed_accounts or not re.fullmatch(
|
||||
r"[0-9a-f]{64}", token_sha256
|
||||
):
|
||||
raise RouteError("writer_route_identity_catalog_rejected", 503)
|
||||
if hmac.compare_digest(token_sha256, digest):
|
||||
observed = account
|
||||
raise RouteError(
|
||||
"writer_route_action_identity_rejected",
|
||||
403,
|
||||
accepted_action_identity=str(identity["account"]),
|
||||
observed_action_identity=observed,
|
||||
identity_alias_accepted=False,
|
||||
)
|
||||
return str(identity["account"])
|
||||
|
||||
|
||||
def authorization_call(operation: str, **payload: Any) -> dict[str, Any]:
|
||||
request = {"op": operation, **payload}
|
||||
try:
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
|
||||
client.settimeout(15)
|
||||
client.connect(str(AUTHORIZATION_SOCKET))
|
||||
client.sendall(canonical_json(request))
|
||||
raw = client.recv(131072)
|
||||
response = json.loads(raw.decode("utf-8"))
|
||||
except (OSError, TimeoutError, UnicodeDecodeError, json.JSONDecodeError):
|
||||
raise RouteError("authorization_controller_unavailable", 503) from None
|
||||
if not isinstance(response, dict) or not response.get("ok"):
|
||||
if not isinstance(response, dict):
|
||||
raise RouteError("authorization_controller_response_rejected", 503)
|
||||
details = {k: v for k, v in response.items() if k not in {"ok", "status", "error"}}
|
||||
raise RouteError(str(response.get("error") or "authorization_controller_rejected"), int(response.get("status") or 503), **details)
|
||||
result = response.get("result")
|
||||
if not isinstance(result, dict):
|
||||
raise RouteError("authorization_controller_response_rejected", 503)
|
||||
return result
|
||||
|
||||
|
||||
def writer_token() -> str:
|
||||
value = authorization_call("get_writer_token").get("token")
|
||||
if not isinstance(value, str) or len(value.encode("utf-8")) < 32:
|
||||
raise RouteError("writer_route_credential_rejected", 503)
|
||||
return value
|
||||
|
||||
|
||||
def writer_call(
|
||||
binding: dict[str, Any],
|
||||
method: str,
|
||||
path: str,
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> tuple[int, dict[str, Any]]:
|
||||
data = canonical_json(payload) if payload is not None else None
|
||||
headers = {"Authorization": "Bearer " + writer_token()}
|
||||
if data is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
request = urllib.request.Request(
|
||||
binding["writer_url"] + path, data=data, method=method, headers=headers
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=90) as response:
|
||||
body = json.loads(response.read())
|
||||
return response.status, body
|
||||
except urllib.error.HTTPError as exc:
|
||||
try:
|
||||
body = json.loads(exc.read())
|
||||
except Exception:
|
||||
body = {"error": "writer_route_upstream_non_json_error"}
|
||||
return exc.code, body
|
||||
except (OSError, TimeoutError):
|
||||
raise RouteError("writer_route_upstream_unavailable", 503) from None
|
||||
|
||||
|
||||
def validate_request(data: Any, binding: dict[str, Any]) -> dict[str, Any]:
|
||||
if not isinstance(data, dict):
|
||||
raise RouteError("writer_route_request_object_required")
|
||||
if not {"branch", "path", "content"}.issubset(data):
|
||||
raise RouteError("writer_route_request_incomplete")
|
||||
if not set(data).issubset(ALLOWED_REQUEST_FIELDS):
|
||||
raise RouteError("writer_route_request_fields_rejected")
|
||||
if data["branch"] != binding["branch"]:
|
||||
raise RouteError("writer_route_request_binding_failed", 403)
|
||||
if data.get("create_branch") not in (None, False):
|
||||
raise RouteError("writer_route_request_binding_failed", 403)
|
||||
if data.get("base", "main") not in {"main", binding["approved_base_oid"]}:
|
||||
raise RouteError("writer_route_request_binding_failed", 403)
|
||||
path = data["path"]
|
||||
if not isinstance(path, str) or not path_allowed(binding, path):
|
||||
raise RouteError("writer_route_request_binding_failed", 403)
|
||||
content = data["content"]
|
||||
if not isinstance(content, str) or "\x00" in content or "\r" in content:
|
||||
raise RouteError("writer_route_content_rejected")
|
||||
if len(content.encode("utf-8")) > 1_048_576:
|
||||
raise RouteError("writer_route_content_too_large", 413)
|
||||
if SECRET_RE.search(content) or SECRET_VALUE_RE.search(content):
|
||||
raise RouteError("writer_route_secret_content_rejected", 403)
|
||||
message = data.get("message") or f"chore(chatgpt): update {path}"
|
||||
if not isinstance(message, str) or not 1 <= len(message) <= 200 or "\x00" in message or "\n" in message or "\r" in message:
|
||||
raise RouteError("writer_route_message_rejected")
|
||||
return {"branch": binding["branch"], "path": path, "content": content, "message": message}
|
||||
|
||||
|
||||
def complete_request(data: dict[str, Any], normalized: dict[str, Any], binding: dict[str, Any], account: str) -> dict[str, Any]:
|
||||
authorization = authorization_call("describe_authorization")
|
||||
expected = {
|
||||
"task_id": binding["task_id"],
|
||||
"development_authorization_id": authorization.get("development_authorization_id"),
|
||||
"actor_identity": account,
|
||||
"audience": authorization.get("audience"),
|
||||
"branch": binding["branch"],
|
||||
"approved_base_oid": binding["approved_base_oid"],
|
||||
}
|
||||
required = {
|
||||
"task_id": EXPECTED_TASK_ID,
|
||||
"development_authorization_id": "authz-aios-autonomous-release-20260726",
|
||||
"actor_identity": "chatgpt-gitops-bot",
|
||||
"audience": "chatgpt-autonomous-release-writer",
|
||||
"branch": binding["branch"],
|
||||
"approved_base_oid": binding["approved_base_oid"],
|
||||
}
|
||||
if expected != required or authorization.get("authorization_status") != "ACTIVE" or authorization.get("current_head_oid") is None:
|
||||
raise RouteError("writer_route_request_binding_failed", 403)
|
||||
for key, value in required.items():
|
||||
if key in data and data[key] != value:
|
||||
raise RouteError("writer_route_request_binding_failed", 403)
|
||||
if "exact_path" in data and data["exact_path"] != normalized["path"]:
|
||||
raise RouteError("writer_route_request_binding_failed", 403)
|
||||
current_head = str(authorization["current_head_oid"])
|
||||
if not OID_RE.fullmatch(current_head):
|
||||
raise RouteError("authorization_current_head_rejected", 503)
|
||||
if "expected_current_head_oid" in data and data["expected_current_head_oid"] != current_head:
|
||||
raise RouteError("BRANCH_HEAD_CHANGED", 409, current_head_oid=current_head)
|
||||
content_digest = hashlib.sha256(normalized["content"].encode("utf-8")).hexdigest()
|
||||
message_digest = hashlib.sha256(normalized["message"].encode("utf-8")).hexdigest()
|
||||
if "content_digest" in data and data["content_digest"] != content_digest:
|
||||
raise RouteError("writer_route_request_binding_failed", 403)
|
||||
if "commit_message_digest" in data and data["commit_message_digest"] != message_digest:
|
||||
raise RouteError("writer_route_request_binding_failed", 403)
|
||||
seed = {**required, "expected_current_head_oid": current_head, "path": normalized["path"], "content_digest": content_digest, "commit_message_digest": message_digest}
|
||||
request_digest = hashlib.sha256(canonical_json(seed)).hexdigest()
|
||||
idempotency_key = "action-" + request_digest
|
||||
operation_id = hashlib.sha256((EXPECTED_TASK_ID + "\0" + idempotency_key).encode("utf-8")).hexdigest()
|
||||
if "idempotency_key" in data and data["idempotency_key"] != idempotency_key:
|
||||
raise RouteError("writer_route_request_binding_failed", 403)
|
||||
if "operation_id" in data and data["operation_id"] != operation_id:
|
||||
raise RouteError("writer_route_request_binding_failed", 403)
|
||||
return {**normalized, **required, "expected_current_head_oid": current_head, "operation_id": operation_id, "idempotency_key": idempotency_key, "exact_path": normalized["path"], "content_digest": content_digest, "commit_message_digest": message_digest, "request_digest": request_digest}
|
||||
|
||||
|
||||
def current_writer_head(binding: dict[str, Any]) -> str:
|
||||
status, body = writer_call(binding, "GET", "/api/runtime-status")
|
||||
if status != 200:
|
||||
raise RouteError(
|
||||
"writer_route_runtime_status_failed", 503, upstream_status=status
|
||||
)
|
||||
required = {
|
||||
"service": "chatgpt-autonomous-release-writer",
|
||||
"writes_enabled": True,
|
||||
"allowed_branch": binding["branch"],
|
||||
"approved_base_oid": binding["approved_base_oid"],
|
||||
"path_policy_sha256": binding["path_policy_sha256"],
|
||||
}
|
||||
if any(body.get(key) != value for key, value in required.items()):
|
||||
raise RouteError("writer_route_runtime_binding_mismatch", 503)
|
||||
head = str(body.get("workspace_head_oid", ""))
|
||||
if not OID_RE.fullmatch(head):
|
||||
raise RouteError("writer_route_runtime_head_rejected", 503)
|
||||
return head
|
||||
|
||||
|
||||
def operation_binding(
|
||||
request_digest: str, binding: dict[str, Any]
|
||||
) -> tuple[str, str]:
|
||||
STATE_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
with sqlite3.connect(STATE_PATH, timeout=30, isolation_level=None) as db:
|
||||
db.execute("PRAGMA journal_mode=WAL")
|
||||
db.execute("PRAGMA synchronous=FULL")
|
||||
db.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS action_operations (
|
||||
request_digest TEXT PRIMARY KEY,
|
||||
expected_head_oid TEXT NOT NULL,
|
||||
idempotency_key TEXT NOT NULL UNIQUE,
|
||||
created_at TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
row = db.execute(
|
||||
"SELECT expected_head_oid,idempotency_key FROM action_operations "
|
||||
"WHERE request_digest=?",
|
||||
(request_digest,),
|
||||
).fetchone()
|
||||
if row:
|
||||
return str(row[0]), str(row[1])
|
||||
head = current_writer_head(binding)
|
||||
key = "action-" + request_digest
|
||||
with sqlite3.connect(STATE_PATH, timeout=30, isolation_level=None) as db:
|
||||
db.execute("PRAGMA journal_mode=WAL")
|
||||
db.execute("PRAGMA synchronous=FULL")
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
db.execute(
|
||||
"INSERT OR IGNORE INTO action_operations"
|
||||
"(request_digest,expected_head_oid,idempotency_key,created_at) "
|
||||
"VALUES(?,?,?,?)",
|
||||
(request_digest, head, key, utc_now()),
|
||||
)
|
||||
row = db.execute(
|
||||
"SELECT expected_head_oid,idempotency_key FROM action_operations "
|
||||
"WHERE request_digest=?",
|
||||
(request_digest,),
|
||||
).fetchone()
|
||||
db.execute("COMMIT")
|
||||
return str(row[0]), str(row[1])
|
||||
|
||||
|
||||
def append_audit(event: dict[str, Any]) -> None:
|
||||
AUDIT_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_APPEND | getattr(os, "O_NOFOLLOW", 0)
|
||||
fd = os.open(AUDIT_PATH, flags, 0o600)
|
||||
try:
|
||||
os.write(fd, canonical_json(event) + b"\n")
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
class Handler(BaseHTTPRequestHandler):
|
||||
server_version = "AIOSActionTaskRouter/1"
|
||||
|
||||
def log_message(self, _format: str, *_args: Any) -> None:
|
||||
return
|
||||
|
||||
def send_json(self, status: int, value: dict[str, Any]) -> None:
|
||||
body = json.dumps(value, ensure_ascii=False, sort_keys=True).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def do_GET(self) -> None:
|
||||
path = urllib.parse.urlparse(self.path).path.rstrip("/") or "/"
|
||||
if path != "/health":
|
||||
return self.send_json(404, {"error": "not_found"})
|
||||
try:
|
||||
binding = load_binding()
|
||||
self.send_json(
|
||||
200,
|
||||
{
|
||||
"status": "ok",
|
||||
"service": "aios-action-task-router",
|
||||
"task_id": binding["task_id"],
|
||||
"writer_instance_id": binding["writer_instance_id"],
|
||||
"branch": binding["branch"],
|
||||
},
|
||||
)
|
||||
except RouteError as exc:
|
||||
self.send_json(exc.status, {"error": exc.code, **exc.details})
|
||||
|
||||
def do_POST(self) -> None:
|
||||
path = urllib.parse.urlparse(self.path).path.rstrip("/") or "/"
|
||||
if path != "/api/file":
|
||||
return self.send_json(404, {"error": "not_found"})
|
||||
if self.client_address[0] not in {"127.0.0.1", "::1"}:
|
||||
return self.send_json(403, {"error": "writer_route_source_rejected"})
|
||||
if self.headers.get("X-AIOS-Route-Task") != EXPECTED_ROUTE_HEADER:
|
||||
return self.send_json(403, {"error": "writer_route_header_rejected"})
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
if not 0 < length <= 1_100_000:
|
||||
raise RouteError("writer_route_content_length_rejected", 413)
|
||||
try:
|
||||
data = json.loads(self.rfile.read(length).decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
raise RouteError("writer_route_request_json_rejected") from None
|
||||
binding = load_binding()
|
||||
account = authorize_action(self.headers, binding)
|
||||
normalized = validate_request(data, binding)
|
||||
normalized = complete_request(data, normalized, binding, account)
|
||||
prepared = authorization_call("prepare_write", request={key: normalized[key] for key in ("expected_current_head_oid", "operation_id", "idempotency_key", "exact_path", "content_digest", "commit_message_digest", "branch", "path", "message")})
|
||||
expected_head = str(prepared["expected_head_oid"])
|
||||
writer_payload = {"branch": normalized["branch"], "path": normalized["path"], "content": normalized["content"], "message": normalized["message"], "expected_head_oid": expected_head, "idempotency_key": str(prepared["idempotency_key"])}
|
||||
request_digest = hashlib.sha256(canonical_json({"task_id": binding["task_id"], "operation_id": normalized["operation_id"], "idempotency_key": normalized["idempotency_key"], "content_digest": normalized["content_digest"], "commit_message_digest": normalized["commit_message_digest"]})).hexdigest()
|
||||
status, result = writer_call(
|
||||
binding, "POST", "/api/commit-file", writer_payload
|
||||
)
|
||||
audit = {
|
||||
"at": utc_now(),
|
||||
"task_id": binding["task_id"],
|
||||
"writer_instance_id": binding["writer_instance_id"],
|
||||
"account": account,
|
||||
"branch": normalized["branch"],
|
||||
"path": normalized["path"],
|
||||
"request_digest": request_digest,
|
||||
"expected_head_oid": expected_head,
|
||||
"status": status,
|
||||
"operation_id": result.get("operation_id"),
|
||||
"commit_oid": result.get("commit_oid"),
|
||||
"error": result.get("error"),
|
||||
"secrets_included": False,
|
||||
}
|
||||
append_audit(audit)
|
||||
if status != 200:
|
||||
try:
|
||||
authorization_call("fail_write", result={"operation_id": normalized["operation_id"]})
|
||||
except RouteError:
|
||||
pass
|
||||
return self.send_json(status, result)
|
||||
advanced = authorization_call("reconcile_commit", result={"previous_head_oid": expected_head, "new_commit_oid": result.get("commit_oid"), "operation_id": result.get("operation_id")})
|
||||
return self.send_json(
|
||||
200,
|
||||
{
|
||||
"status": "committed",
|
||||
"branch": normalized["branch"],
|
||||
"path": normalized["path"],
|
||||
"commit": {
|
||||
"status": "committed",
|
||||
"branch": normalized["branch"],
|
||||
"rev": result.get("commit_oid"),
|
||||
"previous_head_oid": expected_head,
|
||||
"new_tree_oid": result.get("new_tree_oid"),
|
||||
"push_status": result.get("push_status"),
|
||||
"four_way_ref_status": advanced.get("four_way_ref_status") is True,
|
||||
"sync": {"status": "verified", "target": "bare+git-ls-remote+gitea-api"},
|
||||
},
|
||||
"writer_instance_id": binding["writer_instance_id"],
|
||||
"operation_id": result.get("operation_id"),
|
||||
"idempotent": result.get("idempotent"),
|
||||
"secrets_included": False,
|
||||
},
|
||||
)
|
||||
except RouteError as exc:
|
||||
if exc.code == "writer_route_action_identity_rejected":
|
||||
try:
|
||||
append_audit(
|
||||
{
|
||||
"at": utc_now(),
|
||||
"task_id": EXPECTED_TASK_ID,
|
||||
"event": "action_identity_rejected",
|
||||
"accepted_action_identity": exc.details.get(
|
||||
"accepted_action_identity"
|
||||
),
|
||||
"observed_action_identity": exc.details.get(
|
||||
"observed_action_identity"
|
||||
),
|
||||
"identity_alias_accepted": False,
|
||||
"secrets_included": False,
|
||||
}
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
self.send_json(exc.status, {"error": exc.code, **exc.details})
|
||||
except Exception:
|
||||
self.send_json(500, {"error": "writer_route_internal_error"})
|
||||
|
||||
|
||||
def main() -> None:
|
||||
load_binding()
|
||||
server = ThreadingHTTPServer((HOST, PORT), Handler)
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Executable → Regular
+221
-11
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
import hashlib, hmac, json, os, re, subprocess, sys, time, urllib.parse, uuid
|
||||
import hashlib, hmac, json, os, re, subprocess, sys, time, urllib.parse, urllib.request, uuid
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -22,8 +22,15 @@ from gitea_tag_publication import (
|
||||
TagPublisher,
|
||||
redact,
|
||||
validate_version,
|
||||
validate_gitea_api_url,
|
||||
)
|
||||
from orchestrator.action_monitor import ActionMonitor
|
||||
try:
|
||||
from orchestrator.action_monitor import ActionMonitor
|
||||
except ModuleNotFoundError:
|
||||
# The deployed service normally supplies runtime/agent-os on sys.path.
|
||||
# Keep direct, isolated contract execution bound to the repository copy.
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "runtime" / "agent-os"))
|
||||
from orchestrator.action_monitor import ActionMonitor
|
||||
from silent_release_hardening import (
|
||||
ApprovalKeyRing,
|
||||
ApprovalRevocationStore,
|
||||
@@ -33,6 +40,11 @@ from silent_release_hardening import (
|
||||
SignedReleasePlanApproval,
|
||||
SilentReleaseActionService,
|
||||
)
|
||||
try:
|
||||
from aios_gitops_job_controller import GitOpsJobController, JobError, ServiceUnavailable, JobStore
|
||||
except ModuleNotFoundError:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from aios_gitops_job_controller import GitOpsJobController, JobError, ServiceUnavailable, JobStore
|
||||
try:
|
||||
from authoritative_git import (
|
||||
AuthoritativeGitError,
|
||||
@@ -58,6 +70,11 @@ except ModuleNotFoundError:
|
||||
repository_snapshot,
|
||||
run_authoritative_git,
|
||||
)
|
||||
try:
|
||||
from aios_repository_discovery import RepositoryDiscovery, RepositoryDiscoveryError
|
||||
except ModuleNotFoundError:
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from aios_repository_discovery import RepositoryDiscovery, RepositoryDiscoveryError
|
||||
|
||||
ROOT = Path(os.getenv('AIOS_GITOPS_ROOT', '/data/aios-gitops'))
|
||||
WS = Path(os.getenv('AIOS_GITOPS_WORKSPACE', str(ROOT / 'workspace')))
|
||||
@@ -103,6 +120,8 @@ READ_ONLY_GET_PATHS = frozenset({
|
||||
'/admin/ai-evolution/api/data',
|
||||
'/api/release-consistency', '/api/tag-publication-status',
|
||||
'/api/release-acceptance',
|
||||
'/api/repository/tree', '/api/repository/search',
|
||||
'/api/repository/diff-index', '/api/repository/file',
|
||||
'/getReleaseCandidate', '/getReleasePlan', '/getReleaseAcceptance',
|
||||
'/getApprovalEnvelope', '/getReleaseSaga', '/getCapabilityStatus',
|
||||
'/getRecoveryStatus',
|
||||
@@ -120,6 +139,12 @@ def reject_non_read_http(handler, method: str, path: str) -> bool:
|
||||
return False
|
||||
if path in SILENT_RELEASE_POST_PATHS:
|
||||
return False
|
||||
if (
|
||||
method == 'POST'
|
||||
and path in ('/api/file', '/api/commit-file')
|
||||
and os.getenv('AIOS_GITOPS_DURABLE_JOB_EXECUTION_ENABLED', os.getenv('AIOS_GITOPS_JOB_CONTROLLER_ENABLED', 'false')).lower() in {'1', 'true', 'yes', 'on'}
|
||||
):
|
||||
return False
|
||||
decision = evaluate_git_write(WS)
|
||||
if decision.allowed:
|
||||
return False
|
||||
@@ -297,8 +322,7 @@ def resolve_commit_oid(value: str | None, field: str = 'target_oid') -> str:
|
||||
raise ValueError(f'{field} does not resolve to the exact approved commit') from exc
|
||||
|
||||
def existing_tag_target(tag: str) -> str | None:
|
||||
if not re.fullmatch(r'V\d+R\d+C\d+(?:-[A-Za-z0-9_.-]+)?$', str(tag or '')):
|
||||
raise ValueError('invalid release version')
|
||||
validate_version(tag)
|
||||
try:
|
||||
result = run_authoritative_git(
|
||||
['rev-parse', '--verify', f'refs/tags/{tag}^{{commit}}'],
|
||||
@@ -331,9 +355,80 @@ def required_authoritative_git_dir() -> Path:
|
||||
return Path(str(snapshot["authoritative_git_dir"]))
|
||||
|
||||
|
||||
_DISCOVERY_ACTIONS = {
|
||||
'/api/repository/tree': (
|
||||
'list_repository_tree',
|
||||
{'repository', 'ref', 'limit'},
|
||||
{'prefix', 'depth', 'cursor'},
|
||||
),
|
||||
'/api/repository/search': (
|
||||
'search_repository_source',
|
||||
{'repository', 'ref', 'query', 'max_matches', 'max_bytes'},
|
||||
{'path_globs', 'cursor'},
|
||||
),
|
||||
'/api/repository/diff-index': (
|
||||
'get_diff_index',
|
||||
{'repository', 'from', 'to', 'limit'},
|
||||
{'path_prefix', 'cursor'},
|
||||
),
|
||||
'/api/repository/file': (
|
||||
'read_repository_file',
|
||||
{'repository', 'ref', 'path'},
|
||||
{'max_bytes'},
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def repository_discovery_request(
|
||||
path: str, query: dict[str, list[str]]
|
||||
) -> dict[str, Any]:
|
||||
operation, required, optional = _DISCOVERY_ACTIONS[path]
|
||||
if set(query) - required - optional:
|
||||
raise ValueError('unknown repository discovery query field')
|
||||
request: dict[str, Any] = {}
|
||||
for key, values in query.items():
|
||||
if key == 'path_globs':
|
||||
if not values:
|
||||
raise ValueError('empty repository discovery path glob')
|
||||
request[key] = list(values)
|
||||
continue
|
||||
if len(values) != 1:
|
||||
raise ValueError('duplicate repository discovery query field')
|
||||
value: Any = values[0]
|
||||
if key in {'depth', 'limit', 'max_matches', 'max_bytes'}:
|
||||
try:
|
||||
value = int(value, 10)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError('invalid repository discovery integer') from exc
|
||||
request[key] = value
|
||||
if required - set(request):
|
||||
raise ValueError('missing repository discovery query field')
|
||||
if not REPOSITORY_IDENTITY:
|
||||
raise RepositoryDiscoveryError('REPOSITORY_DISCOVERY_UNAVAILABLE')
|
||||
try:
|
||||
discovery = RepositoryDiscovery(
|
||||
allowed_repositories=(REPOSITORY_IDENTITY,),
|
||||
git_dir=required_authoritative_git_dir(),
|
||||
cursor_secret=os.getenv('AIOS_REPOSITORY_DISCOVERY_CURSOR_SECRET'),
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
if isinstance(exc, RepositoryDiscoveryError):
|
||||
raise
|
||||
raise RepositoryDiscoveryError('REPOSITORY_DISCOVERY_UNAVAILABLE') from exc
|
||||
public_to_internal = {
|
||||
'ref': 'revision',
|
||||
'path': 'file_path',
|
||||
'path_prefix': 'prefix',
|
||||
}
|
||||
internal_request = {
|
||||
public_to_internal.get(key, key): value for key, value in request.items()
|
||||
}
|
||||
return dict(getattr(discovery, operation)(internal_request))
|
||||
|
||||
|
||||
def publication_publisher() -> TagPublisher:
|
||||
authoritative_repo = required_authoritative_git_dir()
|
||||
return TagPublisher(
|
||||
publisher = TagPublisher(
|
||||
WS,
|
||||
GitTagBackend(WS, authoritative_repo=authoritative_repo),
|
||||
GiteaTagClient(
|
||||
@@ -344,6 +439,10 @@ def publication_publisher() -> TagPublisher:
|
||||
),
|
||||
os.getenv('AIOS_GITOPS_GITEA_REMOTE','gitea'),
|
||||
)
|
||||
publisher.baseline_path = Path(os.getenv(
|
||||
'AIOS_GITOPS_BASELINE_AUTHORITY', str(WS / 'baseline' / 'current.json')
|
||||
))
|
||||
return publisher
|
||||
|
||||
def commit(paths, msg):
|
||||
ensure_git(); paths = [relpath(p) for p in paths] if paths else ['.']; sh(['add'] + paths, True)
|
||||
@@ -393,15 +492,71 @@ class H(BaseHTTPRequestHandler):
|
||||
if not a['ok']: self.sendj({'error':'unauthorized','hint':'Bearer token required'},401); return None
|
||||
if '*' not in a['scopes'] and scope not in a['scopes']: self.sendj({'error':'forbidden','required_scope':scope,'account':a['account']},403); return None
|
||||
return a
|
||||
def bridge_release(self, path, data):
|
||||
route = {
|
||||
'/createTagApprovalRequest': '/v2/tag/approval-request',
|
||||
'/requestOrCreateTag': '/v2/tag/publish',
|
||||
'/getTagPublicationStatus': '/v2/tag/status',
|
||||
'/buildProductionArtifact': '/v2/release/artifact',
|
||||
'/executeApprovedProductionRelease': '/v2/release/execute',
|
||||
'/getReleaseConsistency': '/v2/release/consistency',
|
||||
'/runReleaseAcceptance': '/v2/release/acceptance',
|
||||
}.get(path)
|
||||
if route is None:
|
||||
raise ValueError('governed_release_route_invalid')
|
||||
base = os.getenv('AIOS_BRIDGE_V2_URL', 'http://127.0.0.1:18892').rstrip('/')
|
||||
request = urllib.request.Request(
|
||||
base + route,
|
||||
data=json.dumps(data, sort_keys=True, separators=(',', ':')).encode('utf-8'),
|
||||
headers={'Content-Type': 'application/json'},
|
||||
method='POST',
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=30) as response:
|
||||
result = json.loads(response.read().decode('utf-8'))
|
||||
return self.sendj(result, response.status)
|
||||
except urllib.error.HTTPError as exc:
|
||||
try:
|
||||
body = json.loads(exc.read().decode('utf-8'))
|
||||
except Exception:
|
||||
body = {'error': 'bridge_http_error'}
|
||||
return self.sendj(body, exc.code)
|
||||
except (urllib.error.URLError, TimeoutError) as exc:
|
||||
return self.sendj({'error': 'GOVERNED_BRIDGE_UNAVAILABLE', 'detail': type(exc).__name__}, 503)
|
||||
def do_POST(self):
|
||||
path = urllib.parse.urlparse(self.path).path
|
||||
if path in _DISCOVERY_ACTIONS:
|
||||
return self.sendj({'error': 'method_not_allowed'},405)
|
||||
if path in {
|
||||
'/createTagApprovalRequest', '/requestOrCreateTag', '/getTagPublicationStatus',
|
||||
'/buildProductionArtifact', '/executeApprovedProductionRelease',
|
||||
'/getReleaseConsistency', '/runReleaseAcceptance',
|
||||
}:
|
||||
actor = self.need('tag:create')
|
||||
if not actor:
|
||||
return
|
||||
return self.bridge_release(path, self.body())
|
||||
if reject_non_read_http(self, 'POST', path):
|
||||
return
|
||||
return self.sendj({'error': 'not_found', 'path': path}, 404)
|
||||
def do_GET(self):
|
||||
u=urllib.parse.urlparse(self.path); path=u.path.rstrip('/') if u.path != '/' else u.path
|
||||
if path not in READ_ONLY_GET_PATHS: return self.sendj({'error':'not_found','path':path},404)
|
||||
q=urllib.parse.parse_qs(u.query)
|
||||
if len(self.path) > 8192:
|
||||
return self.sendj({'error':'query_too_large'},413)
|
||||
try:
|
||||
q=urllib.parse.parse_qs(
|
||||
u.query, keep_blank_values=True, max_num_fields=64
|
||||
)
|
||||
except ValueError:
|
||||
return self.sendj({'error':'query_invalid'},400)
|
||||
try:
|
||||
if path in ('/health','/api/health'): return self.sendj({'status':'ok','service':'chatgpt-gitops-adapter','workspace':str(WS)})
|
||||
if path in ('/api/openapi.yaml','/api/openapi-chatgpt-actions.yaml','/api/schema'):
|
||||
return self.sendt(OPENAPI.read_text(encoding='utf-8'), 'application/yaml; charset=utf-8') if OPENAPI.exists() else self.sendj({'error':'schema_missing'},404)
|
||||
if path in ('/api','/api/status'): return self.sendj(api_spec())
|
||||
if path in _DISCOVERY_ACTIONS:
|
||||
return self.sendj(repository_discovery_request(path, q))
|
||||
if path in ('/api/base','/api/baseline','/base'):
|
||||
data=jload(WS/'baseline'/'current.json', {})
|
||||
data.setdefault('authoritative_repository', repository_snapshot(
|
||||
@@ -454,7 +609,15 @@ class H(BaseHTTPRequestHandler):
|
||||
return self.sendj({'error':redact(exc)},413)
|
||||
except (AuthoritativeGitError, RuntimeError) as exc:
|
||||
return self.sendj({'error':redact(exc)},503)
|
||||
return self.sendj({'from':frm,'to':to,'diff':diff})
|
||||
payload = {'from': frm, 'to': to, 'diff': diff}
|
||||
payload.update({
|
||||
key: diff[key]
|
||||
for key in (
|
||||
'from_oid', 'to_oid', 'changed_paths', 'stats',
|
||||
'total_size_bytes', 'truncated', 'patch_limit_bytes',
|
||||
)
|
||||
})
|
||||
return self.sendj(payload)
|
||||
if path in ('/api/patch','/api/patches','/api/branches','/patch'):
|
||||
snapshot = repository_snapshot(
|
||||
git_dir=AUTHORITATIVE_GIT_DIR,
|
||||
@@ -566,7 +729,8 @@ class H(BaseHTTPRequestHandler):
|
||||
return self.sendj(
|
||||
service.get_capability_status(candidate_id)
|
||||
)
|
||||
return self.sendj(service.get_release_saga(candidate_id))
|
||||
result = service.get_release_saga(candidate_id)
|
||||
return self.sendj(result, 404 if result.get('status') == 'NOT_FOUND' else 200)
|
||||
if path == '/getRecoveryStatus':
|
||||
return self.sendj(
|
||||
silent_release_service().get_recovery_status()
|
||||
@@ -579,6 +743,8 @@ class H(BaseHTTPRequestHandler):
|
||||
return self.sendj({'error':'not_found','path':path},404)
|
||||
except FileNotFoundError:
|
||||
return self.sendj({'error':'release candidate not found'},404)
|
||||
except RepositoryDiscoveryError as e:
|
||||
return self.sendj({'error': redact(e)}, getattr(e, 'status_code', 400))
|
||||
except ValueError as e:
|
||||
error=redact(e)
|
||||
monitor.record_failure('do_GET', error, {'path':path})
|
||||
@@ -595,6 +761,8 @@ class H(BaseHTTPRequestHandler):
|
||||
return self.sendj({'error':'not_found','path':path},404)
|
||||
def do_POST(self):
|
||||
path=urllib.parse.urlparse(self.path).path.rstrip('/')
|
||||
if path in _DISCOVERY_ACTIONS:
|
||||
return self.sendj({'error':'method_not_allowed'},405)
|
||||
if reject_non_read_http(self, 'POST', path): return
|
||||
data=self.body()
|
||||
try:
|
||||
@@ -666,7 +834,44 @@ class H(BaseHTTPRequestHandler):
|
||||
def file(self,data):
|
||||
a=self.need('file:write');
|
||||
if not a: return
|
||||
b=checkout(data.get('branch'), data.get('base','main'), bool(data.get('create_branch'))); r=write_file(data.get('path'), data.get('content','')); c=commit([r], data.get('message') or f'chore(chatgpt): update {r}'); audit(a['account'],'file',r,{'branch':b,'commit':c}); self.sendj({'status':'committed','branch':b,'path':r,'commit':c})
|
||||
if os.getenv('AIOS_GITOPS_DURABLE_JOB_EXECUTION_ENABLED', os.getenv('AIOS_GITOPS_JOB_CONTROLLER_ENABLED', 'false')).lower() not in {'1','true','yes','on'}:
|
||||
return self.sendj({'error':'DURABLE_JOB_CONTROLLER_UNAVAILABLE','retryable':True},503)
|
||||
try:
|
||||
path = relpath(str(data.get('path') or ''))
|
||||
branch = str(data.get('branch') or '')
|
||||
expected = str(data.get('expected_branch_oid') or '')
|
||||
campaign_id = str(data.get('campaign_id') or os.getenv('AIOS_GITOPS_CAMPAIGN_ID') or '')
|
||||
supplied_digest = str(data.get('payload_digest') or '')
|
||||
content = data.get('content')
|
||||
payload_bytes = None
|
||||
if content is not None:
|
||||
payload_bytes = content.encode('utf-8') if isinstance(content, str) else json.dumps(content, sort_keys=True, ensure_ascii=False).encode('utf-8')
|
||||
payload_digest = supplied_digest or hashlib.sha256(payload_bytes or b'').hexdigest()
|
||||
if not campaign_id or not branch or not expected:
|
||||
return self.sendj({'error':'JOB_BINDING_REQUIRED','retryable':False},409)
|
||||
request = {
|
||||
'campaign_id': campaign_id,
|
||||
'repository': REPOSITORY_IDENTITY or 'aios/aios',
|
||||
'operation': 'commit-file',
|
||||
'branch': branch,
|
||||
'expected_branch_oid': expected,
|
||||
'payload_digest': payload_digest,
|
||||
# The controller persists only the binding and digest. Raw
|
||||
# content is never copied into durable state or logs.
|
||||
}
|
||||
receipt = GitOpsJobController(
|
||||
JobStore(Path(os.getenv('AIOS_GITOPS_JOB_ROOT', '/var/lib/aios-gitops/jobs')))
|
||||
).submit(request, payload=payload_bytes)
|
||||
return self.sendj({
|
||||
'job_id': receipt.job_id,
|
||||
'state': receipt.state.value,
|
||||
'side_effect_state': receipt.side_effect_state,
|
||||
'retryable': receipt.http_status == 503,
|
||||
}, receipt.http_status)
|
||||
except ServiceUnavailable as exc:
|
||||
return self.sendj({'error':str(exc),'layer':'durable_job','retryable':True,'side_effect_state':'UNKNOWN'},503)
|
||||
except JobError as exc:
|
||||
return self.sendj({'error':str(exc),'retryable':False},409)
|
||||
def commit(self,data):
|
||||
a=self.need('commit:create');
|
||||
if not a: return
|
||||
@@ -680,7 +885,10 @@ class H(BaseHTTPRequestHandler):
|
||||
a=self.need('tag:create');
|
||||
if not a: return
|
||||
ver=data.get('version') or data.get('tag')
|
||||
if not re.match(r'^V\d+R\d+C\d+(-[A-Za-z0-9_.-]+)?$', ver or ''): return self.sendj({'error':'version must match VxRxCx'},400)
|
||||
try:
|
||||
validate_version(ver)
|
||||
except ValueError:
|
||||
return self.sendj({'error':'version must match VxRxCx'},400)
|
||||
if not data.get('target_oid') or not data.get('evidence_closure_oid') or not data.get('approval_plan_id'):
|
||||
return self.sendj({
|
||||
'error':'production_tag_evidence_required',
|
||||
@@ -851,9 +1059,11 @@ return{vs,cmds,ar,cmd,cdet,tot,sok,sfail,runs,baseline,refreshMsg,loadCmd,selVer
|
||||
|
||||
|
||||
def api_spec():
|
||||
return {'version':'V1R1C0-chatgpt-actions','openapi':'/api/openapi.yaml','read':['GET /api/base','GET /api/ver','GET /api/diff','GET /api/patch','GET /api/model','GET /api/run','GET /getReleaseCandidate','GET /getApprovalEnvelope','GET /getReleaseSaga','GET /admin/ai-evolution'],'write':['POST /api/branch','POST /api/file','POST /api/commit','POST /api/patch','POST /api/tag','POST /api/rollback','POST /api/model','PUT /api/model-priority','POST /api/nl','POST /createReleaseCandidate','POST /submitOwnerApproval','POST /startDeployment'],'policy':{'bearer_required_for_write':True,'direct_main_push':False,'delete_tag':False,'owner_approval_required_for_v_level':True,'api_keys_in_git':False,'git_secret_field':'secret_ref','online_git_sync':True,'silent_release_execution_enabled':SILENT_RELEASE_EXECUTION_ENABLED,'silent_release_shadow_default':True}}
|
||||
return {'version':'V1R1C0-chatgpt-actions','openapi':'/api/openapi.yaml','read':['GET /api/base','GET /api/ver','GET /api/diff','GET /api/repository/tree','GET /api/repository/search','GET /api/repository/diff-index','GET /api/repository/file','GET /api/patch','GET /api/model','GET /api/run','GET /getReleaseCandidate','GET /getApprovalEnvelope','GET /getReleaseSaga','GET /admin/ai-evolution'],'write':['POST /api/branch','POST /api/file','POST /api/commit','POST /api/patch','POST /api/tag','POST /api/rollback','POST /api/model','PUT /api/model-priority','POST /api/nl','POST /createReleaseCandidate','POST /submitOwnerApproval','POST /startDeployment'],'policy':{'bearer_required_for_write':True,'direct_main_push':False,'delete_tag':False,'owner_approval_required_for_v_level':True,'api_keys_in_git':False,'git_secret_field':'secret_ref','online_git_sync':True,'silent_release_execution_enabled':SILENT_RELEASE_EXECUTION_ENABLED,'silent_release_shadow_default':True}}
|
||||
|
||||
def main():
|
||||
validate_gitea_api_url(os.getenv('AIOS_GITOPS_GITEA_API_URL', ''))
|
||||
print(f'AIOS ChatGPT GitOps adapter listening on {HOST}:{PORT}')
|
||||
ThreadingHTTPServer((HOST,PORT), H).serve_forever()
|
||||
if __name__ == '__main__': main()
|
||||
|
||||
|
||||
@@ -0,0 +1,349 @@
|
||||
"""ChatGPT GitOps Bridge v2 contract and governed release operations."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
from aios_task_admission_bridge_contract import (
|
||||
TASK_ADMISSION_OPERATION_OPTIONAL_FIELDS,
|
||||
TASK_ADMISSION_OPERATION_REQUIRED_FIELDS,
|
||||
TASK_ADMISSION_ROUTES,
|
||||
)
|
||||
|
||||
|
||||
BRIDGE_SCHEMA_VERSION = "aios.chatgpt-gitops-bridge.v2"
|
||||
BRIDGE_OPERATIONS = (
|
||||
"getRuntimeStatus",
|
||||
"getGovernedWriteCapabilities",
|
||||
"createGovernedTask",
|
||||
"projectOnlineAgentExecution",
|
||||
"acquireWriterLease",
|
||||
"commitFileGoverned",
|
||||
"commitFilesGoverned",
|
||||
"getGitOpsJob",
|
||||
"resumeGitOpsJob",
|
||||
"reconcileGitOpsJob",
|
||||
"createTagApprovalRequest",
|
||||
"requestOrCreateTag",
|
||||
"getTagPublicationStatus",
|
||||
"buildProductionArtifact",
|
||||
"executeApprovedProductionRelease",
|
||||
"getReleaseConsistency",
|
||||
"runReleaseAcceptance",
|
||||
"getControlPlaneSnapshot",
|
||||
"listRepositoryTree",
|
||||
"searchRepositorySource",
|
||||
"getDiffIndex",
|
||||
"readRepositoryFile",
|
||||
)
|
||||
|
||||
TASK_ADMISSION_OPERATIONS = tuple(TASK_ADMISSION_OPERATION_REQUIRED_FIELDS)
|
||||
|
||||
OPERATION_REQUIRED_FIELDS = {
|
||||
"createGovernedTask": ("task_id", "repository", "base_oid", "target_branch", "risk", "description", "idempotency_key"),
|
||||
"projectOnlineAgentExecution": ("task_id", "required_capabilities"),
|
||||
"acquireWriterLease": ("schema", "task_id", "job_id", "repository", "branch_mode", "base_oid", "expected_branch_oid", "target_branch", "generation", "idempotency_key", "lease_seconds"),
|
||||
"commitFileGoverned": ("task_id", "job_id", "repository", "expected_branch_oid", "target_branch", "generation", "idempotency_key", "path", "content_base64", "message"),
|
||||
"commitFilesGoverned": ("task_id", "job_id", "repository", "expected_branch_oid", "target_branch", "generation", "idempotency_key", "files", "message"),
|
||||
"getGitOpsJob": ("task_id", "job_id"),
|
||||
"resumeGitOpsJob": ("task_id", "job_id", "expected_revision"),
|
||||
"reconcileGitOpsJob": ("task_id", "job_id", "expected_revision"),
|
||||
"createTagApprovalRequest": ("version", "target_oid", "evidence_closure_oid", "approval_plan_id", "owner_approval", "execute"),
|
||||
"requestOrCreateTag": ("version", "target_oid", "evidence_closure_oid", "approval_plan_id", "execute"),
|
||||
"getTagPublicationStatus": ("version",),
|
||||
"buildProductionArtifact": ("version", "target_oid", "evidence_closure_oid", "approval_plan_id"),
|
||||
"executeApprovedProductionRelease": ("version", "target_oid", "evidence_closure_oid", "approval_plan_id", "execute"),
|
||||
"getReleaseConsistency": ("version", "target_oid", "evidence_closure_oid", "approval_plan_id"),
|
||||
"runReleaseAcceptance": ("version", "target_oid", "evidence_closure_oid", "approval_plan_id"),
|
||||
"getControlPlaneSnapshot": (),
|
||||
**TASK_ADMISSION_OPERATION_REQUIRED_FIELDS,
|
||||
"listRepositoryTree": ("repository", "ref", "limit"),
|
||||
"searchRepositorySource": ("repository", "ref", "query", "max_matches", "max_bytes"),
|
||||
"getDiffIndex": ("repository", "from", "to", "limit"),
|
||||
"readRepositoryFile": ("repository", "ref", "path"),
|
||||
}
|
||||
|
||||
OPERATION_OPTIONAL_FIELDS = {
|
||||
"listRepositoryTree": ("prefix", "depth", "cursor"),
|
||||
"searchRepositorySource": ("path_globs", "cursor"),
|
||||
"getDiffIndex": ("path_prefix", "cursor"),
|
||||
"readRepositoryFile": ("max_bytes",),
|
||||
}
|
||||
|
||||
READ_ONLY_OPERATIONS = frozenset(
|
||||
{
|
||||
"getRuntimeStatus",
|
||||
"getGovernedWriteCapabilities",
|
||||
"getControlPlaneSnapshot",
|
||||
"listRepositoryTree",
|
||||
"searchRepositorySource",
|
||||
"getDiffIndex",
|
||||
"readRepositoryFile",
|
||||
}
|
||||
)
|
||||
|
||||
_OPERATION_PATHS = {
|
||||
"createGovernedTask": "/v2/governed-task",
|
||||
"projectOnlineAgentExecution": "/v2/execution-envelope",
|
||||
"acquireWriterLease": "/v2/writer-lease",
|
||||
"commitFileGoverned": "/v2/commit-file",
|
||||
"commitFilesGoverned": "/v2/commit-files",
|
||||
"getGitOpsJob": "/v2/job",
|
||||
"resumeGitOpsJob": "/v2/job/resume",
|
||||
"reconcileGitOpsJob": "/v2/job/reconcile",
|
||||
"getTagPublicationStatus": "/v2/tag/status",
|
||||
"getReleaseConsistency": "/v2/release/consistency",
|
||||
"runReleaseAcceptance": "/v2/release/acceptance",
|
||||
**{operation: path for path, operation in TASK_ADMISSION_ROUTES.items()},
|
||||
"listRepositoryTree": "/v2/repository/tree",
|
||||
"searchRepositorySource": "/v2/repository/search",
|
||||
"getDiffIndex": "/v2/repository/diff-index",
|
||||
"readRepositoryFile": "/v2/repository/file",
|
||||
}
|
||||
|
||||
|
||||
def _has_complete_task_admission_handlers(
|
||||
handlers: Mapping[str, Callable[[Mapping[str, Any]], Any]] | None,
|
||||
) -> bool:
|
||||
return handlers is not None and all(
|
||||
callable(handlers.get(operation)) for operation in TASK_ADMISSION_OPERATIONS
|
||||
)
|
||||
|
||||
|
||||
def operation_contracts(
|
||||
handlers: Mapping[str, Callable[[Mapping[str, Any]], Any]] | None = None,
|
||||
) -> dict[str, dict[str, object]]:
|
||||
"""Return the runtime contract consumed by adapters and schema tooling."""
|
||||
contracts = {
|
||||
operation: {
|
||||
"method": "GET" if operation in OPERATION_OPTIONAL_FIELDS else "POST",
|
||||
"path": _OPERATION_PATHS.get(operation),
|
||||
"required_fields": list(fields),
|
||||
"optional_fields": list(OPERATION_OPTIONAL_FIELDS.get(operation, ())),
|
||||
"read_only": operation in READ_ONLY_OPERATIONS,
|
||||
}
|
||||
for operation, fields in OPERATION_REQUIRED_FIELDS.items()
|
||||
if operation not in TASK_ADMISSION_OPERATIONS
|
||||
}
|
||||
if _has_complete_task_admission_handlers(handlers):
|
||||
contracts.update(
|
||||
{
|
||||
operation: {
|
||||
"method": "GET" if operation == "getTaskAdmissionStatus" else "POST",
|
||||
"path": _OPERATION_PATHS[operation],
|
||||
"required_fields": list(
|
||||
TASK_ADMISSION_OPERATION_REQUIRED_FIELDS[operation]
|
||||
),
|
||||
"optional_fields": list(
|
||||
TASK_ADMISSION_OPERATION_OPTIONAL_FIELDS[operation]
|
||||
),
|
||||
}
|
||||
for operation in TASK_ADMISSION_OPERATIONS
|
||||
}
|
||||
)
|
||||
return contracts
|
||||
|
||||
|
||||
def render_openapi_contract_fragment() -> str:
|
||||
required = "\n".join(
|
||||
f" - {field}"
|
||||
for field in OPERATION_REQUIRED_FIELDS["commitFilesGoverned"]
|
||||
)
|
||||
return (
|
||||
" /v2/commit-files:\n"
|
||||
" post:\n"
|
||||
" operationId: commitFilesGoverned\n"
|
||||
" summary: Submit one atomic governed commit containing multiple files\n"
|
||||
" security: [{taskPrincipalAuth: []}]\n"
|
||||
" x-aios-task-principal: Bridge resolves a fresh server-side TaskPrincipal from the authoritative admitted+claimed Task and re-verifies its Owner epoch, then checks exact task/job/repository/branch/generation/capability binding; caller JSON cannot provide a principal and missing verification is denied. An explicitly injected legacy bearer verifier may still be used by a trusted host.\n"
|
||||
" requestBody:\n"
|
||||
" required: true\n"
|
||||
" content:\n"
|
||||
" application/json:\n"
|
||||
" schema:\n"
|
||||
" type: object\n"
|
||||
" additionalProperties: false\n"
|
||||
" required:\n"
|
||||
f"{required}\n"
|
||||
" properties:\n"
|
||||
" task_id: {type: string}\n"
|
||||
" job_id: {type: string, description: External durable Job identity for exact completed-replay reconciliation}\n"
|
||||
" source_lease_epoch: {type: integer, minimum: 1}\n"
|
||||
" repository: {type: string, example: aios/aios}\n"
|
||||
" expected_branch_oid: {type: string, pattern: '^[0-9a-f]{40}$'}\n"
|
||||
" target_branch: {type: string, pattern: '^agent/[A-Za-z0-9._/-]+$'}\n"
|
||||
" generation: {type: integer, minimum: 1}\n"
|
||||
" idempotency_key: {type: string}\n"
|
||||
" files:\n"
|
||||
" type: array\n"
|
||||
" minItems: 1\n"
|
||||
" items:\n"
|
||||
" type: object\n"
|
||||
" additionalProperties: false\n"
|
||||
" required: [path, content_base64]\n"
|
||||
" properties:\n"
|
||||
" path: {type: string, minLength: 1, maxLength: 512}\n"
|
||||
" content_base64: {type: string}\n"
|
||||
" message: {type: string, minLength: 1, maxLength: 200}\n"
|
||||
" responses:\n"
|
||||
" '200': {description: Governed commit job and verified branch result}\n"
|
||||
" '202': {description: Existing external Job alias requires authoritative reconciliation; no replacement Job is created}\n"
|
||||
" '401': {description: No verified Task principal is available}\n"
|
||||
" '403': {description: Verified Task principal lacks exact task, external Job, or capability scope}\n"
|
||||
" '400': {description: Contract or policy rejection}\n"
|
||||
" '409': {description: Branch CAS or idempotency conflict}\n"
|
||||
" '423': {description: Narrow P0 Task-principal mutation gate is frozen; assertion is not consumed}\n"
|
||||
)
|
||||
|
||||
|
||||
class RuntimeDriftError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class BridgeV2Error(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ControlPlaneReleaseManifest:
|
||||
control_plane_release_version: str
|
||||
control_plane_target_oid: str
|
||||
artifact_sha256: str
|
||||
runtime_path: Path
|
||||
bridge_schema_version: str
|
||||
repository_identity: str
|
||||
authoritative_git_dir: Path
|
||||
deployment_state: str
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"control_plane_release_version": self.control_plane_release_version,
|
||||
"control_plane_target_oid": self.control_plane_target_oid,
|
||||
"artifact_sha256": self.artifact_sha256,
|
||||
"runtime_path": str(self.runtime_path),
|
||||
"bridge_schema_version": self.bridge_schema_version,
|
||||
"repository_identity": self.repository_identity,
|
||||
"authoritative_git_dir": str(self.authoritative_git_dir),
|
||||
"deployment_state": self.deployment_state,
|
||||
}
|
||||
|
||||
def validate(self, artifact: Path, *, observed_oid: str, approved_root: Path = Path("/opt/aios/gitops-control-plane")) -> dict[str, object]:
|
||||
try:
|
||||
runtime = self.runtime_path.resolve(strict=True)
|
||||
root = approved_root.resolve(strict=True)
|
||||
artifact_path = artifact.resolve(strict=True)
|
||||
runtime.relative_to(root)
|
||||
artifact_path.relative_to(runtime)
|
||||
except (OSError, ValueError):
|
||||
raise RuntimeDriftError("CONTROL_PLANE_RUNTIME_DRIFT") from None
|
||||
mutable_markers = {"workspace", "workspaces", "worktree", "worktrees"}
|
||||
if mutable_markers.intersection(part.lower() for part in runtime.parts):
|
||||
raise RuntimeDriftError("CONTROL_PLANE_RUNTIME_DRIFT")
|
||||
valid = (
|
||||
len(self.control_plane_target_oid) == 40
|
||||
and observed_oid == self.control_plane_target_oid
|
||||
and len(self.artifact_sha256) == 64
|
||||
and hashlib.sha256(artifact_path.read_bytes()).hexdigest() == self.artifact_sha256
|
||||
and self.bridge_schema_version == BRIDGE_SCHEMA_VERSION
|
||||
and self.repository_identity == "aios/aios"
|
||||
and self.authoritative_git_dir == Path("/data/aios-gitops/repo.git")
|
||||
and self.authoritative_git_dir.is_absolute()
|
||||
and self.deployment_state == "STAGING"
|
||||
)
|
||||
if not valid:
|
||||
raise RuntimeDriftError("CONTROL_PLANE_RUNTIME_DRIFT")
|
||||
return {**self.as_dict(), "drift_status": "PASS"}
|
||||
|
||||
|
||||
class BridgeV2Adapter:
|
||||
"""Thin operation adapter; all mutating handlers are injected seams."""
|
||||
|
||||
def __init__(self, *, runtime_status: Mapping[str, Any] | Callable[[], Mapping[str, Any]], handlers: Mapping[str, Callable[[Mapping[str, Any]], Any]] | None = None) -> None:
|
||||
self._runtime_status_source = runtime_status if callable(runtime_status) else dict(runtime_status)
|
||||
self.handlers = dict(handlers or {})
|
||||
|
||||
def _runtime_status(self) -> dict[str, Any]:
|
||||
value = self._runtime_status_source() if callable(self._runtime_status_source) else self._runtime_status_source
|
||||
if not isinstance(value, Mapping):
|
||||
raise BridgeV2Error("RUNTIME_STATUS_INVALID")
|
||||
return dict(value)
|
||||
|
||||
def getRuntimeStatus(self) -> dict[str, Any]:
|
||||
return {"bridge_schema_version": BRIDGE_SCHEMA_VERSION, **self._runtime_status()}
|
||||
|
||||
def getGovernedWriteCapabilities(self) -> dict[str, Any]:
|
||||
runtime_status = self._runtime_status()
|
||||
governed_writes_enabled = runtime_status.get("governed_writes_enabled") is True
|
||||
return {
|
||||
"bridge_schema_version": BRIDGE_SCHEMA_VERSION,
|
||||
"legacy_writes_enabled": False,
|
||||
"governed_writes_enabled": governed_writes_enabled,
|
||||
"governed_write_blockers": list(
|
||||
runtime_status.get("governed_write_blockers") or []
|
||||
),
|
||||
"operations": [
|
||||
*BRIDGE_OPERATIONS,
|
||||
*(
|
||||
TASK_ADMISSION_OPERATIONS
|
||||
if _has_complete_task_admission_handlers(self.handlers)
|
||||
else ()
|
||||
),
|
||||
],
|
||||
"operation_contracts": operation_contracts(self.handlers),
|
||||
"force_push": False,
|
||||
"main_write": False,
|
||||
"tag_write": False,
|
||||
"baseline_write": False,
|
||||
"production_write": False,
|
||||
"approved_release_operations": True,
|
||||
"approved_release_scope": {
|
||||
"version": "V2R2C1",
|
||||
"target_oid": "998d30304787e31e5ae607c0bd86b5edb2b0e5ad",
|
||||
"evidence_closure_oid": "a8b906d1bc24088c19647e98de6a679268f6d0a2",
|
||||
"approval_plan_id": "sha256:c0df52008ae7f64cec5ee2d28947aa7b892066c670ab8295e4549140b1a7500e",
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def legacy_write_disabled() -> dict[str, object]:
|
||||
return {"error": "LEGACY_WRITE_DISABLED", "replacement": "createGovernedTask", "governed_writes_available": False}
|
||||
|
||||
def _call(self, operation: str, request: Mapping[str, Any]) -> Any:
|
||||
required = OPERATION_REQUIRED_FIELDS[operation]
|
||||
if any(key not in request for key in required):
|
||||
raise BridgeV2Error("BRIDGE_REQUEST_FIELDS_MISSING")
|
||||
if operation in OPERATION_OPTIONAL_FIELDS:
|
||||
allowed = set(required) | set(OPERATION_OPTIONAL_FIELDS[operation])
|
||||
if set(request) - allowed:
|
||||
raise BridgeV2Error("BRIDGE_REQUEST_FIELDS_UNKNOWN")
|
||||
handler = self.handlers.get(operation)
|
||||
if handler is None:
|
||||
raise BridgeV2Error("GOVERNED_HANDLER_UNAVAILABLE")
|
||||
return handler(dict(request))
|
||||
|
||||
def createGovernedTask(self, request: Mapping[str, Any]) -> Any: return self._call("createGovernedTask", request)
|
||||
def projectOnlineAgentExecution(self, request: Mapping[str, Any]) -> Any: return self._call("projectOnlineAgentExecution", request)
|
||||
def acquireWriterLease(self, request: Mapping[str, Any]) -> Any: return self._call("acquireWriterLease", request)
|
||||
def commitFileGoverned(self, request: Mapping[str, Any]) -> Any: return self._call("commitFileGoverned", request)
|
||||
def commitFilesGoverned(self, request: Mapping[str, Any]) -> Any: return self._call("commitFilesGoverned", request)
|
||||
def getGitOpsJob(self, request: Mapping[str, Any]) -> Any: return self._call("getGitOpsJob", request)
|
||||
def resumeGitOpsJob(self, request: Mapping[str, Any]) -> Any: return self._call("resumeGitOpsJob", request)
|
||||
def reconcileGitOpsJob(self, request: Mapping[str, Any]) -> Any: return self._call("reconcileGitOpsJob", request)
|
||||
def createTagApprovalRequest(self, request: Mapping[str, Any]) -> Any: return self._call("createTagApprovalRequest", request)
|
||||
def requestOrCreateTag(self, request: Mapping[str, Any]) -> Any: return self._call("requestOrCreateTag", request)
|
||||
def getTagPublicationStatus(self, request: Mapping[str, Any]) -> Any: return self._call("getTagPublicationStatus", request)
|
||||
def buildProductionArtifact(self, request: Mapping[str, Any]) -> Any: return self._call("buildProductionArtifact", request)
|
||||
def executeApprovedProductionRelease(self, request: Mapping[str, Any]) -> Any: return self._call("executeApprovedProductionRelease", request)
|
||||
def getReleaseConsistency(self, request: Mapping[str, Any]) -> Any: return self._call("getReleaseConsistency", request)
|
||||
def runReleaseAcceptance(self, request: Mapping[str, Any]) -> Any: return self._call("runReleaseAcceptance", request)
|
||||
def getTaskAdmissionEnvelope(self, request: Mapping[str, Any]) -> Any: return self._call("getTaskAdmissionEnvelope", request)
|
||||
def submitTaskAdmissionAuthorization(self, request: Mapping[str, Any]) -> Any: return self._call("submitTaskAdmissionAuthorization", request)
|
||||
def getTaskAdmissionStatus(self, request: Mapping[str, Any]) -> Any: return self._call("getTaskAdmissionStatus", request)
|
||||
def listRepositoryTree(self, request: Mapping[str, Any]) -> Any: return self._call("listRepositoryTree", request)
|
||||
def searchRepositorySource(self, request: Mapping[str, Any]) -> Any: return self._call("searchRepositorySource", request)
|
||||
def getDiffIndex(self, request: Mapping[str, Any]) -> Any: return self._call("getDiffIndex", request)
|
||||
def readRepositoryFile(self, request: Mapping[str, Any]) -> Any: return self._call("readRepositoryFile", request)
|
||||
|
||||
def getControlPlaneSnapshot(self, request: Mapping[str, Any] | None = None) -> Any: return self._call("getControlPlaneSnapshot", request or {})
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,615 @@
|
||||
"""Governed continuation executor for exact existing task branches.
|
||||
|
||||
The executor composes the existing continuation lease and durable Job store.
|
||||
All Git and Gitea side effects live behind injected narrow interfaces so the
|
||||
state machine can prove Authority, Git-network, API-branch, and commit-object
|
||||
agreement before it reports completion.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from dataclasses import asdict, dataclass
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePosixPath
|
||||
import re
|
||||
import subprocess
|
||||
from typing import Callable, Mapping, Protocol
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import quote, urlsplit
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from aios_authority_git_executor import AuthorityGitExecutor
|
||||
from aios_continuation_lease import WriterLeaseRequestV2
|
||||
from aios_gitops_job_controller import GitOpsJobController, JobState
|
||||
from aios_gitops_reconciliation_core import ReconciliationAction, RemoteObservation, decide_reconciliation
|
||||
from authorized_task_writer_provisioning import validate_writer_lease_request_v2
|
||||
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
SECRET_PATTERNS = (
|
||||
re.compile(rb"gh[pousr]_[A-Za-z0-9]{20,}"),
|
||||
re.compile(rb"(?i)(?:token|password|secret|api[_-]?key)\s*[:=]\s*[\"'][^\"'\s]{12,}[\"']"),
|
||||
re.compile(rb"-----BEGIN " + rb"(?:RSA |EC |OPENSSH )?PRIVATE KEY-----"),
|
||||
)
|
||||
|
||||
|
||||
class ContinuationExecutionError(RuntimeError):
|
||||
"""Stable fail-closed continuation execution error."""
|
||||
|
||||
|
||||
class DetachedWorkspace(Protocol):
|
||||
def is_detached(self) -> bool: ...
|
||||
def head_oid(self) -> str: ...
|
||||
def commit_files(self, files: Mapping[str, bytes], message: str) -> str: ...
|
||||
|
||||
|
||||
class AuthorityPort(Protocol):
|
||||
def branch_exists(self, branch: str) -> bool: ...
|
||||
def branch_head(self, branch: str) -> str: ...
|
||||
def is_ancestor(self, older: str, newer: str) -> bool: ...
|
||||
def compare_and_swap_branch(self, branch: str, expected: str, new: str) -> str: ...
|
||||
|
||||
|
||||
class RemotePort(Protocol):
|
||||
def repository_identity(self) -> str: ...
|
||||
def push_exact(self, branch: str, expected: str, new: str, *, allow_absent: bool = False) -> None: ...
|
||||
def read_git_ref(self, branch: str) -> str | None: ...
|
||||
def read_api_branch(self, branch: str) -> str | None: ...
|
||||
def commit_visible(self, oid: str) -> bool: ...
|
||||
|
||||
|
||||
class CredentialProjection(Protocol):
|
||||
def project(self) -> None: ...
|
||||
def revoke(self) -> None: ...
|
||||
|
||||
|
||||
def _run(argv: list[str], *, cwd: Path | None = None, environment: Mapping[str, str] | None = None) -> str:
|
||||
result = subprocess.run(
|
||||
argv,
|
||||
cwd=cwd,
|
||||
env=None if environment is None else dict(environment),
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise ContinuationExecutionError("SUBPROCESS_FAILED")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AuthorityGitAdapter:
|
||||
"""Branch-name adapter over the existing narrow AuthorityGitExecutor."""
|
||||
|
||||
executor: AuthorityGitExecutor
|
||||
|
||||
@staticmethod
|
||||
def _ref(branch: str) -> str:
|
||||
return f"refs/heads/{branch}"
|
||||
|
||||
def branch_exists(self, branch: str) -> bool:
|
||||
return self.executor.read_ref(self._ref(branch)) is not None
|
||||
|
||||
def branch_head(self, branch: str) -> str:
|
||||
value = self.executor.read_ref(self._ref(branch))
|
||||
if value is None:
|
||||
raise ContinuationExecutionError("EXISTING_BRANCH_MISSING")
|
||||
return value
|
||||
|
||||
def is_ancestor(self, older: str, newer: str) -> bool:
|
||||
return self.executor.ancestry_check(older, newer)
|
||||
|
||||
def compare_and_swap_branch(self, branch: str, expected: str, new: str) -> str:
|
||||
return self.executor.advance_task_branch_CAS(self._ref(branch), expected, new)
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DetachedGitWorkspace:
|
||||
path: Path
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.path.is_absolute() or self.path.is_symlink() or not self.path.is_dir():
|
||||
raise ContinuationExecutionError("WORKTREE_PATH_INVALID")
|
||||
top = Path(_run(["git", "rev-parse", "--show-toplevel"], cwd=self.path)).resolve()
|
||||
if top != self.path.resolve():
|
||||
raise ContinuationExecutionError("WORKTREE_PATH_INVALID")
|
||||
|
||||
def is_detached(self) -> bool:
|
||||
result = subprocess.run(
|
||||
["git", "symbolic-ref", "-q", "HEAD"],
|
||||
cwd=self.path,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
return result.returncode == 1
|
||||
|
||||
def head_oid(self) -> str:
|
||||
return _run(["git", "rev-parse", "HEAD"], cwd=self.path)
|
||||
|
||||
def _dirty_paths(self) -> set[str]:
|
||||
command = subprocess.run(
|
||||
["git", "status", "--porcelain=v1", "--untracked-files=all"],
|
||||
cwd=self.path,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if command.returncode != 0:
|
||||
raise ContinuationExecutionError("SUBPROCESS_FAILED")
|
||||
output = command.stdout
|
||||
result: set[str] = set()
|
||||
for line in output.splitlines():
|
||||
path = line[3:]
|
||||
if " -> " in path:
|
||||
path = path.split(" -> ", 1)[1]
|
||||
result.add(path)
|
||||
return result
|
||||
|
||||
def materialize_files(self, files: Mapping[str, bytes]) -> None:
|
||||
dirty = self._dirty_paths()
|
||||
expected_paths = set(files)
|
||||
if dirty and dirty != expected_paths:
|
||||
raise ContinuationExecutionError("WORKTREE_DIRTY_SCOPE_MISMATCH")
|
||||
for relative, content in files.items():
|
||||
target = (self.path / relative).resolve()
|
||||
try:
|
||||
target.relative_to(self.path.resolve())
|
||||
except ValueError as exc:
|
||||
raise ContinuationExecutionError("COMMIT_PATH_REJECTED") from exc
|
||||
if target.is_symlink():
|
||||
raise ContinuationExecutionError("COMMIT_PATH_REJECTED")
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_bytes(content)
|
||||
|
||||
def commit_files(self, files: Mapping[str, bytes], message: str) -> str:
|
||||
expected_paths = set(files)
|
||||
if self._dirty_paths() != expected_paths:
|
||||
raise ContinuationExecutionError("WORKTREE_DIRTY_SCOPE_MISMATCH")
|
||||
for relative, content in files.items():
|
||||
target = (self.path / relative).resolve()
|
||||
try:
|
||||
target.relative_to(self.path.resolve())
|
||||
except ValueError as exc:
|
||||
raise ContinuationExecutionError("COMMIT_PATH_REJECTED") from exc
|
||||
if not target.is_file() or target.is_symlink() or target.read_bytes() != content:
|
||||
raise ContinuationExecutionError("AUTHORING_PAYLOAD_MISMATCH")
|
||||
_run(["git", "add", "--", *sorted(expected_paths)], cwd=self.path)
|
||||
staged = set(_run(["git", "diff", "--cached", "--name-only"], cwd=self.path).splitlines())
|
||||
if staged != expected_paths:
|
||||
raise ContinuationExecutionError("STAGED_SCOPE_MISMATCH")
|
||||
environment = os.environ.copy()
|
||||
environment.update(
|
||||
{
|
||||
"GIT_AUTHOR_NAME": "AIOS Governed Continuation",
|
||||
"GIT_AUTHOR_EMAIL": "aios-governed-continuation@localhost",
|
||||
"GIT_COMMITTER_NAME": "AIOS Governed Continuation",
|
||||
"GIT_COMMITTER_EMAIL": "aios-governed-continuation@localhost",
|
||||
}
|
||||
)
|
||||
_run(["git", "commit", "--no-gpg-sign", "-m", message, "--", *sorted(expected_paths)], cwd=self.path, environment=environment)
|
||||
if not self.is_detached():
|
||||
raise ContinuationExecutionError("WORKTREE_NOT_DETACHED")
|
||||
return self.head_oid()
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ServerSideCredentialProjection:
|
||||
ssh_key_path: Path
|
||||
api_token_path: Path
|
||||
active: bool = False
|
||||
|
||||
def project(self) -> None:
|
||||
for path in (self.ssh_key_path, self.api_token_path):
|
||||
if path.is_symlink() or not path.is_file() or path.stat().st_size < 32:
|
||||
raise ContinuationExecutionError("CREDENTIAL_PROJECTION_MISSING")
|
||||
self.active = True
|
||||
|
||||
def revoke(self) -> None:
|
||||
self.active = False
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class GiteaContinuationRemote:
|
||||
workspace: Path
|
||||
credential: ServerSideCredentialProjection
|
||||
remote_name: str = "gitea"
|
||||
repository: str = "aios/aios"
|
||||
api_base: str = "https://g.cnnetmkt.top/git/api/v1"
|
||||
expected_host: str = "g.cnnetmkt.top"
|
||||
|
||||
@staticmethod
|
||||
def validate_remote_url(value: str, expected_host: str = "g.cnnetmkt.top") -> None:
|
||||
if value.startswith(("/", "file:")) or value.endswith("/data/aios-gitops/repo.git"):
|
||||
raise ContinuationExecutionError("GITEA_REMOTE_IDENTITY_INVALID")
|
||||
if "://" in value:
|
||||
parsed = urlsplit(value)
|
||||
host, path = parsed.hostname, parsed.path
|
||||
else:
|
||||
location = value.split("@", 1)[-1]
|
||||
host, separator, path = location.partition(":")
|
||||
if not separator:
|
||||
raise ContinuationExecutionError("GITEA_REMOTE_IDENTITY_INVALID")
|
||||
path = "/" + path
|
||||
if host != expected_host or path.rstrip("/") not in {"/aios/aios.git", "/git/aios/aios.git"}:
|
||||
raise ContinuationExecutionError("GITEA_REMOTE_IDENTITY_INVALID")
|
||||
|
||||
def repository_identity(self) -> str:
|
||||
url = _run(["git", "remote", "get-url", self.remote_name], cwd=self.workspace)
|
||||
self.validate_remote_url(url, self.expected_host)
|
||||
if self.repository != "aios/aios":
|
||||
raise ContinuationExecutionError("REMOTE_REPOSITORY_IDENTITY_MISMATCH")
|
||||
return self.repository
|
||||
|
||||
def _require_projected(self) -> None:
|
||||
if not self.credential.active:
|
||||
raise ContinuationExecutionError("CREDENTIAL_PROJECTION_MISSING")
|
||||
|
||||
def read_git_ref(self, branch: str) -> str | None:
|
||||
self._require_projected()
|
||||
output = _run(["git", "ls-remote", self.remote_name, f"refs/heads/{branch}"], cwd=self.workspace)
|
||||
if not output:
|
||||
return None
|
||||
oid, ref = output.split(maxsplit=1)
|
||||
if ref != f"refs/heads/{branch}" or not OID_RE.fullmatch(oid):
|
||||
raise ContinuationExecutionError("GITEA_GIT_READBACK_INVALID")
|
||||
return oid
|
||||
|
||||
def push_exact(self, branch: str, expected: str, new: str, *, allow_absent: bool = False) -> None:
|
||||
self._require_projected()
|
||||
self.repository_identity()
|
||||
current = self.read_git_ref(branch)
|
||||
if current != expected and not (allow_absent and current is None):
|
||||
raise ContinuationExecutionError("GITEA_BRANCH_EXPECTATION_MISMATCH")
|
||||
_run(["git", "push", self.remote_name, f"{new}:refs/heads/{branch}"], cwd=self.workspace)
|
||||
|
||||
def _api(self, path: str) -> object:
|
||||
self._require_projected()
|
||||
token = self.credential.api_token_path.read_text(encoding="utf-8").strip()
|
||||
request = Request(
|
||||
self.api_base.rstrip("/") + path,
|
||||
headers={"Accept": "application/json", "Authorization": f"token {token}"},
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=20) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
except HTTPError as exc:
|
||||
if exc.code == 404:
|
||||
return None
|
||||
raise ContinuationExecutionError(f"GITEA_API_HTTP_{exc.code}") from exc
|
||||
except (URLError, TimeoutError, OSError, json.JSONDecodeError) as exc:
|
||||
raise ContinuationExecutionError("GITEA_API_UNAVAILABLE") from exc
|
||||
|
||||
def read_api_branch(self, branch: str) -> str | None:
|
||||
owner, name = self.repository.split("/", 1)
|
||||
page = 1
|
||||
while page <= 100:
|
||||
value = self._api(f"/repos/{quote(owner)}/{quote(name)}/branches?page={page}&limit=50")
|
||||
if value is None:
|
||||
return None
|
||||
if not isinstance(value, list):
|
||||
raise ContinuationExecutionError("GITEA_API_BRANCH_COLLECTION_INVALID")
|
||||
for item in value:
|
||||
if not isinstance(item, dict) or item.get("name") != branch:
|
||||
continue
|
||||
oid = item.get("commit", {}).get("id")
|
||||
if not isinstance(oid, str) or not OID_RE.fullmatch(oid):
|
||||
raise ContinuationExecutionError("GITEA_API_BRANCH_INVALID")
|
||||
return oid
|
||||
if len(value) < 50:
|
||||
return None
|
||||
page += 1
|
||||
raise ContinuationExecutionError("GITEA_API_BRANCH_PAGINATION_LIMIT")
|
||||
|
||||
def commit_visible(self, oid: str) -> bool:
|
||||
owner, name = self.repository.split("/", 1)
|
||||
value = self._api(f"/repos/{quote(owner)}/{quote(name)}/git/commits/{oid}")
|
||||
if not isinstance(value, dict):
|
||||
return False
|
||||
observed = value.get("sha") or value.get("id")
|
||||
return observed == oid
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContinuationPermissions:
|
||||
create_target_branch: bool = False
|
||||
force_push: bool = False
|
||||
tag_write: bool = False
|
||||
baseline_write: bool = False
|
||||
production_write: bool = False
|
||||
main_write: bool = False
|
||||
|
||||
def validate(self) -> None:
|
||||
if any(asdict(self).values()):
|
||||
raise ContinuationExecutionError("MUTATION_PERMISSION_DENIED")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContinuationRuntimeBinding:
|
||||
request: WriterLeaseRequestV2
|
||||
workspace_path: Path
|
||||
lease_not_before: datetime
|
||||
lease_expires_at: datetime
|
||||
original_receipt_valid: bool
|
||||
original_grant_consumed: bool
|
||||
permissions: ContinuationPermissions
|
||||
remote_branch_create_allowed: bool = False
|
||||
external_job_id: str | None = None
|
||||
worker_principal: str = "aios-autonomy-agent"
|
||||
worker_lineage: str = "governed-continuation"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContinuationCommit:
|
||||
idempotency_key: str
|
||||
files: Mapping[str, bytes]
|
||||
message: str
|
||||
|
||||
def payload(self) -> bytes:
|
||||
if not self.idempotency_key or not self.message.strip() or not self.files:
|
||||
raise ContinuationExecutionError("COMMIT_REQUEST_INVALID")
|
||||
normalized: dict[str, str] = {}
|
||||
for path, content in self.files.items():
|
||||
pure = PurePosixPath(path)
|
||||
if (
|
||||
not isinstance(path, str)
|
||||
or pure.is_absolute()
|
||||
or ".." in pure.parts
|
||||
or path.startswith((".git/", "baseline/", "production/"))
|
||||
or not isinstance(content, bytes)
|
||||
):
|
||||
raise ContinuationExecutionError("COMMIT_PATH_REJECTED")
|
||||
if any(pattern.search(content) for pattern in SECRET_PATTERNS):
|
||||
raise ContinuationExecutionError("SECRET_LIKE_PAYLOAD_REJECTED")
|
||||
normalized[path] = base64.b64encode(content).decode("ascii")
|
||||
return json.dumps(
|
||||
{"files_base64": normalized, "message": self.message},
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
class ContinuationExecutor:
|
||||
def __init__(
|
||||
self,
|
||||
controller: GitOpsJobController,
|
||||
authority: AuthorityPort,
|
||||
workspace: DetachedWorkspace,
|
||||
remote: RemotePort,
|
||||
credential: CredentialProjection,
|
||||
*,
|
||||
now: Callable[[], datetime] = lambda: datetime.now(timezone.utc),
|
||||
) -> None:
|
||||
self.controller = controller
|
||||
self.authority = authority
|
||||
self.workspace = workspace
|
||||
self.remote = remote
|
||||
self.credential = credential
|
||||
self.now = now
|
||||
self._observed_generations: dict[str, int] = {}
|
||||
|
||||
def observe_generation(self, branch: str, generation: int) -> None:
|
||||
self._observed_generations[branch] = max(generation, self._observed_generations.get(branch, 0))
|
||||
|
||||
def _max_generation(self, branch: str) -> int:
|
||||
observed = self._observed_generations.get(branch, 0)
|
||||
with self.controller.store._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT MAX(generation) AS generation FROM branch_leases WHERE branch=?",
|
||||
(branch,),
|
||||
).fetchone()
|
||||
durable = int(row["generation"] or 0)
|
||||
return max(observed, durable)
|
||||
|
||||
@staticmethod
|
||||
def _request_mapping(request: WriterLeaseRequestV2) -> dict[str, object]:
|
||||
return {
|
||||
"schema": WriterLeaseRequestV2.schema,
|
||||
"task_id": request.task_id,
|
||||
"repository": request.repository,
|
||||
"branch_mode": request.branch_mode,
|
||||
"base_oid": request.base_oid,
|
||||
"expected_branch_oid": request.expected_branch_oid,
|
||||
"target_branch": request.target_branch,
|
||||
"generation": request.generation,
|
||||
"lease_seconds": request.lease_seconds,
|
||||
}
|
||||
|
||||
def _validate_preflight(self, binding: ContinuationRuntimeBinding) -> None:
|
||||
request = binding.request
|
||||
normalized = validate_writer_lease_request_v2(self._request_mapping(request), self.authority)
|
||||
if normalized != {**self._request_mapping(request), "create_target_branch": False, "worktree_mode": "detached_at_expected_head", "cas_ref_update_required": True}:
|
||||
raise ContinuationExecutionError("CONTINUATION_NORMALIZATION_INVALID")
|
||||
if not binding.original_receipt_valid or not binding.original_grant_consumed:
|
||||
raise ContinuationExecutionError("ORIGINAL_GRANT_LINEAGE_INVALID")
|
||||
binding.permissions.validate()
|
||||
now = self.now()
|
||||
if now < binding.lease_not_before:
|
||||
raise ContinuationExecutionError("LEASE_NOT_ACTIVE")
|
||||
if now >= binding.lease_expires_at:
|
||||
raise ContinuationExecutionError("LEASE_EXPIRED")
|
||||
if request.generation <= self._max_generation(request.target_branch):
|
||||
raise ContinuationExecutionError("GENERATION_NOT_MONOTONIC")
|
||||
if not self.workspace.is_detached():
|
||||
raise ContinuationExecutionError("WORKTREE_NOT_DETACHED")
|
||||
if self.workspace.head_oid() != request.expected_branch_oid:
|
||||
raise ContinuationExecutionError("WORKTREE_HEAD_MISMATCH")
|
||||
if self.remote.repository_identity() != request.repository:
|
||||
raise ContinuationExecutionError("REMOTE_REPOSITORY_IDENTITY_MISMATCH")
|
||||
|
||||
def _job_request(self, binding: ContinuationRuntimeBinding, commit: ContinuationCommit, payload: bytes) -> dict[str, object]:
|
||||
request = binding.request
|
||||
return {
|
||||
"idempotency_key": commit.idempotency_key,
|
||||
"campaign_id": request.task_id,
|
||||
"task_id": request.task_id,
|
||||
"repository": request.repository,
|
||||
"operation": "CONTINUATION_COMMIT_FILES",
|
||||
"branch": request.target_branch,
|
||||
"expected_branch_oid": request.expected_branch_oid,
|
||||
"base_oid": request.base_oid,
|
||||
"branch_mode": request.branch_mode,
|
||||
"payload_digest": hashlib.sha256(payload).hexdigest(),
|
||||
"lineage_anchor_oid": request.base_oid,
|
||||
"generation": request.generation,
|
||||
"remote_branch_create_allowed": binding.remote_branch_create_allowed,
|
||||
"external_job_id": binding.external_job_id,
|
||||
"files_digest": hashlib.sha256(
|
||||
json.dumps(
|
||||
{path: hashlib.sha256(content).hexdigest() for path, content in sorted(commit.files.items())},
|
||||
sort_keys=True, separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
).hexdigest(),
|
||||
"message_digest": hashlib.sha256(commit.message.encode("utf-8")).hexdigest(),
|
||||
"worker_principal": binding.worker_principal,
|
||||
"worker_lineage": binding.worker_lineage,
|
||||
}
|
||||
|
||||
def execute(self, binding: ContinuationRuntimeBinding, commit: ContinuationCommit) -> dict[str, object]:
|
||||
payload = commit.payload()
|
||||
submitted = self.controller.submit(self._job_request(binding, commit, payload), payload=payload)
|
||||
current = self.controller.store.read(submitted.job_id)
|
||||
if JobState(current["state"]) == JobState.COMPLETED:
|
||||
return self.controller.store._receipt(current, duplicate=True).as_dict()
|
||||
self._validate_preflight(binding)
|
||||
request = binding.request
|
||||
projected = False
|
||||
try:
|
||||
self.credential.project()
|
||||
projected = True
|
||||
row = self.controller.store.read(submitted.job_id)
|
||||
if JobState(row["state"]) == JobState.REQUESTED:
|
||||
self.controller.advance(
|
||||
submitted.job_id,
|
||||
JobState.AUTHORIZED,
|
||||
side_effect_state="AUTHORIZED",
|
||||
receipt={"task_id": request.task_id, "original_grant_consumed": True},
|
||||
)
|
||||
row = self.controller.store.read(submitted.job_id)
|
||||
if JobState(row["state"]) == JobState.AUTHORIZED:
|
||||
self.controller.lease_job(
|
||||
submitted.job_id,
|
||||
lease_id=f"lease-{submitted.job_id}",
|
||||
lineage_anchor_oid=request.base_oid,
|
||||
current_head_oid=request.expected_branch_oid,
|
||||
generation=request.generation,
|
||||
lease_expires_at=binding.lease_expires_at.astimezone(timezone.utc).isoformat(),
|
||||
expected_revision=int(row["revision"]),
|
||||
)
|
||||
self.controller.advance(submitted.job_id, JobState.WORKTREE_READY, side_effect_state="WORKTREE_READY")
|
||||
self.controller.advance(submitted.job_id, JobState.EXECUTING, side_effect_state="IN_PROGRESS")
|
||||
new_oid = self.workspace.commit_files(commit.files, commit.message)
|
||||
if not OID_RE.fullmatch(new_oid) or not self.authority.is_ancestor(request.expected_branch_oid, new_oid):
|
||||
raise ContinuationExecutionError("NON_FAST_FORWARD_COMMIT")
|
||||
authority_oid = self.authority.compare_and_swap_branch(request.target_branch, request.expected_branch_oid, new_oid)
|
||||
if authority_oid != new_oid:
|
||||
raise ContinuationExecutionError("AUTHORITY_CAS_READBACK_MISMATCH")
|
||||
self.controller.advance(
|
||||
submitted.job_id,
|
||||
JobState.LOCAL_VERIFIED,
|
||||
side_effect_state="LOCAL_VERIFIED",
|
||||
receipt={"authority_oid": authority_oid, "force_push": False},
|
||||
)
|
||||
try:
|
||||
self.controller.advance(submitted.job_id, JobState.REMOTE_SYNCING, side_effect_state="IN_PROGRESS")
|
||||
self.remote.push_exact(request.target_branch, request.expected_branch_oid, new_oid, allow_absent=binding.remote_branch_create_allowed)
|
||||
except Exception:
|
||||
return self._remote_pending(submitted.job_id, authority_oid, new_oid)
|
||||
return self._finish_remote_verification(submitted.job_id, authority_oid, new_oid)
|
||||
finally:
|
||||
if projected:
|
||||
self.credential.revoke()
|
||||
|
||||
def _observe_remote(self, branch: str, oid: str) -> tuple[str | None, str | None, bool]:
|
||||
return self.remote.read_git_ref(branch), self.remote.read_api_branch(branch), self.remote.commit_visible(oid)
|
||||
|
||||
def _remote_pending(self, job_id: str, authority_oid: str, new_oid: str) -> dict[str, object]:
|
||||
receipt = {
|
||||
"authority_oid": authority_oid,
|
||||
"commit_oid": new_oid,
|
||||
"desired_remote_oid": new_oid,
|
||||
"remote_verified": False,
|
||||
"force_push": False,
|
||||
}
|
||||
result = self.controller.advance(
|
||||
job_id,
|
||||
JobState.RECONCILIATION_REQUIRED,
|
||||
side_effect_state="REMOTE_PUSH_PENDING",
|
||||
receipt=receipt,
|
||||
)
|
||||
return result.as_dict()
|
||||
|
||||
def _finish_remote_verification(self, job_id: str, authority_oid: str, new_oid: str) -> dict[str, object]:
|
||||
row = self.controller.store.read(job_id)
|
||||
branch = row["branch"]
|
||||
request = json.loads(row["request_json"])["binding"]
|
||||
git_oid, api_oid, visible = self._observe_remote(branch, new_oid)
|
||||
decision = decide_reconciliation(RemoteObservation("aios/aios", branch, new_oid, authority_oid, git_oid, api_oid, visible, datetime.now(timezone.utc).isoformat(), request.get("expected_branch_oid")))
|
||||
if decision.action is not ReconciliationAction.MARK_REMOTE_VERIFIED:
|
||||
return self._remote_pending(job_id, authority_oid, new_oid)
|
||||
receipt = {
|
||||
"authority_oid": authority_oid,
|
||||
"commit_oid": new_oid,
|
||||
"git_remote_oid": git_oid,
|
||||
"api_branch_oid": api_oid,
|
||||
"commit_visible": True,
|
||||
"remote_verified": True,
|
||||
"reconciliation_decision": decision.action.value,
|
||||
"force_push": False,
|
||||
}
|
||||
self.controller.advance(job_id, JobState.REMOTE_VERIFIED, side_effect_state="REMOTE_VERIFIED", receipt=receipt)
|
||||
return self.controller.advance(job_id, JobState.COMPLETED, side_effect_state="REMOTE_VERIFIED", receipt=receipt).as_dict()
|
||||
|
||||
def reconcile(self, job_id: str) -> dict[str, object]:
|
||||
row = self.controller.store.read(job_id)
|
||||
state = JobState(row["state"])
|
||||
if state == JobState.COMPLETED:
|
||||
return self.controller.store._receipt(row, duplicate=True).as_dict()
|
||||
if state != JobState.RECONCILIATION_REQUIRED:
|
||||
raise ContinuationExecutionError("JOB_NOT_RECONCILABLE")
|
||||
request = json.loads(row["request_json"])["binding"]
|
||||
receipt = json.loads(row["receipt_json"])
|
||||
new_oid = receipt["desired_remote_oid"]
|
||||
authority_oid = self.authority.branch_head(row["branch"])
|
||||
try:
|
||||
generation = int(request.get("generation") or 0)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ContinuationExecutionError("DURABLE_BINDING_GENERATION_INVALID") from exc
|
||||
if generation > 0:
|
||||
with self.controller.store._connect() as connection:
|
||||
earlier = connection.execute(
|
||||
"SELECT 1 FROM jobs WHERE branch=? AND state=? "
|
||||
"AND CAST(json_extract(request_json, '$.binding.generation') AS INTEGER) < ? LIMIT 1",
|
||||
(row["branch"], JobState.RECONCILIATION_REQUIRED.value, generation),
|
||||
).fetchone()
|
||||
if earlier is not None:
|
||||
raise ContinuationExecutionError("EARLIER_RECONCILIATION_REQUIRED")
|
||||
self.credential.project()
|
||||
try:
|
||||
git_oid, api_oid, visible = self._observe_remote(row["branch"], new_oid)
|
||||
if authority_oid == git_oid == api_oid == new_oid and visible:
|
||||
self.controller.advance(job_id, JobState.RECONCILING, side_effect_state="OBSERVATION_FIRST")
|
||||
return self._finish_remote_verification(job_id, new_oid, new_oid)
|
||||
expected_oid = request["expected_branch_oid"]
|
||||
if (
|
||||
self.authority.is_ancestor(new_oid, authority_oid)
|
||||
and git_oid is not None
|
||||
and git_oid == api_oid
|
||||
and self.authority.is_ancestor(git_oid, new_oid)
|
||||
):
|
||||
self.controller.advance(job_id, JobState.RECONCILING, side_effect_state="OBSERVATION_FIRST_CHAIN")
|
||||
self.remote.push_exact(row["branch"], git_oid, new_oid, allow_absent=bool(request.get("remote_branch_create_allowed")) or request.get("branch_mode") == "CREATE_NEW")
|
||||
return self._finish_remote_verification(job_id, new_oid, new_oid)
|
||||
allow_remote_create = bool(request.get("remote_branch_create_allowed")) or request.get("branch_mode") == "CREATE_NEW" or (git_oid is None and api_oid is None)
|
||||
remote_matches_expected = git_oid == api_oid == expected_oid
|
||||
remote_absent_for_create = git_oid is None and api_oid is None
|
||||
if authority_oid == new_oid and (remote_matches_expected or remote_absent_for_create):
|
||||
self.controller.advance(job_id, JobState.RECONCILING, side_effect_state="OBSERVATION_FIRST_RETRY")
|
||||
self.remote.push_exact(row["branch"], expected_oid, new_oid, allow_absent=allow_remote_create)
|
||||
return self._finish_remote_verification(job_id, new_oid, new_oid)
|
||||
return self.controller.store._receipt(row, duplicate=True).as_dict()
|
||||
finally:
|
||||
self.credential.revoke()
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Campaign-aware continuation leases for an existing Git branch.
|
||||
|
||||
This module is deliberately transport-neutral. It validates branch lineage
|
||||
and hands ref mutation to an injected compare-and-swap broker; it does not run
|
||||
Git, inspect credentials, or create a branch itself.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import re
|
||||
from typing import Protocol
|
||||
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
TARGET_BRANCH = "agent/autonomous-release-campaign-v1-20260807"
|
||||
LINEAGE_ANCHOR = "69b45e81dcd2c3707c367003b08c55fd361faae8"
|
||||
CAMPAIGN_HEAD = "bba675fab3b7e0c5fba7066720252b3c91b9f20d"
|
||||
|
||||
|
||||
class ContinuationError(RuntimeError):
|
||||
"""Stable fail-closed error for lease or branch binding violations."""
|
||||
|
||||
|
||||
class BranchProbe(Protocol):
|
||||
def branch_exists(self, branch: str) -> bool: ...
|
||||
def branch_head(self, branch: str) -> str: ...
|
||||
def is_ancestor(self, older: str, newer: str) -> bool: ...
|
||||
|
||||
|
||||
class RefCAS(Protocol):
|
||||
def compare_and_swap_branch(self, branch: str, expected: str, new: str) -> str: ...
|
||||
|
||||
|
||||
def _oid(value: str, field: str) -> str:
|
||||
if not isinstance(value, str) or not OID_RE.fullmatch(value):
|
||||
raise ContinuationError(f"{field}_invalid")
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WriterLeaseRequestV2:
|
||||
task_id: str
|
||||
repository: str
|
||||
branch_mode: str
|
||||
base_oid: str
|
||||
expected_branch_oid: str
|
||||
target_branch: str
|
||||
generation: int
|
||||
lease_seconds: int = 900
|
||||
|
||||
schema = "aios.task-writer-lease.v2"
|
||||
accepted_schemas = frozenset({"aios.task-writer-lease.v2", "aios.task-writer-lease-request.v2"})
|
||||
|
||||
def validate(self, probe: BranchProbe) -> None:
|
||||
if not self.task_id or self.repository != "aios/aios":
|
||||
raise ContinuationError("request_identity_invalid")
|
||||
if self.branch_mode not in {"CREATE_NEW", "CONTINUE_EXISTING"}:
|
||||
raise ContinuationError("branch_mode_invalid")
|
||||
_oid(self.base_oid, "base_oid")
|
||||
_oid(self.expected_branch_oid, "expected_branch_oid")
|
||||
if self.target_branch in {"main", "master"} or self.target_branch.startswith(("refs/", "tags/")):
|
||||
raise ContinuationError("protected_branch_rejected")
|
||||
if not isinstance(self.generation, int) or isinstance(self.generation, bool) or self.generation < 1:
|
||||
raise ContinuationError("generation_invalid")
|
||||
if not isinstance(self.lease_seconds, int) or not 1 <= self.lease_seconds <= 86_400:
|
||||
raise ContinuationError("lease_duration_invalid")
|
||||
if self.branch_mode == "CONTINUE_EXISTING":
|
||||
if not probe.branch_exists(self.target_branch):
|
||||
raise ContinuationError("EXISTING_BRANCH_MISSING")
|
||||
actual = _oid(probe.branch_head(self.target_branch), "actual_branch_oid")
|
||||
if actual != self.expected_branch_oid:
|
||||
raise ContinuationError("EXISTING_BRANCH_OID_CONFLICT")
|
||||
if not probe.is_ancestor(self.base_oid, actual):
|
||||
raise ContinuationError("BASE_NOT_ANCESTOR_OF_BRANCH")
|
||||
elif hasattr(probe, "commit_exists") and not probe.commit_exists(self.base_oid):
|
||||
raise ContinuationError("BASE_COMMIT_NOT_FOUND")
|
||||
elif self.branch_mode == "CREATE_NEW" and probe.branch_exists(self.target_branch):
|
||||
raise ContinuationError("TARGET_BRANCH_ALREADY_EXISTS")
|
||||
elif self.branch_mode == "CREATE_NEW" and self.base_oid != self.expected_branch_oid:
|
||||
raise ContinuationError("CREATE_NEW_BASE_EXPECTATION_MISMATCH")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CampaignWriterLease:
|
||||
task_id: str
|
||||
repository: str
|
||||
branch: str
|
||||
lineage_anchor_oid: str
|
||||
current_head_oid: str
|
||||
generation: int
|
||||
lease_expires_at: datetime
|
||||
append_only: bool = True
|
||||
renewable: bool = True
|
||||
multi_commit: bool = True
|
||||
recoverable: bool = True
|
||||
state: str = "ACTIVE"
|
||||
branch_bound: bool = True
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_oid(self.lineage_anchor_oid, "lineage_anchor_oid")
|
||||
_oid(self.current_head_oid, "current_head_oid")
|
||||
if not all((self.append_only, self.renewable, self.multi_commit, self.recoverable, self.branch_bound)):
|
||||
raise ContinuationError("lease_capability_downgrade")
|
||||
|
||||
|
||||
class ContinuationLeaseManager:
|
||||
"""State machine for a multi-commit lease with CAS-only ref updates."""
|
||||
|
||||
def __init__(self, probe: BranchProbe, refs: RefCAS, *, now=datetime.now) -> None:
|
||||
self.probe = probe
|
||||
self.refs = refs
|
||||
self.now = now
|
||||
|
||||
def open(self, request: WriterLeaseRequestV2) -> CampaignWriterLease:
|
||||
request.validate(self.probe)
|
||||
if request.branch_mode != "CONTINUE_EXISTING":
|
||||
raise ContinuationError("CREATE_NEW_REQUIRES_SEPARATE_PROVISIONER")
|
||||
return CampaignWriterLease(
|
||||
task_id=request.task_id,
|
||||
repository=request.repository,
|
||||
branch=request.target_branch,
|
||||
lineage_anchor_oid=request.base_oid,
|
||||
current_head_oid=request.expected_branch_oid,
|
||||
generation=request.generation,
|
||||
lease_expires_at=self.now(timezone.utc) + timedelta(seconds=request.lease_seconds),
|
||||
)
|
||||
|
||||
def renew(self, lease: CampaignWriterLease, seconds: int) -> CampaignWriterLease:
|
||||
if lease.state != "ACTIVE" or not lease.renewable or seconds < 1:
|
||||
raise ContinuationError("lease_not_renewable")
|
||||
return replace(lease, lease_expires_at=self.now(timezone.utc) + timedelta(seconds=seconds))
|
||||
|
||||
def append_commit(self, lease: CampaignWriterLease, new_head_oid: str) -> CampaignWriterLease:
|
||||
if lease.state != "ACTIVE":
|
||||
raise ContinuationError("lease_not_active")
|
||||
_oid(new_head_oid, "new_head_oid")
|
||||
if not self.probe.is_ancestor(lease.current_head_oid, new_head_oid):
|
||||
raise ContinuationError("NON_FAST_FORWARD_COMMIT")
|
||||
try:
|
||||
actual = self.refs.compare_and_swap_branch(
|
||||
lease.branch, lease.current_head_oid, new_head_oid
|
||||
)
|
||||
except Exception as exc:
|
||||
raise ContinuationError("BRANCH_LEASE_STALE") from exc
|
||||
if actual != new_head_oid:
|
||||
raise ContinuationError("BRANCH_LEASE_STALE")
|
||||
return replace(lease, current_head_oid=new_head_oid)
|
||||
|
||||
def supersede(self, lease: CampaignWriterLease) -> CampaignWriterLease:
|
||||
if lease.state not in {"ACTIVE", "RECOVERABLE_FAILURE", "RECONCILIATION_REQUIRED"}:
|
||||
raise ContinuationError("lease_not_supersedable")
|
||||
return replace(lease, generation=lease.generation + 1, state="SUPERSEDED")
|
||||
@@ -0,0 +1,25 @@
|
||||
"""Separate read, write, and release plane readiness evaluation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Mapping
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RuntimeStatus:
|
||||
read_plane: Mapping[str, bool]
|
||||
write_plane: Mapping[str, bool]
|
||||
release_plane: Mapping[str, bool]
|
||||
|
||||
@property
|
||||
def release_control_plane_ready(self) -> bool:
|
||||
return all(self.read_plane.values()) and all(self.write_plane.values()) and all(self.release_plane.values())
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"READ_PLANE": dict(self.read_plane),
|
||||
"WRITE_PLANE": dict(self.write_plane),
|
||||
"RELEASE_PLANE": dict(self.release_plane),
|
||||
"release_control_plane_ready": self.release_control_plane_ready,
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Workspace-independent authority/network/Gitea ref sentinel."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from typing import Callable, Mapping
|
||||
|
||||
|
||||
class SentinelError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SentinelInputs:
|
||||
authority_git_dir: Path
|
||||
gitea_remote: str
|
||||
gitea_api: str
|
||||
ref: str
|
||||
expected_oid: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SentinelResult:
|
||||
authority_oid: str | None
|
||||
network_oid: str | None
|
||||
gitea_oid: str | None
|
||||
equal: bool
|
||||
error: str | None = None
|
||||
|
||||
|
||||
def authority_oid(inputs: SentinelInputs) -> str:
|
||||
result = subprocess.run(
|
||||
["git", "--git-dir", str(inputs.authority_git_dir), "rev-parse", inputs.ref],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise SentinelError("AUTHORITY_REF_UNAVAILABLE")
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def evaluate(
|
||||
inputs: SentinelInputs,
|
||||
*,
|
||||
network_reader: Callable[[str, str], str | None],
|
||||
gitea_reader: Callable[[str, str], str | None],
|
||||
) -> SentinelResult:
|
||||
try:
|
||||
local = authority_oid(inputs)
|
||||
network = network_reader(inputs.gitea_remote, inputs.ref)
|
||||
gitea = gitea_reader(inputs.gitea_api, inputs.ref)
|
||||
equal = local == network == gitea == inputs.expected_oid
|
||||
return SentinelResult(local, network, gitea, equal, None if equal else "OID_MISMATCH")
|
||||
except SentinelError as exc:
|
||||
return SentinelResult(None, None, None, False, str(exc))
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Non-sensitive control-plane snapshot assembly."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
def _copy(value: Mapping[str, Any] | None, keys: tuple[str, ...]) -> dict[str, Any]:
|
||||
value = value or {}
|
||||
return {key: value.get(key) for key in keys}
|
||||
|
||||
|
||||
def build_control_plane_snapshot(*, target_version: str, runtime: Mapping[str, Any] | None = None, repository: Mapping[str, Any] | None = None, production: Mapping[str, Any] | None = None, task: Mapping[str, Any] | None = None, capabilities: Mapping[str, Any] | None = None, credential_broker: Mapping[str, Any] | None = None) -> dict[str, Any]:
|
||||
task_snapshot = _copy(task, ("task_id", "branch", "authority_branch_oid", "gitea_git_oid", "gitea_api_oid", "commit_visible", "generation", "reconciliation_state"))
|
||||
for key in ("external_job_id", "commit_oid", "current_head_oid", "completed_replay_reconciliation_id", "receipt_authority_oid"):
|
||||
if task is not None and key in task:
|
||||
task_snapshot[key] = task[key]
|
||||
return {
|
||||
"version": {"target_release": target_version},
|
||||
"runtime": _copy(runtime, ("bridge_schema_version", "control_plane_release_version", "runtime_release_commit", "runtime_path", "artifact_sha256", "drift_status")),
|
||||
"repository": _copy(repository, ("identity", "authority_git_dir", "authority_main_oid")),
|
||||
"production": _copy(production, ("baseline_version", "target_oid", "deployment_state")),
|
||||
"task": task_snapshot,
|
||||
"capabilities": _copy(capabilities, ("legacy_writes_enabled", "governed_writes_enabled", "force_push", "main_write", "tag_write", "baseline_write", "production_write")),
|
||||
"credential_broker": _copy(credential_broker, ("status", "provider")),
|
||||
"secret_material_exposed": False,
|
||||
}
|
||||
|
||||
|
||||
def getControlPlaneSnapshot(**kwargs: Any) -> dict[str, Any]:
|
||||
return build_control_plane_snapshot(**kwargs)
|
||||
@@ -0,0 +1,478 @@
|
||||
"""Minimal convergence contracts for AIOS V2R2C2.
|
||||
|
||||
This module deliberately contains no external side effects. It provides the shared
|
||||
data contracts and pure evaluation rules used by domain controllers. Domain
|
||||
controllers remain authoritative for their own final states.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any, Iterable, Mapping, Sequence
|
||||
|
||||
|
||||
class ConvergenceContractError(ValueError):
|
||||
"""Raised when an invalid convergence contract is constructed."""
|
||||
|
||||
|
||||
class EvidenceState(str, Enum):
|
||||
PASS = "PASS"
|
||||
FAIL = "FAIL"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
BLOCKED = "BLOCKED"
|
||||
NOT_APPLICABLE = "NOT_APPLICABLE"
|
||||
|
||||
|
||||
class AcceptanceDecision(str, Enum):
|
||||
ACCEPT = "ACCEPT"
|
||||
REJECT = "REJECT"
|
||||
BLOCKED = "BLOCKED"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class ObservedHealth(str, Enum):
|
||||
HEALTHY = "HEALTHY"
|
||||
DEGRADED = "DEGRADED"
|
||||
FAILED = "FAILED"
|
||||
UNKNOWN = "UNKNOWN"
|
||||
|
||||
|
||||
class ReconciliationState(str, Enum):
|
||||
PENDING = "PENDING"
|
||||
OBSERVING = "OBSERVING"
|
||||
DIFF_FOUND = "DIFF_FOUND"
|
||||
APPLYING = "APPLYING"
|
||||
VERIFYING = "VERIFYING"
|
||||
CONVERGED = "CONVERGED"
|
||||
DEGRADED = "DEGRADED"
|
||||
BLOCKED = "BLOCKED"
|
||||
|
||||
|
||||
class ContinuationStopReason(str, Enum):
|
||||
CONTEXT_BOUNDARY = "context_boundary"
|
||||
WORKER_RESTART = "worker_restart"
|
||||
HANDOFF = "handoff"
|
||||
AUTHORITY_GATE = "authority_gate"
|
||||
WAIT_EXTERNAL = "wait_external"
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _canonical_json(value: Mapping[str, Any]) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
|
||||
|
||||
|
||||
def _sha256(value: Mapping[str, Any]) -> str:
|
||||
return "sha256:" + hashlib.sha256(_canonical_json(value)).hexdigest()
|
||||
|
||||
|
||||
def _nonempty(value: object, field_name: str) -> str:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
raise ConvergenceContractError(f"{field_name}_required")
|
||||
return text
|
||||
|
||||
|
||||
def _generation(value: object, field_name: str = "generation") -> int:
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ConvergenceContractError(f"{field_name}_invalid") from exc
|
||||
if parsed < 1:
|
||||
raise ConvergenceContractError(f"{field_name}_invalid")
|
||||
return parsed
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StateOwnership:
|
||||
object_type: str
|
||||
authoritative_service: str
|
||||
authoritative_store: str
|
||||
final_writer: str | None = None
|
||||
projections: tuple[str, ...] = ()
|
||||
schema_version: str = "aios.state-ownership.v1"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_nonempty(self.object_type, "object_type")
|
||||
_nonempty(self.authoritative_service, "authoritative_service")
|
||||
_nonempty(self.authoritative_store, "authoritative_store")
|
||||
if self.final_writer is not None:
|
||||
_nonempty(self.final_writer, "final_writer")
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
DEFAULT_STATE_OWNERSHIP: tuple[StateOwnership, ...] = (
|
||||
StateOwnership(
|
||||
object_type="goal",
|
||||
authoritative_service="goal-kernel",
|
||||
authoritative_store="goal-store",
|
||||
final_writer="goal-kernel",
|
||||
projections=("admin-web", "context-pack"),
|
||||
),
|
||||
StateOwnership(
|
||||
object_type="task_run",
|
||||
authoritative_service="task-plane-18940",
|
||||
authoritative_store="durable-task-store",
|
||||
final_writer="task-plane-18940",
|
||||
projections=("admin-web", "context-pack"),
|
||||
),
|
||||
StateOwnership(
|
||||
object_type="release",
|
||||
authoritative_service="release-controller",
|
||||
authoritative_store="release-registry",
|
||||
final_writer="release-controller",
|
||||
projections=("baseline-authority", "admin-web"),
|
||||
),
|
||||
StateOwnership(
|
||||
object_type="git_branch",
|
||||
authoritative_service="authority-git-executor",
|
||||
authoritative_store="authoritative-git",
|
||||
final_writer="authority-git-executor",
|
||||
projections=("gitea",),
|
||||
),
|
||||
StateOwnership(
|
||||
object_type="external_projection",
|
||||
authoritative_service="projection-controller",
|
||||
authoritative_store="projection-store",
|
||||
final_writer="projection-controller",
|
||||
projections=("openapi", "gpt-action", "nginx"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def ownership_index(
|
||||
ownership: Iterable[StateOwnership] = DEFAULT_STATE_OWNERSHIP,
|
||||
) -> dict[str, StateOwnership]:
|
||||
result: dict[str, StateOwnership] = {}
|
||||
for row in ownership:
|
||||
if row.object_type in result:
|
||||
raise ConvergenceContractError(f"duplicate_state_owner:{row.object_type}")
|
||||
result[row.object_type] = row
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AcceptanceCriterion:
|
||||
criterion_id: str
|
||||
required: bool
|
||||
state: EvidenceState
|
||||
verifier_id: str
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_nonempty(self.criterion_id, "criterion_id")
|
||||
_nonempty(self.verifier_id, "verifier_id")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AcceptanceInvariant:
|
||||
invariant_id: str
|
||||
state: EvidenceState
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_nonempty(self.invariant_id, "invariant_id")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AcceptanceVector:
|
||||
subject_type: str
|
||||
subject_id: str
|
||||
subject_revision: int
|
||||
criteria: tuple[AcceptanceCriterion, ...]
|
||||
invariants: tuple[AcceptanceInvariant, ...] = ()
|
||||
unresolved_side_effects: tuple[str, ...] = ()
|
||||
unresolved_blockers: tuple[str, ...] = ()
|
||||
runtime_proofs: tuple[str, ...] = ()
|
||||
schema: str = "aios.acceptance-vector.v1"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_nonempty(self.subject_type, "subject_type")
|
||||
_nonempty(self.subject_id, "subject_id")
|
||||
_generation(self.subject_revision, "subject_revision")
|
||||
ids = [row.criterion_id for row in self.criteria]
|
||||
if len(ids) != len(set(ids)):
|
||||
raise ConvergenceContractError("duplicate_acceptance_criterion")
|
||||
invariant_ids = [row.invariant_id for row in self.invariants]
|
||||
if len(invariant_ids) != len(set(invariant_ids)):
|
||||
raise ConvergenceContractError("duplicate_acceptance_invariant")
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
value = asdict(self)
|
||||
for row in value["criteria"]:
|
||||
row["state"] = row["state"].value
|
||||
for row in value["invariants"]:
|
||||
row["state"] = row["state"].value
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class AcceptanceResult:
|
||||
decision: AcceptanceDecision
|
||||
reasons: tuple[str, ...]
|
||||
vector_hash: str
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"decision": self.decision.value,
|
||||
"reasons": list(self.reasons),
|
||||
"vector_hash": self.vector_hash,
|
||||
}
|
||||
|
||||
|
||||
def evaluate_acceptance(vector: AcceptanceVector) -> AcceptanceResult:
|
||||
"""Pure fail-closed acceptance evaluator.
|
||||
|
||||
The evaluator never writes a domain final state. The domain owner consumes this
|
||||
result and remains the only final writer for Goal/Release/Product state.
|
||||
"""
|
||||
reasons: list[str] = []
|
||||
required = [row for row in vector.criteria if row.required]
|
||||
|
||||
for row in required:
|
||||
if row.state == EvidenceState.FAIL:
|
||||
reasons.append(f"criterion_failed:{row.criterion_id}")
|
||||
elif row.state == EvidenceState.BLOCKED:
|
||||
reasons.append(f"criterion_blocked:{row.criterion_id}")
|
||||
elif row.state == EvidenceState.UNKNOWN:
|
||||
reasons.append(f"criterion_unknown:{row.criterion_id}")
|
||||
|
||||
for row in vector.invariants:
|
||||
if row.state == EvidenceState.FAIL:
|
||||
reasons.append(f"invariant_failed:{row.invariant_id}")
|
||||
elif row.state in {EvidenceState.UNKNOWN, EvidenceState.BLOCKED}:
|
||||
reasons.append(f"invariant_unknown:{row.invariant_id}")
|
||||
|
||||
if vector.unresolved_side_effects:
|
||||
reasons.append("unresolved_side_effects")
|
||||
if vector.unresolved_blockers:
|
||||
reasons.append("unresolved_blockers")
|
||||
|
||||
digest = _sha256(vector.as_dict())
|
||||
|
||||
if any(reason.startswith(("criterion_failed:", "invariant_failed:")) for reason in reasons):
|
||||
decision = AcceptanceDecision.REJECT
|
||||
elif any(reason.startswith("criterion_blocked:") for reason in reasons) or vector.unresolved_blockers:
|
||||
decision = AcceptanceDecision.BLOCKED
|
||||
elif reasons:
|
||||
decision = AcceptanceDecision.UNKNOWN
|
||||
else:
|
||||
decision = AcceptanceDecision.ACCEPT
|
||||
|
||||
return AcceptanceResult(decision=decision, reasons=tuple(reasons), vector_hash=digest)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DesiredState:
|
||||
resource_id: str
|
||||
resource_type: str
|
||||
desired_generation: int
|
||||
desired_spec: Mapping[str, Any]
|
||||
owner_goal_id: str | None = None
|
||||
lifecycle: str = "active"
|
||||
schema: str = "aios.desired-state.v1"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_nonempty(self.resource_id, "resource_id")
|
||||
_nonempty(self.resource_type, "resource_type")
|
||||
_generation(self.desired_generation, "desired_generation")
|
||||
if self.lifecycle not in {"active", "suspended", "deleted"}:
|
||||
raise ConvergenceContractError("desired_lifecycle_invalid")
|
||||
|
||||
def identity(self) -> tuple[str, int]:
|
||||
return self.resource_id, self.desired_generation
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ObservedState:
|
||||
resource_id: str
|
||||
observed_generation: int | None
|
||||
observed_spec: Mapping[str, Any]
|
||||
health: ObservedHealth
|
||||
source_identity: str
|
||||
observed_at: str
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
schema: str = "aios.observed-state.v1"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_nonempty(self.resource_id, "resource_id")
|
||||
_nonempty(self.source_identity, "source_identity")
|
||||
_nonempty(self.observed_at, "observed_at")
|
||||
if self.observed_generation is not None:
|
||||
_generation(self.observed_generation, "observed_generation")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReconciliationPlan:
|
||||
resource_id: str
|
||||
desired_generation: int
|
||||
state: ReconciliationState
|
||||
diff: Mapping[str, Any]
|
||||
observation_required: bool
|
||||
idempotency_key: str
|
||||
reason: str
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
value = asdict(self)
|
||||
value["state"] = self.state.value
|
||||
return value
|
||||
|
||||
|
||||
def plan_reconciliation(
|
||||
desired: DesiredState,
|
||||
observed: ObservedState | None,
|
||||
) -> ReconciliationPlan:
|
||||
"""Return a side-effect-free reconciliation decision.
|
||||
|
||||
UNKNOWN/missing observed state always requires observation and never converges.
|
||||
"""
|
||||
identity = {
|
||||
"resource_id": desired.resource_id,
|
||||
"desired_generation": desired.desired_generation,
|
||||
"desired_spec": desired.desired_spec,
|
||||
}
|
||||
key = _sha256(identity)
|
||||
|
||||
if observed is None:
|
||||
return ReconciliationPlan(
|
||||
desired.resource_id,
|
||||
desired.desired_generation,
|
||||
ReconciliationState.OBSERVING,
|
||||
{"observed": None},
|
||||
True,
|
||||
key,
|
||||
"observed_state_missing",
|
||||
)
|
||||
if observed.resource_id != desired.resource_id:
|
||||
raise ConvergenceContractError("resource_identity_mismatch")
|
||||
if observed.health == ObservedHealth.UNKNOWN:
|
||||
return ReconciliationPlan(
|
||||
desired.resource_id,
|
||||
desired.desired_generation,
|
||||
ReconciliationState.OBSERVING,
|
||||
{"health": observed.health.value},
|
||||
True,
|
||||
key,
|
||||
"observed_state_unknown",
|
||||
)
|
||||
|
||||
same_generation = observed.observed_generation == desired.desired_generation
|
||||
same_spec = dict(observed.observed_spec) == dict(desired.desired_spec)
|
||||
if same_generation and same_spec and observed.health == ObservedHealth.HEALTHY:
|
||||
return ReconciliationPlan(
|
||||
desired.resource_id,
|
||||
desired.desired_generation,
|
||||
ReconciliationState.CONVERGED,
|
||||
{},
|
||||
False,
|
||||
key,
|
||||
"desired_equals_observed",
|
||||
)
|
||||
|
||||
return ReconciliationPlan(
|
||||
desired.resource_id,
|
||||
desired.desired_generation,
|
||||
ReconciliationState.DIFF_FOUND,
|
||||
{
|
||||
"desired_generation": desired.desired_generation,
|
||||
"observed_generation": observed.observed_generation,
|
||||
"desired_spec": dict(desired.desired_spec),
|
||||
"observed_spec": dict(observed.observed_spec),
|
||||
"observed_health": observed.health.value,
|
||||
},
|
||||
False,
|
||||
key,
|
||||
"desired_observed_drift",
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SideEffectReceipt:
|
||||
mutation_id: str
|
||||
external_system: str
|
||||
state: str
|
||||
observed_at: str
|
||||
external_request_id: str | None = None
|
||||
external_object_id: str | None = None
|
||||
observed_result_hash: str | None = None
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
schema: str = "aios.side-effect-receipt.v1"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_nonempty(self.mutation_id, "mutation_id")
|
||||
_nonempty(self.external_system, "external_system")
|
||||
_nonempty(self.observed_at, "observed_at")
|
||||
if self.state not in {
|
||||
"confirmed_applied",
|
||||
"confirmed_not_applied",
|
||||
"unknown",
|
||||
}:
|
||||
raise ConvergenceContractError("side_effect_receipt_state_invalid")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContinuationManifest:
|
||||
trace_id: str
|
||||
goal_id: str
|
||||
goal_revision: int
|
||||
task_id: str
|
||||
run_id: str
|
||||
current_node_id: str
|
||||
acceptance_vector_ref: str
|
||||
next_action: str
|
||||
stop_reason: ContinuationStopReason
|
||||
checkpoint_ref: str | None = None
|
||||
unresolved_blocker_refs: tuple[str, ...] = ()
|
||||
authoritative_decision_refs: tuple[str, ...] = ()
|
||||
resource_refs: tuple[str, ...] = ()
|
||||
artifact_refs: tuple[str, ...] = ()
|
||||
evidence_refs: tuple[str, ...] = ()
|
||||
created_at: str = field(default_factory=_utc_now)
|
||||
schema: str = "aios.continuation-manifest.v1"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in (
|
||||
"trace_id",
|
||||
"goal_id",
|
||||
"task_id",
|
||||
"run_id",
|
||||
"current_node_id",
|
||||
"acceptance_vector_ref",
|
||||
"next_action",
|
||||
"created_at",
|
||||
):
|
||||
_nonempty(getattr(self, name), name)
|
||||
_generation(self.goal_revision, "goal_revision")
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
value = asdict(self)
|
||||
value["stop_reason"] = self.stop_reason.value
|
||||
return value
|
||||
|
||||
@property
|
||||
def content_hash(self) -> str:
|
||||
return _sha256(self.as_dict())
|
||||
|
||||
|
||||
def validate_state_ownership(
|
||||
object_type: str,
|
||||
writer: str,
|
||||
ownership: Iterable[StateOwnership] = DEFAULT_STATE_OWNERSHIP,
|
||||
*,
|
||||
final_state: bool = False,
|
||||
) -> None:
|
||||
row = ownership_index(ownership).get(object_type)
|
||||
if row is None:
|
||||
raise ConvergenceContractError(f"state_owner_missing:{object_type}")
|
||||
expected = row.final_writer if final_state and row.final_writer else row.authoritative_service
|
||||
if writer != expected:
|
||||
raise ConvergenceContractError(
|
||||
f"STATE_OWNERSHIP_VIOLATION:{object_type}:{writer}:{expected}"
|
||||
)
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Metadata-only, server-side credential lease broker."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CredentialLease:
|
||||
lease_id: str
|
||||
task_id: str
|
||||
repository: str
|
||||
target_branch: str
|
||||
generation: int
|
||||
issued_at: str
|
||||
expires_at: str
|
||||
scopes: tuple[str, ...]
|
||||
provider: str
|
||||
state: str = "ACTIVE"
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {"lease_id": self.lease_id, "task_id": self.task_id, "repository": self.repository, "target_branch": self.target_branch, "generation": self.generation, "issued_at": self.issued_at, "expires_at": self.expires_at, "scopes": list(self.scopes), "provider": self.provider, "state": self.state}
|
||||
|
||||
|
||||
class FileCredentialProvider:
|
||||
def __init__(self, token_path: str | os.PathLike[str], *, actor: str = "aios-autonomy-agent") -> None:
|
||||
self.token_path = Path(token_path)
|
||||
self.actor = actor
|
||||
|
||||
def environment(self, lease: CredentialLease) -> dict[str, str]:
|
||||
if not self.token_path.is_file():
|
||||
raise RuntimeError("CREDENTIAL_PROVIDER_UNAVAILABLE")
|
||||
return {"CREDENTIAL_FILE": str(self.token_path), "GITEA_MACHINE_ACTOR": self.actor}
|
||||
|
||||
|
||||
class CredentialBroker:
|
||||
def __init__(self, provider: Any, *, now: Callable[[], datetime] | None = None) -> None:
|
||||
self.provider = provider
|
||||
self.now = now or (lambda: datetime.now(timezone.utc))
|
||||
self._leases: dict[str, CredentialLease] = {}
|
||||
|
||||
def issue(self, *, task_id: str, repository: str, target_branch: str, generation: int, scopes: tuple[str, ...] = ("git:push", "gitea:read"), lease_seconds: int = 900) -> dict[str, Any]:
|
||||
if repository != "aios/aios" or not target_branch.startswith("agent/") or generation < 1 or not 1 <= lease_seconds <= 86400:
|
||||
raise ValueError("CREDENTIAL_LEASE_BINDING_INVALID")
|
||||
now = self.now().astimezone(timezone.utc)
|
||||
lease = CredentialLease("lease-" + secrets.token_hex(16), task_id, repository, target_branch, generation, now.isoformat(), (now + timedelta(seconds=lease_seconds)).isoformat(), tuple(scopes), type(self.provider).__name__)
|
||||
self._leases[lease.lease_id] = lease
|
||||
return lease.as_dict()
|
||||
|
||||
def _get(self, lease_id: str) -> CredentialLease:
|
||||
lease = self._leases.get(lease_id)
|
||||
if lease is None:
|
||||
raise KeyError("CREDENTIAL_LEASE_NOT_FOUND")
|
||||
if lease.state == "ACTIVE" and self.now().astimezone(timezone.utc) >= datetime.fromisoformat(lease.expires_at):
|
||||
lease = CredentialLease(**{**lease.as_dict(), "scopes": tuple(lease.scopes), "state": "EXPIRED"})
|
||||
self._leases[lease_id] = lease
|
||||
return lease
|
||||
|
||||
def environment(self, lease_id: str) -> Mapping[str, str]:
|
||||
lease = self._get(lease_id)
|
||||
if lease.state != "ACTIVE":
|
||||
raise RuntimeError("CREDENTIAL_LEASE_NOT_ACTIVE")
|
||||
return self.provider.environment(lease)
|
||||
|
||||
def status(self, lease_id: str) -> dict[str, Any]:
|
||||
return self._get(lease_id).as_dict()
|
||||
|
||||
def revoke(self, lease_id: str) -> dict[str, Any]:
|
||||
lease = self._get(lease_id)
|
||||
if lease.state == "REVOKED":
|
||||
return lease.as_dict()
|
||||
updated = CredentialLease(**{**lease.as_dict(), "scopes": tuple(lease.scopes), "state": "REVOKED"})
|
||||
self._leases[lease_id] = updated
|
||||
return updated.as_dict()
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Non-Owner Gitea machine capability and branch reconciliation boundary."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Mapping, Protocol
|
||||
|
||||
|
||||
class MachineBrokerError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class GiteaTransport(Protocol):
|
||||
def compare_and_swap_branch(self, branch: str, expected_oid: str, new_oid: str) -> str: ...
|
||||
def create_or_update_pr(self, branch: str, target: str, title: str, body: str) -> Mapping[str, Any]: ...
|
||||
def publish_status(self, branch: str, state: str, description: str) -> Mapping[str, Any]: ...
|
||||
def protected_merge(self, pr_number: int) -> Mapping[str, Any]: ...
|
||||
def read_branch(self, branch: str) -> str | None: ...
|
||||
def whoami(self) -> Mapping[str, Any]: ...
|
||||
def repository(self, owner: str, name: str) -> Mapping[str, Any]: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MachineCredentialProjection:
|
||||
"""Opaque systemd credential reference; the credential value is never accepted."""
|
||||
|
||||
reference: str
|
||||
account: str
|
||||
owner_account: str = "aiman"
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> "MachineCredentialProjection":
|
||||
if set(value) != {"scheme", "name", "account"}:
|
||||
raise MachineBrokerError("CREDENTIAL_REFERENCE_INVALID")
|
||||
if value["scheme"] != "systemd-credential" or value["name"] != "gitea-machine-token":
|
||||
raise MachineBrokerError("CREDENTIAL_REFERENCE_INVALID")
|
||||
return cls(f"{value['scheme']}:{value['name']}", str(value["account"]))
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.reference != "systemd-credential:gitea-machine-token":
|
||||
raise MachineBrokerError("CREDENTIAL_REFERENCE_INVALID")
|
||||
if not self.account or self.account == self.owner_account:
|
||||
raise MachineBrokerError("OWNER_MACHINE_IDENTITY_REJECTED")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CampaignCapabilityReceipt:
|
||||
campaign_id: str
|
||||
task_id: str
|
||||
repository: str
|
||||
branch: str
|
||||
expected_oid: str
|
||||
capabilities: frozenset[str]
|
||||
denied: frozenset[str]
|
||||
|
||||
def require(self, capability: str) -> None:
|
||||
if capability in self.denied or capability not in self.capabilities:
|
||||
raise MachineBrokerError("CAPABILITY_DENIED")
|
||||
if self.branch in {"main", "master"}:
|
||||
raise MachineBrokerError("PROTECTED_BRANCH_DENIED")
|
||||
|
||||
|
||||
class GiteaMachineBroker:
|
||||
def __init__(self, transport: GiteaTransport, credential: MachineCredentialProjection, receipt: CampaignCapabilityReceipt) -> None:
|
||||
self.transport = transport
|
||||
self.credential = credential
|
||||
self.receipt = receipt
|
||||
|
||||
def verify_identity(self) -> Mapping[str, Any]:
|
||||
identity = dict(self.transport.whoami())
|
||||
login = identity.get("login") or identity.get("username")
|
||||
if not isinstance(login, str) or not login or login == self.credential.owner_account or login != self.credential.account:
|
||||
raise MachineBrokerError("GITEA_MACHINE_IDENTITY_INVALID")
|
||||
owner, name = self.receipt.repository.split("/", 1)
|
||||
repo = dict(self.transport.repository(owner, name))
|
||||
if repo.get("full_name") != self.receipt.repository:
|
||||
raise MachineBrokerError("GITEA_REPOSITORY_MISMATCH")
|
||||
return {"login": login, "repository": self.receipt.repository, "owner_account": self.credential.owner_account}
|
||||
|
||||
def push_branch(self, new_oid: str) -> str:
|
||||
self.receipt.require("exact_task_branch_push")
|
||||
return self.transport.compare_and_swap_branch(self.receipt.branch, self.receipt.expected_oid, new_oid)
|
||||
|
||||
def create_pr(self, title: str, body: str) -> Mapping[str, Any]:
|
||||
self.receipt.require("pr_create_update")
|
||||
return self.transport.create_or_update_pr(self.receipt.branch, "main", title, body)
|
||||
|
||||
def status(self, state: str, description: str, context: str = "aios/durable-release") -> Mapping[str, Any]:
|
||||
self.receipt.require("status_publish")
|
||||
return self.transport.publish_status(self.receipt.branch, state, description, context)
|
||||
|
||||
def merge(self, pr_number: int) -> Mapping[str, Any]:
|
||||
self.receipt.require("protected_merge")
|
||||
return self.transport.protected_merge(pr_number)
|
||||
|
||||
def readback(self) -> str | None:
|
||||
self.receipt.require("remote_readback")
|
||||
return self.transport.read_branch(self.receipt.branch)
|
||||
|
||||
|
||||
def reconcile_exact_branch(authority_oid: str, gitea_oid: str | None, expected_oid: str) -> str:
|
||||
if authority_oid != expected_oid:
|
||||
return "AUTHORITY_EXPECTATION_MISMATCH"
|
||||
if gitea_oid is None:
|
||||
return "CREATE_REQUIRED"
|
||||
if gitea_oid == expected_oid:
|
||||
return "ALREADY_IN_SYNC"
|
||||
return "MANUAL_OWNER_REVIEW_REQUIRED"
|
||||
|
||||
|
||||
def reconcile_fast_forward(
|
||||
remote_oid: str | None,
|
||||
expected_old_oid: str,
|
||||
new_oid: str,
|
||||
*,
|
||||
old_is_ancestor_of_new: bool,
|
||||
) -> str:
|
||||
"""Return a bounded action for an active Job/lease branch scope."""
|
||||
if remote_oid is None:
|
||||
return "CREATE_REQUIRED"
|
||||
if remote_oid == new_oid:
|
||||
return "ALREADY_IN_SYNC"
|
||||
if remote_oid == expected_old_oid and old_is_ancestor_of_new:
|
||||
return "FAST_FORWARD_REQUIRED"
|
||||
return "MANUAL_OWNER_REVIEW_REQUIRED"
|
||||
@@ -0,0 +1,321 @@
|
||||
"""Gitea HTTP/Git transport using only a projected systemd credential."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
from typing import Any, Mapping
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import quote, urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
|
||||
class GiteaTransportError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _safe_oid(value: str, field: str) -> str:
|
||||
if not isinstance(value, str) or len(value) != 40 or any(ch not in "0123456789abcdef" for ch in value):
|
||||
raise GiteaTransportError(f"{field}_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _safe_branch(value: str) -> str:
|
||||
branch = str(value or "")
|
||||
if (
|
||||
not branch
|
||||
or len(branch) > 240
|
||||
or branch != branch.strip()
|
||||
or branch.startswith(("/", "-", "."))
|
||||
or branch.endswith(("/", ".", ".lock"))
|
||||
or ".." in branch
|
||||
or "//" in branch
|
||||
or "@{" in branch
|
||||
or "\\" in branch
|
||||
or any(part in {"", ".", ".."} for part in branch.split("/"))
|
||||
or any(ch not in "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._/-" for ch in branch)
|
||||
):
|
||||
raise GiteaTransportError("BRANCH_INVALID")
|
||||
return branch
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class GiteaMachineTransport:
|
||||
api_base: str
|
||||
repository_id: str
|
||||
remote_url: str
|
||||
authority_git_dir: Path
|
||||
credential_name: str = "gitea-machine-token"
|
||||
actor: str = "aios-autonomy-agent"
|
||||
timeout: int = 20
|
||||
expected_merge_head: str | None = None
|
||||
expected_merge_base: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.credential_name != "gitea-machine-token":
|
||||
raise GiteaTransportError("CREDENTIAL_NAME_INVALID")
|
||||
if "/" not in self.repository_id or self.repository_id.startswith("/"):
|
||||
raise GiteaTransportError("REPOSITORY_INVALID")
|
||||
if not self.api_base.startswith("https://") and not self.api_base.startswith("http://127.0.0.1"):
|
||||
raise GiteaTransportError("GITEA_API_SCOPE_INVALID")
|
||||
if not self.remote_url.startswith("https://"):
|
||||
raise GiteaTransportError("GITEA_REMOTE_SCOPE_INVALID")
|
||||
|
||||
@property
|
||||
def _credential_path(self) -> Path:
|
||||
directory = os.environ.get("CREDENTIALS_DIRECTORY", "")
|
||||
if not directory:
|
||||
raise GiteaTransportError("CREDENTIAL_PROJECTION_MISSING")
|
||||
path = Path(directory) / self.credential_name
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise GiteaTransportError("CREDENTIAL_PROJECTION_MISSING")
|
||||
return path
|
||||
|
||||
def _token(self) -> str:
|
||||
value = self._credential_path.read_text(encoding="utf-8").strip()
|
||||
if not value:
|
||||
raise GiteaTransportError("CREDENTIAL_EMPTY")
|
||||
return value
|
||||
|
||||
def _request(self, method: str, path: str, body: Mapping[str, Any] | None = None) -> tuple[int, Any]:
|
||||
data = None if body is None else json.dumps(dict(body), separators=(",", ":")).encode()
|
||||
headers = {"Accept": "application/json", "Authorization": f"token {self._token()}"}
|
||||
if data is not None:
|
||||
headers["Content-Type"] = "application/json"
|
||||
request = Request(self.api_base.rstrip("/") + path, data=data, headers=headers, method=method)
|
||||
try:
|
||||
with urlopen(request, timeout=self.timeout) as response:
|
||||
raw = response.read()
|
||||
return response.status, json.loads(raw.decode()) if raw else {}
|
||||
except HTTPError as exc:
|
||||
# Never include response bodies: Gitea may echo sensitive request context.
|
||||
raise GiteaTransportError(f"GITEA_HTTP_{exc.code}") from exc
|
||||
except (URLError, TimeoutError, OSError) as exc:
|
||||
raise GiteaTransportError("GITEA_NETWORK_ERROR") from exc
|
||||
|
||||
def whoami(self) -> Mapping[str, Any]:
|
||||
return self._request("GET", "/user")[1]
|
||||
|
||||
def repository_info(self) -> Mapping[str, Any]:
|
||||
owner, name = self.repository_id.split("/", 1)
|
||||
return self._request("GET", f"/repos/{quote(owner)}/{quote(name)}")[1]
|
||||
|
||||
def repository(self, owner: str, name: str) -> Mapping[str, Any]:
|
||||
if f"{owner}/{name}" != self.repository_id:
|
||||
raise GiteaTransportError("REPOSITORY_SCOPE_DENIED")
|
||||
return self.repository_info()
|
||||
|
||||
def read_branch(self, branch: str) -> str | None:
|
||||
branch = _safe_branch(branch)
|
||||
owner, name = self.repository_id.split("/", 1)
|
||||
try:
|
||||
value = self._request("GET", f"/repos/{quote(owner)}/{quote(name)}/branches/{quote(branch, safe='/')}")[1]
|
||||
except GiteaTransportError as exc:
|
||||
if str(exc) == "GITEA_HTTP_404":
|
||||
return None
|
||||
raise
|
||||
sha = value.get("commit", {}).get("id") if isinstance(value, dict) else None
|
||||
return _safe_oid(sha, "remote_branch_oid")
|
||||
|
||||
def _askpass(self) -> tuple[Path, dict[str, str]]:
|
||||
credential = self._credential_path
|
||||
fd, name = tempfile.mkstemp(prefix="aios-gitea-askpass-", dir="/tmp")
|
||||
path = Path(name)
|
||||
os.fchmod(fd, 0o700)
|
||||
script = "#!/bin/sh\ncase \"$1\" in *Username*) printf '%s\\n' \"$GITEA_MACHINE_ACTOR\" ;; *) cat \"$CREDENTIAL_FILE\" ;; esac\n"
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
||||
stream.write(script)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
return path, {
|
||||
"GIT_ASKPASS": str(path),
|
||||
"GIT_TERMINAL_PROMPT": "0",
|
||||
"GITEA_MACHINE_ACTOR": self.actor,
|
||||
"CREDENTIAL_FILE": str(credential),
|
||||
}
|
||||
|
||||
def _push_branch_fast_forward(self, branch: str, new_oid: str) -> None:
|
||||
askpass, additions = self._askpass()
|
||||
environment = os.environ.copy()
|
||||
environment.update(additions)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["git", f"--git-dir={self.authority_git_dir}", "push", self.remote_url, f"{new_oid}:refs/heads/{branch}"],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
env=environment,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise GiteaTransportError("GITEA_BRANCH_CREATE_FAILED")
|
||||
finally:
|
||||
askpass.unlink(missing_ok=True)
|
||||
|
||||
def fetch_branch_exact(self, branch: str, expected_oid: str) -> str:
|
||||
"""Fetch one exact remote branch tip into a quarantine ref.
|
||||
|
||||
This never updates ``main`` or any caller-visible branch. The remote
|
||||
ref is read before and after the fetch so a concurrent branch move
|
||||
cannot be silently admitted.
|
||||
"""
|
||||
|
||||
branch = _safe_branch(branch)
|
||||
expected_oid = _safe_oid(expected_oid, "expected_oid")
|
||||
remote_before = self.read_branch(branch)
|
||||
if remote_before != expected_oid:
|
||||
raise GiteaTransportError("GITEA_SOURCE_REF_MISMATCH")
|
||||
target_ref = f"refs/aios/release-sources/{expected_oid}"
|
||||
|
||||
current = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
f"--git-dir={self.authority_git_dir}",
|
||||
"rev-parse",
|
||||
"--verify",
|
||||
f"{target_ref}^{{commit}}",
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
if current.returncode == 0 and current.stdout.strip().lower() == expected_oid:
|
||||
remote_after = self.read_branch(branch)
|
||||
if remote_after != expected_oid:
|
||||
raise GiteaTransportError("GITEA_SOURCE_REF_MOVED")
|
||||
return expected_oid
|
||||
|
||||
askpass, additions = self._askpass()
|
||||
environment = os.environ.copy()
|
||||
environment.update(additions)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
f"--git-dir={self.authority_git_dir}",
|
||||
"fetch",
|
||||
"--no-tags",
|
||||
"--no-write-fetch-head",
|
||||
self.remote_url,
|
||||
f"refs/heads/{branch}:{target_ref}",
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
env=environment,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise GiteaTransportError("GITEA_SOURCE_FETCH_FAILED")
|
||||
finally:
|
||||
askpass.unlink(missing_ok=True)
|
||||
|
||||
local = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
f"--git-dir={self.authority_git_dir}",
|
||||
"rev-parse",
|
||||
"--verify",
|
||||
f"{target_ref}^{{commit}}",
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
local_oid = local.stdout.strip().lower()
|
||||
if local.returncode != 0 or local_oid != expected_oid:
|
||||
subprocess.run(
|
||||
[
|
||||
"git",
|
||||
f"--git-dir={self.authority_git_dir}",
|
||||
"update-ref",
|
||||
"-d",
|
||||
target_ref,
|
||||
],
|
||||
text=True,
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=self.timeout,
|
||||
)
|
||||
raise GiteaTransportError("GITEA_SOURCE_FETCH_MISMATCH")
|
||||
remote_after = self.read_branch(branch)
|
||||
if remote_after != expected_oid:
|
||||
raise GiteaTransportError("GITEA_SOURCE_REF_MOVED")
|
||||
return expected_oid
|
||||
|
||||
def compare_and_swap_branch(self, branch: str, expected_oid: str, new_oid: str) -> str:
|
||||
_safe_oid(expected_oid, "expected_oid")
|
||||
_safe_oid(new_oid, "new_oid")
|
||||
current = self.read_branch(branch)
|
||||
if current == new_oid:
|
||||
return new_oid
|
||||
if current is None:
|
||||
self._push_branch_fast_forward(branch, new_oid)
|
||||
readback = self.read_branch(branch)
|
||||
if readback != new_oid:
|
||||
raise GiteaTransportError("GITEA_BRANCH_CREATE_READBACK_MISMATCH")
|
||||
return readback
|
||||
if current != expected_oid:
|
||||
raise GiteaTransportError("GITEA_BRANCH_EXPECTATION_MISMATCH")
|
||||
# The preflight read binds the expected old tip. The Git receive-pack
|
||||
# then enforces the actual fast-forward CAS while transferring the new
|
||||
# objects; no force/lease override or arbitrary ref is permitted.
|
||||
self._push_branch_fast_forward(branch, new_oid)
|
||||
readback = self.read_branch(branch)
|
||||
if readback != new_oid:
|
||||
raise GiteaTransportError("GITEA_BRANCH_CAS_READBACK_MISMATCH")
|
||||
return readback
|
||||
|
||||
def _pulls(self) -> list[Mapping[str, Any]]:
|
||||
owner, name = self.repository_id.split("/", 1)
|
||||
query = urlencode({"state": "all", "base_branch": "main", "limit": 100})
|
||||
value = self._request("GET", f"/repos/{quote(owner)}/{quote(name)}/pulls?{query}")[1]
|
||||
return value if isinstance(value, list) else []
|
||||
|
||||
def create_or_update_pr(self, branch: str, target: str, title: str, body: str) -> Mapping[str, Any]:
|
||||
if target != "main":
|
||||
raise GiteaTransportError("PR_BASE_SCOPE_DENIED")
|
||||
head = self.read_branch(branch)
|
||||
if head is None:
|
||||
raise GiteaTransportError("PR_HEAD_MISSING")
|
||||
matches = [p for p in self._pulls() if (p.get("head") or {}).get("ref") == branch and (p.get("base") or {}).get("ref") == target]
|
||||
open_matches = [p for p in matches if p.get("state") == "open"]
|
||||
if len(open_matches) > 1:
|
||||
raise GiteaTransportError("MULTIPLE_MATCHING_PRS")
|
||||
if open_matches:
|
||||
if (open_matches[0].get("head") or {}).get("sha") != head:
|
||||
raise GiteaTransportError("PR_HEAD_CONFLICT")
|
||||
return open_matches[0]
|
||||
owner, name = self.repository_id.split("/", 1)
|
||||
return self._request("POST", f"/repos/{quote(owner)}/{quote(name)}/pulls", {"head": branch, "base": target, "title": title, "body": body, "allow_maintainer_edit": False})[1]
|
||||
|
||||
def publish_status(self, branch: str, state: str, description: str, context: str = "aios/durable-release") -> Mapping[str, Any]:
|
||||
oid = self.read_branch(branch)
|
||||
if oid is None:
|
||||
raise GiteaTransportError("STATUS_BRANCH_MISSING")
|
||||
owner, name = self.repository_id.split("/", 1)
|
||||
return self._request("POST", f"/repos/{quote(owner)}/{quote(name)}/statuses/{oid}", {"state": state, "context": context, "description": description})[1]
|
||||
|
||||
def protected_merge(self, pr_number: int) -> Mapping[str, Any]:
|
||||
if not isinstance(pr_number, int) or pr_number < 1:
|
||||
raise GiteaTransportError("PR_NUMBER_INVALID")
|
||||
owner, name = self.repository_id.split("/", 1)
|
||||
pr = self._request("GET", f"/repos/{quote(owner)}/{quote(name)}/pulls/{pr_number}")[1]
|
||||
if pr.get("state") != "open" or (pr.get("base") or {}).get("ref") != "main":
|
||||
raise GiteaTransportError("PR_MERGE_BINDING_INVALID")
|
||||
if pr.get("mergeable") is False:
|
||||
raise GiteaTransportError("PR_NOT_MERGEABLE")
|
||||
head_oid = (pr.get("head") or {}).get("sha")
|
||||
base_oid = (pr.get("base") or {}).get("sha")
|
||||
if self.expected_merge_head is not None and head_oid != self.expected_merge_head:
|
||||
raise GiteaTransportError("PR_HEAD_EXPECTATION_MISMATCH")
|
||||
if self.expected_merge_base is not None and base_oid != self.expected_merge_base:
|
||||
raise GiteaTransportError("PR_BASE_EXPECTATION_MISMATCH")
|
||||
body = {"do": "fast-forward-only", "force_merge": False, "merge_when_checks_succeed": False, "head_commit_id": head_oid}
|
||||
return self._request("POST", f"/repos/{quote(owner)}/{quote(name)}/pulls/{pr_number}/merge", body)[1]
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Canonical Gitea transport and slash-branch verification."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
from typing import Any, Callable, Mapping, Optional
|
||||
from urllib.parse import quote
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from aios_gitops_reconciliation_core import RemoteObservation
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GiteaPublisherConfig:
|
||||
repository: str = "aios/aios"
|
||||
api_base: str = "https://g.cnnetmkt.top/git/api/v1"
|
||||
remote_name: str = "gitea-http"
|
||||
|
||||
|
||||
class GiteaPublisher:
|
||||
def __init__(self, workspace: Path, *, config: GiteaPublisherConfig = GiteaPublisherConfig(), credential: Any = None, api_get: Callable[[str], Any] | None = None, git_ref: Callable[[str], Optional[str]] | None = None) -> None:
|
||||
self.workspace = Path(workspace)
|
||||
self.config = config
|
||||
self.credential = credential
|
||||
self._api_get = api_get
|
||||
self._git_ref = git_ref
|
||||
|
||||
@staticmethod
|
||||
def validate_repository(repository: str) -> None:
|
||||
if repository != "aios/aios":
|
||||
raise ValueError("REPOSITORY_IDENTITY_MISMATCH")
|
||||
|
||||
@staticmethod
|
||||
def validate_branch(branch: str) -> None:
|
||||
if not isinstance(branch, str) or not branch.startswith("agent/") or branch in {"main", "master"}:
|
||||
raise ValueError("BRANCH_SCOPE_DENIED")
|
||||
|
||||
def repository_identity(self) -> str:
|
||||
self.validate_repository(self.config.repository)
|
||||
return self.config.repository
|
||||
|
||||
def _environment(self) -> dict[str, str]:
|
||||
if self.credential is not None and hasattr(self.credential, "environment"):
|
||||
return dict(self.credential.environment())
|
||||
return {}
|
||||
|
||||
def read_git_ref(self, branch: str) -> Optional[str]:
|
||||
self.validate_branch(branch)
|
||||
if self._git_ref is not None:
|
||||
return self._git_ref(branch)
|
||||
result = subprocess.run(["git", "-C", str(self.workspace), "ls-remote", self.config.remote_name, f"refs/heads/{branch}"], text=True, capture_output=True, check=False, env={**os.environ, **self._environment(), "GIT_TERMINAL_PROMPT": "0"})
|
||||
if result.returncode != 0 or not result.stdout.strip():
|
||||
return None
|
||||
return result.stdout.split()[0]
|
||||
|
||||
def _api(self, path: str) -> Any:
|
||||
if self._api_get is not None:
|
||||
return self._api_get(path)
|
||||
headers = {"Accept": "application/json"}
|
||||
env = self._environment()
|
||||
if env.get("AIOS_GITEA_TOKEN"):
|
||||
headers["Authorization"] = "token " + env["AIOS_GITEA_TOKEN"]
|
||||
with urlopen(Request(self.config.api_base.rstrip("/") + path, headers=headers), timeout=20) as response:
|
||||
return json.loads(response.read() or b"{}")
|
||||
|
||||
def read_api_branch(self, branch: str) -> Optional[str]:
|
||||
self.validate_branch(branch)
|
||||
for page in range(1, 101):
|
||||
value = self._api(f"/repos/aios/aios/branches?page={page}&limit=50")
|
||||
if not isinstance(value, list):
|
||||
return None
|
||||
for item in value:
|
||||
if isinstance(item, Mapping) and item.get("name") == branch:
|
||||
commit = item.get("commit")
|
||||
return commit.get("id") if isinstance(commit, Mapping) else None
|
||||
if len(value) < 50:
|
||||
return None
|
||||
raise RuntimeError("GITEA_BRANCH_PAGINATION_LIMIT")
|
||||
|
||||
def commit_visible(self, oid: str) -> bool:
|
||||
if not isinstance(oid, str) or len(oid) != 40:
|
||||
return False
|
||||
try:
|
||||
value = self._api(f"/repos/aios/aios/git/commits/{quote(oid, safe='')}")
|
||||
return isinstance(value, Mapping) and value.get("sha", oid) == oid
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def observe(self, branch: str, desired_oid: str, authority_oid: Optional[str], *, expected_oid: Optional[str] = None, observed_at: str = "") -> RemoteObservation:
|
||||
return RemoteObservation(self.repository_identity(), branch, desired_oid, authority_oid, self.read_git_ref(branch), self.read_api_branch(branch), self.commit_visible(desired_oid), observed_at, expected_oid)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,518 @@
|
||||
"""Durable Job side-effect executor for the AIOS campaign control plane."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
from dataclasses import dataclass, field, replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
from aios_authority_git_executor import AuthorityGitExecutor, AuthorityGitError
|
||||
from aios_campaign_recovery import RecoveryEvidenceError, load_recovered_authorization
|
||||
from aios_gitea_machine_broker import CampaignCapabilityReceipt, GiteaMachineBroker, MachineBrokerError
|
||||
from aios_gitops_job_controller import GitOpsJobController, JobError, JobState, JobStore, ServiceUnavailable
|
||||
|
||||
|
||||
TASK_ID = "AIOS_AUTONOMOUS_RELEASE_CAMPAIGN_V1"
|
||||
CAMPAIGN_ID = "AIOS-AUTONOMOUS-RELEASE-CAMPAIGN-20260807"
|
||||
REPOSITORY = "aios/aios"
|
||||
BRANCH = "agent/autonomous-release-campaign-v1-20260807"
|
||||
EXPECTED_BRANCH_OID = "bba675fab3b7e0c5fba7066720252b3c91b9f20d"
|
||||
AUTHORIZATION_DIGEST = "6b0bf550eb702126e967eeff2d561fa808444b924d8a71f5399accad508ed3b2"
|
||||
LINEAGE_ANCHOR = "69b45e81dcd2c3707c367003b08c55fd361faae8"
|
||||
|
||||
|
||||
class JobExecutionError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class DurableJobExecutor:
|
||||
controller: GitOpsJobController
|
||||
authority: AuthorityGitExecutor
|
||||
broker: GiteaMachineBroker
|
||||
recovery_path: Path
|
||||
clock: Callable[[], datetime] = field(default=lambda: datetime.now(timezone.utc))
|
||||
lease_seconds: int = 60
|
||||
_persistence_halted: bool = field(default=False, init=False, repr=False)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
try:
|
||||
self.controller.assert_effect_admission()
|
||||
except JobError as exc:
|
||||
if str(exc) != "EFFECT_ADMISSION_LATCHED":
|
||||
raise
|
||||
self._persistence_halted = True
|
||||
|
||||
def _request(self, job_id: str) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
row = self.controller.store.read(job_id)
|
||||
try:
|
||||
request = json.loads(row["request_json"])
|
||||
except (KeyError, TypeError, json.JSONDecodeError) as exc:
|
||||
raise JobExecutionError("JOB_REQUEST_INVALID") from exc
|
||||
binding = dict(request.get("binding") or {})
|
||||
# Payload location is derived from and protected by payload_digest, but
|
||||
# is intentionally outside the idempotency binding. Execution still
|
||||
# needs it after a durable round trip.
|
||||
for field_name in ("payload_ref", "payload_size"):
|
||||
if field_name in request:
|
||||
binding[field_name] = request[field_name]
|
||||
return row, binding
|
||||
|
||||
def _validate_campaign(self, request: Mapping[str, Any]) -> str:
|
||||
operation = str(request.get("operation", ""))
|
||||
expected_branch_oid = request.get("expected_branch_oid")
|
||||
required = {
|
||||
"task_id": TASK_ID,
|
||||
"campaign_id": CAMPAIGN_ID,
|
||||
"repository": REPOSITORY,
|
||||
"branch": BRANCH,
|
||||
"authorization_recovery_digest": AUTHORIZATION_DIGEST,
|
||||
}
|
||||
for key, expected in required.items():
|
||||
if request.get(key) != expected:
|
||||
raise JobExecutionError(f"JOB_{key.upper()}_MISMATCH")
|
||||
new_oid = request.get("new_branch_oid")
|
||||
if not isinstance(new_oid, str) or len(new_oid) != 40 or any(ch not in "0123456789abcdef" for ch in new_oid):
|
||||
raise JobExecutionError("NEW_BRANCH_OID_INVALID")
|
||||
if not isinstance(expected_branch_oid, str) or len(expected_branch_oid) != 40 or any(ch not in "0123456789abcdef" for ch in expected_branch_oid):
|
||||
raise JobExecutionError("EXPECTED_BRANCH_OID_INVALID")
|
||||
if operation == "ADVANCE_EXISTING_CAMPAIGN_BRANCH" and expected_branch_oid == new_oid:
|
||||
raise JobExecutionError("EXPECTED_BRANCH_OID_INVALID")
|
||||
if operation != "ADVANCE_EXISTING_CAMPAIGN_BRANCH" and expected_branch_oid != new_oid:
|
||||
raise JobExecutionError("POST_ADVANCE_HEAD_BINDING_INVALID")
|
||||
try:
|
||||
recovery = load_recovered_authorization(self.recovery_path)
|
||||
except RecoveryEvidenceError as exc:
|
||||
raise JobExecutionError(str(exc)) from exc
|
||||
if recovery["digest"] != AUTHORIZATION_DIGEST:
|
||||
raise JobExecutionError("RECOVERY_DIGEST_MISMATCH")
|
||||
for key, expected in required.items():
|
||||
if key == "campaign_id":
|
||||
actual = recovery.get(key)
|
||||
elif key == "authorization_recovery_digest":
|
||||
continue
|
||||
else:
|
||||
actual = recovery.get({"expected_branch_oid": "expected_branch_oid"}.get(key, key))
|
||||
if actual is not None and actual != expected:
|
||||
raise JobExecutionError("RECOVERY_BINDING_MISMATCH")
|
||||
if recovery.get("expected_branch_oid") != EXPECTED_BRANCH_OID:
|
||||
raise JobExecutionError("RECOVERY_BRANCH_OID_MISMATCH")
|
||||
authority_head = self.authority.read_ref(f"refs/heads/{BRANCH}")
|
||||
if authority_head not in {expected_branch_oid, new_oid}:
|
||||
raise JobExecutionError("AUTHORITY_BRANCH_EXPECTATION_MISMATCH")
|
||||
if not self.authority.ancestry_check(expected_branch_oid, new_oid):
|
||||
raise JobExecutionError("NEW_OID_NOT_DESCENDANT")
|
||||
if not self.authority.ancestry_check(EXPECTED_BRANCH_OID, new_oid):
|
||||
raise JobExecutionError("NEW_OID_OUTSIDE_AUTHORIZED_LINEAGE")
|
||||
if operation == "RECONCILE_AUTHORITY_MAIN":
|
||||
old_main = request.get("expected_main_oid")
|
||||
if (
|
||||
not isinstance(old_main, str)
|
||||
or len(old_main) != 40
|
||||
or any(ch not in "0123456789abcdef" for ch in old_main)
|
||||
):
|
||||
raise JobExecutionError("MAIN_RECONCILIATION_BINDING_INVALID")
|
||||
if not self.authority.ancestry_check(old_main, new_oid):
|
||||
raise JobExecutionError("MAIN_OID_NOT_DESCENDANT")
|
||||
identity = self.broker.verify_identity()
|
||||
if identity.get("repository") != REPOSITORY or identity.get("login") == "aiman":
|
||||
raise JobExecutionError("MACHINE_IDENTITY_SCOPE_INVALID")
|
||||
return new_oid
|
||||
|
||||
def dry_run(self, job_id: str) -> dict[str, Any]:
|
||||
row, request = self._request(job_id)
|
||||
operation = str(request.get("operation") or "")
|
||||
if operation not in {
|
||||
"ADVANCE_EXISTING_CAMPAIGN_BRANCH", "COMMIT_FILE",
|
||||
"SYNC_TASK_BRANCH_TO_GITEA", "CREATE_PR", "PUBLISH_CHECK",
|
||||
"PROTECTED_MERGE", "RECONCILE_AUTHORITY_MAIN",
|
||||
}:
|
||||
raise JobExecutionError("OPERATION_NOT_ALLOWED")
|
||||
self._validate_campaign(request)
|
||||
if operation == "COMMIT_FILE":
|
||||
self._commit_file_spec(row, request)
|
||||
observation = self._observe_operation(request)
|
||||
result = {
|
||||
"mode": "DRY_RUN",
|
||||
"operation": operation,
|
||||
"decision": "SAFE_TO_ADVANCE",
|
||||
"effect_performed": False,
|
||||
"readback": observation,
|
||||
}
|
||||
if operation == "ADVANCE_EXISTING_CAMPAIGN_BRANCH":
|
||||
result.update({
|
||||
"authority_action": "CAS_REQUIRED",
|
||||
"gitea_action": "ALREADY_IN_SYNC" if observation["verification"] == "VERIFIED" else "FAST_FORWARD_REQUIRED",
|
||||
"previous_branch_oid": request["expected_branch_oid"],
|
||||
"final_branch_oid": request["new_branch_oid"],
|
||||
"force_push": False,
|
||||
})
|
||||
return result
|
||||
|
||||
def execute(self, job_id: str, *, dry_run: bool = False) -> dict[str, Any]:
|
||||
if dry_run:
|
||||
return self.dry_run(job_id)
|
||||
if self._persistence_halted:
|
||||
raise JobExecutionError("PERSISTENCE_FAILURE_LATCHED")
|
||||
try:
|
||||
return self._execute_unprotected(job_id)
|
||||
except ServiceUnavailable:
|
||||
self._persistence_halted = True
|
||||
raise
|
||||
except Exception as exc:
|
||||
try:
|
||||
row = self.controller.store.read(job_id)
|
||||
state = JobState(row["state"])
|
||||
if state in {
|
||||
JobState.EXECUTING, JobState.LOCAL_VERIFIED,
|
||||
JobState.REMOTE_SYNCING, JobState.REMOTE_VERIFIED,
|
||||
}:
|
||||
self.controller.advance(
|
||||
job_id,
|
||||
JobState.RECONCILIATION_REQUIRED,
|
||||
side_effect_state="UNKNOWN",
|
||||
receipt={"error_code": type(exc).__name__, "verification": "UNKNOWN"},
|
||||
)
|
||||
elif state not in {
|
||||
JobState.COMPLETED, JobState.MANUAL_OWNER_REVIEW_REQUIRED,
|
||||
JobState.RECOVERABLE_FAILURE, JobState.RECONCILIATION_REQUIRED,
|
||||
}:
|
||||
self.controller.advance(
|
||||
job_id, JobState.RECOVERABLE_FAILURE,
|
||||
side_effect_state="NONE",
|
||||
receipt={"error_code": type(exc).__name__, "effect_performed": False},
|
||||
)
|
||||
except ServiceUnavailable:
|
||||
self._persistence_halted = True
|
||||
raise
|
||||
|
||||
def _execute_unprotected(self, job_id: str) -> dict[str, Any]:
|
||||
row, request = self._request(job_id)
|
||||
state = JobState(row["state"])
|
||||
if state == JobState.COMPLETED:
|
||||
return self.controller.store._receipt(row, duplicate=True).as_dict()
|
||||
if state in {
|
||||
JobState.RECOVERABLE_FAILURE, JobState.RECONCILIATION_REQUIRED,
|
||||
JobState.RECONCILING, JobState.EXECUTING, JobState.LOCAL_VERIFIED,
|
||||
JobState.REMOTE_SYNCING, JobState.REMOTE_VERIFIED,
|
||||
}:
|
||||
return self.reconcile(job_id)
|
||||
|
||||
operation = str(request.get("operation") or "")
|
||||
if operation not in {
|
||||
"ADVANCE_EXISTING_CAMPAIGN_BRANCH", "COMMIT_FILE",
|
||||
"SYNC_TASK_BRANCH_TO_GITEA", "CREATE_PR", "PUBLISH_CHECK",
|
||||
"PROTECTED_MERGE", "RECONCILE_AUTHORITY_MAIN",
|
||||
}:
|
||||
raise JobExecutionError("OPERATION_NOT_ALLOWED")
|
||||
try:
|
||||
self.controller.assert_effect_admission(exclude_job_id=job_id)
|
||||
except JobError as exc:
|
||||
if str(exc) == "EFFECT_ADMISSION_LATCHED":
|
||||
self._persistence_halted = True
|
||||
raise JobExecutionError("PERSISTENCE_FAILURE_LATCHED") from exc
|
||||
raise
|
||||
self._validate_campaign(request)
|
||||
if operation == "COMMIT_FILE":
|
||||
self._commit_file_spec(row, request)
|
||||
row = self._prepare_intent(job_id, row, request)
|
||||
generation = int(request.get("generation", "1"))
|
||||
self.controller.assert_execution_lease(job_id, generation=generation)
|
||||
|
||||
before = self._observe_operation(request)
|
||||
if before["verification"] == "VERIFIED":
|
||||
return self._complete(job_id, request, {"reconciled_before_effect": True}, before)
|
||||
if before["verification"] != "ABSENT":
|
||||
return self._record_unknown(job_id, request, before)
|
||||
|
||||
effect = self._perform_effect(job_id, row, request, generation)
|
||||
after = self._observe_operation(request)
|
||||
if after["verification"] != "VERIFIED":
|
||||
return self._record_unknown(job_id, request, after, effect=effect)
|
||||
return self._complete(job_id, request, effect, after)
|
||||
|
||||
def _prepare_intent(
|
||||
self, job_id: str, row: Mapping[str, Any], request: Mapping[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
state = JobState(row["state"])
|
||||
if state == JobState.REQUESTED:
|
||||
self.controller.advance(
|
||||
job_id, JobState.AUTHORIZED, side_effect_state="AUTHORIZED",
|
||||
receipt={"authorization_digest": AUTHORIZATION_DIGEST, "task_id": TASK_ID},
|
||||
expected_revision=int(row["revision"]),
|
||||
)
|
||||
row = self.controller.store.read(job_id)
|
||||
state = JobState(row["state"])
|
||||
if state == JobState.AUTHORIZED:
|
||||
deadline = self.clock().astimezone(timezone.utc) + timedelta(seconds=self.lease_seconds)
|
||||
self.controller.lease_job(
|
||||
job_id,
|
||||
lease_id=f"lease-{job_id}",
|
||||
lineage_anchor_oid=LINEAGE_ANCHOR,
|
||||
current_head_oid=str(request["expected_branch_oid"]),
|
||||
generation=int(request.get("generation", "1")),
|
||||
lease_expires_at=deadline.isoformat(),
|
||||
expected_revision=int(row["revision"]),
|
||||
)
|
||||
row = self.controller.store.read(job_id)
|
||||
state = JobState(row["state"])
|
||||
if state == JobState.LEASED:
|
||||
self.controller.advance(
|
||||
job_id, JobState.WORKTREE_READY, side_effect_state="WORKTREE_READY",
|
||||
expected_revision=int(row["revision"]),
|
||||
)
|
||||
row = self.controller.store.read(job_id)
|
||||
state = JobState(row["state"])
|
||||
if state == JobState.WORKTREE_READY:
|
||||
self.controller.advance(
|
||||
job_id, JobState.EXECUTING, side_effect_state="INTENT_COMMITTED",
|
||||
receipt={
|
||||
"effect_id": job_id,
|
||||
"operation": request["operation"],
|
||||
"input_digest": row["payload_digest"],
|
||||
"intent_committed": True,
|
||||
},
|
||||
expected_revision=int(row["revision"]),
|
||||
)
|
||||
row = self.controller.store.read(job_id)
|
||||
if JobState(row["state"]) != JobState.EXECUTING:
|
||||
raise JobExecutionError("JOB_EXECUTION_STATE_INVALID")
|
||||
return dict(row)
|
||||
|
||||
def _perform_effect(
|
||||
self,
|
||||
job_id: str,
|
||||
row: Mapping[str, Any],
|
||||
request: Mapping[str, Any],
|
||||
generation: int,
|
||||
) -> dict[str, Any]:
|
||||
operation = str(request["operation"])
|
||||
new_oid = str(request["new_branch_oid"])
|
||||
self.controller.assert_execution_lease(job_id, generation=generation)
|
||||
if operation == "ADVANCE_EXISTING_CAMPAIGN_BRANCH":
|
||||
expected = str(request["expected_branch_oid"])
|
||||
current = self.authority.read_ref(f"refs/heads/{BRANCH}")
|
||||
authority_oid = new_oid if current == new_oid else self.authority.advance_task_branch_CAS(
|
||||
f"refs/heads/{BRANCH}", expected, new_oid
|
||||
)
|
||||
self.controller.assert_execution_lease(job_id, generation=generation)
|
||||
remote = self.broker.readback()
|
||||
if remote != new_oid:
|
||||
if hasattr(self.broker, "receipt"):
|
||||
self.broker.receipt = replace(self.broker.receipt, expected_oid=expected)
|
||||
self.broker.push_branch(new_oid)
|
||||
return {"authority_oid": authority_oid, "network_oid": new_oid, "force_push": False}
|
||||
if operation == "SYNC_TASK_BRANCH_TO_GITEA":
|
||||
return {"final_network_oid": self.broker.push_branch(new_oid), "force_push": False}
|
||||
if operation == "CREATE_PR":
|
||||
return dict(self.broker.create_pr(
|
||||
"AIOS autonomous release control plane",
|
||||
"Durable campaign continuation; exact head and recovery evidence are bound in the Job.",
|
||||
))
|
||||
if operation == "PUBLISH_CHECK":
|
||||
return dict(self.broker.status(
|
||||
str(request.get("check_state", "success")),
|
||||
str(request.get("check_description", "Durable campaign control-plane check passed")),
|
||||
str(request.get("check_context", "aios/durable-release")),
|
||||
))
|
||||
if operation == "PROTECTED_MERGE":
|
||||
number = int(request.get("pr_number", "0"))
|
||||
self.broker.transport.expected_merge_head = request["expected_branch_oid"]
|
||||
if request.get("expected_main_oid") is not None:
|
||||
self.broker.transport.expected_merge_base = request["expected_main_oid"]
|
||||
return dict(self.broker.merge(number))
|
||||
if operation == "RECONCILE_AUTHORITY_MAIN":
|
||||
old = request.get("expected_main_oid")
|
||||
if not isinstance(old, str):
|
||||
raise JobExecutionError("MAIN_RECONCILIATION_BINDING_INVALID")
|
||||
if self.broker.transport.read_branch("main") != new_oid:
|
||||
raise JobExecutionError("GITEA_MAIN_EXPECTATION_MISMATCH")
|
||||
return {"authority_main": self.authority.ff_CAS_main_after_verified_protected_merge(old, new_oid)}
|
||||
if operation == "COMMIT_FILE":
|
||||
path, content = self._commit_file_spec(row, request)
|
||||
return {
|
||||
"new_oid": self.authority.commit_file_in_isolated_worktree(
|
||||
base_oid=row["expected_branch_oid"], files={path: content},
|
||||
message="chore(gitops): durable commit-file", worktree_name=job_id,
|
||||
)
|
||||
}
|
||||
raise JobExecutionError("OPERATION_NOT_ALLOWED")
|
||||
|
||||
def _observe_operation(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
operation = str(request["operation"])
|
||||
new_oid = str(request["new_branch_oid"])
|
||||
if operation == "ADVANCE_EXISTING_CAMPAIGN_BRANCH":
|
||||
authority = self.authority.read_ref(f"refs/heads/{BRANCH}")
|
||||
remote = self.broker.readback()
|
||||
if authority == new_oid and remote == new_oid:
|
||||
return {"verification": "VERIFIED", "authority_oid": authority, "remote_oid": remote}
|
||||
expected = request["expected_branch_oid"]
|
||||
if authority == expected and remote in {None, expected}:
|
||||
return {"verification": "ABSENT", "authority_oid": authority, "remote_oid": remote}
|
||||
return {"verification": "UNKNOWN", "blocker": "PARTIAL_OR_CONFLICTING_BRANCH_EFFECT", "authority_oid": authority, "remote_oid": remote}
|
||||
if operation == "SYNC_TASK_BRANCH_TO_GITEA":
|
||||
remote = self.broker.readback()
|
||||
if remote == new_oid:
|
||||
return {"verification": "VERIFIED", "remote_oid": remote}
|
||||
if remote in {None, EXPECTED_BRANCH_OID}:
|
||||
return {"verification": "ABSENT", "remote_oid": remote}
|
||||
return {"verification": "UNKNOWN", "blocker": "REMOTE_BRANCH_CONFLICT", "remote_oid": remote}
|
||||
if operation == "RECONCILE_AUTHORITY_MAIN":
|
||||
current = self.authority.read_ref("refs/heads/main")
|
||||
if current == new_oid:
|
||||
return {"verification": "VERIFIED", "authority_main": current}
|
||||
if current == request.get("expected_main_oid"):
|
||||
return {"verification": "ABSENT", "authority_main": current}
|
||||
return {"verification": "UNKNOWN", "blocker": "AUTHORITY_MAIN_CONFLICT", "authority_main": current}
|
||||
if operation == "CREATE_PR" and hasattr(self.broker, "read_pr"):
|
||||
value = self.broker.read_pr(BRANCH, "main", new_oid)
|
||||
return {"verification": "VERIFIED" if value else "ABSENT", "pr": value}
|
||||
if operation == "PUBLISH_CHECK" and hasattr(self.broker, "read_status"):
|
||||
value = self.broker.read_status(
|
||||
new_oid, str(request.get("check_context", "aios/durable-release"))
|
||||
)
|
||||
if value is None:
|
||||
return {"verification": "ABSENT"}
|
||||
expected_state = str(request.get("check_state", "success"))
|
||||
return {"verification": "VERIFIED" if value.get("status") == expected_state else "UNKNOWN", "status": dict(value), **({} if value.get("status") == expected_state else {"blocker": "STATUS_READBACK_MISMATCH"})}
|
||||
if operation == "PROTECTED_MERGE" and hasattr(self.broker, "read_merge"):
|
||||
value = self.broker.read_merge(int(request.get("pr_number", "0")))
|
||||
if value is None:
|
||||
return {"verification": "ABSENT"}
|
||||
if value.get("merged") is True and value.get("head_oid") == request["expected_branch_oid"]:
|
||||
return {"verification": "VERIFIED", "merge": dict(value)}
|
||||
if value.get("merged") is False:
|
||||
return {"verification": "ABSENT", "merge": dict(value)}
|
||||
return {"verification": "UNKNOWN", "blocker": "MERGE_READBACK_MISMATCH", "merge": dict(value)}
|
||||
return {"verification": "UNKNOWN", "blocker": "EXACT_READBACK_UNSUPPORTED"}
|
||||
|
||||
def _complete(
|
||||
self, job_id: str, request: Mapping[str, Any], effect: Mapping[str, Any], observation: Mapping[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
row = self.controller.store.read(job_id)
|
||||
receipt = {
|
||||
"effect_id": job_id,
|
||||
"operation": request["operation"],
|
||||
"effect": dict(effect),
|
||||
"verification": "VERIFIED",
|
||||
"readback": dict(observation),
|
||||
}
|
||||
completed = self.controller.advance(
|
||||
job_id, JobState.COMPLETED, side_effect_state="VERIFIED",
|
||||
receipt=receipt, expected_revision=int(row["revision"]),
|
||||
)
|
||||
return completed.as_dict()
|
||||
|
||||
def _record_unknown(
|
||||
self,
|
||||
job_id: str,
|
||||
request: Mapping[str, Any],
|
||||
observation: Mapping[str, Any],
|
||||
*,
|
||||
effect: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
row = self.controller.store.read(job_id)
|
||||
receipt = {
|
||||
"effect_id": job_id,
|
||||
"operation": request["operation"],
|
||||
"effect": dict(effect) if effect is not None else None,
|
||||
"verification": "UNKNOWN",
|
||||
"blocker": observation.get("blocker", "READBACK_INCONCLUSIVE"),
|
||||
"readback": dict(observation),
|
||||
}
|
||||
pending = self.controller.advance(
|
||||
job_id, JobState.RECONCILIATION_REQUIRED, side_effect_state="UNKNOWN",
|
||||
receipt=receipt, expected_revision=int(row["revision"]),
|
||||
)
|
||||
return pending.as_dict()
|
||||
|
||||
def reconcile(self, job_id: str) -> dict[str, Any]:
|
||||
def observer(_current: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
_row, request = self._request(job_id)
|
||||
self._validate_campaign(request)
|
||||
observation = self._observe_operation(request)
|
||||
if observation["verification"] == "VERIFIED":
|
||||
return {
|
||||
"state": JobState.COMPLETED.value,
|
||||
"side_effect_state": "VERIFIED",
|
||||
"receipt": {
|
||||
"effect_id": job_id, "operation": request["operation"],
|
||||
"effect": None, "verification": "VERIFIED",
|
||||
"readback": observation, "reconciled": True,
|
||||
},
|
||||
}
|
||||
if observation["verification"] == "ABSENT":
|
||||
return {
|
||||
"state": JobState.REQUESTED.value,
|
||||
"side_effect_state": "RECONCILED_ABSENT",
|
||||
"receipt": {
|
||||
"effect_id": job_id, "operation": request["operation"],
|
||||
"verification": "ABSENT", "readback": observation,
|
||||
"reconciled": True,
|
||||
},
|
||||
}
|
||||
return {
|
||||
"state": JobState.RECONCILIATION_REQUIRED.value,
|
||||
"side_effect_state": "UNKNOWN",
|
||||
"receipt": {
|
||||
"effect_id": job_id, "operation": request["operation"],
|
||||
"verification": "UNKNOWN",
|
||||
"blocker": observation.get("blocker", "READBACK_INCONCLUSIVE"),
|
||||
"readback": observation, "reconciled": True,
|
||||
},
|
||||
}
|
||||
|
||||
result = self.controller.reconcile(job_id, observer).as_dict()
|
||||
if result["state"] in {JobState.COMPLETED.value, JobState.REQUESTED.value}:
|
||||
self._persistence_halted = False
|
||||
return result
|
||||
|
||||
def _commit_file_spec(
|
||||
self, row: Mapping[str, Any], request: Mapping[str, Any]
|
||||
) -> tuple[str, bytes]:
|
||||
payload_ref = request.get("payload_ref")
|
||||
if not payload_ref:
|
||||
raise JobExecutionError("PAYLOAD_REQUIRED")
|
||||
payload = self.controller.store.payloads.read_verified(payload_ref, row["payload_digest"], int(request["payload_size"]))
|
||||
try:
|
||||
spec = json.loads(payload.decode())
|
||||
path = str(spec["path"])
|
||||
content = base64.b64decode(spec["content_base64"], validate=True)
|
||||
except Exception as exc:
|
||||
raise JobExecutionError("COMMIT_FILE_PAYLOAD_INVALID") from exc
|
||||
return path, content
|
||||
|
||||
|
||||
def build_from_environment(job_root: Path) -> DurableJobExecutor:
|
||||
from aios_gitea_machine_transport import GiteaMachineTransport
|
||||
from aios_gitea_machine_broker import MachineCredentialProjection
|
||||
|
||||
account = __import__("os").environ.get("AIOS_GITEA_MACHINE_ACCOUNT", "aios-autonomy-agent")
|
||||
api = __import__("os").environ.get("AIOS_GITEA_API", "https://g.cnnetmkt.top/git/api/v1")
|
||||
remote = __import__("os").environ.get("AIOS_GITEA_REMOTE", "https://g.cnnetmkt.top/git/aios/aios.git")
|
||||
authority_dir = Path(__import__("os").environ.get("AIOS_GITOPS_AUTHORITY_GIT_DIR", "/data/aios-gitops/repo.git"))
|
||||
worktree_root = Path(__import__("os").environ.get("AIOS_GITOPS_WORKTREE_ROOT", "/var/lib/aios-gitops/worktrees"))
|
||||
recovery = Path(__import__("os").environ.get("AIOS_RELEASE_RECOVERY_RECEIPT", "/var/lib/aios-gitops/release-campaigns/AIOS-AUTONOMOUS-RELEASE-CAMPAIGN-20260807/authorization-recovery.json"))
|
||||
transport = GiteaMachineTransport(api, REPOSITORY, remote, authority_dir)
|
||||
credential = MachineCredentialProjection.from_mapping({"scheme": "systemd-credential", "name": "gitea-machine-token", "account": account})
|
||||
receipt = CampaignCapabilityReceipt(CAMPAIGN_ID, TASK_ID, REPOSITORY, BRANCH, EXPECTED_BRANCH_OID, frozenset({"exact_task_branch_push", "pr_create_update", "status_publish", "protected_merge", "remote_readback"}), frozenset({"direct_main_push", "force_push", "tag_update", "tag_delete", "baseline_write", "production_deploy"}))
|
||||
return DurableJobExecutor(GitOpsJobController(JobStore(job_root)), AuthorityGitExecutor(authority_dir, worktree_root), GiteaMachineBroker(transport, credential, receipt), recovery)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--job-root", required=True, type=Path)
|
||||
parser.add_argument("--job-id", required=True)
|
||||
parser.add_argument("--dry-run", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
result = build_from_environment(args.job_root).execute(args.job_id, dry_run=args.dry_run)
|
||||
print(json.dumps(result, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Collision-resistant storage keys for durable governed Task state."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import re
|
||||
|
||||
|
||||
class JobIdentityError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def legacy_task_storage_key(task_id: str) -> str:
|
||||
"""Return the historical lossy key solely to detect migration conflicts."""
|
||||
if not isinstance(task_id, str) or not task_id:
|
||||
raise JobIdentityError("TASK_ID_INVALID")
|
||||
return re.sub(r"[^A-Za-z0-9_.-]+", "-", task_id).strip("-")[:120] or "task"
|
||||
|
||||
|
||||
def task_storage_key(task_id: str) -> str:
|
||||
"""Return a canonical collision-resistant key for every Task state sink."""
|
||||
if (
|
||||
not isinstance(task_id, str)
|
||||
or not task_id
|
||||
or task_id != task_id.strip()
|
||||
or len(task_id) > 512
|
||||
):
|
||||
raise JobIdentityError("TASK_ID_INVALID")
|
||||
readable = re.sub(r"[^A-Za-z0-9_.-]+", "-", task_id).strip("-")[:48] or "task"
|
||||
digest = hashlib.sha256(
|
||||
("aios-task-storage-v1\0" + task_id).encode("utf-8")
|
||||
).hexdigest()
|
||||
return f"{readable}-{digest}"
|
||||
@@ -5,6 +5,8 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from release_version import validate_release_version
|
||||
|
||||
ROOT = Path(os.getenv('AIOS_GITOPS_ROOT', '/data/aios-gitops'))
|
||||
WORKSPACE = Path(os.getenv('AIOS_GITOPS_WORKSPACE', str(ROOT / 'workspace')))
|
||||
DB_PATH = Path(os.getenv('AIOS_GITOPS_DB', str(ROOT / 'runtime' / 'gitops_model.sqlite3')))
|
||||
@@ -218,7 +220,11 @@ class Handler(BaseHTTPRequestHandler):
|
||||
a = self.require_write(['owner']);
|
||||
if not a: return
|
||||
ver=data.get('version') or data.get('tag')
|
||||
if not re.match(r'^V\d+R\d+C\d+(-[a-zA-Z0-9_.-]+)?$', ver or ''): self.send_json({'error':'version must match VxRxCx'},400); return
|
||||
try:
|
||||
validate_release_version(ver)
|
||||
except ValueError:
|
||||
self.send_json({'error':'version must match VxRxCx'},400)
|
||||
return
|
||||
msg=data.get('message') or f'release {ver}'; out=git_cmd(['tag','-a',ver,'-m',msg]);
|
||||
if data.get('push',False): git_cmd(['push','origin',ver])
|
||||
level='V' if re.match(r'^V[1-9]\d*R0C0', ver) else ('R' if re.match(r'^V\d+R[1-9]\d*C0', ver) else 'C'); exec_sql('INSERT OR REPLACE INTO gitops_versions(version,tag,level,summary,status,created_by,created_at) VALUES(?,?,?,?,?,?,?)',(ver,ver,level,msg,'ready',a['username'],now())); exec_sql('UPDATE gitops_baseline SET active_version=?,active_tag=?,updated_at=? WHERE id=?',(ver,ver,now(),'current')); audit(a['username'],'git_tag',ver,{'level':level}); self.send_json({'status':'tagged','version':ver,'git':out}); return
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Pure, observation-first decisions for governed GitOps reconciliation."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
|
||||
|
||||
class ReconciliationAction(str, Enum):
|
||||
NOOP = "NOOP"
|
||||
PUSH_EXACT = "PUSH_EXACT"
|
||||
VERIFY_REMOTE = "VERIFY_REMOTE"
|
||||
MARK_REMOTE_VERIFIED = "MARK_REMOTE_VERIFIED"
|
||||
MARK_RECONCILIATION_REQUIRED = "MARK_RECONCILIATION_REQUIRED"
|
||||
FAIL_CONFLICT = "FAIL_CONFLICT"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RemoteObservation:
|
||||
repository_identity: str
|
||||
branch: str
|
||||
desired_oid: str
|
||||
authority_oid: Optional[str]
|
||||
remote_git_oid: Optional[str]
|
||||
remote_api_oid: Optional[str]
|
||||
commit_visible: bool
|
||||
observed_at: str
|
||||
expected_oid: Optional[str] = None
|
||||
|
||||
@property
|
||||
def all_equal(self) -> bool:
|
||||
values = (self.authority_oid, self.remote_git_oid, self.remote_api_oid, self.desired_oid)
|
||||
return all(isinstance(value, str) for value in values) and len(set(values)) == 1
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ReconciliationDecision:
|
||||
action: ReconciliationAction
|
||||
state: str
|
||||
reason: str
|
||||
retry_safe: bool = False
|
||||
|
||||
|
||||
def _valid_oid(value: Optional[str]) -> bool:
|
||||
return value is None or bool(OID_RE.fullmatch(value))
|
||||
|
||||
|
||||
def decide_reconciliation(observation: RemoteObservation) -> ReconciliationDecision:
|
||||
"""Decide from a fresh observation; never complete on a missing remote."""
|
||||
if observation.repository_identity != "aios/aios":
|
||||
return ReconciliationDecision(ReconciliationAction.FAIL_CONFLICT, "CONFLICT", "REPOSITORY_IDENTITY_MISMATCH")
|
||||
if not observation.branch.startswith("agent/"):
|
||||
return ReconciliationDecision(ReconciliationAction.FAIL_CONFLICT, "CONFLICT", "BRANCH_SCOPE_DENIED")
|
||||
if not _valid_oid(observation.desired_oid) or not _valid_oid(observation.expected_oid):
|
||||
return ReconciliationDecision(ReconciliationAction.FAIL_CONFLICT, "CONFLICT", "OID_INVALID")
|
||||
if observation.authority_oid is None:
|
||||
return ReconciliationDecision(ReconciliationAction.MARK_RECONCILIATION_REQUIRED, "RECONCILIATION_REQUIRED", "AUTHORITY_UNAVAILABLE")
|
||||
if observation.authority_oid != observation.desired_oid:
|
||||
return ReconciliationDecision(ReconciliationAction.MARK_RECONCILIATION_REQUIRED, "RECONCILIATION_REQUIRED", "AUTHORITY_CONFLICT")
|
||||
present = [oid for oid in (observation.remote_git_oid, observation.remote_api_oid) if oid is not None]
|
||||
if any(oid not in {observation.desired_oid, observation.expected_oid} for oid in present):
|
||||
return ReconciliationDecision(ReconciliationAction.FAIL_CONFLICT, "CONFLICT", "REMOTE_OID_CONFLICT")
|
||||
if observation.all_equal and observation.commit_visible:
|
||||
return ReconciliationDecision(ReconciliationAction.MARK_REMOTE_VERIFIED, "REMOTE_VERIFIED", "ALL_OIDS_EQUAL_AND_VISIBLE")
|
||||
if observation.remote_git_oid is None and observation.remote_api_oid is None:
|
||||
return ReconciliationDecision(ReconciliationAction.MARK_RECONCILIATION_REQUIRED, "REMOTE_PUSH_PENDING", "AUTHORITY_DESIRED_REMOTE_MISSING")
|
||||
if observation.remote_git_oid == observation.desired_oid or observation.remote_api_oid == observation.desired_oid:
|
||||
return ReconciliationDecision(ReconciliationAction.VERIFY_REMOTE, "REMOTE_VERIFY_PENDING", "REMOTE_PROOF_PARTIAL")
|
||||
if observation.expected_oid is not None and observation.remote_git_oid == observation.expected_oid and observation.remote_api_oid == observation.expected_oid:
|
||||
return ReconciliationDecision(ReconciliationAction.PUSH_EXACT, "REMOTE_PUSH_PENDING", "SAFE_EXACT_FAST_FORWARD_RETRY", retry_safe=True)
|
||||
if not observation.commit_visible:
|
||||
return ReconciliationDecision(ReconciliationAction.VERIFY_REMOTE, "COMMIT_VISIBILITY_PENDING", "COMMIT_VISIBILITY_LAG")
|
||||
return ReconciliationDecision(ReconciliationAction.MARK_RECONCILIATION_REQUIRED, "RECONCILIATION_REQUIRED", "PARTIAL_REMOTE_OBSERVATION")
|
||||
|
||||
|
||||
def reconcile_observation(**kwargs: object) -> ReconciliationDecision:
|
||||
return decide_reconciliation(RemoteObservation(**kwargs))
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
"""Single supervised socket-activated worker for durable GitOps Jobs.
|
||||
|
||||
The worker consumes durable requests and reconciliation queries. Repository
|
||||
mutation is a separate injected executor and remains impossible while the
|
||||
legacy global write gate is false.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import signal
|
||||
import socket
|
||||
import threading
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_gitops_job_controller import GitOpsJobController, JobError, JobState, JobStore, ServiceUnavailable
|
||||
|
||||
|
||||
def runtime_policy(environment: Mapping[str, str] | None = None) -> dict[str, bool]:
|
||||
env = environment or os.environ
|
||||
legacy = env.get("AIOS_GITOPS_WRITES_ENABLED", "false").lower() in {"1", "true", "yes", "on"}
|
||||
durable = env.get("AIOS_GITOPS_DURABLE_JOB_EXECUTION_ENABLED", "false").lower() in {"1", "true", "yes", "on"}
|
||||
# Legacy direct Git mutation remains disabled. Authorized durable Jobs use
|
||||
# their own campaign/task capability and may mutate only through the
|
||||
# worker's lease/CAS path while the legacy gate stays false.
|
||||
return {"legacy_global_write": legacy, "durable_job_execution": durable, "repository_mutation_allowed": durable}
|
||||
|
||||
|
||||
def handle_request(controller: GitOpsJobController, request: Mapping[str, Any], executor: Any | None = None) -> dict[str, Any]:
|
||||
job_id = str(request.get("job_id") or "")
|
||||
if not job_id:
|
||||
raise JobError("JOB_ID_REQUIRED")
|
||||
if request.get("action") == "execute":
|
||||
if executor is None:
|
||||
raise JobError("JOB_EXECUTOR_UNAVAILABLE")
|
||||
result = executor.execute(job_id, dry_run=bool(request.get("dry_run", False)))
|
||||
receipt = controller.store._receipt(controller.store.read(job_id), duplicate=True)
|
||||
return {"job": result, **receipt.as_dict()}
|
||||
if request.get("action") == "resume":
|
||||
receipt = controller.resume(job_id)
|
||||
else:
|
||||
row = controller.store.read(job_id)
|
||||
receipt = controller.store._receipt(row, duplicate=True)
|
||||
return receipt.as_dict()
|
||||
|
||||
|
||||
def serve_socket(sock: socket.socket, controller: GitOpsJobController, stopping: threading.Event, executor: Any | None = None) -> None:
|
||||
sock.listen(32)
|
||||
sock.settimeout(1.0)
|
||||
while not stopping.is_set():
|
||||
try:
|
||||
connection, _ = sock.accept()
|
||||
except socket.timeout:
|
||||
continue
|
||||
with connection:
|
||||
try:
|
||||
raw = connection.makefile("rb").readline(1_048_576)
|
||||
request = json.loads(raw.decode("utf-8"))
|
||||
response = {"status": "ok", **handle_request(controller, request, executor)}
|
||||
except ServiceUnavailable as exc:
|
||||
response = {"status": "error", "error": str(exc), "retryable": True, "side_effect_state": "UNKNOWN"}
|
||||
except Exception as exc:
|
||||
response = {"status": "error", "error": type(exc).__name__, "retryable": False}
|
||||
connection.sendall((json.dumps(response, sort_keys=True) + "\n").encode("utf-8"))
|
||||
|
||||
|
||||
def socket_from_systemd() -> socket.socket:
|
||||
if os.environ.get("LISTEN_FDS") != "1":
|
||||
raise RuntimeError("SOCKET_ACTIVATION_REQUIRED")
|
||||
return socket.fromfd(3, socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--job-root", required=True, type=Path)
|
||||
args = parser.parse_args(argv)
|
||||
policy = runtime_policy()
|
||||
if policy["legacy_global_write"]:
|
||||
raise RuntimeError("LEGACY_GLOBAL_WRITES_MUST_REMAIN_FALSE")
|
||||
if not policy["durable_job_execution"]:
|
||||
raise RuntimeError("DURABLE_JOB_EXECUTION_GATE_CLOSED")
|
||||
controller = GitOpsJobController(JobStore(args.job_root))
|
||||
from aios_gitops_job_executor import build_from_environment
|
||||
executor = build_from_environment(args.job_root)
|
||||
stopping = threading.Event()
|
||||
signal.signal(signal.SIGTERM, lambda *_: stopping.set())
|
||||
signal.signal(signal.SIGINT, lambda *_: stopping.set())
|
||||
sock = socket_from_systemd()
|
||||
try:
|
||||
serve_socket(sock, controller, stopping, executor)
|
||||
finally:
|
||||
sock.close()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,69 @@
|
||||
"""CLI client for the governed local writer service."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import socket
|
||||
import sys
|
||||
|
||||
|
||||
def _parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Send one governed-local-writer request")
|
||||
parser.add_argument("--socket", required=True, help="AF_UNIX socket path")
|
||||
group = parser.add_mutually_exclusive_group(required=True)
|
||||
group.add_argument("--request", help="Request JSON string")
|
||||
group.add_argument("--request-file", help="Path to JSON request file")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def _load_request_text(args: argparse.Namespace) -> str:
|
||||
if args.request is not None:
|
||||
return args.request
|
||||
request_path = os.fspath(args.request_file)
|
||||
with open(request_path, "r", encoding="utf-8") as request_file:
|
||||
return request_file.read()
|
||||
|
||||
|
||||
def send_request(socket_path: str, request_text: str) -> dict[str, object]:
|
||||
"""Open one Unix socket, send one NDJSON request, and parse one NDJSON response."""
|
||||
payload = json.loads(request_text)
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("request must be a JSON object")
|
||||
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client:
|
||||
client.connect(socket_path)
|
||||
client.sendall((json.dumps(payload) + "\n").encode("utf-8"))
|
||||
response_chunks: list[str] = []
|
||||
while True:
|
||||
chunk = client.recv(1024 * 1024).decode("utf-8")
|
||||
if not chunk:
|
||||
break
|
||||
response_chunks.append(chunk)
|
||||
if "\n" in chunk:
|
||||
break
|
||||
response_text = "".join(response_chunks)
|
||||
|
||||
response_line = response_text.split("\n", 1)[0].strip()
|
||||
if not response_line:
|
||||
raise RuntimeError("empty response")
|
||||
response = json.loads(response_line)
|
||||
if not isinstance(response, dict):
|
||||
raise RuntimeError("invalid response")
|
||||
return response
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = _parse_args()
|
||||
request_text = _load_request_text(args)
|
||||
response = send_request(args.socket, request_text)
|
||||
print(json.dumps(response, separators=(",", ":"), sort_keys=True))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception as exc: # pragma: no cover - CLI entrypoint behavior
|
||||
print(json.dumps({"status": "error", "error": str(exc)}), file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
@@ -0,0 +1,931 @@
|
||||
"""Unix-socket service for governed local writer operations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import hashlib
|
||||
import argparse
|
||||
import importlib.util
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Mapping, Protocol
|
||||
|
||||
from governed_local_writer import (
|
||||
AppendCommitRequest,
|
||||
AuthorizationError,
|
||||
BranchPolicy,
|
||||
CreateBranchRequest,
|
||||
GovernedAuthorization,
|
||||
PeerIdentity,
|
||||
PRINCIPAL,
|
||||
TASK_ID,
|
||||
extract_peer_identity,
|
||||
contains_secret_like_content,
|
||||
load_authorization_file,
|
||||
validate_file_changes,
|
||||
)
|
||||
|
||||
|
||||
class ExistingGitOpsHandler:
|
||||
"""Delegate repository mutations to the installed GitOps handler seam."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
workspace_by_branch: Mapping[str, str | os.PathLike[str]],
|
||||
bare_repo: str | os.PathLike[str],
|
||||
api_source: str | os.PathLike[str],
|
||||
) -> None:
|
||||
self.workspace_by_branch = {str(k): Path(v) for k, v in workspace_by_branch.items()}
|
||||
self.bare_repo = Path(bare_repo)
|
||||
self.api_source = Path(api_source)
|
||||
|
||||
@staticmethod
|
||||
def _restore_env(previous: Mapping[str, str | None]) -> None:
|
||||
for key, value in previous.items():
|
||||
if value is None:
|
||||
os.environ.pop(key, None)
|
||||
else:
|
||||
os.environ[key] = value
|
||||
|
||||
def _load_api(self, workspace: Path):
|
||||
if not self.api_source.is_file() or self.api_source.is_symlink():
|
||||
raise RuntimeError("installed GitOps handler source is unavailable")
|
||||
keys = (
|
||||
"AIOS_GITOPS_WORKSPACE",
|
||||
"AIOS_GITOPS_BARE_REPO",
|
||||
"AIOS_GITOPS_WRITES_ENABLED",
|
||||
"PYTHONDONTWRITEBYTECODE",
|
||||
"GIT_CONFIG_COUNT",
|
||||
"GIT_CONFIG_KEY_0",
|
||||
"GIT_CONFIG_VALUE_0",
|
||||
"GIT_CONFIG_KEY_1",
|
||||
"GIT_CONFIG_VALUE_1",
|
||||
)
|
||||
previous = {key: os.environ.get(key) for key in keys}
|
||||
os.environ.update(
|
||||
{
|
||||
"AIOS_GITOPS_WORKSPACE": str(workspace),
|
||||
"AIOS_GITOPS_BARE_REPO": str(self.bare_repo),
|
||||
"AIOS_GITOPS_WRITES_ENABLED": "true",
|
||||
"PYTHONDONTWRITEBYTECODE": "1",
|
||||
"GIT_CONFIG_COUNT": "2",
|
||||
"GIT_CONFIG_KEY_0": "safe.directory",
|
||||
"GIT_CONFIG_VALUE_0": str(workspace),
|
||||
"GIT_CONFIG_KEY_1": "safe.directory",
|
||||
"GIT_CONFIG_VALUE_1": str(self.bare_repo),
|
||||
}
|
||||
)
|
||||
module_name = f"_governed_gitops_api_{uuid.uuid4().hex}"
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location(module_name, self.api_source)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError("unable to load installed GitOps handler")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
decision = module.evaluate_git_write(module.WS)
|
||||
if not decision.allowed:
|
||||
raise RuntimeError("existing GitOps write gate denied bootstrap")
|
||||
return module, previous, module_name
|
||||
except Exception:
|
||||
sys.modules.pop(module_name, None)
|
||||
self._restore_env(previous)
|
||||
raise
|
||||
|
||||
def _workspace(self, branch: str) -> Path:
|
||||
workspace = self.workspace_by_branch.get(branch)
|
||||
if workspace is None or not workspace.is_dir():
|
||||
raise RuntimeError("branch workspace is not authorized")
|
||||
return workspace
|
||||
|
||||
@staticmethod
|
||||
def _git(workspace: Path, args: list[str]) -> str:
|
||||
result = subprocess.run(
|
||||
["git", *args], cwd=workspace, text=True, capture_output=True, check=False
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError("governed handler git probe failed")
|
||||
return result.stdout.strip()
|
||||
|
||||
def create_branch(self, request: CreateBranchRequest) -> Mapping[str, object]:
|
||||
workspace = self._workspace(request.branch)
|
||||
# A prior handler call may have completed the fast-forward sync before
|
||||
# the process observed a post-commit error. Reconcile only an exact,
|
||||
# metadata-only result; never overwrite or move an existing ref here.
|
||||
try:
|
||||
existing = self._git(
|
||||
workspace,
|
||||
[
|
||||
"--git-dir",
|
||||
str(self.bare_repo),
|
||||
"rev-parse",
|
||||
f"refs/heads/{request.branch}",
|
||||
],
|
||||
)
|
||||
except RuntimeError:
|
||||
existing = ""
|
||||
if existing:
|
||||
parent = self._git(workspace, ["--git-dir", str(self.bare_repo), "rev-parse", f"{existing}^1"])
|
||||
changed = self._git(
|
||||
workspace,
|
||||
[
|
||||
"--git-dir",
|
||||
str(self.bare_repo),
|
||||
"diff-tree",
|
||||
"--no-commit-id",
|
||||
"--name-only",
|
||||
"-r",
|
||||
existing,
|
||||
],
|
||||
).splitlines()
|
||||
safe_branch = re.sub(r"[^A-Za-z0-9_.-]+", "-", request.branch).strip("-")
|
||||
if parent != request.base_oid or changed != [f"patches/{safe_branch}.branch.json"]:
|
||||
raise RuntimeError("existing branch is not an exact metadata-only bootstrap")
|
||||
return {"head_oid": existing, "parent_oid": parent, "handler_status": "reconciled"}
|
||||
module, previous, module_name = self._load_api(workspace)
|
||||
phase = "loaded"
|
||||
try:
|
||||
phase = "verify_base"
|
||||
parent = self._git(
|
||||
workspace,
|
||||
["--git-dir", str(self.bare_repo), "rev-parse", "refs/heads/main"],
|
||||
)
|
||||
if parent != request.base_oid:
|
||||
raise RuntimeError("authorized base OID drift")
|
||||
phase = "checkout"
|
||||
module.checkout(request.branch, base=request.base_oid, create=True)
|
||||
phase = "write_metadata"
|
||||
safe_branch = re.sub(r"[^A-Za-z0-9_.-]+", "-", request.branch).strip("-")
|
||||
metadata_path = f"patches/{safe_branch}.branch.json"
|
||||
module.write_json(
|
||||
metadata_path,
|
||||
{
|
||||
"schema": "aios.governed-local-writer-bootstrap.v1",
|
||||
"task_id": TASK_ID,
|
||||
"principal": PRINCIPAL,
|
||||
"branch": request.branch,
|
||||
"parent_oid": request.base_oid,
|
||||
"authorization_sha256": request.authorization_digest,
|
||||
},
|
||||
)
|
||||
phase = "commit_and_sync"
|
||||
result = module.commit(
|
||||
[metadata_path], "chore(governance): bootstrap repair lease branch"
|
||||
)
|
||||
head_oid = self._git(workspace, ["rev-parse", "HEAD"])
|
||||
first_parent = self._git(workspace, ["rev-parse", "HEAD^1"])
|
||||
if first_parent != request.base_oid or (request.head_oid and head_oid != request.head_oid):
|
||||
raise RuntimeError("bootstrap branch lineage check failed")
|
||||
return {
|
||||
"head_oid": head_oid,
|
||||
"parent_oid": first_parent,
|
||||
"handler_status": str(result.get("status")),
|
||||
}
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"handler_phase={phase};error={exc.__class__.__name__}") from exc
|
||||
finally:
|
||||
sys.modules.pop(module_name, None)
|
||||
self._restore_env(previous)
|
||||
|
||||
def append_commit(self, request: AppendCommitRequest) -> Mapping[str, object]:
|
||||
workspace = self._workspace(request.branch)
|
||||
module, previous, module_name = self._load_api(workspace)
|
||||
phase = "loaded"
|
||||
try:
|
||||
phase = "verify_head"
|
||||
current = self._git(workspace, ["rev-parse", "HEAD"])
|
||||
if current != request.expected_head_oid:
|
||||
raise RuntimeError("branch head advanced or is stale")
|
||||
phase = "checkout"
|
||||
module.checkout(request.branch, base=request.expected_head_oid, create=False)
|
||||
phase = "write_files"
|
||||
paths: list[str] = []
|
||||
for change in request.changes:
|
||||
paths.append(module.write_file(change.path, change.content))
|
||||
phase = "commit_and_sync"
|
||||
result = module.commit(paths, request.commit_message)
|
||||
head_oid = self._git(workspace, ["rev-parse", "HEAD"])
|
||||
first_parent = self._git(workspace, ["rev-parse", "HEAD^1"])
|
||||
if first_parent != request.expected_head_oid or head_oid == first_parent:
|
||||
raise RuntimeError("append commit lineage check failed")
|
||||
return {
|
||||
"head_oid": head_oid,
|
||||
"parent_oid": first_parent,
|
||||
"handler_status": str(result.get("status")),
|
||||
}
|
||||
except Exception as exc:
|
||||
raise RuntimeError(f"handler_phase={phase};error={exc.__class__.__name__}") from exc
|
||||
finally:
|
||||
sys.modules.pop(module_name, None)
|
||||
self._restore_env(previous)
|
||||
|
||||
|
||||
class GovernedWriterHandler(Protocol):
|
||||
"""Small adapter interface for real writer backend handlers."""
|
||||
|
||||
def create_branch(self, request: CreateBranchRequest) -> Mapping[str, object]:
|
||||
"""Create a branch using internal repo services."""
|
||||
|
||||
def append_commit(self, request: AppendCommitRequest) -> Mapping[str, object]:
|
||||
"""Append a commit using internal repo services."""
|
||||
|
||||
|
||||
class AuditSink(Protocol):
|
||||
"""Pluggable sink for sanitized audit records."""
|
||||
|
||||
def append(self, record: "AuditRecord") -> None:
|
||||
"""Persist one audit record."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class AuditRecord:
|
||||
"""Sanitized audit log entry."""
|
||||
|
||||
authorization_digest: str
|
||||
peer_pid: int
|
||||
peer_uid: int
|
||||
peer_gid: int
|
||||
peer_executable: str | None
|
||||
timestamp: datetime
|
||||
operation: str
|
||||
branch: str
|
||||
result: str
|
||||
|
||||
|
||||
class InMemoryAuditSink:
|
||||
"""Simple in-memory audit log used by tests."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.records: list[AuditRecord] = []
|
||||
|
||||
def append(self, record: AuditRecord) -> None:
|
||||
self.records.append(record)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _AuthorizationState:
|
||||
"""Tracks one-shot operation consumption."""
|
||||
|
||||
create_done: set[str] = field(default_factory=set)
|
||||
append_done: set[str] = field(default_factory=set)
|
||||
consumed: bool = False
|
||||
|
||||
|
||||
class GovernedLocalWriterService:
|
||||
"""Policy-aware AF_UNIX service that calls an injected handler."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
socket_path: str | os.PathLike[str],
|
||||
authorization: GovernedAuthorization,
|
||||
handler: GovernedWriterHandler,
|
||||
*,
|
||||
audit_sink: AuditSink | None = None,
|
||||
peer_identity_fn: Callable[[socket.socket], PeerIdentity] | None = None,
|
||||
state_path: str | os.PathLike[str] | None = None,
|
||||
) -> None:
|
||||
self.socket_path = Path(socket_path)
|
||||
self.authorization = authorization
|
||||
self.handler = handler
|
||||
self.peer_identity_fn = peer_identity_fn or extract_peer_identity
|
||||
self.state = _AuthorizationState()
|
||||
self.audit_sink = audit_sink or InMemoryAuditSink()
|
||||
self.state_path = Path(state_path) if state_path is not None else None
|
||||
self._load_state()
|
||||
|
||||
def _load_state(self) -> None:
|
||||
"""Restore one-shot state without restoring request or secret material."""
|
||||
if self.state_path is None or not self.state_path.exists():
|
||||
return
|
||||
try:
|
||||
raw = json.loads(self.state_path.read_text(encoding="utf-8"))
|
||||
if not isinstance(raw, dict):
|
||||
raise ValueError("state must be an object")
|
||||
if raw.get("authorization_sha256") != self.authorization.authorization_sha256:
|
||||
raise ValueError("state authorization digest mismatch")
|
||||
self.state.create_done = {str(v) for v in raw.get("create_done", [])}
|
||||
self.state.append_done = {str(v) for v in raw.get("append_done", [])}
|
||||
self.state.consumed = bool(raw.get("consumed", False))
|
||||
except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc:
|
||||
raise AuthorizationError("authorization state is invalid") from exc
|
||||
|
||||
def _persist_state(self) -> None:
|
||||
if self.state_path is None:
|
||||
return
|
||||
self.state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
"authorization_sha256": self.authorization.authorization_sha256,
|
||||
"create_done": sorted(self.state.create_done),
|
||||
"append_done": sorted(self.state.append_done),
|
||||
"consumed": self.state.consumed,
|
||||
}
|
||||
fd, temp_name = tempfile.mkstemp(prefix=".writer-state-", dir=self.state_path.parent)
|
||||
try:
|
||||
os.fchmod(fd, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
||||
json.dump(payload, stream, sort_keys=True, separators=(",", ":"))
|
||||
stream.write("\n")
|
||||
os.replace(temp_name, self.state_path)
|
||||
os.chmod(self.state_path, 0o600)
|
||||
finally:
|
||||
if os.path.exists(temp_name):
|
||||
os.unlink(temp_name)
|
||||
|
||||
def _new_audit(self, peer: PeerIdentity, operation: str, branch: str, result: str) -> None:
|
||||
self.audit_sink.append(
|
||||
AuditRecord(
|
||||
authorization_digest=self.authorization.authorization_sha256,
|
||||
peer_pid=peer.pid,
|
||||
peer_uid=peer.uid,
|
||||
peer_gid=peer.gid,
|
||||
peer_executable=peer.executable,
|
||||
timestamp=datetime.now(tz=timezone.utc),
|
||||
operation=operation,
|
||||
branch=branch,
|
||||
result=result,
|
||||
)
|
||||
)
|
||||
|
||||
def _require_request_field(self, request: Mapping[str, Any], field_name: str) -> str:
|
||||
value = request.get(field_name)
|
||||
if not isinstance(value, str) or not value:
|
||||
raise AuthorizationError(f"{field_name} must be a non-empty string")
|
||||
return value
|
||||
|
||||
def _branch_policy(self, branch: str) -> BranchPolicy:
|
||||
policy = self.authorization.branch_operations.get(branch)
|
||||
if policy is None:
|
||||
raise AuthorizationError("branch is not authorized")
|
||||
return policy
|
||||
|
||||
def _ensure_not_consumed(self) -> None:
|
||||
if self.state.consumed:
|
||||
raise AuthorizationError("authorization consumed")
|
||||
if datetime.now(tz=timezone.utc) >= self.authorization.expires_at:
|
||||
raise AuthorizationError("authorization expired")
|
||||
|
||||
def _mark_success(self, op: str, branch: str) -> None:
|
||||
if op == "create_branch":
|
||||
self.state.create_done.add(branch)
|
||||
elif op == "append_commit":
|
||||
self.state.append_done.add(branch)
|
||||
|
||||
if self.state.create_done.issuperset(self.authorization.create_branches) and self.state.append_done.issuperset(
|
||||
self.authorization.append_branches
|
||||
):
|
||||
self.state.consumed = True
|
||||
self._persist_state()
|
||||
|
||||
def _validate_request(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
task_id = self._require_request_field(request, "task_id")
|
||||
if task_id != TASK_ID:
|
||||
raise AuthorizationError("invalid task_id")
|
||||
|
||||
principal = self._require_request_field(request, "principal")
|
||||
if principal != PRINCIPAL:
|
||||
raise AuthorizationError("invalid principal")
|
||||
|
||||
operation = self._require_request_field(request, "op")
|
||||
if operation not in {"create_branch", "append_commit"}:
|
||||
raise AuthorizationError("unsupported operation")
|
||||
if operation in self.authorization.denied_operations:
|
||||
raise AuthorizationError("operation denied by policy")
|
||||
|
||||
branch = self._require_request_field(request, "branch")
|
||||
self._ensure_not_consumed()
|
||||
policy = self._branch_policy(branch)
|
||||
|
||||
if operation == "create_branch":
|
||||
if policy.create_branch is None:
|
||||
raise AuthorizationError("branch does not allow create_branch")
|
||||
if branch in self.state.create_done:
|
||||
raise AuthorizationError("create_branch already used for this branch")
|
||||
|
||||
base_oid = self._require_request_field(request, "base_oid")
|
||||
raw_head_oid = request.get("head_oid")
|
||||
if raw_head_oid is not None and (not isinstance(raw_head_oid, str) or not raw_head_oid):
|
||||
raise AuthorizationError("head_oid must be a non-empty string when supplied")
|
||||
head_oid = str(raw_head_oid) if raw_head_oid is not None else None
|
||||
if base_oid != policy.create_branch.base_oid:
|
||||
raise AuthorizationError("base_oid mismatch")
|
||||
if policy.create_branch.head_oid is not None and head_oid != policy.create_branch.head_oid:
|
||||
raise AuthorizationError("head_oid mismatch")
|
||||
|
||||
return {
|
||||
"operation": operation,
|
||||
"branch": branch,
|
||||
"base_oid": base_oid,
|
||||
"head_oid": head_oid,
|
||||
}
|
||||
|
||||
if policy.append_commit is None:
|
||||
raise AuthorizationError("branch does not allow append_commit")
|
||||
if policy.create_branch is not None and branch not in self.state.create_done:
|
||||
raise AuthorizationError("append_commit requires prior successful create_branch")
|
||||
if branch in self.state.append_done:
|
||||
raise AuthorizationError("append_commit already used for this branch")
|
||||
|
||||
expected_head = policy.append_commit.expected_head_oid
|
||||
if "expected_head_oid" in request:
|
||||
supplied_head = self._require_request_field(request, "expected_head_oid")
|
||||
elif "current_head_oid" in request:
|
||||
supplied_head = self._require_request_field(request, "current_head_oid")
|
||||
else:
|
||||
raise AuthorizationError("expected_head_oid or current_head_oid required")
|
||||
if not re.fullmatch(r"[0-9a-fA-F]{40}", supplied_head):
|
||||
raise AuthorizationError("head check must be a full commit OID")
|
||||
if expected_head is not None and supplied_head != expected_head:
|
||||
raise AuthorizationError("head check failed")
|
||||
|
||||
if request.get("force") is True or request.get("non_fast_forward") is True:
|
||||
raise AuthorizationError("non-fast-forward operations are not allowed")
|
||||
if request.get("history_rewrite") is True:
|
||||
raise AuthorizationError("history rewrite is not allowed")
|
||||
parent_oids = request.get("parent_oids")
|
||||
if parent_oids is not None:
|
||||
if not isinstance(parent_oids, list) or parent_oids != [supplied_head]:
|
||||
raise AuthorizationError("parent_oids must contain exactly expected_head_oid")
|
||||
|
||||
changes = request.get("changes")
|
||||
if not isinstance(changes, list):
|
||||
raise AuthorizationError("changes must be a list")
|
||||
validated_changes = validate_file_changes(changes, policy.append_commit.allowed_paths)
|
||||
commit_message = request.get("commit_message", "chore(governance): append governed commit")
|
||||
if not isinstance(commit_message, str) or not commit_message.strip() or len(commit_message) > 200:
|
||||
raise AuthorizationError("commit_message must be a short non-empty string")
|
||||
if "\n" in commit_message or "\r" in commit_message or contains_secret_like_content(commit_message):
|
||||
raise AuthorizationError("commit_message failed policy")
|
||||
|
||||
return {
|
||||
"operation": operation,
|
||||
"branch": branch,
|
||||
"expected_head_oid": supplied_head,
|
||||
"changes": validated_changes,
|
||||
"commit_message": commit_message.strip(),
|
||||
}
|
||||
|
||||
def process_request(self, raw_request: Mapping[str, Any], peer: PeerIdentity) -> Mapping[str, Any]:
|
||||
"""Run one request through policy checks and handler adapter."""
|
||||
operation = raw_request.get("op") if isinstance(raw_request.get("op"), str) else "unknown"
|
||||
branch = raw_request.get("branch") if isinstance(raw_request.get("branch"), str) else ""
|
||||
try:
|
||||
details = self._validate_request(raw_request)
|
||||
operation = str(details["operation"])
|
||||
branch = str(details["branch"])
|
||||
|
||||
if operation == "create_branch":
|
||||
self.handler.create_branch(
|
||||
CreateBranchRequest(
|
||||
branch=branch,
|
||||
base_oid=str(details["base_oid"]),
|
||||
head_oid=(
|
||||
str(details["head_oid"])
|
||||
if details.get("head_oid") is not None
|
||||
else None
|
||||
),
|
||||
authorization_digest=self.authorization.authorization_sha256,
|
||||
)
|
||||
)
|
||||
self._mark_success("create_branch", branch)
|
||||
self._new_audit(peer, operation, branch, "ok")
|
||||
return {
|
||||
"status": "ok",
|
||||
"operation": operation,
|
||||
"branch": branch,
|
||||
"result": "applied",
|
||||
}
|
||||
|
||||
self.handler.append_commit(
|
||||
AppendCommitRequest(
|
||||
branch=branch,
|
||||
expected_head_oid=str(details["expected_head_oid"]),
|
||||
changes=tuple(details["changes"]),
|
||||
authorization_digest=self.authorization.authorization_sha256,
|
||||
commit_message=str(details["commit_message"]),
|
||||
)
|
||||
)
|
||||
self._mark_success("append_commit", branch)
|
||||
self._new_audit(peer, operation, branch, "ok")
|
||||
return {
|
||||
"status": "ok",
|
||||
"operation": operation,
|
||||
"branch": branch,
|
||||
"result": "applied",
|
||||
}
|
||||
except Exception as error:
|
||||
self._new_audit(peer, operation or "unknown", branch, f"error:{error.__class__.__name__}")
|
||||
if isinstance(error, PermissionError):
|
||||
code = "permission_denied"
|
||||
elif isinstance(error, AuthorizationError):
|
||||
code = "request_denied"
|
||||
else:
|
||||
code = "handler_failed"
|
||||
if code == "handler_failed" and str(error).startswith("handler_phase="):
|
||||
code = str(error)
|
||||
return {
|
||||
"status": "error",
|
||||
"operation": operation or "unknown",
|
||||
"branch": branch,
|
||||
"error": code,
|
||||
}
|
||||
|
||||
def _write_json(self, connection: socket.socket, payload: Mapping[str, Any]) -> None:
|
||||
connection.sendall((json.dumps(payload) + "\n").encode("utf-8"))
|
||||
|
||||
def _handle_client(self, connection: socket.socket) -> None:
|
||||
with connection:
|
||||
try:
|
||||
peer = self.peer_identity_fn(connection)
|
||||
except Exception:
|
||||
self._write_json(
|
||||
connection,
|
||||
{
|
||||
"status": "error",
|
||||
"operation": "connection",
|
||||
"branch": "",
|
||||
"error": "peer identity validation failed",
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
with connection.makefile("r", encoding="utf-8") as stream:
|
||||
for raw_line in stream:
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
request = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
self._write_json(
|
||||
connection,
|
||||
{
|
||||
"status": "error",
|
||||
"operation": "request",
|
||||
"branch": "",
|
||||
"error": "invalid json",
|
||||
},
|
||||
)
|
||||
continue
|
||||
if not isinstance(request, Mapping):
|
||||
self._write_json(
|
||||
connection,
|
||||
{
|
||||
"status": "error",
|
||||
"operation": "request",
|
||||
"branch": "",
|
||||
"error": "request must be a JSON object",
|
||||
},
|
||||
)
|
||||
continue
|
||||
self._write_json(connection, self.process_request(request, peer))
|
||||
|
||||
def _bind_socket(self) -> socket.socket:
|
||||
if self.socket_path.exists():
|
||||
raise RuntimeError("governed writer socket already exists")
|
||||
parent = self.socket_path.parent
|
||||
if not parent.is_dir():
|
||||
raise RuntimeError("governed writer socket parent is missing")
|
||||
if parent.is_symlink():
|
||||
raise RuntimeError("governed writer socket parent must not be a symlink")
|
||||
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
server.bind(str(self.socket_path))
|
||||
os.chmod(self.socket_path, 0o600)
|
||||
server.listen(16)
|
||||
return server
|
||||
|
||||
def serve(self) -> None:
|
||||
"""Serve newline-delimited JSON over a filesystem AF_UNIX socket."""
|
||||
server = self._bind_socket()
|
||||
try:
|
||||
while True:
|
||||
connection, _ = server.accept()
|
||||
self._handle_client(connection)
|
||||
finally:
|
||||
server.close()
|
||||
if self.socket_path.exists():
|
||||
self.socket_path.unlink()
|
||||
|
||||
|
||||
class JsonAuditSink:
|
||||
"""Append-only root-owned audit sink containing no request payloads."""
|
||||
|
||||
def __init__(self, path: str | os.PathLike[str]) -> None:
|
||||
self.path = Path(path)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def append(self, record: AuditRecord) -> None:
|
||||
payload = {
|
||||
"authorization_sha256": record.authorization_digest,
|
||||
"peer_pid": record.peer_pid,
|
||||
"peer_uid": record.peer_uid,
|
||||
"peer_gid": record.peer_gid,
|
||||
"peer_executable": record.peer_executable,
|
||||
"timestamp": record.timestamp.isoformat(),
|
||||
"operation": record.operation,
|
||||
"branch": record.branch,
|
||||
"result": record.result,
|
||||
}
|
||||
line = (json.dumps(payload, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8")
|
||||
fd = os.open(self.path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
||||
try:
|
||||
os.fchmod(fd, 0o600)
|
||||
os.write(fd, line)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
|
||||
# V2R1C6 durable receipt overlay. This subclasses the original root-only
|
||||
# writer in-place so the existing AF_UNIX/SO_PEERCRED and handler policy remain
|
||||
# the only mutation path.
|
||||
_LegacyGovernedLocalWriterService = GovernedLocalWriterService
|
||||
|
||||
class GovernedLocalWriterService(_LegacyGovernedLocalWriterService):
|
||||
"""Existing writer with durable request receipts and safe recovery."""
|
||||
|
||||
def _load_state(self) -> None:
|
||||
super()._load_state()
|
||||
self.state.receipts = {}
|
||||
if self.state_path is None or not self.state_path.exists():
|
||||
return
|
||||
raw = json.loads(self.state_path.read_text(encoding="utf-8"))
|
||||
if not isinstance(raw, dict):
|
||||
raise AuthorizationError("authorization state is invalid")
|
||||
receipts = raw.get("receipts", {})
|
||||
if not isinstance(receipts, dict):
|
||||
raise AuthorizationError("authorization receipts are invalid")
|
||||
self.state.receipts = {
|
||||
str(key): dict(value)
|
||||
for key, value in receipts.items()
|
||||
if isinstance(value, dict)
|
||||
}
|
||||
|
||||
def _persist_state(self) -> None:
|
||||
if self.state_path is None:
|
||||
return
|
||||
self.state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
payload = {
|
||||
"authorization_sha256": self.authorization.authorization_sha256,
|
||||
"create_done": sorted(self.state.create_done),
|
||||
"append_done": sorted(self.state.append_done),
|
||||
"consumed": bool(self.state.consumed),
|
||||
"receipts": self.state.receipts,
|
||||
}
|
||||
fd, temp_name = tempfile.mkstemp(prefix=".writer-state-", dir=self.state_path.parent)
|
||||
try:
|
||||
os.fchmod(fd, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as stream:
|
||||
json.dump(payload, stream, sort_keys=True, separators=(",", ":"))
|
||||
stream.write("\n")
|
||||
os.replace(temp_name, self.state_path)
|
||||
os.chmod(self.state_path, 0o600)
|
||||
finally:
|
||||
if os.path.exists(temp_name):
|
||||
os.unlink(temp_name)
|
||||
|
||||
def _request_identity(self, request: Mapping[str, Any]) -> dict[str, str]:
|
||||
canonical = json.dumps(dict(request), sort_keys=True, separators=(",", ":"), ensure_ascii=True)
|
||||
fallback = hashlib.sha256(canonical.encode("utf-8")).hexdigest()
|
||||
request_id = request.get("request_id")
|
||||
idempotency_key = request.get("idempotency_key")
|
||||
request_id = request_id.strip() if isinstance(request_id, str) and request_id.strip() else "legacy-" + fallback
|
||||
idempotency_key = idempotency_key.strip() if isinstance(idempotency_key, str) and idempotency_key.strip() else "legacy-" + fallback
|
||||
for field_name, value in (("request_id", request_id), ("idempotency_key", idempotency_key)):
|
||||
if len(value) > 200 or "\n" in value or "\r" in value:
|
||||
raise AuthorizationError(field_name + " is invalid")
|
||||
return {
|
||||
"request_id": request_id,
|
||||
"idempotency_key": idempotency_key,
|
||||
"request_fingerprint": fallback,
|
||||
}
|
||||
|
||||
def _find_receipt(self, details: Mapping[str, Any], compare_fingerprint: bool = True):
|
||||
request_id = str(details["request_id"])
|
||||
idempotency_key = str(details["idempotency_key"])
|
||||
fingerprint = str(details.get("request_fingerprint", ""))
|
||||
for receipt in self.state.receipts.values():
|
||||
if receipt.get("request_id") == request_id or receipt.get("idempotency_key") == idempotency_key:
|
||||
if compare_fingerprint and receipt.get("request_fingerprint") != fingerprint:
|
||||
raise AuthorizationError("idempotency key reused with different request")
|
||||
return receipt
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _receipt_response(receipt: Mapping[str, object], status: str = "receipt") -> dict[str, object]:
|
||||
response = {
|
||||
"status": status,
|
||||
"request_id": receipt.get("request_id"),
|
||||
"idempotency_key": receipt.get("idempotency_key"),
|
||||
"operation": receipt.get("operation"),
|
||||
"branch": receipt.get("branch", ""),
|
||||
"receipt_status": receipt.get("status"),
|
||||
}
|
||||
for field_name in ("commit_oid", "final_head", "parent_oid", "audit_sha256", "error_code", "safe_to_retry"):
|
||||
if field_name in receipt:
|
||||
response[field_name] = receipt[field_name]
|
||||
return response
|
||||
|
||||
def _new_receipt(self, details: Mapping[str, Any]) -> dict[str, object]:
|
||||
return {
|
||||
"request_id": str(details["request_id"]),
|
||||
"idempotency_key": str(details["idempotency_key"]),
|
||||
"request_fingerprint": str(details["request_fingerprint"]),
|
||||
"operation": str(details["operation"]),
|
||||
"branch": str(details.get("branch", "")),
|
||||
"expected_head_oid": str(details.get("expected_head_oid", "")),
|
||||
"status": "accepted",
|
||||
"created_at": datetime.now(tz=timezone.utc).isoformat(),
|
||||
"safe_to_retry": False,
|
||||
}
|
||||
|
||||
def _new_audit(self, peer: PeerIdentity, operation: str, branch: str, result: str) -> str:
|
||||
super()._new_audit(peer, operation, branch, result)
|
||||
payload = {
|
||||
"authorization_sha256": self.authorization.authorization_sha256,
|
||||
"peer_pid": peer.pid,
|
||||
"peer_uid": peer.uid,
|
||||
"peer_gid": peer.gid,
|
||||
"peer_executable": peer.executable,
|
||||
"operation": operation,
|
||||
"branch": branch,
|
||||
"result": result,
|
||||
}
|
||||
return hashlib.sha256(json.dumps(payload, sort_keys=True, separators=(",", ":")).encode("utf-8")).hexdigest()
|
||||
|
||||
def _mark_success(self, op: str, branch: str) -> None:
|
||||
if op == "create_branch":
|
||||
self.state.create_done.add(branch)
|
||||
elif op in {"append_commit", "append_files", "append_batch"}:
|
||||
self.state.append_done.add(branch)
|
||||
# The provisioning grant is one-shot; the task writer lease itself must
|
||||
# support multiple append receipts for the exact authorized branches.
|
||||
self.state.consumed = False
|
||||
self._persist_state()
|
||||
|
||||
def _validate_durable(self, raw_request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
operation = self._require_request_field(raw_request, "op")
|
||||
identity = self._request_identity(raw_request)
|
||||
if operation == "query_receipt":
|
||||
return {"operation": operation, **identity}
|
||||
if operation == "recover_request":
|
||||
branch = self._require_request_field(raw_request, "branch")
|
||||
policy = self._branch_policy(branch)
|
||||
if policy.append_commit is None:
|
||||
raise AuthorizationError("branch does not allow recovery")
|
||||
expected = raw_request.get("expected_head_oid") or policy.append_commit.expected_head_oid
|
||||
if not isinstance(expected, str) or not re.fullmatch(r"[0-9a-fA-F]{40}", expected):
|
||||
raise AuthorizationError("recovery requires expected_head_oid")
|
||||
return {"operation": operation, "branch": branch, "expected_head_oid": expected.lower(), **identity}
|
||||
if operation not in {"create_branch", "append_commit", "append_files", "append_batch"}:
|
||||
raise AuthorizationError("unsupported operation")
|
||||
request = dict(raw_request)
|
||||
if operation in {"append_files", "append_batch"}:
|
||||
request["op"] = "append_commit"
|
||||
if operation in {"append_commit", "append_files", "append_batch"}:
|
||||
self.state.consumed = False
|
||||
branch = request.get("branch")
|
||||
removed = False
|
||||
dynamic_expected = False
|
||||
if isinstance(branch, str) and branch in self.state.append_done:
|
||||
self.state.append_done.remove(branch)
|
||||
removed = True
|
||||
policy = self.authorization.branch_operations.get(branch)
|
||||
if policy is not None and policy.append_commit is not None and policy.append_commit.expected_head_oid is not None:
|
||||
from governed_local_writer import BranchAppendPolicy, BranchPolicy
|
||||
self.authorization.branch_operations[branch] = BranchPolicy(create_branch=policy.create_branch, append_commit=BranchAppendPolicy(branch=branch, expected_head_oid=None, allowed_paths=policy.append_commit.allowed_paths))
|
||||
dynamic_expected = True
|
||||
try:
|
||||
details = super()._validate_request(request)
|
||||
finally:
|
||||
if dynamic_expected:
|
||||
policy = self.authorization.branch_operations.get(str(branch))
|
||||
if policy is not None and policy.append_commit is not None:
|
||||
from governed_local_writer import BranchAppendPolicy, BranchPolicy
|
||||
self.authorization.branch_operations[str(branch)] = BranchPolicy(create_branch=policy.create_branch, append_commit=BranchAppendPolicy(branch=str(branch), expected_head_oid=str(raw_request.get("expected_head_oid") or ""), allowed_paths=policy.append_commit.allowed_paths))
|
||||
if removed:
|
||||
self.state.append_done.add(str(branch))
|
||||
details.update(identity)
|
||||
details["operation"] = operation
|
||||
return details
|
||||
|
||||
def _recover_backend(self, details: Mapping[str, Any]) -> dict[str, object]:
|
||||
if not hasattr(self.handler, "_workspace") or not hasattr(self.handler, "_git"):
|
||||
raise RuntimeError("recovery backend unavailable")
|
||||
workspace = self.handler._workspace(str(details["branch"]))
|
||||
expected = str(details["expected_head_oid"])
|
||||
current = self.handler._git(workspace, ["rev-parse", "HEAD"])
|
||||
if current == expected:
|
||||
return {"status": "not_started", "final_head": current}
|
||||
parent = self.handler._git(workspace, ["rev-parse", "HEAD^1"])
|
||||
if parent != expected:
|
||||
raise RuntimeError("external branch advance during recovery")
|
||||
return {"status": "applied", "commit_oid": current, "final_head": current, "parent_oid": parent}
|
||||
|
||||
def process_request(self, raw_request: Mapping[str, Any], peer: PeerIdentity) -> Mapping[str, Any]:
|
||||
operation = raw_request.get("op") if isinstance(raw_request.get("op"), str) else "unknown"
|
||||
branch = raw_request.get("branch") if isinstance(raw_request.get("branch"), str) else ""
|
||||
try:
|
||||
details = self._validate_durable(raw_request)
|
||||
operation = str(details["operation"])
|
||||
branch = str(details.get("branch", ""))
|
||||
if operation == "query_receipt":
|
||||
receipt = self._find_receipt(details, compare_fingerprint=False)
|
||||
return self._receipt_response(receipt) if receipt is not None else {"status": "not_found", "request_id": details["request_id"], "idempotency_key": details["idempotency_key"]}
|
||||
if operation == "recover_request":
|
||||
receipt = self._find_receipt(details, compare_fingerprint=False)
|
||||
if receipt is None:
|
||||
return {"status": "not_found", "request_id": details["request_id"], "idempotency_key": details["idempotency_key"], "safe_to_retry": True}
|
||||
if receipt.get("status") == "applied":
|
||||
return self._receipt_response(receipt)
|
||||
recovered = self._recover_backend(details)
|
||||
if recovered.get("status") == "applied":
|
||||
receipt.update({"status": "applied", "commit_oid": recovered.get("commit_oid"), "final_head": recovered.get("final_head"), "parent_oid": recovered.get("parent_oid"), "safe_to_retry": False})
|
||||
self._mark_success("append_commit", branch)
|
||||
return self._receipt_response(receipt)
|
||||
receipt["safe_to_retry"] = True
|
||||
self._persist_state()
|
||||
return self._receipt_response(receipt)
|
||||
receipt = self._find_receipt(details)
|
||||
if receipt is not None:
|
||||
return self._receipt_response(receipt)
|
||||
receipt = self._new_receipt(details)
|
||||
self.state.receipts[str(details["request_id"])] = receipt
|
||||
self._persist_state()
|
||||
if operation == "create_branch":
|
||||
result = self.handler.create_branch(CreateBranchRequest(branch=branch, base_oid=str(details["base_oid"]), head_oid=str(details["head_oid"]) if details.get("head_oid") is not None else None, authorization_digest=self.authorization.authorization_sha256))
|
||||
commit_oid = str(result.get("head_oid", ""))
|
||||
parent_oid = str(result.get("parent_oid", ""))
|
||||
audit_sha = self._new_audit(peer, operation, branch, "ok")
|
||||
receipt.update({"status": "applied", "commit_oid": commit_oid, "final_head": commit_oid, "parent_oid": parent_oid, "audit_sha256": audit_sha, "safe_to_retry": False})
|
||||
self._mark_success("create_branch", branch)
|
||||
return self._receipt_response(receipt, status="ok") | {"result": "applied"}
|
||||
result = self.handler.append_commit(AppendCommitRequest(branch=branch, expected_head_oid=str(details["expected_head_oid"]), changes=tuple(details["changes"]), authorization_digest=self.authorization.authorization_sha256, commit_message=str(details["commit_message"])))
|
||||
commit_oid = str(result.get("head_oid", ""))
|
||||
parent_oid = str(result.get("parent_oid", ""))
|
||||
audit_sha = self._new_audit(peer, operation, branch, "ok")
|
||||
receipt.update({"status": "applied", "commit_oid": commit_oid, "final_head": commit_oid, "parent_oid": parent_oid, "audit_sha256": audit_sha, "safe_to_retry": False})
|
||||
self._mark_success("append_commit", branch)
|
||||
return self._receipt_response(receipt, status="ok") | {"result": "applied"}
|
||||
except Exception as error:
|
||||
try:
|
||||
identity = self._request_identity(raw_request)
|
||||
receipt = self.state.receipts.get(identity["request_id"])
|
||||
if receipt is not None and receipt.get("status") == "accepted":
|
||||
receipt.update({"status": "failed", "error_code": error.__class__.__name__, "safe_to_retry": False})
|
||||
self._persist_state()
|
||||
except Exception:
|
||||
pass
|
||||
self._new_audit(peer, operation, branch, "error:" + error.__class__.__name__)
|
||||
return {"status": "error", "operation": operation, "branch": branch, "error": "request_denied" if isinstance(error, AuthorizationError) else "handler_failed"}
|
||||
|
||||
def _parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="AIOS governed local writer")
|
||||
parser.add_argument("--socket", required=True)
|
||||
parser.add_argument("--authorization", required=True)
|
||||
parser.add_argument("--state", required=True)
|
||||
parser.add_argument("--audit", required=True)
|
||||
parser.add_argument("--repair-branch", required=True)
|
||||
parser.add_argument("--repair-workspace", required=True)
|
||||
parser.add_argument("--candidate-branch", required=True)
|
||||
parser.add_argument("--candidate-workspace", required=True)
|
||||
parser.add_argument("--bare-repo", required=True)
|
||||
parser.add_argument("--api-source", required=True)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if hasattr(os, "geteuid") and os.geteuid() != 0:
|
||||
print("governed local writer requires uid 0", file=sys.stderr)
|
||||
return 2
|
||||
args = _parse_args()
|
||||
authorization = load_authorization_file(args.authorization)
|
||||
handler = ExistingGitOpsHandler(
|
||||
workspace_by_branch={
|
||||
args.repair_branch: args.repair_workspace,
|
||||
args.candidate_branch: args.candidate_workspace,
|
||||
},
|
||||
bare_repo=args.bare_repo,
|
||||
api_source=args.api_source,
|
||||
)
|
||||
service = GovernedLocalWriterService(
|
||||
args.socket,
|
||||
authorization,
|
||||
handler,
|
||||
audit_sink=JsonAuditSink(args.audit),
|
||||
state_path=args.state,
|
||||
)
|
||||
service.serve()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover - systemd entrypoint
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,59 @@
|
||||
"""Central fail-closed policy seam for governed task and continuation requests."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
from typing import Any, Mapping
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GovernedPolicyInput:
|
||||
identity: str
|
||||
task_id: str
|
||||
repository: str
|
||||
operation: str
|
||||
base_oid: str
|
||||
expected_branch_oid: str
|
||||
target_branch: str
|
||||
generation: int
|
||||
risk: str = "engineering"
|
||||
requested_capabilities: tuple[str, ...] = ()
|
||||
previous_generation: int | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PolicyDecision:
|
||||
decision: str
|
||||
reason: str
|
||||
|
||||
def as_dict(self) -> dict[str, str]:
|
||||
return {"decision": self.decision, "reason": self.reason}
|
||||
|
||||
|
||||
def evaluate_policy(value: GovernedPolicyInput | Mapping[str, Any]) -> PolicyDecision:
|
||||
if isinstance(value, Mapping):
|
||||
value = GovernedPolicyInput(**value)
|
||||
if value.repository != "aios/aios":
|
||||
return PolicyDecision("DENY", "REPOSITORY_IDENTITY_MISMATCH")
|
||||
if not OID_RE.fullmatch(value.base_oid) or not OID_RE.fullmatch(value.expected_branch_oid):
|
||||
return PolicyDecision("DENY", "OID_INVALID")
|
||||
if not value.target_branch.startswith("agent/") or value.target_branch in {"main", "master"}:
|
||||
return PolicyDecision("DENY", "BRANCH_SCOPE_DENIED")
|
||||
if "force_push" in value.requested_capabilities or "force" in value.operation.lower():
|
||||
return PolicyDecision("DENY", "FORCE_PUSH_DENIED")
|
||||
if {"main_write", "tag_write", "baseline_write", "production_write", "production_deploy", "tag_mutation", "baseline_mutation"}.intersection(value.requested_capabilities):
|
||||
return PolicyDecision("DENY", "MUTATION_SCOPE_DENIED")
|
||||
if not isinstance(value.generation, int) or isinstance(value.generation, bool) or value.generation < 1:
|
||||
return PolicyDecision("DENY", "GENERATION_INVALID")
|
||||
if value.previous_generation is not None and value.generation <= value.previous_generation:
|
||||
return PolicyDecision("MANUAL_OWNER_REVIEW_REQUIRED", "GENERATION_NOT_MONOTONIC")
|
||||
if not value.task_id:
|
||||
return PolicyDecision("DENY", "TASK_ID_INVALID")
|
||||
return PolicyDecision("ALLOW", "GOVERNED_ENGINEERING_SCOPE")
|
||||
|
||||
|
||||
class GovernedPolicy:
|
||||
def decide(self, request: GovernedPolicyInput | Mapping[str, Any]) -> PolicyDecision:
|
||||
return evaluate_policy(request)
|
||||
@@ -0,0 +1,303 @@
|
||||
"""Durable governed task binding for CREATE_NEW and CONTINUE_EXISTING."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, asdict
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import stat
|
||||
import tempfile
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_gitops_job_controller import GitOpsJobController, JobStore
|
||||
from aios_gitops_job_identity import legacy_task_storage_key, task_storage_key
|
||||
from aios_online_agent_execution import GovernedExecutionBinding, project_execution_envelope
|
||||
|
||||
try: # pragma: no cover - exercised on the Linux activation host
|
||||
import fcntl
|
||||
except ImportError: # pragma: no cover - non-POSIX execution stays fail closed
|
||||
fcntl = None # type: ignore[assignment]
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
BRANCH_RE = re.compile(r"^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$")
|
||||
|
||||
|
||||
class BootstrapError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _lexists(path: Path) -> bool:
|
||||
try:
|
||||
path.lstat()
|
||||
return True
|
||||
except FileNotFoundError:
|
||||
return False
|
||||
|
||||
|
||||
def _require_runtime_directory(path: Path) -> None:
|
||||
try:
|
||||
metadata = path.lstat()
|
||||
except OSError as exc:
|
||||
raise BootstrapError("TASK_STORAGE_PARENT_INVALID") from exc
|
||||
expected_uid = getattr(os, "geteuid", lambda: metadata.st_uid)()
|
||||
if (
|
||||
stat.S_ISLNK(metadata.st_mode)
|
||||
or not stat.S_ISDIR(metadata.st_mode)
|
||||
or metadata.st_mode & 0o022
|
||||
or metadata.st_uid != expected_uid
|
||||
):
|
||||
raise BootstrapError("TASK_STORAGE_PARENT_INVALID")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GovernedTaskBinding:
|
||||
task_id: str
|
||||
repository: str
|
||||
base_oid: str
|
||||
target_branch: str
|
||||
risk: str
|
||||
description: str
|
||||
idempotency_key: str
|
||||
expected_branch_oid: str
|
||||
branch_mode: str
|
||||
generation: int
|
||||
job_id: str | None = None
|
||||
|
||||
def response(self, *, idempotent: bool = False) -> dict[str, Any]:
|
||||
return {"task_id": self.task_id, "repository": self.repository, "base_oid": self.base_oid, "target_branch": self.target_branch, "expected_branch_oid": self.expected_branch_oid, "generation": self.generation, "branch_mode": self.branch_mode, "create_target_branch": self.branch_mode == "CREATE_NEW", "worktree_mode": "detached_at_base_oid" if self.branch_mode == "CREATE_NEW" else "detached_at_expected_head", "cas_ref_update_required": True, "idempotent": idempotent, "state": "BOUND", "job_id": self.job_id}
|
||||
|
||||
|
||||
|
||||
# ===== AIOS Formal Lineage producer-native reservation v12 =====
|
||||
import re as _fl_re
|
||||
|
||||
_FL_PRODUCER_KEY = _fl_re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
def _formal_lineage_reservation_receipt(
|
||||
value,
|
||||
*,
|
||||
request_idempotency_key: str,
|
||||
expected_side_effect_state: str,
|
||||
):
|
||||
if callable(getattr(value, "as_dict", None)):
|
||||
raw=value.as_dict()
|
||||
elif isinstance(value, dict):
|
||||
raw=dict(value)
|
||||
else:
|
||||
raise RuntimeError("GOVERNED_TASK_SUBMIT_RECEIPT_TYPE_INVALID")
|
||||
|
||||
producer_key=raw.get("idempotency_key")
|
||||
job_id=raw.get("job_id")
|
||||
if not isinstance(job_id,str) or not job_id:
|
||||
raise RuntimeError("GOVERNED_TASK_SUBMIT_RECEIPT_JOB_ID_INVALID")
|
||||
if raw.get("state")!="REQUESTED":
|
||||
raise RuntimeError("GOVERNED_TASK_SUBMIT_RECEIPT_STATE_INVALID")
|
||||
if raw.get("side_effect_state")!=expected_side_effect_state:
|
||||
raise RuntimeError("GOVERNED_TASK_SUBMIT_RECEIPT_SIDE_EFFECT_STATE_INVALID")
|
||||
if not isinstance(producer_key,str) or not _FL_PRODUCER_KEY.fullmatch(producer_key):
|
||||
raise RuntimeError("GOVERNED_TASK_SUBMIT_RECEIPT_PRODUCER_IDEMPOTENCY_INVALID")
|
||||
if job_id!="job-"+producer_key[:32]:
|
||||
raise RuntimeError("GOVERNED_TASK_SUBMIT_RECEIPT_JOB_PRODUCER_KEY_CONFLICT")
|
||||
if not isinstance(request_idempotency_key,str) or not request_idempotency_key:
|
||||
raise RuntimeError("GOVERNED_TASK_REQUEST_IDEMPOTENCY_INVALID")
|
||||
|
||||
return {
|
||||
"job_id":job_id,
|
||||
"state":"REQUESTED",
|
||||
"side_effect_state":raw["side_effect_state"],
|
||||
"producer_idempotency_key":producer_key,
|
||||
"request_idempotency_key":request_idempotency_key,
|
||||
}
|
||||
|
||||
def _formal_lineage_attach_receipt(response, receipt):
|
||||
if not isinstance(response, dict):
|
||||
raise RuntimeError("GOVERNED_TASK_BINDING_RESPONSE_INVALID")
|
||||
if response.get("state")!="BOUND":
|
||||
raise RuntimeError("GOVERNED_TASK_BINDING_STATE_INVALID")
|
||||
if response.get("job_id")!=receipt.get("job_id"):
|
||||
raise RuntimeError("GOVERNED_TASK_BINDING_RECEIPT_JOB_CONFLICT")
|
||||
result=dict(response)
|
||||
result["reservation_receipt"]=dict(receipt)
|
||||
return result
|
||||
|
||||
class GovernedTaskBootstrap:
|
||||
def __init__(self, authority: Any, root: Path, *, remote_name: str, token_path: Path, ssh_key_path: Path, api_base: str = "https://g.cnnetmkt.top/git/api/v1") -> None:
|
||||
self.authority = authority; self.root = Path(root); self.remote_name = remote_name; self.token_path = Path(token_path); self.ssh_key_path = Path(ssh_key_path); self.api_base = api_base; self.task_meta_root = self.root / "task-bindings"; self.job_root = self.root / "jobs"; self.lock_root = self.root / "task-binding-locks"; self.task_meta_root.mkdir(parents=True, exist_ok=True, mode=0o700); self.job_root.mkdir(parents=True, exist_ok=True, mode=0o700); self.lock_root.mkdir(parents=True, exist_ok=True, mode=0o700); _require_runtime_directory(self.task_meta_root); _require_runtime_directory(self.job_root); _require_runtime_directory(self.lock_root)
|
||||
|
||||
def _store(self, task_id: str) -> tuple[JobStore, GitOpsJobController]:
|
||||
root = self.job_root / task_storage_key(task_id)
|
||||
legacy = self.job_root / legacy_task_storage_key(task_id)
|
||||
if legacy != root and _lexists(legacy):
|
||||
if _lexists(root):
|
||||
raise BootstrapError("TASK_STORAGE_LEGACY_CONFLICT")
|
||||
raise BootstrapError("TASK_STORAGE_LEGACY_MIGRATION_REQUIRED")
|
||||
store = JobStore(root, payload_root=root / "payloads")
|
||||
return store, GitOpsJobController(store)
|
||||
|
||||
def _metadata(self, task_id: str) -> Path:
|
||||
return self.task_meta_root / (task_storage_key(task_id) + ".json")
|
||||
|
||||
def _legacy_metadata(self, task_id: str) -> Path:
|
||||
return self.task_meta_root / (legacy_task_storage_key(task_id) + ".json")
|
||||
|
||||
def _read_existing(self, task_id: str) -> GovernedTaskBinding | None:
|
||||
path = self._metadata(task_id)
|
||||
legacy = self._legacy_metadata(task_id)
|
||||
if legacy != path and _lexists(legacy):
|
||||
if _lexists(path):
|
||||
raise BootstrapError("TASK_BINDING_LEGACY_CONFLICT")
|
||||
raise BootstrapError("TASK_BINDING_LEGACY_MIGRATION_REQUIRED")
|
||||
if not _lexists(path):
|
||||
return None
|
||||
if not path.is_file() or path.is_symlink():
|
||||
raise BootstrapError("TASK_BINDING_PATH_INVALID")
|
||||
|
||||
binding = GovernedTaskBinding(**json.loads(path.read_text(encoding="utf-8")))
|
||||
if binding.task_id != task_id:
|
||||
raise BootstrapError("TASK_BINDING_IDENTITY_CONFLICT")
|
||||
return binding
|
||||
def get_binding(self, task_id: str) -> GovernedTaskBinding:
|
||||
"""Read the exact durable binding; never synthesize missing lineage."""
|
||||
binding = self._read_existing(str(task_id))
|
||||
if binding is None or not binding.job_id:
|
||||
raise BootstrapError("TASK_EXECUTION_PROJECTION_MISSING")
|
||||
return binding
|
||||
|
||||
def execution_binding(self, binding: GovernedTaskBinding) -> GovernedExecutionBinding:
|
||||
return GovernedExecutionBinding.from_mapping(
|
||||
{
|
||||
"task_id": binding.task_id,
|
||||
"job_id": binding.job_id,
|
||||
"repository": binding.repository,
|
||||
"base_oid": binding.base_oid,
|
||||
"expected_branch_oid": binding.expected_branch_oid,
|
||||
"target_branch": binding.target_branch,
|
||||
"branch_mode": binding.branch_mode,
|
||||
"generation": binding.generation,
|
||||
"idempotency_key": binding.idempotency_key,
|
||||
}
|
||||
)
|
||||
|
||||
def execution_envelope(
|
||||
self,
|
||||
task_id: str,
|
||||
execution_request: Mapping[str, Any] | None = None,
|
||||
*,
|
||||
required_capabilities: tuple[str, ...] = (),
|
||||
expected_job_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
binding = self.get_binding(task_id)
|
||||
if expected_job_id and binding.job_id != expected_job_id:
|
||||
raise BootstrapError("JOB_BINDING_CONFLICT")
|
||||
return project_execution_envelope(
|
||||
self.execution_binding(binding),
|
||||
execution_request,
|
||||
required_capabilities=required_capabilities,
|
||||
)
|
||||
|
||||
def _write(self, binding: GovernedTaskBinding) -> None:
|
||||
path = self._metadata(binding.task_id)
|
||||
descriptor, temporary_name = tempfile.mkstemp(
|
||||
prefix=".task-binding-", dir=self.task_meta_root,
|
||||
)
|
||||
temporary = Path(temporary_name)
|
||||
try:
|
||||
os.fchmod(descriptor, 0o600)
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as stream:
|
||||
stream.write(json.dumps(asdict(binding), sort_keys=True) + "\n")
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
os.replace(temporary, path)
|
||||
os.chmod(path, 0o600)
|
||||
parent = os.open(self.task_meta_root, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
try:
|
||||
os.fsync(parent)
|
||||
finally:
|
||||
os.close(parent)
|
||||
except BaseException:
|
||||
temporary.unlink(missing_ok=True)
|
||||
raise
|
||||
|
||||
def _task_lock(self, task_id: str) -> int:
|
||||
if fcntl is None:
|
||||
raise BootstrapError("TASK_BINDING_LOCK_UNAVAILABLE")
|
||||
path = self.lock_root / (task_storage_key(task_id) + ".lock")
|
||||
descriptor = os.open(
|
||||
path,
|
||||
os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0),
|
||||
0o600,
|
||||
)
|
||||
metadata = os.fstat(descriptor)
|
||||
expected_uid = getattr(os, "geteuid", lambda: metadata.st_uid)()
|
||||
if (
|
||||
not stat.S_ISREG(metadata.st_mode)
|
||||
or metadata.st_nlink != 1
|
||||
or metadata.st_uid != expected_uid
|
||||
):
|
||||
os.close(descriptor)
|
||||
raise BootstrapError("TASK_BINDING_LOCK_INVALID")
|
||||
os.fchmod(descriptor, 0o600)
|
||||
try:
|
||||
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
except OSError as exc:
|
||||
os.close(descriptor)
|
||||
raise BootstrapError("TASK_BINDING_BUSY") from exc
|
||||
return descriptor
|
||||
|
||||
@staticmethod
|
||||
def _unlock_task(descriptor: int) -> None:
|
||||
if fcntl is not None:
|
||||
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
||||
os.close(descriptor)
|
||||
|
||||
def create(self, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
values = {key: str(request[key]) for key in ("task_id", "repository", "base_oid", "target_branch", "risk", "description", "idempotency_key")}
|
||||
if values["repository"] != "aios/aios" or not OID_RE.fullmatch(values["base_oid"]): raise BootstrapError("TASK_BINDING_INVALID")
|
||||
if not BRANCH_RE.fullmatch(values["target_branch"]): raise BootstrapError("TARGET_BRANCH_INVALID")
|
||||
descriptor = self._task_lock(values["task_id"])
|
||||
try:
|
||||
existing = self._read_existing(values["task_id"])
|
||||
if existing is not None:
|
||||
if any(getattr(existing, key) != values[key] for key in ("repository", "base_oid", "target_branch", "risk", "description", "idempotency_key")): raise BootstrapError("TASK_BINDING_CONFLICT")
|
||||
expected = str(request.get('expected_branch_oid') or values['base_oid'])
|
||||
payload = json.dumps(values, sort_keys=True, separators=(',', ':')).encode()
|
||||
_, controller = self._store(values['task_id'])
|
||||
_fl_submit_receipt = controller.submit({'idempotency_key': values['idempotency_key'], 'campaign_id': values['task_id'], 'task_id': values['task_id'], 'repository': values['repository'], 'operation': 'GOVERNED_TASK_CREATE', 'branch': values['target_branch'], 'expected_branch_oid': expected, 'payload_digest': hashlib.sha256(payload).hexdigest(), 'lineage_anchor_oid': values['base_oid']}, payload=payload)
|
||||
_fl_receipt = _formal_lineage_reservation_receipt(
|
||||
_fl_submit_receipt, request_idempotency_key=request["idempotency_key"],
|
||||
expected_side_effect_state='SAME_JOB'
|
||||
)
|
||||
_fl_response = existing.response(idempotent=True)
|
||||
return _formal_lineage_attach_receipt(_fl_response,_fl_receipt)
|
||||
if not self.authority.commit_exists(values["base_oid"]): raise BootstrapError("BASE_COMMIT_NOT_FOUND")
|
||||
current = self.authority.read_ref("refs/heads/" + values["target_branch"])
|
||||
expected = str(request.get("expected_branch_oid") or values["base_oid"])
|
||||
if current is None:
|
||||
mode, generation = "CREATE_NEW", 1
|
||||
if expected != values["base_oid"]: raise BootstrapError("EXPECTED_BASE_CONFLICT")
|
||||
else:
|
||||
if expected != current: raise BootstrapError("EXISTING_BRANCH_OID_CONFLICT")
|
||||
mode, generation = "CONTINUE_EXISTING", int(request.get("generation", 2))
|
||||
if generation < 1: raise BootstrapError("GENERATION_INVALID")
|
||||
payload = json.dumps(values, sort_keys=True, separators=(",", ":")).encode()
|
||||
_, controller = self._store(values["task_id"])
|
||||
expected_job_id = controller.internal_job_id_for_idempotency_key(values["idempotency_key"])
|
||||
with controller.store._connect() as connection:
|
||||
bootstrap_jobs = connection.execute(
|
||||
"SELECT job_id FROM jobs WHERE operation='GOVERNED_TASK_CREATE' ORDER BY job_id"
|
||||
).fetchall()
|
||||
if bootstrap_jobs and [str(row[0]) for row in bootstrap_jobs] != [expected_job_id]:
|
||||
raise BootstrapError("TASK_BINDING_RECOVERY_CONFLICT")
|
||||
receipt = controller.submit({"idempotency_key": values["idempotency_key"], "campaign_id": values["task_id"], "task_id": values["task_id"], "repository": values["repository"], "operation": "GOVERNED_TASK_CREATE", "branch": values["target_branch"], "expected_branch_oid": expected, "payload_digest": hashlib.sha256(payload).hexdigest(), "lineage_anchor_oid": values["base_oid"]}, payload=payload)
|
||||
binding = GovernedTaskBinding(**values, expected_branch_oid=expected, branch_mode=mode, generation=generation, job_id=receipt.job_id)
|
||||
self._write(binding)
|
||||
_fl_receipt = _formal_lineage_reservation_receipt(
|
||||
receipt, request_idempotency_key=request["idempotency_key"],
|
||||
expected_side_effect_state="QUEUED"
|
||||
)
|
||||
_fl_response = binding.response()
|
||||
return _formal_lineage_attach_receipt(_fl_response,_fl_receipt)
|
||||
finally:
|
||||
self._unlock_task(descriptor)
|
||||
@@ -1,27 +0,0 @@
|
||||
"""AIOS-managed Hermes runtime boundary."""
|
||||
|
||||
from .adapter import (
|
||||
HermesAdapter,
|
||||
HermesConflict,
|
||||
HermesNotFound,
|
||||
HermesRuntimeError,
|
||||
InMemoryHermesStore,
|
||||
SQLiteHermesStore,
|
||||
)
|
||||
from .contract import HermesHealth, HermesJob, HermesJobState, HermesResult
|
||||
from .worker import HermesWorker, WorkerRunResult
|
||||
|
||||
__all__ = [
|
||||
"HermesAdapter",
|
||||
"HermesConflict",
|
||||
"HermesHealth",
|
||||
"HermesJob",
|
||||
"HermesJobState",
|
||||
"HermesNotFound",
|
||||
"HermesResult",
|
||||
"HermesRuntimeError",
|
||||
"HermesWorker",
|
||||
"InMemoryHermesStore",
|
||||
"SQLiteHermesStore",
|
||||
"WorkerRunResult",
|
||||
]
|
||||
@@ -1,593 +0,0 @@
|
||||
"""Tenant-partitioned Hermes queue and worker lease boundary."""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
import threading
|
||||
from dataclasses import asdict, replace
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Mapping
|
||||
|
||||
from .contract import (
|
||||
ALLOWED_TRANSITIONS,
|
||||
TERMINAL_STATES,
|
||||
HermesHealth,
|
||||
HermesJob,
|
||||
HermesJobState,
|
||||
HermesResult,
|
||||
utc_now,
|
||||
)
|
||||
|
||||
|
||||
class HermesRuntimeError(RuntimeError):
|
||||
"""Base Hermes runtime-boundary failure."""
|
||||
|
||||
|
||||
class HermesNotFound(HermesRuntimeError):
|
||||
"""A tenant-scoped job was not found."""
|
||||
|
||||
|
||||
class HermesConflict(HermesRuntimeError):
|
||||
"""A state, lease, idempotency, or revision conflict occurred."""
|
||||
|
||||
|
||||
class InMemoryHermesStore:
|
||||
"""Thread-safe reference store; production backends implement this contract."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.lock = threading.RLock()
|
||||
self.jobs: dict[str, HermesJob] = {}
|
||||
self.results: dict[str, HermesResult] = {}
|
||||
self.idempotency: dict[tuple[str, str], tuple[str, str]] = {}
|
||||
self.worker_heartbeats: dict[str, tuple[str, str | None]] = {}
|
||||
|
||||
def persist_locked(self) -> None:
|
||||
"""Persist a mutation while ``lock`` is held; memory stores are a no-op."""
|
||||
|
||||
|
||||
class SQLiteHermesStore(InMemoryHermesStore):
|
||||
"""Durable snapshot for the existing Hermes queue state machine.
|
||||
|
||||
The adapter remains the sole owner of transitions and lease rules. This
|
||||
store only makes its jobs, results, idempotency keys and worker heartbeats
|
||||
survive a process restart.
|
||||
"""
|
||||
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self.path = Path(path).resolve()
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
super().__init__()
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS hermes_state (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
payload TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
self._load()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(str(self.path), timeout=30)
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute("PRAGMA synchronous=FULL")
|
||||
return connection
|
||||
|
||||
@staticmethod
|
||||
def _record(value: HermesJob | HermesResult) -> dict[str, object]:
|
||||
record = asdict(value)
|
||||
record["state"] = value.state.value
|
||||
return record
|
||||
|
||||
def _load(self) -> None:
|
||||
with self.lock, self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT payload FROM hermes_state WHERE singleton = 1"
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return
|
||||
payload = json.loads(str(row[0]))
|
||||
self.jobs = {
|
||||
str(item["job_id"]): HermesJob(**item)
|
||||
for item in payload.get("jobs", [])
|
||||
}
|
||||
self.results = {
|
||||
str(item["job_id"]): HermesResult(**item)
|
||||
for item in payload.get("results", [])
|
||||
}
|
||||
self.idempotency = {
|
||||
(str(item["tenant_id"]), str(item["idempotency_key"])): (
|
||||
str(item["job_id"]),
|
||||
str(item["digest"]),
|
||||
)
|
||||
for item in payload.get("idempotency", [])
|
||||
}
|
||||
self.worker_heartbeats = {
|
||||
str(item["worker_id"]): (
|
||||
str(item["heartbeat_at"]),
|
||||
str(item["job_id"]) if item.get("job_id") is not None else None,
|
||||
)
|
||||
for item in payload.get("worker_heartbeats", [])
|
||||
}
|
||||
|
||||
def persist_locked(self) -> None:
|
||||
payload = {
|
||||
"version": 1,
|
||||
"jobs": [self._record(item) for item in self.jobs.values()],
|
||||
"results": [self._record(item) for item in self.results.values()],
|
||||
"idempotency": [
|
||||
{
|
||||
"tenant_id": tenant_id,
|
||||
"idempotency_key": idempotency_key,
|
||||
"job_id": job_id,
|
||||
"digest": digest,
|
||||
}
|
||||
for (tenant_id, idempotency_key), (job_id, digest)
|
||||
in self.idempotency.items()
|
||||
],
|
||||
"worker_heartbeats": [
|
||||
{
|
||||
"worker_id": worker_id,
|
||||
"heartbeat_at": heartbeat_at,
|
||||
"job_id": job_id,
|
||||
}
|
||||
for worker_id, (heartbeat_at, job_id)
|
||||
in self.worker_heartbeats.items()
|
||||
],
|
||||
}
|
||||
encoded = json.dumps(
|
||||
payload,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO hermes_state(singleton, payload) VALUES(1, ?)
|
||||
ON CONFLICT(singleton) DO UPDATE SET payload=excluded.payload
|
||||
""",
|
||||
(encoded,),
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
|
||||
class HermesAdapter:
|
||||
"""Queue and reconcile Hermes jobs without owning AIOS policy."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: InMemoryHermesStore | None = None,
|
||||
*,
|
||||
runtime_id: str = "hermes-runtime",
|
||||
runtime_version: str = "v1",
|
||||
default_lease_seconds: int = 60,
|
||||
) -> None:
|
||||
if default_lease_seconds < 5:
|
||||
raise ValueError("default_lease_seconds must be >= 5")
|
||||
self.store = store or InMemoryHermesStore()
|
||||
self.runtime_id = runtime_id
|
||||
self.runtime_version = runtime_version
|
||||
self.default_lease_seconds = int(default_lease_seconds)
|
||||
|
||||
@staticmethod
|
||||
def _digest(job: HermesJob) -> str:
|
||||
raw = json.dumps(
|
||||
{
|
||||
"tenant_id": job.tenant_id,
|
||||
"cell_id": job.cell_id,
|
||||
"profile_id": job.profile_id,
|
||||
"task_id": job.task_id,
|
||||
"trace_id": job.trace_id,
|
||||
"action": job.action,
|
||||
"payload": dict(job.payload),
|
||||
"priority": job.priority,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(raw).hexdigest()
|
||||
|
||||
@staticmethod
|
||||
def _parse(value: str | None) -> datetime | None:
|
||||
if value is None:
|
||||
return None
|
||||
normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
|
||||
parsed = datetime.fromisoformat(normalized)
|
||||
if parsed.tzinfo is None:
|
||||
raise ValueError("timestamp must include timezone")
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
@staticmethod
|
||||
def _format(value: datetime) -> str:
|
||||
return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
@staticmethod
|
||||
def _assert_tenant(job: HermesJob, tenant_id: str) -> None:
|
||||
if job.tenant_id != tenant_id:
|
||||
raise HermesNotFound("job not found")
|
||||
|
||||
def enqueue(self, job: HermesJob) -> HermesJob:
|
||||
if job.state != HermesJobState.QUEUED or job.revision != 1:
|
||||
raise HermesConflict("new job must be QUEUED at revision 1")
|
||||
digest = self._digest(job)
|
||||
idem_ref = (job.tenant_id, job.idempotency_key)
|
||||
with self.store.lock:
|
||||
existing = self.store.jobs.get(job.job_id)
|
||||
if existing is not None:
|
||||
if self._digest(existing) != digest:
|
||||
raise HermesConflict("job_id reused with different content")
|
||||
return existing
|
||||
prior = self.store.idempotency.get(idem_ref)
|
||||
if prior is not None:
|
||||
prior_id, prior_digest = prior
|
||||
if prior_digest != digest:
|
||||
raise HermesConflict("idempotency key reused with different content")
|
||||
return self.store.jobs[prior_id]
|
||||
self.store.jobs[job.job_id] = job
|
||||
self.store.idempotency[idem_ref] = (job.job_id, digest)
|
||||
self.store.persist_locked()
|
||||
return job
|
||||
|
||||
def get(self, job_id: str, *, tenant_id: str) -> HermesJob:
|
||||
with self.store.lock:
|
||||
job = self.store.jobs.get(job_id)
|
||||
if job is None:
|
||||
raise HermesNotFound("job not found")
|
||||
self._assert_tenant(job, tenant_id)
|
||||
return job
|
||||
|
||||
def list_jobs(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
cell_id: str | None = None,
|
||||
profile_id: str | None = None,
|
||||
states: Iterable[HermesJobState | str] | None = None,
|
||||
) -> list[HermesJob]:
|
||||
state_filter = {HermesJobState(item) for item in states} if states else None
|
||||
with self.store.lock:
|
||||
if self._requeue_expired_locked():
|
||||
self.store.persist_locked()
|
||||
jobs = [
|
||||
job
|
||||
for job in self.store.jobs.values()
|
||||
if job.tenant_id == tenant_id
|
||||
and (cell_id is None or job.cell_id == cell_id)
|
||||
and (profile_id is None or job.profile_id == profile_id)
|
||||
and (state_filter is None or job.state in state_filter)
|
||||
]
|
||||
return sorted(jobs, key=lambda item: (item.priority, item.created_at, item.job_id))
|
||||
|
||||
def claim(
|
||||
self,
|
||||
*,
|
||||
tenant_id: str,
|
||||
cell_id: str,
|
||||
profile_id: str,
|
||||
worker_id: str,
|
||||
lease_seconds: int | None = None,
|
||||
job_id: str | None = None,
|
||||
) -> HermesJob | None:
|
||||
worker_id = str(worker_id or "").strip()
|
||||
if not worker_id:
|
||||
raise ValueError("worker_id is required")
|
||||
duration = int(lease_seconds or self.default_lease_seconds)
|
||||
if duration < 5:
|
||||
raise ValueError("lease_seconds must be >= 5")
|
||||
with self.store.lock:
|
||||
self._requeue_expired_locked()
|
||||
candidates = [
|
||||
job
|
||||
for job in self.store.jobs.values()
|
||||
if job.tenant_id == tenant_id
|
||||
and job.cell_id == cell_id
|
||||
and job.profile_id == profile_id
|
||||
and (job_id is None or job.job_id == job_id)
|
||||
and job.state == HermesJobState.QUEUED
|
||||
]
|
||||
self.store.worker_heartbeats[worker_id] = (utc_now(), None)
|
||||
if not candidates:
|
||||
self.store.persist_locked()
|
||||
return None
|
||||
current = sorted(
|
||||
candidates,
|
||||
key=lambda item: (item.priority, item.created_at, item.job_id),
|
||||
)[0]
|
||||
claimed = self._transition_locked(
|
||||
current,
|
||||
HermesJobState.CLAIMED,
|
||||
assigned_worker_id=worker_id,
|
||||
lease_expires_at=self._format(
|
||||
datetime.now(timezone.utc) + timedelta(seconds=duration)
|
||||
),
|
||||
)
|
||||
self.store.worker_heartbeats[worker_id] = (utc_now(), claimed.job_id)
|
||||
self.store.persist_locked()
|
||||
return claimed
|
||||
|
||||
def start(
|
||||
self,
|
||||
job_id: str,
|
||||
*,
|
||||
tenant_id: str,
|
||||
worker_id: str,
|
||||
expected_revision: int | None = None,
|
||||
) -> HermesJob:
|
||||
with self.store.lock:
|
||||
self._requeue_expired_locked()
|
||||
current = self._owned_job_locked(job_id, tenant_id, worker_id)
|
||||
self._assert_revision(current, expected_revision)
|
||||
updated = self._transition_locked(current, HermesJobState.RUNNING)
|
||||
self.store.persist_locked()
|
||||
return updated
|
||||
|
||||
def heartbeat(
|
||||
self,
|
||||
job_id: str,
|
||||
*,
|
||||
tenant_id: str,
|
||||
worker_id: str,
|
||||
lease_seconds: int | None = None,
|
||||
) -> HermesJob:
|
||||
duration = int(lease_seconds or self.default_lease_seconds)
|
||||
if duration < 5:
|
||||
raise ValueError("lease_seconds must be >= 5")
|
||||
with self.store.lock:
|
||||
self._requeue_expired_locked()
|
||||
current = self._owned_job_locked(job_id, tenant_id, worker_id)
|
||||
if current.state not in {HermesJobState.CLAIMED, HermesJobState.RUNNING}:
|
||||
raise HermesConflict("job does not accept heartbeat")
|
||||
updated = replace(
|
||||
current,
|
||||
revision=current.revision + 1,
|
||||
lease_expires_at=self._format(
|
||||
datetime.now(timezone.utc) + timedelta(seconds=duration)
|
||||
),
|
||||
updated_at=utc_now(),
|
||||
)
|
||||
self.store.jobs[job_id] = updated
|
||||
self.store.worker_heartbeats[worker_id] = (utc_now(), job_id)
|
||||
self.store.persist_locked()
|
||||
return updated
|
||||
|
||||
def complete(
|
||||
self,
|
||||
job_id: str,
|
||||
*,
|
||||
tenant_id: str,
|
||||
worker_id: str,
|
||||
output: Mapping[str, object],
|
||||
expected_revision: int | None = None,
|
||||
) -> HermesResult:
|
||||
return self._finish(
|
||||
job_id,
|
||||
tenant_id=tenant_id,
|
||||
worker_id=worker_id,
|
||||
state=HermesJobState.COMPLETED,
|
||||
output=output,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
|
||||
def fail(
|
||||
self,
|
||||
job_id: str,
|
||||
*,
|
||||
tenant_id: str,
|
||||
worker_id: str,
|
||||
error: Mapping[str, object],
|
||||
expected_revision: int | None = None,
|
||||
) -> HermesResult:
|
||||
return self._finish(
|
||||
job_id,
|
||||
tenant_id=tenant_id,
|
||||
worker_id=worker_id,
|
||||
state=HermesJobState.FAILED,
|
||||
error=error,
|
||||
expected_revision=expected_revision,
|
||||
)
|
||||
|
||||
def cancel(
|
||||
self,
|
||||
job_id: str,
|
||||
*,
|
||||
tenant_id: str,
|
||||
expected_revision: int | None = None,
|
||||
) -> HermesResult:
|
||||
with self.store.lock:
|
||||
current = self.store.jobs.get(job_id)
|
||||
if current is None:
|
||||
raise HermesNotFound("job not found")
|
||||
self._assert_tenant(current, tenant_id)
|
||||
self._assert_revision(current, expected_revision)
|
||||
if current.state in TERMINAL_STATES:
|
||||
result = self.store.results.get(job_id)
|
||||
if result is None:
|
||||
raise HermesConflict("terminal result missing")
|
||||
if result.state != HermesJobState.CANCELLED:
|
||||
raise HermesConflict("job already has a different terminal result")
|
||||
return result
|
||||
updated = self._transition_locked(current, HermesJobState.CANCELLED)
|
||||
result = HermesResult(
|
||||
job_id=updated.job_id,
|
||||
tenant_id=updated.tenant_id,
|
||||
task_id=updated.task_id,
|
||||
trace_id=updated.trace_id,
|
||||
state=HermesJobState.CANCELLED,
|
||||
worker_id=updated.assigned_worker_id,
|
||||
error={"code": "cancelled", "message": "cancelled by AIOS"},
|
||||
finished_at=utc_now(),
|
||||
)
|
||||
self.store.results[job_id] = result
|
||||
self.store.persist_locked()
|
||||
return result
|
||||
|
||||
def result(self, job_id: str, *, tenant_id: str) -> HermesResult:
|
||||
with self.store.lock:
|
||||
job = self.store.jobs.get(job_id)
|
||||
if job is None:
|
||||
raise HermesNotFound("job not found")
|
||||
self._assert_tenant(job, tenant_id)
|
||||
result = self.store.results.get(job_id)
|
||||
if result is None:
|
||||
raise HermesConflict("job has no terminal result")
|
||||
return result
|
||||
|
||||
def requeue_expired(self) -> tuple[str, ...]:
|
||||
"""Persistently release expired leases without changing policy state."""
|
||||
with self.store.lock:
|
||||
changed = self._requeue_expired_locked()
|
||||
if changed:
|
||||
self.store.persist_locked()
|
||||
return tuple(sorted(changed))
|
||||
|
||||
def health(self) -> HermesHealth:
|
||||
with self.store.lock:
|
||||
if self._requeue_expired_locked():
|
||||
self.store.persist_locked()
|
||||
active_jobs = sum(
|
||||
1 for job in self.store.jobs.values()
|
||||
if job.state in {HermesJobState.CLAIMED, HermesJobState.RUNNING}
|
||||
)
|
||||
queued_jobs = sum(
|
||||
1 for job in self.store.jobs.values()
|
||||
if job.state == HermesJobState.QUEUED
|
||||
)
|
||||
partitions = {
|
||||
(job.tenant_id, job.cell_id, job.profile_id)
|
||||
for job in self.store.jobs.values()
|
||||
}
|
||||
active_workers = sum(
|
||||
1 for heartbeat, _ in self.store.worker_heartbeats.values()
|
||||
if self._parse(heartbeat)
|
||||
and self._parse(heartbeat) >= datetime.now(timezone.utc) - timedelta(seconds=120)
|
||||
)
|
||||
return HermesHealth(
|
||||
runtime_id=self.runtime_id,
|
||||
status="healthy",
|
||||
version=self.runtime_version,
|
||||
heartbeat_at=utc_now(),
|
||||
active_workers=active_workers,
|
||||
active_jobs=active_jobs,
|
||||
queued_jobs=queued_jobs,
|
||||
tenant_partitions=len(partitions),
|
||||
profile_isolation_enforced=True,
|
||||
details={"store": type(self.store).__name__},
|
||||
)
|
||||
|
||||
def _finish(
|
||||
self,
|
||||
job_id: str,
|
||||
*,
|
||||
tenant_id: str,
|
||||
worker_id: str,
|
||||
state: HermesJobState,
|
||||
output: Mapping[str, object] | None = None,
|
||||
error: Mapping[str, object] | None = None,
|
||||
expected_revision: int | None,
|
||||
) -> HermesResult:
|
||||
with self.store.lock:
|
||||
self._requeue_expired_locked()
|
||||
current = self._owned_job_locked(job_id, tenant_id, worker_id)
|
||||
if current.state in TERMINAL_STATES:
|
||||
existing = self.store.results.get(job_id)
|
||||
if existing is None:
|
||||
raise HermesConflict("terminal result missing")
|
||||
if (
|
||||
existing.state == state
|
||||
and existing.worker_id == worker_id
|
||||
and existing.output == output
|
||||
and existing.error == error
|
||||
):
|
||||
return existing
|
||||
raise HermesConflict("job already has a different terminal result")
|
||||
self._assert_revision(current, expected_revision)
|
||||
updated = self._transition_locked(current, state)
|
||||
result = HermesResult(
|
||||
job_id=updated.job_id,
|
||||
tenant_id=updated.tenant_id,
|
||||
task_id=updated.task_id,
|
||||
trace_id=updated.trace_id,
|
||||
state=state,
|
||||
worker_id=worker_id,
|
||||
output=output,
|
||||
error=error,
|
||||
started_at=current.updated_at,
|
||||
finished_at=utc_now(),
|
||||
)
|
||||
self.store.results[job_id] = result
|
||||
self.store.worker_heartbeats[worker_id] = (utc_now(), None)
|
||||
self.store.persist_locked()
|
||||
return result
|
||||
|
||||
def _owned_job_locked(self, job_id: str, tenant_id: str, worker_id: str) -> HermesJob:
|
||||
current = self.store.jobs.get(job_id)
|
||||
if current is None:
|
||||
raise HermesNotFound("job not found")
|
||||
self._assert_tenant(current, tenant_id)
|
||||
if current.assigned_worker_id != worker_id:
|
||||
raise HermesConflict("worker does not own job lease")
|
||||
return current
|
||||
|
||||
@staticmethod
|
||||
def _assert_revision(job: HermesJob, expected: int | None) -> None:
|
||||
if expected is not None and job.revision != expected:
|
||||
raise HermesConflict("job revision changed")
|
||||
|
||||
def _transition_locked(
|
||||
self,
|
||||
current: HermesJob,
|
||||
target: HermesJobState,
|
||||
*,
|
||||
assigned_worker_id: str | None = None,
|
||||
lease_expires_at: str | None = None,
|
||||
) -> HermesJob:
|
||||
if target not in ALLOWED_TRANSITIONS[current.state]:
|
||||
raise HermesConflict(
|
||||
f"invalid transition {current.state.value} -> {target.value}"
|
||||
)
|
||||
updated = replace(
|
||||
current,
|
||||
state=target,
|
||||
revision=current.revision + 1,
|
||||
assigned_worker_id=(
|
||||
assigned_worker_id
|
||||
if assigned_worker_id is not None
|
||||
else current.assigned_worker_id
|
||||
),
|
||||
lease_expires_at=(
|
||||
lease_expires_at
|
||||
if lease_expires_at is not None
|
||||
else (None if target in TERMINAL_STATES else current.lease_expires_at)
|
||||
),
|
||||
updated_at=utc_now(),
|
||||
)
|
||||
self.store.jobs[current.job_id] = updated
|
||||
return updated
|
||||
|
||||
def _requeue_expired_locked(self) -> set[str]:
|
||||
now = datetime.now(timezone.utc)
|
||||
changed: set[str] = set()
|
||||
for current in list(self.store.jobs.values()):
|
||||
expiry = self._parse(current.lease_expires_at)
|
||||
if (
|
||||
current.state in {HermesJobState.CLAIMED, HermesJobState.RUNNING}
|
||||
and expiry is not None
|
||||
and expiry <= now
|
||||
):
|
||||
self.store.jobs[current.job_id] = replace(
|
||||
current,
|
||||
state=HermesJobState.QUEUED,
|
||||
revision=current.revision + 1,
|
||||
assigned_worker_id=None,
|
||||
lease_expires_at=None,
|
||||
updated_at=utc_now(),
|
||||
)
|
||||
changed.add(current.job_id)
|
||||
return changed
|
||||
@@ -1,153 +0,0 @@
|
||||
"""Tenant-scoped Hermes runtime contracts."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from typing import Any, Mapping
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _required(value: str, label: str) -> str:
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized:
|
||||
raise ValueError(f"{label} is required")
|
||||
return normalized
|
||||
|
||||
|
||||
def _safe_mapping(value: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
return json.loads(json.dumps(dict(value or {}), ensure_ascii=False, sort_keys=True, default=str))
|
||||
|
||||
|
||||
class HermesJobState(str, Enum):
|
||||
QUEUED = "QUEUED"
|
||||
CLAIMED = "CLAIMED"
|
||||
RUNNING = "RUNNING"
|
||||
COMPLETED = "COMPLETED"
|
||||
FAILED = "FAILED"
|
||||
CANCELLED = "CANCELLED"
|
||||
|
||||
|
||||
ALLOWED_TRANSITIONS: Mapping[HermesJobState, frozenset[HermesJobState]] = {
|
||||
HermesJobState.QUEUED: frozenset({HermesJobState.CLAIMED, HermesJobState.CANCELLED}),
|
||||
HermesJobState.CLAIMED: frozenset({HermesJobState.RUNNING, HermesJobState.CANCELLED}),
|
||||
HermesJobState.RUNNING: frozenset(
|
||||
{HermesJobState.COMPLETED, HermesJobState.FAILED, HermesJobState.CANCELLED}
|
||||
),
|
||||
HermesJobState.COMPLETED: frozenset(),
|
||||
HermesJobState.FAILED: frozenset(),
|
||||
HermesJobState.CANCELLED: frozenset(),
|
||||
}
|
||||
TERMINAL_STATES = frozenset(
|
||||
{HermesJobState.COMPLETED, HermesJobState.FAILED, HermesJobState.CANCELLED}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HermesJob:
|
||||
job_id: str
|
||||
tenant_id: str
|
||||
cell_id: str
|
||||
profile_id: str
|
||||
task_id: str
|
||||
trace_id: str
|
||||
action: str
|
||||
payload: Mapping[str, Any] = field(default_factory=dict)
|
||||
idempotency_key: str = ""
|
||||
priority: int = 100
|
||||
state: HermesJobState = HermesJobState.QUEUED
|
||||
revision: int = 1
|
||||
assigned_worker_id: str | None = None
|
||||
lease_expires_at: str | None = None
|
||||
created_at: str = field(default_factory=utc_now)
|
||||
updated_at: str = field(default_factory=utc_now)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in (
|
||||
"job_id", "tenant_id", "cell_id", "profile_id", "task_id",
|
||||
"trace_id", "action", "idempotency_key", "created_at", "updated_at",
|
||||
):
|
||||
object.__setattr__(self, name, _required(getattr(self, name), name))
|
||||
object.__setattr__(self, "payload", _safe_mapping(self.payload))
|
||||
object.__setattr__(self, "state", HermesJobState(self.state))
|
||||
if not 0 <= int(self.priority) <= 1000:
|
||||
raise ValueError("priority must be between 0 and 1000")
|
||||
if self.revision < 1:
|
||||
raise ValueError("revision must be >= 1")
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
cell_id: str,
|
||||
profile_id: str,
|
||||
task_id: str,
|
||||
trace_id: str,
|
||||
action: str,
|
||||
payload: Mapping[str, Any] | None,
|
||||
idempotency_key: str,
|
||||
priority: int = 100,
|
||||
) -> "HermesJob":
|
||||
return cls(
|
||||
job_id=f"hermes_{uuid.uuid4().hex}",
|
||||
tenant_id=tenant_id,
|
||||
cell_id=cell_id,
|
||||
profile_id=profile_id,
|
||||
task_id=task_id,
|
||||
trace_id=trace_id,
|
||||
action=action,
|
||||
payload=_safe_mapping(payload),
|
||||
idempotency_key=idempotency_key,
|
||||
priority=priority,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HermesResult:
|
||||
job_id: str
|
||||
tenant_id: str
|
||||
task_id: str
|
||||
trace_id: str
|
||||
state: HermesJobState
|
||||
worker_id: str | None = None
|
||||
output: Mapping[str, Any] | None = None
|
||||
error: Mapping[str, Any] | None = None
|
||||
started_at: str | None = None
|
||||
finished_at: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in ("job_id", "tenant_id", "task_id", "trace_id"):
|
||||
object.__setattr__(self, name, _required(getattr(self, name), name))
|
||||
object.__setattr__(self, "state", HermesJobState(self.state))
|
||||
if self.output is not None:
|
||||
object.__setattr__(self, "output", _safe_mapping(self.output))
|
||||
if self.error is not None:
|
||||
object.__setattr__(self, "error", _safe_mapping(self.error))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HermesHealth:
|
||||
runtime_id: str
|
||||
status: str
|
||||
version: str
|
||||
heartbeat_at: str
|
||||
active_workers: int = 0
|
||||
active_jobs: int = 0
|
||||
queued_jobs: int = 0
|
||||
tenant_partitions: int = 0
|
||||
profile_isolation_enforced: bool = True
|
||||
details: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in ("runtime_id", "status", "version", "heartbeat_at"):
|
||||
object.__setattr__(self, name, _required(getattr(self, name), name))
|
||||
for name in ("active_workers", "active_jobs", "queued_jobs", "tenant_partitions"):
|
||||
if int(getattr(self, name)) < 0:
|
||||
raise ValueError(f"{name} must be >= 0")
|
||||
object.__setattr__(self, "details", _safe_mapping(self.details))
|
||||
@@ -1,155 +0,0 @@
|
||||
"""Hermes worker loop with lease renewal and bounded recovery."""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
from .adapter import HermesAdapter, HermesConflict
|
||||
from .contract import HermesJob, HermesJobState
|
||||
|
||||
|
||||
JobHandler = Callable[[HermesJob], Mapping[str, Any]]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkerRunResult:
|
||||
claimed: bool
|
||||
job_id: str | None = None
|
||||
state: str | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class HermesWorker:
|
||||
"""Poll one tenant/cell/profile partition and execute jobs safely."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
adapter: HermesAdapter,
|
||||
handler: JobHandler,
|
||||
*,
|
||||
worker_id: str,
|
||||
tenant_id: str,
|
||||
cell_id: str,
|
||||
profile_id: str,
|
||||
poll_seconds: float = 1.0,
|
||||
heartbeat_seconds: float = 10.0,
|
||||
) -> None:
|
||||
if poll_seconds <= 0 or heartbeat_seconds <= 0:
|
||||
raise ValueError("poll_seconds and heartbeat_seconds must be positive")
|
||||
for name, value in {
|
||||
"worker_id": worker_id,
|
||||
"tenant_id": tenant_id,
|
||||
"cell_id": cell_id,
|
||||
"profile_id": profile_id,
|
||||
}.items():
|
||||
if not str(value or "").strip():
|
||||
raise ValueError(f"{name} is required")
|
||||
self.adapter = adapter
|
||||
self.handler = handler
|
||||
self.worker_id = worker_id
|
||||
self.tenant_id = tenant_id
|
||||
self.cell_id = cell_id
|
||||
self.profile_id = profile_id
|
||||
self.poll_seconds = float(poll_seconds)
|
||||
self.heartbeat_seconds = float(heartbeat_seconds)
|
||||
self._stop = threading.Event()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
|
||||
def run_once(self) -> WorkerRunResult:
|
||||
job = self.adapter.claim(
|
||||
tenant_id=self.tenant_id,
|
||||
cell_id=self.cell_id,
|
||||
profile_id=self.profile_id,
|
||||
worker_id=self.worker_id,
|
||||
)
|
||||
if job is None:
|
||||
return WorkerRunResult(claimed=False)
|
||||
|
||||
running = self.adapter.start(
|
||||
job.job_id,
|
||||
tenant_id=self.tenant_id,
|
||||
worker_id=self.worker_id,
|
||||
expected_revision=job.revision,
|
||||
)
|
||||
|
||||
heartbeat_stop = threading.Event()
|
||||
heartbeat_thread = threading.Thread(
|
||||
target=self._heartbeat_loop,
|
||||
args=(running.job_id, heartbeat_stop),
|
||||
name=f"hermes-heartbeat-{self.worker_id}",
|
||||
daemon=True,
|
||||
)
|
||||
heartbeat_thread.start()
|
||||
|
||||
output: Mapping[str, Any] | None = None
|
||||
execution_error: Exception | None = None
|
||||
try:
|
||||
output = dict(self.handler(running))
|
||||
except Exception as exc: # runtime boundary: normalize below
|
||||
execution_error = exc
|
||||
finally:
|
||||
# Stop the lease heartbeat before reading the final revision. This
|
||||
# prevents a revision increment racing with complete/fail.
|
||||
heartbeat_stop.set()
|
||||
heartbeat_thread.join(timeout=max(self.heartbeat_seconds * 2, 1.0))
|
||||
|
||||
try:
|
||||
current = self.adapter.get(running.job_id, tenant_id=self.tenant_id)
|
||||
if execution_error is None:
|
||||
result = self.adapter.complete(
|
||||
running.job_id,
|
||||
tenant_id=self.tenant_id,
|
||||
worker_id=self.worker_id,
|
||||
output=dict(output or {}),
|
||||
expected_revision=current.revision,
|
||||
)
|
||||
return WorkerRunResult(
|
||||
claimed=True,
|
||||
job_id=result.job_id,
|
||||
state=result.state.value,
|
||||
)
|
||||
|
||||
result = self.adapter.fail(
|
||||
running.job_id,
|
||||
tenant_id=self.tenant_id,
|
||||
worker_id=self.worker_id,
|
||||
error={
|
||||
"code": "worker_error",
|
||||
"message": str(execution_error),
|
||||
},
|
||||
expected_revision=current.revision,
|
||||
)
|
||||
return WorkerRunResult(
|
||||
claimed=True,
|
||||
job_id=result.job_id,
|
||||
state=result.state.value,
|
||||
error=str(execution_error),
|
||||
)
|
||||
except HermesConflict as exc:
|
||||
return WorkerRunResult(
|
||||
claimed=True,
|
||||
job_id=running.job_id,
|
||||
state=HermesJobState.FAILED.value,
|
||||
error=f"reconciliation_conflict: {exc}",
|
||||
)
|
||||
|
||||
def run_forever(self) -> None:
|
||||
while not self._stop.is_set():
|
||||
result = self.run_once()
|
||||
if not result.claimed:
|
||||
self._stop.wait(self.poll_seconds)
|
||||
|
||||
def _heartbeat_loop(self, job_id: str, stop_event: threading.Event) -> None:
|
||||
while not stop_event.wait(self.heartbeat_seconds):
|
||||
try:
|
||||
self.adapter.heartbeat(
|
||||
job_id,
|
||||
tenant_id=self.tenant_id,
|
||||
worker_id=self.worker_id,
|
||||
)
|
||||
except Exception:
|
||||
stop_event.set()
|
||||
return
|
||||
@@ -1,23 +0,0 @@
|
||||
"""Task-bound Integration Refresh control-plane primitives."""
|
||||
|
||||
from .integration_merge_writer import IntegrationMergeWriter, MergeError, MergeRequest
|
||||
from .integration_refresh import (
|
||||
AppendOnlyAudit,
|
||||
Binding,
|
||||
GiteaBranchReadProjection,
|
||||
GrantStore,
|
||||
IntegrationRefreshProvisioner,
|
||||
ProvisioningError,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AppendOnlyAudit",
|
||||
"Binding",
|
||||
"GiteaBranchReadProjection",
|
||||
"GrantStore",
|
||||
"IntegrationMergeWriter",
|
||||
"IntegrationRefreshProvisioner",
|
||||
"MergeError",
|
||||
"MergeRequest",
|
||||
"ProvisioningError",
|
||||
]
|
||||
@@ -1,90 +0,0 @@
|
||||
"""Constrained integration-only merge writer with rollback and reconciliation states."""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
|
||||
class MergeError(RuntimeError):
|
||||
def __init__(self, code: str, status: int = 409):
|
||||
super().__init__(code); self.code, self.status = code, status
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MergeRequest:
|
||||
integration_branch: str
|
||||
source_branch: str
|
||||
approved_source_oid: str
|
||||
|
||||
|
||||
class IntegrationMergeWriter:
|
||||
def __init__(self, workspace: Path | str, remote: str, projection):
|
||||
self.workspace, self.remote, self.projection = Path(workspace), remote, projection
|
||||
|
||||
def _git(self, *args: str, check=True) -> str:
|
||||
p = subprocess.run(["git", *args], cwd=self.workspace, text=True, capture_output=True)
|
||||
if check and p.returncode:
|
||||
raise MergeError("git_operation_failed", 500)
|
||||
return p.stdout.strip()
|
||||
|
||||
def merge(self, request: MergeRequest) -> dict:
|
||||
def bad(value: str) -> bool:
|
||||
return (
|
||||
value == "main"
|
||||
or value.startswith("main/")
|
||||
or value == "governance"
|
||||
or value.startswith("governance/")
|
||||
or value == "approvals"
|
||||
or value.startswith("refs/")
|
||||
or value.startswith("tags/")
|
||||
)
|
||||
if (bad(request.integration_branch) or bad(request.source_branch) or
|
||||
not request.integration_branch.startswith("integration/") or
|
||||
re.fullmatch(r"[0-9a-fA-F]{40}", request.approved_source_oid) is None):
|
||||
raise MergeError("integration_merge_scope_rejected", 403)
|
||||
for branch in (request.integration_branch, request.source_branch):
|
||||
if subprocess.run(["git", "check-ref-format", "--branch", branch], cwd=self.workspace,
|
||||
text=True, capture_output=True).returncode:
|
||||
raise MergeError("integration_merge_scope_rejected", 403)
|
||||
if self._git("rev-parse", "--abbrev-ref", "HEAD") != request.integration_branch:
|
||||
raise MergeError("integration_branch_checkout_required")
|
||||
before = self._git("rev-parse", "HEAD")
|
||||
remote_before = self.projection(request.integration_branch)
|
||||
if remote_before != before:
|
||||
raise MergeError("integration_remote_advanced")
|
||||
source = self._git("rev-parse", "--verify", f"refs/heads/{request.source_branch}^{{commit}}")
|
||||
if source != request.approved_source_oid:
|
||||
raise MergeError("integration_source_not_approved", 403)
|
||||
attempt = subprocess.run(["git", "merge", "--no-ff", "--no-commit", request.source_branch], cwd=self.workspace, text=True, capture_output=True)
|
||||
if attempt.returncode:
|
||||
subprocess.run(["git", "merge", "--abort"], cwd=self.workspace, text=True, capture_output=True)
|
||||
raise MergeError("integration_merge_conflict")
|
||||
try:
|
||||
self._git("commit", "-m", "merge(integration): approved source")
|
||||
merged = self._git("rev-parse", "HEAD")
|
||||
parents = self._git("show", "-s", "--format=%P", "HEAD").split()
|
||||
if parents != [before, source]:
|
||||
raise MergeError("integration_parent_invariant_failed", 500)
|
||||
pushed = subprocess.run(["git", "push", "--porcelain", self.remote, f"HEAD:refs/heads/{request.integration_branch}"], cwd=self.workspace, text=True, capture_output=True)
|
||||
if pushed.returncode:
|
||||
try:
|
||||
observed = self.projection(request.integration_branch)
|
||||
except Exception:
|
||||
return {"state": "RECONCILIATION_REQUIRED", "pre_merge_oid": before, "merge_oid": merged}
|
||||
if observed == before:
|
||||
self._git("reset", "--hard", before)
|
||||
return {"state": "PUSH_FAILED_RESTORED", "pre_merge_oid": before}
|
||||
return {"state": "RECONCILIATION_REQUIRED", "pre_merge_oid": before, "merge_oid": merged}
|
||||
try:
|
||||
observed = self.projection(request.integration_branch)
|
||||
except Exception:
|
||||
return {"state": "RECONCILIATION_REQUIRED", "pre_merge_oid": before, "merge_oid": merged}
|
||||
if observed != merged:
|
||||
return {"state": "RECONCILIATION_REQUIRED", "pre_merge_oid": before, "merge_oid": merged}
|
||||
return {"state": "COMPLETED", "pre_merge_oid": before, "merge_oid": merged}
|
||||
except MergeError:
|
||||
if self._git("rev-parse", "HEAD") != before:
|
||||
self._git("reset", "--hard", before)
|
||||
raise
|
||||
@@ -1,257 +0,0 @@
|
||||
"""Fail-closed, task-bound integration refresh provisioning primitives.
|
||||
|
||||
This module deliberately has no HTTP or credential-provider dependency. The
|
||||
caller supplies a branch-read projection and an apply callback, making the
|
||||
authorization and recovery boundary independently testable.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import argparse
|
||||
import secrets
|
||||
import sqlite3
|
||||
import threading
|
||||
import re
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
|
||||
class ProvisioningError(RuntimeError):
|
||||
def __init__(self, code: str, status: int = 403):
|
||||
super().__init__(code)
|
||||
self.code, self.status = code, status
|
||||
|
||||
|
||||
def _now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def _digest(secret: str) -> str:
|
||||
return hashlib.sha256(secret.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _canonical(value: dict) -> str:
|
||||
return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
||||
|
||||
|
||||
def _is_oid(value: object) -> bool:
|
||||
return isinstance(value, str) and re.fullmatch(r"[0-9a-fA-F]{40}", value) is not None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Binding:
|
||||
task_id: str
|
||||
repository: str
|
||||
branch: str
|
||||
actor_identity: str
|
||||
audience: str
|
||||
paths: tuple[str, ...]
|
||||
quota: int
|
||||
|
||||
|
||||
class AppendOnlyAudit:
|
||||
"""Append only redacted JSONL audit, durable before a result is returned."""
|
||||
def __init__(self, path: Path | str):
|
||||
self.path = Path(path)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def append(self, event: dict) -> None:
|
||||
with self._lock:
|
||||
safe = {key: value for key, value in event.items() if key not in {"token", "credential", "authorization"}}
|
||||
previous = "0" * 64
|
||||
if self.path.exists():
|
||||
lines = self.path.read_text(encoding="utf-8").splitlines()
|
||||
if lines:
|
||||
previous = json.loads(lines[-1])["event_hash"]
|
||||
safe["previous_event_hash"] = previous
|
||||
safe["event_hash"] = _canonical(safe)
|
||||
encoded = (json.dumps(safe, sort_keys=True, separators=(",", ":")) + "\n").encode()
|
||||
fd = os.open(self.path, os.O_APPEND | os.O_CREAT | os.O_WRONLY, 0o600)
|
||||
try:
|
||||
os.write(fd, encoded)
|
||||
os.fsync(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
class GrantStore:
|
||||
"""Digest-only short grant registry; SQLite transactions serialize quota use."""
|
||||
def __init__(self, path: Path | str):
|
||||
self.path = Path(path)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self._db() as db:
|
||||
db.execute("""CREATE TABLE IF NOT EXISTS grants(
|
||||
token_digest TEXT PRIMARY KEY, binding_json TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL, quota INTEGER NOT NULL, uses INTEGER NOT NULL,
|
||||
revoked INTEGER NOT NULL DEFAULT 0)""")
|
||||
|
||||
@contextmanager
|
||||
def _db(self):
|
||||
db = sqlite3.connect(self.path, timeout=15, isolation_level=None)
|
||||
db.row_factory = sqlite3.Row
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def issue(self, binding: Binding, *, ttl_seconds: int = 300) -> str:
|
||||
if ttl_seconds < 1 or ttl_seconds > 1800 or binding.quota < 1 or not binding.paths:
|
||||
raise ValueError("invalid_short_grant")
|
||||
token = secrets.token_urlsafe(32)
|
||||
payload = json.dumps(binding.__dict__ | {"paths": list(binding.paths)}, sort_keys=True)
|
||||
with self._db() as db:
|
||||
db.execute("INSERT INTO grants VALUES(?,?,?,?,?,0)", (_digest(token), payload,
|
||||
(_now() + timedelta(seconds=ttl_seconds)).isoformat(), binding.quota, 0))
|
||||
return token
|
||||
|
||||
def rotate(self, old_token: str, binding: Binding, *, revocation_probe, ttl_seconds: int = 300) -> str:
|
||||
self.revoke(old_token)
|
||||
try: status = revocation_probe(old_token)
|
||||
except TimeoutError as exc: raise ProvisioningError("revocation_probe_timeout", 503) from exc
|
||||
if status != 401: raise ProvisioningError("revocation_probe_rejected", 503)
|
||||
return self.issue(binding, ttl_seconds=ttl_seconds)
|
||||
|
||||
def revoke(self, token: str) -> None:
|
||||
with self._db() as db:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
db.execute("UPDATE grants SET revoked=1 WHERE token_digest=?", (_digest(token),))
|
||||
db.execute("COMMIT")
|
||||
|
||||
def consume(self, token: str, binding: Binding, path: str) -> None:
|
||||
with self._db() as db:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
row = db.execute("SELECT * FROM grants WHERE token_digest=?", (_digest(token),)).fetchone()
|
||||
if not row or row["revoked"]:
|
||||
db.execute("ROLLBACK"); raise ProvisioningError("credential_rejected", 401)
|
||||
if _now() >= datetime.fromisoformat(row["expires_at"]):
|
||||
db.execute("ROLLBACK"); raise ProvisioningError("credential_expired", 401)
|
||||
stored = json.loads(row["binding_json"])
|
||||
requested = binding.__dict__ | {"paths": list(binding.paths)}
|
||||
if stored != requested or path not in stored["paths"]:
|
||||
db.execute("ROLLBACK"); raise ProvisioningError("grant_binding_rejected")
|
||||
if row["uses"] >= row["quota"]:
|
||||
db.execute("ROLLBACK"); raise ProvisioningError("grant_quota_exhausted")
|
||||
db.execute("UPDATE grants SET uses=uses+1 WHERE token_digest=?", (_digest(token),))
|
||||
db.execute("COMMIT")
|
||||
|
||||
|
||||
class GiteaBranchReadProjection:
|
||||
"""Normalizes a trusted task-scoped Gitea reader; every ambiguity fails closed."""
|
||||
def __init__(self, reader: Callable[[str, str], dict]):
|
||||
self.reader = reader
|
||||
|
||||
def head(self, repository: str, branch: str) -> str:
|
||||
try:
|
||||
reply = self.reader(repository, branch)
|
||||
except TimeoutError as exc:
|
||||
raise ProvisioningError("gitea_projection_timeout", 503) from exc
|
||||
except Exception as exc:
|
||||
raise ProvisioningError("gitea_projection_unavailable", 503) from exc
|
||||
if not isinstance(reply, dict) or reply.get("status") in {401, 403}:
|
||||
raise ProvisioningError("gitea_projection_forbidden", 503)
|
||||
oid = reply.get("oid")
|
||||
if reply.get("status") != 200 or not _is_oid(oid):
|
||||
raise ProvisioningError("gitea_projection_invalid", 503)
|
||||
return oid.lower()
|
||||
|
||||
|
||||
class IntegrationRefreshProvisioner:
|
||||
_locks: dict[tuple[str, str], threading.Lock] = {}
|
||||
_locks_guard = threading.Lock()
|
||||
|
||||
def __init__(self, *, grants: GrantStore, projection: GiteaBranchReadProjection,
|
||||
ledger_path: Path | str, audit: AppendOnlyAudit, approved_binding: Binding):
|
||||
self.grants, self.projection, self.audit, self.approved_binding = grants, projection, audit, approved_binding
|
||||
self.ledger_path = Path(ledger_path); self.ledger_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self._ledger() as db:
|
||||
db.execute("CREATE TABLE IF NOT EXISTS refreshes(operation_id TEXT PRIMARY KEY, digest TEXT NOT NULL, state TEXT NOT NULL, result_oid TEXT)")
|
||||
|
||||
@contextmanager
|
||||
def _ledger(self):
|
||||
db = sqlite3.connect(self.ledger_path)
|
||||
try:
|
||||
yield db
|
||||
db.commit()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def refresh(self, *, token: str, binding: Binding, path: str, operation_id: str,
|
||||
target_head_oid: str, apply: Callable[[], str]) -> dict:
|
||||
if not operation_id or not _is_oid(target_head_oid):
|
||||
raise ProvisioningError("refresh_request_rejected")
|
||||
if binding != self.approved_binding:
|
||||
raise ProvisioningError("grant_binding_rejected")
|
||||
request = {"binding": binding.__dict__ | {"paths": list(binding.paths)}, "path": path, "target": target_head_oid}
|
||||
digest = _canonical(request)
|
||||
with self._locks_guard:
|
||||
lock = self._locks.setdefault((binding.repository, binding.branch), threading.Lock())
|
||||
with lock, self._ledger() as db:
|
||||
db.execute("BEGIN IMMEDIATE")
|
||||
row = db.execute("SELECT * FROM refreshes WHERE operation_id=?", (operation_id,)).fetchone()
|
||||
new_operation = row is None
|
||||
if row:
|
||||
if row[1] != digest:
|
||||
db.execute("ROLLBACK"); raise ProvisioningError("operation_id_reuse_rejected", 409)
|
||||
if row[2] == "COMPLETED":
|
||||
db.execute("COMMIT")
|
||||
return {"state": row[2], "operation_id": operation_id, "oid": row[3], "idempotent": True}
|
||||
if row[2] not in {"PENDING", "RECOVERABLE"}:
|
||||
db.execute("COMMIT")
|
||||
return {"state": row[2], "operation_id": operation_id, "oid": row[3], "idempotent": True}
|
||||
else:
|
||||
db.execute("INSERT INTO refreshes VALUES(?,?,?,NULL)", (operation_id, digest, "PENDING"))
|
||||
db.execute("COMMIT")
|
||||
if new_operation:
|
||||
self.grants.consume(token, binding, path)
|
||||
current = self.projection.head(binding.repository, binding.branch)
|
||||
if current != target_head_oid.lower():
|
||||
self._state(operation_id, "STALE_TARGET")
|
||||
raise ProvisioningError("stale_target_head", 409)
|
||||
try:
|
||||
oid = apply()
|
||||
if not _is_oid(oid):
|
||||
raise ProvisioningError("apply_result_invalid", 500)
|
||||
# Detect a competing remote advancement before claiming completion.
|
||||
observed = self.projection.head(binding.repository, binding.branch)
|
||||
if observed != oid.lower():
|
||||
self._state(operation_id, "RECONCILIATION_REQUIRED")
|
||||
raise ProvisioningError("remote_concurrent_advance", 409)
|
||||
self._state(operation_id, "COMPLETED", oid)
|
||||
self.audit.append({"operation_id": operation_id, "task_id": binding.task_id, "repository": binding.repository,
|
||||
"branch": binding.branch, "actor_identity": binding.actor_identity, "audience": binding.audience, "path": path, "state": "COMPLETED"})
|
||||
return {"state": "COMPLETED", "operation_id": operation_id, "oid": oid, "idempotent": False}
|
||||
except ProvisioningError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
self._state(operation_id, "RECOVERABLE")
|
||||
self.audit.append({"operation_id": operation_id, "task_id": binding.task_id, "repository": binding.repository,
|
||||
"branch": binding.branch, "actor_identity": binding.actor_identity, "audience": binding.audience, "path": path, "state": "RECOVERABLE"})
|
||||
raise ProvisioningError("refresh_apply_failed", 503) from exc
|
||||
|
||||
def _state(self, operation_id: str, state: str, oid: str | None = None) -> None:
|
||||
with self._ledger() as db:
|
||||
db.execute("UPDATE refreshes SET state=?, result_oid=COALESCE(?,result_oid) WHERE operation_id=?", (state, oid, operation_id))
|
||||
|
||||
|
||||
def _sandbox_preflight(state_dir: Path) -> int:
|
||||
if os.environ.get("AIOS_GITOPS_WRITES_ENABLED") != "false":
|
||||
raise ProvisioningError("global_gitops_writes_must_remain_disabled", 500)
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
audit = AppendOnlyAudit(state_dir / "preflight-audit.jsonl")
|
||||
audit.append({"event": "SANDBOX_PREFLIGHT", "writes_enabled": False})
|
||||
print(json.dumps({"status": "READY", "mode": "sandbox", "writes_enabled": False}, sort_keys=True))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--sandbox-preflight", action="store_true", required=True)
|
||||
parser.add_argument("--state-dir", type=Path, required=True)
|
||||
options = parser.parse_args()
|
||||
raise SystemExit(_sandbox_preflight(options.state_dir))
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,528 @@
|
||||
"""Canonical governed execution projection for Online Agent Tasks.
|
||||
|
||||
The durable governed binding is authoritative. Natural-language task fields and
|
||||
worker configuration are never allowed to invent repository, ref, generation, or
|
||||
idempotency coordinates.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
EXECUTION_SCHEMA = "aios.online-agent.execution.v2"
|
||||
PROJECTION_SCHEMA = "aios.online-agent.task-projection.v1"
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
TASK_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{2,179}$")
|
||||
BRANCH_RE = re.compile(r"^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$")
|
||||
KEY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{7,159}$")
|
||||
PROTECTED_PREFIXES = ("main", "master", "refs/", "tags/", "tag/", "release/", "baseline/", "production/")
|
||||
MUTATION_ACTIONS = frozenset({"deterministic"})
|
||||
READ_ONLY_ACTIONS = frozenset({"source_capture", "agent_runtime"})
|
||||
SUPPORTED_ACTIONS = frozenset({"deterministic", "source_capture", "agent_runtime"})
|
||||
AGENT_RUNTIME_ADAPTERS = frozenset({"hermes", "openclaw"})
|
||||
MAX_AGENT_RUNTIME_INPUT_BYTES = 64 * 1024
|
||||
|
||||
|
||||
class ExecutionProjectionError(RuntimeError):
|
||||
"""A canonical fail-closed projection or validation error."""
|
||||
|
||||
|
||||
class OnlineTaskProjectionClient:
|
||||
"""Official HTTP projection client using only a control credential reference."""
|
||||
|
||||
def __init__(self, api_base: str, control_token_path: Path) -> None:
|
||||
self.api_base = api_base.rstrip("/")
|
||||
self.control_token_path = Path(control_token_path)
|
||||
|
||||
def _token(self) -> str:
|
||||
path = self.control_token_path
|
||||
if path.is_symlink() or not path.is_file():
|
||||
_error("ONLINE_TASK_PROJECTION_CONTROL_UNAVAILABLE")
|
||||
value = path.read_text(encoding="utf-8").strip()
|
||||
if len(value) < 32:
|
||||
_error("ONLINE_TASK_PROJECTION_CONTROL_INVALID")
|
||||
return value
|
||||
|
||||
def create(
|
||||
self,
|
||||
projected_task: Mapping[str, Any],
|
||||
*,
|
||||
binding: "GovernedExecutionBinding",
|
||||
) -> dict[str, Any]:
|
||||
body = dict(projected_task)
|
||||
payload = _canonical(body).encode("utf-8")
|
||||
request = urllib.request.Request(
|
||||
self.api_base + "/v1/tasks",
|
||||
method="POST",
|
||||
data=payload,
|
||||
headers={
|
||||
"Authorization": "Bearer " + self._token(),
|
||||
"Content-Type": "application/json",
|
||||
"Accept": "application/json",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=15) as response:
|
||||
result = json.loads(response.read().decode("utf-8"))
|
||||
status = response.status
|
||||
except urllib.error.HTTPError as exc:
|
||||
raw = exc.read(8192).decode("utf-8", "replace")
|
||||
try:
|
||||
error = json.loads(raw).get("error")
|
||||
except (AttributeError, json.JSONDecodeError):
|
||||
error = "HTTP_ERROR"
|
||||
raise ExecutionProjectionError(f"ONLINE_TASK_PROJECTION_FAILED:{exc.code}:{error}") from exc
|
||||
except (OSError, urllib.error.URLError, TimeoutError) as exc:
|
||||
raise ExecutionProjectionError("ONLINE_TASK_PROJECTION_UNAVAILABLE") from exc
|
||||
if status not in {200, 201} or not isinstance(result, dict):
|
||||
_error("ONLINE_TASK_PROJECTION_RESPONSE_INVALID")
|
||||
if result.get("task_id") != binding.task_id or result.get("status") not in {"QUEUED", "VERIFYING", "SUCCEEDED"}:
|
||||
_error("ONLINE_TASK_PROJECTION_RESPONSE_INVALID")
|
||||
metadata = result.get("metadata")
|
||||
if not isinstance(metadata, Mapping):
|
||||
_error("ONLINE_TASK_PROJECTION_RESPONSE_INVALID")
|
||||
returned_execution = validate_execution_envelope(
|
||||
_object(metadata.get("execution"), "ONLINE_TASK_PROJECTION_RESPONSE_INVALID"),
|
||||
binding=binding,
|
||||
)
|
||||
submitted_metadata = _object(body.get("metadata"), "ONLINE_TASK_PROJECTION_REQUEST_INVALID")
|
||||
submitted_execution = validate_execution_envelope(
|
||||
_object(submitted_metadata.get("execution"), "ONLINE_TASK_PROJECTION_REQUEST_INVALID"),
|
||||
binding=binding,
|
||||
)
|
||||
if _canonical(returned_execution) != _canonical(submitted_execution):
|
||||
_error("ONLINE_TASK_PROJECTION_RESPONSE_BINDING_INVALID")
|
||||
return result
|
||||
|
||||
|
||||
def _error(code: str) -> None:
|
||||
raise ExecutionProjectionError(code)
|
||||
|
||||
|
||||
def _object(value: object, code: str) -> dict[str, Any]:
|
||||
if not isinstance(value, Mapping):
|
||||
_error(code)
|
||||
return dict(value)
|
||||
|
||||
|
||||
def _string(value: object, code: str) -> str:
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
_error(code)
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _task_id(value: object) -> str:
|
||||
value = _string(value, "TASK_ID_INVALID")
|
||||
if TASK_RE.fullmatch(value) is None:
|
||||
_error("TASK_ID_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _oid(value: object, code: str) -> str:
|
||||
value = _string(value, code)
|
||||
if OID_RE.fullmatch(value) is None:
|
||||
_error(code)
|
||||
return value
|
||||
|
||||
|
||||
def _branch(value: object) -> str:
|
||||
value = _string(value, "TARGET_BRANCH_INVALID")
|
||||
if BRANCH_RE.fullmatch(value) is None or value.startswith(PROTECTED_PREFIXES):
|
||||
_error("TARGET_BRANCH_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _generation(value: object) -> int:
|
||||
if not isinstance(value, int) or isinstance(value, bool) or value < 1:
|
||||
_error("GENERATION_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _canonical(value: object) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def _digest(value: object) -> str:
|
||||
return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _source_signals(value: object) -> tuple[str, ...]:
|
||||
"""Normalize untrusted natural-language hints before they enter a signed envelope."""
|
||||
if value is None:
|
||||
return ()
|
||||
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
|
||||
_error("SOURCE_CAPTURE_SIGNALS_INVALID")
|
||||
result: list[str] = []
|
||||
for item in value:
|
||||
if not isinstance(item, str):
|
||||
_error("SOURCE_CAPTURE_SIGNALS_INVALID")
|
||||
text = " ".join(item.split()).strip()
|
||||
if not text:
|
||||
continue
|
||||
# Projection may accept long task prose, but execution envelopes stay bounded.
|
||||
text = text[:512].rstrip()
|
||||
if text not in result:
|
||||
result.append(text)
|
||||
if len(result) == 16:
|
||||
break
|
||||
return tuple(result)
|
||||
|
||||
|
||||
def _agent_runtime_input(value: object) -> dict[str, Any]:
|
||||
if not isinstance(value, Mapping):
|
||||
_error("AGENT_RUNTIME_INPUT_REQUIRED")
|
||||
raw = dict(value)
|
||||
allowed = {
|
||||
"goal", "instructions", "success_criteria", "constraints",
|
||||
"scope", "trace_id",
|
||||
}
|
||||
if set(raw) - allowed:
|
||||
_error("AGENT_RUNTIME_INPUT_FIELDS_INVALID")
|
||||
goal = _string(raw.get("goal"), "AGENT_RUNTIME_GOAL_REQUIRED")
|
||||
instructions = raw.get("instructions", "")
|
||||
if not isinstance(instructions, str):
|
||||
_error("AGENT_RUNTIME_INSTRUCTIONS_INVALID")
|
||||
criteria = raw.get("success_criteria", [])
|
||||
if not isinstance(criteria, list) or len(criteria) > 32:
|
||||
_error("AGENT_RUNTIME_SUCCESS_CRITERIA_INVALID")
|
||||
normalized_criteria: list[dict[str, str]] = []
|
||||
for item in criteria:
|
||||
if not isinstance(item, Mapping):
|
||||
_error("AGENT_RUNTIME_SUCCESS_CRITERIA_INVALID")
|
||||
criterion_id = _string(item.get("id"), "AGENT_RUNTIME_SUCCESS_CRITERIA_INVALID")
|
||||
description = _string(
|
||||
item.get("description"), "AGENT_RUNTIME_SUCCESS_CRITERIA_INVALID"
|
||||
)
|
||||
normalized_criteria.append(
|
||||
{"id": criterion_id[:120], "description": description[:1000]}
|
||||
)
|
||||
constraints = raw.get("constraints", {})
|
||||
if not isinstance(constraints, Mapping):
|
||||
_error("AGENT_RUNTIME_CONSTRAINTS_INVALID")
|
||||
sensitive_markers = ("token", "secret", "password", "credential", "authorization", "api_key", "apikey")
|
||||
for key in constraints:
|
||||
lowered = str(key).lower()
|
||||
if any(marker in lowered for marker in sensitive_markers):
|
||||
_error("AGENT_RUNTIME_SECRET_FIELD_DENY")
|
||||
|
||||
scope = raw.get("scope", {})
|
||||
if not isinstance(scope, Mapping):
|
||||
_error("AGENT_RUNTIME_SCOPE_INVALID")
|
||||
scope = {
|
||||
"scope_type": str(scope.get("scope_type") or "legacy"),
|
||||
"tenant_id": scope.get("tenant_id"),
|
||||
"project_id": scope.get("project_id"),
|
||||
}
|
||||
if scope["scope_type"] not in {"legacy", "system", "tenant"}:
|
||||
_error("AGENT_RUNTIME_SCOPE_INVALID")
|
||||
if scope["scope_type"] == "tenant":
|
||||
if not isinstance(scope["tenant_id"], str) or not scope["tenant_id"].strip():
|
||||
_error("AGENT_RUNTIME_SCOPE_INVALID")
|
||||
elif scope["tenant_id"] not in (None, "") or scope["project_id"] not in (None, ""):
|
||||
_error("AGENT_RUNTIME_SCOPE_INVALID")
|
||||
|
||||
trace_id = str(raw.get("trace_id") or "").strip()
|
||||
if not trace_id or len(trace_id) > 255:
|
||||
_error("AGENT_RUNTIME_TRACE_ID_INVALID")
|
||||
|
||||
normalized = {
|
||||
"goal": goal[:12000],
|
||||
"instructions": instructions[:32000],
|
||||
"success_criteria": normalized_criteria,
|
||||
"constraints": dict(constraints),
|
||||
"scope": scope,
|
||||
"trace_id": trace_id,
|
||||
}
|
||||
if len(_canonical(normalized).encode("utf-8")) > MAX_AGENT_RUNTIME_INPUT_BYTES:
|
||||
_error("AGENT_RUNTIME_INPUT_TOO_LARGE")
|
||||
return normalized
|
||||
|
||||
|
||||
def _capabilities(value: object) -> tuple[str, ...]:
|
||||
if value is None:
|
||||
return ()
|
||||
if not isinstance(value, Sequence) or isinstance(value, (str, bytes)):
|
||||
_error("CAPABILITY_SCOPE_INVALID")
|
||||
result = tuple(sorted({_string(item, "CAPABILITY_SCOPE_INVALID") for item in value}))
|
||||
if any(len(item) > 120 for item in result):
|
||||
_error("CAPABILITY_SCOPE_INVALID")
|
||||
return result
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GovernedExecutionBinding:
|
||||
task_id: str
|
||||
job_id: str
|
||||
repository: str
|
||||
base_oid: str
|
||||
expected_branch_oid: str
|
||||
target_branch: str
|
||||
branch_mode: str
|
||||
generation: int
|
||||
idempotency_key: str
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> "GovernedExecutionBinding":
|
||||
data = dict(value)
|
||||
task_id = _task_id(data.get("task_id"))
|
||||
job_id = _string(data.get("job_id"), "JOB_ID_REQUIRED")
|
||||
if TASK_RE.fullmatch(job_id) is None:
|
||||
_error("JOB_ID_INVALID")
|
||||
repository = _string(data.get("repository"), "REPOSITORY_INVALID")
|
||||
if repository != "aios/aios":
|
||||
_error("WRONG_REPOSITORY_ENVELOPE_DENY")
|
||||
base_oid = _oid(data.get("base_oid"), "BASE_OID_INVALID")
|
||||
expected = _oid(data.get("expected_branch_oid"), "EXPECTED_BRANCH_OID_INVALID")
|
||||
target = _branch(data.get("target_branch"))
|
||||
mode = _string(data.get("branch_mode"), "BRANCH_MODE_MISSING")
|
||||
if mode not in {"CREATE_NEW", "CONTINUE_EXISTING"}:
|
||||
_error("BRANCH_MODE_INVALID")
|
||||
generation = _generation(data.get("generation"))
|
||||
if mode == "CREATE_NEW" and expected != base_oid:
|
||||
_error("EXPECTED_BASE_CONFLICT")
|
||||
key = _string(data.get("idempotency_key"), "IDEMPOTENCY_KEY_INVALID")
|
||||
if KEY_RE.fullmatch(key) is None:
|
||||
_error("IDEMPOTENCY_KEY_INVALID")
|
||||
return cls(task_id, job_id, repository, base_oid, expected, target, mode, generation, key)
|
||||
|
||||
def public(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
def binding_from_bootstrap(value: Mapping[str, Any]) -> GovernedExecutionBinding:
|
||||
return GovernedExecutionBinding.from_mapping(value)
|
||||
|
||||
|
||||
def validate_execution_envelope(
|
||||
value: Mapping[str, Any],
|
||||
*,
|
||||
binding: GovernedExecutionBinding | None = None,
|
||||
worker_capabilities: Sequence[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
envelope = dict(value)
|
||||
if envelope.get("schema") != EXECUTION_SCHEMA:
|
||||
_error("EXECUTION_ENVELOPE_SCHEMA_VALIDATION")
|
||||
candidate = GovernedExecutionBinding.from_mapping(envelope)
|
||||
if binding is not None and candidate != binding:
|
||||
comparisons = (
|
||||
(candidate.task_id, binding.task_id, "TASK_BINDING_CONFLICT"),
|
||||
(candidate.job_id, binding.job_id, "JOB_BINDING_CONFLICT"),
|
||||
(candidate.repository, binding.repository, "WRONG_REPOSITORY_ENVELOPE_DENY"),
|
||||
(candidate.base_oid, binding.base_oid, "WRONG_BASE_ENVELOPE_DENY"),
|
||||
(candidate.expected_branch_oid, binding.expected_branch_oid, "WRONG_EXPECTED_HEAD_ENVELOPE_DENY"),
|
||||
(candidate.target_branch, binding.target_branch, "WRONG_BRANCH_ENVELOPE_DENY"),
|
||||
(candidate.generation, binding.generation, "WRONG_GENERATION_ENVELOPE_DENY"),
|
||||
(candidate.branch_mode, binding.branch_mode, "BRANCH_MODE_BINDING_CONFLICT"),
|
||||
(candidate.idempotency_key, binding.idempotency_key, "IDEMPOTENCY_BINDING_CONFLICT"),
|
||||
)
|
||||
for actual, expected, code in comparisons:
|
||||
if actual != expected:
|
||||
_error(code)
|
||||
action = _string(envelope.get("action"), "EXECUTION_ACTION_MISSING")
|
||||
if action not in SUPPORTED_ACTIONS:
|
||||
_error("EXECUTION_ACTION_UNSUPPORTED")
|
||||
capability_scope = _capabilities(envelope.get("capability_scope"))
|
||||
if worker_capabilities is not None and not set(capability_scope).issubset(set(worker_capabilities)):
|
||||
_error("CAPABILITY_MISMATCH_DENY")
|
||||
workspace = _object(envelope.get("workspace"), "WORKSPACE_POLICY_REQUIRED")
|
||||
if workspace.get("authority_git_dir") != "/data/aios-gitops/repo.git" or workspace.get("read_only_repository") is not True:
|
||||
_error("WORKSPACE_POLICY_DENY")
|
||||
governance = _object(envelope.get("governance"), "GOVERNANCE_POLICY_REQUIRED")
|
||||
governed_coordinates = (
|
||||
(governance.get("task_id"), candidate.task_id, "TASK_BINDING_CONFLICT"),
|
||||
(governance.get("job_id"), candidate.job_id, "JOB_BINDING_CONFLICT"),
|
||||
(governance.get("idempotency_key"), candidate.idempotency_key, "IDEMPOTENCY_BINDING_CONFLICT"),
|
||||
)
|
||||
for actual_coordinate, expected_coordinate, code in governed_coordinates:
|
||||
if actual_coordinate != expected_coordinate:
|
||||
_error(code)
|
||||
commit_key = _string(governance.get("commit_idempotency_key"), "COMMIT_IDEMPOTENCY_KEY_INVALID")
|
||||
if KEY_RE.fullmatch(commit_key) is None:
|
||||
_error("COMMIT_IDEMPOTENCY_KEY_INVALID")
|
||||
if governance.get("writer_lease") is not (action in MUTATION_ACTIONS):
|
||||
_error("WRITER_LEASE_POLICY_DENY")
|
||||
for field in ("main_write", "production_write", "tag_write", "baseline_write", "force_push", "protected_ref_bypass"):
|
||||
if governance.get(field, False) is not False:
|
||||
_error("POLICY_DENY")
|
||||
changes = envelope.get("change_set")
|
||||
verification = envelope.get("verification")
|
||||
if not isinstance(changes, list) or not isinstance(verification, list):
|
||||
_error("EXECUTION_OPERATIONS_INVALID")
|
||||
if action == "source_capture":
|
||||
if changes or verification:
|
||||
_error("SOURCE_CAPTURE_MUTATION_DENY")
|
||||
signals = list(_source_signals(envelope.get("source_signals") or []))
|
||||
if envelope.get("source_signals") != signals:
|
||||
_error("SOURCE_CAPTURE_SIGNALS_INVALID")
|
||||
if envelope.get("runtime_input") not in (None, {}):
|
||||
_error("SOURCE_CAPTURE_RUNTIME_INPUT_DENY")
|
||||
elif action == "agent_runtime":
|
||||
if changes or verification:
|
||||
_error("AGENT_RUNTIME_MUTATION_DENY")
|
||||
adapter = str(envelope.get("adapter") or "")
|
||||
if adapter not in AGENT_RUNTIME_ADAPTERS:
|
||||
_error("AGENT_RUNTIME_ADAPTER_INVALID")
|
||||
runtime_input = _agent_runtime_input(envelope.get("runtime_input"))
|
||||
if envelope.get("runtime_input") != runtime_input:
|
||||
_error("AGENT_RUNTIME_INPUT_NOT_CANONICAL")
|
||||
required_runtime_capability = f"agent.runtime.{adapter}"
|
||||
if required_runtime_capability not in capability_scope:
|
||||
_error("AGENT_RUNTIME_CAPABILITY_REQUIRED")
|
||||
if envelope.get("source_signals") not in (None, []):
|
||||
_error("AGENT_RUNTIME_SOURCE_SIGNALS_DENY")
|
||||
elif not changes:
|
||||
_error("CHANGE_SET_INVALID")
|
||||
declared = str(envelope.get("execution_digest") or "")
|
||||
material = {key: item for key, item in envelope.items() if key != "execution_digest"}
|
||||
actual = _digest(material)
|
||||
if declared != actual:
|
||||
_error("EXECUTION_DIGEST_INVALID")
|
||||
return {**material, "execution_digest": actual}
|
||||
|
||||
|
||||
def project_execution_envelope(
|
||||
binding: GovernedExecutionBinding,
|
||||
execution_request: Mapping[str, Any] | None = None,
|
||||
*,
|
||||
required_capabilities: Sequence[str] = (),
|
||||
) -> dict[str, Any]:
|
||||
request = dict(execution_request or {})
|
||||
action = str(request.get("action") or "source_capture")
|
||||
if action not in SUPPORTED_ACTIONS:
|
||||
_error("EXECUTION_ACTION_UNSUPPORTED")
|
||||
capabilities = _capabilities(request.get("capability_scope", required_capabilities))
|
||||
governance = {
|
||||
"task_id": binding.task_id,
|
||||
"job_id": binding.job_id,
|
||||
"idempotency_key": binding.idempotency_key,
|
||||
"commit_idempotency_key": str(request.get("commit_idempotency_key") or f"{binding.task_id}-commit-g{binding.generation}"),
|
||||
"writer_lease": action in MUTATION_ACTIONS,
|
||||
"main_write": False,
|
||||
"production_write": False,
|
||||
"tag_write": False,
|
||||
"baseline_write": False,
|
||||
"force_push": False,
|
||||
"protected_ref_bypass": False,
|
||||
}
|
||||
supplied_governance = request.get("governance")
|
||||
if supplied_governance is not None:
|
||||
governance.update(_object(supplied_governance, "GOVERNANCE_POLICY_REQUIRED"))
|
||||
material: dict[str, Any] = {
|
||||
"schema": EXECUTION_SCHEMA,
|
||||
**binding.public(),
|
||||
"action": action,
|
||||
"adapter": str(
|
||||
request.get("adapter")
|
||||
or ("deterministic" if action == "deterministic" else "source_capture")
|
||||
),
|
||||
"capability_scope": list(capabilities),
|
||||
"change_set": list(request.get("change_set") or []),
|
||||
"verification": list(request.get("verification") or []),
|
||||
"source_signals": list(_source_signals(request.get("source_signals") or [])) if action == "source_capture" else [],
|
||||
"runtime_input": _agent_runtime_input(request.get("runtime_input")) if action == "agent_runtime" else {},
|
||||
"governance": governance,
|
||||
"workspace": {
|
||||
"authority_git_dir": "/data/aios-gitops/repo.git",
|
||||
"read_only_repository": True,
|
||||
"cleanup_policy": "always",
|
||||
},
|
||||
}
|
||||
envelope = {**material, "execution_digest": _digest(material)}
|
||||
return validate_execution_envelope(envelope, binding=binding)
|
||||
|
||||
|
||||
def project_online_task(
|
||||
binding: GovernedExecutionBinding,
|
||||
task_request: Mapping[str, Any],
|
||||
execution_request: Mapping[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
request = dict(task_request)
|
||||
required = _capabilities(request.get("required_capabilities"))
|
||||
execution_value = dict(execution_request or {})
|
||||
if str(execution_value.get("action") or "source_capture") == "agent_runtime":
|
||||
adapter = str(execution_value.get("adapter") or "")
|
||||
if adapter not in AGENT_RUNTIME_ADAPTERS:
|
||||
_error("AGENT_RUNTIME_ADAPTER_INVALID")
|
||||
runtime_input = execution_value.get("runtime_input")
|
||||
tenant_id = request.get("tenant_id")
|
||||
project_id = request.get("project_id")
|
||||
declared_scope = str(
|
||||
request.get("scope_type")
|
||||
or ("tenant" if isinstance(tenant_id, str) and tenant_id.strip() else "legacy")
|
||||
)
|
||||
if runtime_input is None:
|
||||
runtime_input = {
|
||||
"goal": request.get("goal"),
|
||||
"instructions": request.get("instructions") or "",
|
||||
"success_criteria": request.get("success_criteria") or [],
|
||||
"constraints": request.get("constraints") or {},
|
||||
}
|
||||
elif not isinstance(runtime_input, Mapping):
|
||||
_error("AGENT_RUNTIME_INPUT_REQUIRED")
|
||||
runtime_input = dict(runtime_input)
|
||||
# Scope/trace are authoritative Task identity, never caller-selected runtime hints.
|
||||
runtime_input["scope"] = {
|
||||
"scope_type": declared_scope,
|
||||
"tenant_id": tenant_id,
|
||||
"project_id": project_id,
|
||||
}
|
||||
runtime_input["trace_id"] = str(
|
||||
request.get("trace_id")
|
||||
or request.get("run_id")
|
||||
or binding.task_id
|
||||
)
|
||||
execution_value["runtime_input"] = runtime_input
|
||||
execution = project_execution_envelope(
|
||||
binding,
|
||||
execution_value,
|
||||
required_capabilities=required,
|
||||
)
|
||||
metadata = dict(request.get("metadata") or {})
|
||||
if "execution" in metadata:
|
||||
validate_execution_envelope(_object(metadata["execution"], "EXECUTION_ENVELOPE_INVALID"), binding=binding)
|
||||
if metadata["execution"] != execution:
|
||||
_error("EXECUTION_PROJECTION_CONFLICT")
|
||||
metadata["execution"] = execution
|
||||
metadata["execution_projection"] = {
|
||||
"schema": PROJECTION_SCHEMA,
|
||||
"task_id": binding.task_id,
|
||||
"job_id": binding.job_id,
|
||||
"execution_digest": execution["execution_digest"],
|
||||
}
|
||||
return {
|
||||
**request,
|
||||
"task_id": binding.task_id,
|
||||
"metadata": metadata,
|
||||
"required_capabilities": list(required),
|
||||
"idempotency_key": str(request.get("idempotency_key") or f"online-task:{binding.idempotency_key}"),
|
||||
}
|
||||
|
||||
|
||||
def resolve_task_execution(
|
||||
task: Mapping[str, Any],
|
||||
binding: GovernedExecutionBinding,
|
||||
*,
|
||||
worker_capabilities: Sequence[str] | None = None,
|
||||
) -> tuple[dict[str, Any], str]:
|
||||
metadata = task.get("metadata")
|
||||
if isinstance(metadata, Mapping) and "execution" in metadata:
|
||||
value = validate_execution_envelope(
|
||||
_object(metadata["execution"], "EXECUTION_ENVELOPE_INVALID"),
|
||||
binding=binding,
|
||||
worker_capabilities=worker_capabilities,
|
||||
)
|
||||
return value, "persisted"
|
||||
projected = project_execution_envelope(
|
||||
binding,
|
||||
{"action": "source_capture", "capability_scope": task.get("required_capabilities") or []},
|
||||
required_capabilities=task.get("required_capabilities") or [],
|
||||
)
|
||||
validate_execution_envelope(projected, binding=binding, worker_capabilities=worker_capabilities)
|
||||
return projected, "recovered_projection"
|
||||
@@ -1,45 +0,0 @@
|
||||
"""AIOS-managed OpenClaw Ed25519 execution adapter."""
|
||||
|
||||
from .adapter import (
|
||||
AdapterError,
|
||||
CommandConflict,
|
||||
CommandNotFound,
|
||||
CommandRejected,
|
||||
InMemoryCallbackSink,
|
||||
OpenClawAdapter,
|
||||
)
|
||||
from .contract import (
|
||||
CONTRACT_VERSION,
|
||||
SIGNATURE_ALGORITHM,
|
||||
CommandState,
|
||||
Ed25519CommandSigner,
|
||||
Ed25519CommandVerifier,
|
||||
OpenClawCommand,
|
||||
OpenClawResult,
|
||||
RuntimeHealth,
|
||||
sign_command,
|
||||
verify_command_signature,
|
||||
)
|
||||
from .transport import HTTPRuntimeTransport, InProcessRuntimeTransport, RuntimeTransport
|
||||
|
||||
__all__ = [
|
||||
"AdapterError",
|
||||
"CONTRACT_VERSION",
|
||||
"CommandConflict",
|
||||
"CommandNotFound",
|
||||
"CommandRejected",
|
||||
"CommandState",
|
||||
"Ed25519CommandSigner",
|
||||
"Ed25519CommandVerifier",
|
||||
"HTTPRuntimeTransport",
|
||||
"InMemoryCallbackSink",
|
||||
"InProcessRuntimeTransport",
|
||||
"OpenClawAdapter",
|
||||
"OpenClawCommand",
|
||||
"OpenClawResult",
|
||||
"RuntimeHealth",
|
||||
"RuntimeTransport",
|
||||
"SIGNATURE_ALGORITHM",
|
||||
"sign_command",
|
||||
"verify_command_signature",
|
||||
]
|
||||
@@ -1,228 +0,0 @@
|
||||
"""Signed, tenant-isolated execution adapter for OpenClaw runtimes."""
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
from dataclasses import replace
|
||||
from typing import Callable, Mapping, Protocol
|
||||
|
||||
from .contract import (
|
||||
CommandState,
|
||||
OpenClawCommand,
|
||||
OpenClawResult,
|
||||
RuntimeHealth,
|
||||
utc_now,
|
||||
verify_command_signature,
|
||||
)
|
||||
|
||||
|
||||
class AdapterError(RuntimeError):
|
||||
"""Base adapter failure."""
|
||||
|
||||
|
||||
class CommandRejected(AdapterError):
|
||||
"""A command failed signature or contract validation."""
|
||||
|
||||
|
||||
class CommandNotFound(AdapterError):
|
||||
"""A tenant-scoped command is not visible."""
|
||||
|
||||
|
||||
class CommandConflict(AdapterError):
|
||||
"""A command or idempotency key conflicts with an existing execution."""
|
||||
|
||||
|
||||
class RuntimeTransport(Protocol):
|
||||
def execute(self, command: OpenClawCommand, timeout_seconds: float) -> OpenClawResult: ...
|
||||
def cancel(self, command_id: str) -> bool: ...
|
||||
def health(self) -> RuntimeHealth: ...
|
||||
|
||||
|
||||
class CallbackSink(Protocol):
|
||||
def on_started(self, result: OpenClawResult) -> None: ...
|
||||
def on_completed(self, result: OpenClawResult) -> None: ...
|
||||
def on_failed(self, result: OpenClawResult) -> None: ...
|
||||
|
||||
|
||||
class InMemoryCallbackSink:
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.RLock()
|
||||
self.started: list[OpenClawResult] = []
|
||||
self.completed: list[OpenClawResult] = []
|
||||
self.failed: list[OpenClawResult] = []
|
||||
|
||||
def on_started(self, result: OpenClawResult) -> None:
|
||||
with self._lock:
|
||||
self.started.append(result)
|
||||
|
||||
def on_completed(self, result: OpenClawResult) -> None:
|
||||
with self._lock:
|
||||
self.completed.append(result)
|
||||
|
||||
def on_failed(self, result: OpenClawResult) -> None:
|
||||
with self._lock:
|
||||
self.failed.append(result)
|
||||
|
||||
|
||||
SigningMaterialResolver = Callable[[str, str], bytes | str]
|
||||
|
||||
|
||||
class OpenClawAdapter:
|
||||
"""Verify, de-duplicate, execute, cancel and reconcile commands."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
transport: RuntimeTransport,
|
||||
signing_material_resolver: SigningMaterialResolver,
|
||||
*,
|
||||
callback_sink: CallbackSink | None = None,
|
||||
default_timeout_seconds: float = 300.0,
|
||||
) -> None:
|
||||
if default_timeout_seconds <= 0:
|
||||
raise ValueError("default_timeout_seconds must be positive")
|
||||
self.transport = transport
|
||||
self.signing_material_resolver = signing_material_resolver
|
||||
self.callback_sink = callback_sink or InMemoryCallbackSink()
|
||||
self.default_timeout_seconds = float(default_timeout_seconds)
|
||||
self._lock = threading.RLock()
|
||||
self._commands: dict[str, OpenClawCommand] = {}
|
||||
self._results: dict[str, OpenClawResult] = {}
|
||||
self._idempotency: dict[tuple[str, str], tuple[str, str]] = {}
|
||||
|
||||
def _verify(self, command: OpenClawCommand) -> None:
|
||||
try:
|
||||
key_material = self.signing_material_resolver(command.tenant_id, command.key_id)
|
||||
except Exception as exc:
|
||||
raise CommandRejected("signing material unavailable") from exc
|
||||
if not verify_command_signature(command, key_material):
|
||||
raise CommandRejected("invalid or expired command signature")
|
||||
|
||||
@staticmethod
|
||||
def _validate_result(command: OpenClawCommand, result: OpenClawResult) -> None:
|
||||
if (
|
||||
result.command_id != command.command_id
|
||||
or result.tenant_id != command.tenant_id
|
||||
or result.task_id != command.task_id
|
||||
or result.trace_id != command.trace_id
|
||||
):
|
||||
raise AdapterError("runtime returned mismatched command identity")
|
||||
if result.state not in {
|
||||
CommandState.COMPLETED,
|
||||
CommandState.FAILED,
|
||||
CommandState.CANCELLED,
|
||||
CommandState.TIMED_OUT,
|
||||
}:
|
||||
raise AdapterError("runtime returned a non-terminal result")
|
||||
|
||||
def execute(
|
||||
self,
|
||||
command: OpenClawCommand,
|
||||
*,
|
||||
timeout_seconds: float | None = None,
|
||||
) -> OpenClawResult:
|
||||
timeout = self.default_timeout_seconds if timeout_seconds is None else float(timeout_seconds)
|
||||
if timeout <= 0:
|
||||
raise ValueError("timeout_seconds must be positive")
|
||||
self._verify(command)
|
||||
digest = command.signature
|
||||
idem_ref = (command.tenant_id, command.idempotency_key)
|
||||
|
||||
with self._lock:
|
||||
prior_command = self._commands.get(command.command_id)
|
||||
if prior_command is not None:
|
||||
if prior_command.signature != command.signature:
|
||||
raise CommandConflict("command_id reused with different content")
|
||||
return self._results[command.command_id]
|
||||
prior_idem = self._idempotency.get(idem_ref)
|
||||
if prior_idem is not None:
|
||||
prior_id, prior_digest = prior_idem
|
||||
if prior_digest != digest:
|
||||
raise CommandConflict("idempotency key reused with different content")
|
||||
return self._results[prior_id]
|
||||
running = OpenClawResult(
|
||||
command_id=command.command_id,
|
||||
tenant_id=command.tenant_id,
|
||||
task_id=command.task_id,
|
||||
trace_id=command.trace_id,
|
||||
state=CommandState.RUNNING,
|
||||
started_at=utc_now(),
|
||||
)
|
||||
self._commands[command.command_id] = command
|
||||
self._results[command.command_id] = running
|
||||
self._idempotency[idem_ref] = (command.command_id, digest)
|
||||
|
||||
self.callback_sink.on_started(running)
|
||||
try:
|
||||
result = self.transport.execute(command, timeout)
|
||||
self._validate_result(command, result)
|
||||
except TimeoutError as exc:
|
||||
result = replace(
|
||||
running,
|
||||
state=CommandState.TIMED_OUT,
|
||||
error={"code": "runtime_timeout", "message": str(exc) or "runtime timeout"},
|
||||
finished_at=utc_now(),
|
||||
)
|
||||
except Exception as exc:
|
||||
result = replace(
|
||||
running,
|
||||
state=CommandState.FAILED,
|
||||
error={"code": "runtime_error", "message": str(exc)},
|
||||
finished_at=utc_now(),
|
||||
)
|
||||
|
||||
if result.started_at is None:
|
||||
result = replace(result, started_at=running.started_at)
|
||||
if result.finished_at is None:
|
||||
result = replace(result, finished_at=utc_now())
|
||||
with self._lock:
|
||||
self._results[command.command_id] = result
|
||||
if result.state == CommandState.COMPLETED:
|
||||
self.callback_sink.on_completed(result)
|
||||
else:
|
||||
self.callback_sink.on_failed(result)
|
||||
return result
|
||||
|
||||
def status(self, command_id: str, *, tenant_id: str) -> OpenClawResult:
|
||||
with self._lock:
|
||||
command = self._commands.get(command_id)
|
||||
result = self._results.get(command_id)
|
||||
if command is None or result is None or command.tenant_id != tenant_id:
|
||||
raise CommandNotFound("command not found")
|
||||
return result
|
||||
|
||||
def cancel(self, command_id: str, *, tenant_id: str) -> OpenClawResult:
|
||||
with self._lock:
|
||||
command = self._commands.get(command_id)
|
||||
current = self._results.get(command_id)
|
||||
if command is None or current is None or command.tenant_id != tenant_id:
|
||||
raise CommandNotFound("command not found")
|
||||
if current.state in {
|
||||
CommandState.COMPLETED,
|
||||
CommandState.FAILED,
|
||||
CommandState.CANCELLED,
|
||||
CommandState.TIMED_OUT,
|
||||
CommandState.REJECTED,
|
||||
}:
|
||||
return current
|
||||
if not self.transport.cancel(command_id):
|
||||
raise AdapterError("runtime rejected cancellation")
|
||||
cancelled = replace(
|
||||
current,
|
||||
state=CommandState.CANCELLED,
|
||||
error={"code": "cancelled", "message": "cancelled by AIOS"},
|
||||
finished_at=utc_now(),
|
||||
)
|
||||
with self._lock:
|
||||
self._results[command_id] = cancelled
|
||||
self.callback_sink.on_failed(cancelled)
|
||||
return cancelled
|
||||
|
||||
def health(self) -> RuntimeHealth:
|
||||
return self.transport.health()
|
||||
|
||||
def snapshot(self, *, tenant_id: str) -> Mapping[str, OpenClawResult]:
|
||||
with self._lock:
|
||||
return {
|
||||
command_id: result
|
||||
for command_id, result in self._results.items()
|
||||
if self._commands[command_id].tenant_id == tenant_id
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
"""Audit-oriented lifecycle callbacks for OpenClaw execution."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Mapping, Protocol
|
||||
|
||||
from .contract import OpenClawResult, utc_now
|
||||
|
||||
|
||||
class EventPublisher(Protocol):
|
||||
def publish(self, event: Mapping[str, Any]) -> None: ...
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionEvent:
|
||||
event_type: str
|
||||
tenant_id: str
|
||||
task_id: str
|
||||
trace_id: str
|
||||
command_id: str
|
||||
state: str
|
||||
created_at: str = field(default_factory=utc_now)
|
||||
details: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"event_type": self.event_type,
|
||||
"tenant_id": self.tenant_id,
|
||||
"task_id": self.task_id,
|
||||
"trace_id": self.trace_id,
|
||||
"command_id": self.command_id,
|
||||
"state": self.state,
|
||||
"created_at": self.created_at,
|
||||
"details": json.loads(
|
||||
json.dumps(dict(self.details), ensure_ascii=False, default=str)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class InMemoryEventPublisher:
|
||||
"""Thread-safe publisher used by tests and first integration slice."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.RLock()
|
||||
self.events: list[dict[str, Any]] = []
|
||||
|
||||
def publish(self, event: Mapping[str, Any]) -> None:
|
||||
with self._lock:
|
||||
self.events.append(
|
||||
json.loads(json.dumps(dict(event), ensure_ascii=False, default=str))
|
||||
)
|
||||
|
||||
|
||||
class AuditCallbackSink:
|
||||
"""Convert runtime callbacks into tenant-scoped audit events."""
|
||||
|
||||
def __init__(self, publisher: EventPublisher) -> None:
|
||||
self.publisher = publisher
|
||||
|
||||
@staticmethod
|
||||
def _event(
|
||||
event_type: str,
|
||||
result: OpenClawResult,
|
||||
) -> ExecutionEvent:
|
||||
details: dict[str, Any] = {
|
||||
"runtime_id": result.runtime_id,
|
||||
"attempts": result.attempts,
|
||||
}
|
||||
if result.error:
|
||||
details["error"] = dict(result.error)
|
||||
if result.output:
|
||||
details["output_present"] = True
|
||||
return ExecutionEvent(
|
||||
event_type=event_type,
|
||||
tenant_id=result.tenant_id,
|
||||
task_id=result.task_id,
|
||||
trace_id=result.trace_id,
|
||||
command_id=result.command_id,
|
||||
state=result.state.value,
|
||||
details=details,
|
||||
)
|
||||
|
||||
def on_started(self, result: OpenClawResult) -> None:
|
||||
self.publisher.publish(self._event("OPENCLAW_STARTED", result).as_dict())
|
||||
|
||||
def on_completed(self, result: OpenClawResult) -> None:
|
||||
self.publisher.publish(self._event("OPENCLAW_COMPLETED", result).as_dict())
|
||||
|
||||
def on_failed(self, result: OpenClawResult) -> None:
|
||||
event_type = {
|
||||
"CANCELLED": "OPENCLAW_CANCELLED",
|
||||
"TIMED_OUT": "OPENCLAW_TIMED_OUT",
|
||||
"REJECTED": "OPENCLAW_REJECTED",
|
||||
}.get(result.state.value, "OPENCLAW_FAILED")
|
||||
self.publisher.publish(self._event(event_type, result).as_dict())
|
||||
@@ -1,383 +0,0 @@
|
||||
"""AIOS 到 OpenClaw 的 Ed25519 执行合同。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import uuid
|
||||
from dataclasses import dataclass, field, replace
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from typing import Any, Mapping, Protocol
|
||||
|
||||
CONTRACT_VERSION = "aios.openclaw.command.v2"
|
||||
SIGNATURE_ALGORITHM = "Ed25519"
|
||||
DEFAULT_CLOCK_SKEW_SECONDS = 300
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _parse_timestamp(value: str) -> datetime:
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized:
|
||||
raise ValueError("timestamp is required")
|
||||
if normalized.endswith("Z"):
|
||||
normalized = normalized[:-1] + "+00:00"
|
||||
parsed = datetime.fromisoformat(normalized)
|
||||
if parsed.tzinfo is None:
|
||||
raise ValueError("timestamp must include a timezone")
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _required(value: str, label: str) -> str:
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized:
|
||||
raise ValueError(f"{label} is required")
|
||||
return normalized
|
||||
|
||||
|
||||
def _safe_mapping(value: Mapping[str, Any] | None) -> dict[str, Any]:
|
||||
return json.loads(
|
||||
json.dumps(dict(value or {}), ensure_ascii=False, sort_keys=True, default=str)
|
||||
)
|
||||
|
||||
|
||||
def _load_crypto() -> tuple[Any, Any, Any]:
|
||||
try:
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import (
|
||||
Ed25519PrivateKey,
|
||||
Ed25519PublicKey,
|
||||
)
|
||||
except ImportError as exc: # pragma: no cover
|
||||
raise RuntimeError("cryptography with Ed25519 support is required") from exc
|
||||
return serialization, Ed25519PrivateKey, Ed25519PublicKey
|
||||
|
||||
|
||||
class CommandSigner(Protocol):
|
||||
key_id: str
|
||||
fingerprint: str
|
||||
|
||||
def sign(self, payload: bytes) -> bytes: ...
|
||||
|
||||
|
||||
class CommandVerifier(Protocol):
|
||||
key_id: str
|
||||
fingerprint: str
|
||||
|
||||
def verify(self, payload: bytes, signature: bytes) -> bool: ...
|
||||
|
||||
|
||||
class Ed25519CommandSigner:
|
||||
def __init__(self, private_key: Any, *, key_id: str) -> None:
|
||||
serialization, private_cls, _ = _load_crypto()
|
||||
if not isinstance(private_key, private_cls):
|
||||
raise ValueError("private key is not Ed25519")
|
||||
self.private_key = private_key
|
||||
self.key_id = _required(key_id, "key_id")
|
||||
raw = private_key.public_key().public_bytes(
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PublicFormat.Raw,
|
||||
)
|
||||
self.fingerprint = hashlib.sha256(raw).hexdigest()
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, value: bytes | str, *, key_id: str) -> "Ed25519CommandSigner":
|
||||
serialization, private_cls, _ = _load_crypto()
|
||||
raw = value.encode("utf-8") if isinstance(value, str) else bytes(value)
|
||||
if raw.startswith(b"-----BEGIN"):
|
||||
key = serialization.load_pem_private_key(raw, password=None)
|
||||
elif len(raw) == 32:
|
||||
key = private_cls.from_private_bytes(raw)
|
||||
else:
|
||||
raise ValueError("Ed25519 private key must be PEM or a raw 32-byte seed")
|
||||
return cls(key, key_id=key_id)
|
||||
|
||||
def sign(self, payload: bytes) -> bytes:
|
||||
return self.private_key.sign(bytes(payload))
|
||||
|
||||
def verifier(self) -> "Ed25519CommandVerifier":
|
||||
return Ed25519CommandVerifier(self.private_key.public_key(), key_id=self.key_id)
|
||||
|
||||
|
||||
class Ed25519CommandVerifier:
|
||||
def __init__(self, public_key: Any, *, key_id: str) -> None:
|
||||
serialization, _, public_cls = _load_crypto()
|
||||
if not isinstance(public_key, public_cls):
|
||||
raise ValueError("public key is not Ed25519")
|
||||
self.public_key = public_key
|
||||
self.key_id = _required(key_id, "key_id")
|
||||
raw = public_key.public_bytes(
|
||||
encoding=serialization.Encoding.Raw,
|
||||
format=serialization.PublicFormat.Raw,
|
||||
)
|
||||
self.fingerprint = hashlib.sha256(raw).hexdigest()
|
||||
|
||||
@classmethod
|
||||
def from_bytes(cls, value: bytes | str, *, key_id: str) -> "Ed25519CommandVerifier":
|
||||
serialization, private_cls, public_cls = _load_crypto()
|
||||
raw = value.encode("utf-8") if isinstance(value, str) else bytes(value)
|
||||
if raw.startswith(b"-----BEGIN"):
|
||||
try:
|
||||
key = serialization.load_pem_public_key(raw)
|
||||
except ValueError:
|
||||
private = serialization.load_pem_private_key(raw, password=None)
|
||||
if not isinstance(private, private_cls):
|
||||
raise ValueError("key is not Ed25519")
|
||||
key = private.public_key()
|
||||
elif len(raw) == 32:
|
||||
key = public_cls.from_public_bytes(raw)
|
||||
else:
|
||||
raise ValueError("Ed25519 public key must be PEM or raw 32-byte key")
|
||||
return cls(key, key_id=key_id)
|
||||
|
||||
def verify(self, payload: bytes, signature: bytes) -> bool:
|
||||
try:
|
||||
self.public_key.verify(bytes(signature), bytes(payload))
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class CommandState(str, Enum):
|
||||
ACCEPTED = "ACCEPTED"
|
||||
RUNNING = "RUNNING"
|
||||
COMPLETED = "COMPLETED"
|
||||
FAILED = "FAILED"
|
||||
CANCELLED = "CANCELLED"
|
||||
TIMED_OUT = "TIMED_OUT"
|
||||
REJECTED = "REJECTED"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OpenClawCommand:
|
||||
command_id: str
|
||||
tenant_id: str
|
||||
task_id: str
|
||||
trace_id: str
|
||||
action: str
|
||||
payload: Mapping[str, Any] = field(default_factory=dict)
|
||||
idempotency_key: str = ""
|
||||
created_at: str = field(default_factory=utc_now)
|
||||
expires_at: str | None = None
|
||||
contract_version: str = CONTRACT_VERSION
|
||||
signature_algorithm: str = SIGNATURE_ALGORITHM
|
||||
key_id: str = "default"
|
||||
public_key_fingerprint: str = ""
|
||||
signature: str = ""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for field_name in (
|
||||
"command_id",
|
||||
"tenant_id",
|
||||
"task_id",
|
||||
"trace_id",
|
||||
"action",
|
||||
"idempotency_key",
|
||||
"created_at",
|
||||
"contract_version",
|
||||
"signature_algorithm",
|
||||
"key_id",
|
||||
):
|
||||
object.__setattr__(
|
||||
self, field_name, _required(getattr(self, field_name), field_name)
|
||||
)
|
||||
if self.contract_version != CONTRACT_VERSION:
|
||||
raise ValueError(f"unsupported contract_version: {self.contract_version}")
|
||||
if self.signature_algorithm != SIGNATURE_ALGORITHM:
|
||||
raise ValueError(
|
||||
f"unsupported signature_algorithm: {self.signature_algorithm}"
|
||||
)
|
||||
object.__setattr__(self, "payload", _safe_mapping(self.payload))
|
||||
object.__setattr__(
|
||||
self, "public_key_fingerprint", str(self.public_key_fingerprint or "").strip()
|
||||
)
|
||||
object.__setattr__(self, "signature", str(self.signature or "").strip())
|
||||
_parse_timestamp(self.created_at)
|
||||
if self.expires_at is not None:
|
||||
object.__setattr__(self, "expires_at", _required(self.expires_at, "expires_at"))
|
||||
if _parse_timestamp(self.expires_at) <= _parse_timestamp(self.created_at):
|
||||
raise ValueError("expires_at must be later than created_at")
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
task_id: str,
|
||||
trace_id: str,
|
||||
action: str,
|
||||
payload: Mapping[str, Any] | None,
|
||||
idempotency_key: str,
|
||||
expires_at: str | None = None,
|
||||
key_id: str = "default",
|
||||
command_id: str | None = None,
|
||||
) -> "OpenClawCommand":
|
||||
return cls(
|
||||
command_id=command_id or f"cmd_{uuid.uuid4().hex}",
|
||||
tenant_id=tenant_id,
|
||||
task_id=task_id,
|
||||
trace_id=trace_id,
|
||||
action=action,
|
||||
payload=_safe_mapping(payload),
|
||||
idempotency_key=idempotency_key,
|
||||
expires_at=expires_at,
|
||||
key_id=key_id,
|
||||
)
|
||||
|
||||
def unsigned_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"command_id": self.command_id,
|
||||
"tenant_id": self.tenant_id,
|
||||
"task_id": self.task_id,
|
||||
"trace_id": self.trace_id,
|
||||
"action": self.action,
|
||||
"payload": _safe_mapping(self.payload),
|
||||
"idempotency_key": self.idempotency_key,
|
||||
"created_at": self.created_at,
|
||||
"expires_at": self.expires_at,
|
||||
"contract_version": self.contract_version,
|
||||
"signature_algorithm": self.signature_algorithm,
|
||||
"key_id": self.key_id,
|
||||
"public_key_fingerprint": self.public_key_fingerprint,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OpenClawResult:
|
||||
command_id: str
|
||||
tenant_id: str
|
||||
task_id: str
|
||||
trace_id: str
|
||||
state: CommandState
|
||||
output: Mapping[str, Any] | None = None
|
||||
error: Mapping[str, Any] | None = None
|
||||
started_at: str | None = None
|
||||
finished_at: str | None = None
|
||||
runtime_id: str | None = None
|
||||
attempts: int = 1
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for field_name in ("command_id", "tenant_id", "task_id", "trace_id"):
|
||||
object.__setattr__(
|
||||
self, field_name, _required(getattr(self, field_name), field_name)
|
||||
)
|
||||
object.__setattr__(self, "state", CommandState(self.state))
|
||||
if self.output is not None:
|
||||
object.__setattr__(self, "output", _safe_mapping(self.output))
|
||||
if self.error is not None:
|
||||
object.__setattr__(self, "error", _safe_mapping(self.error))
|
||||
if self.started_at is not None:
|
||||
_parse_timestamp(self.started_at)
|
||||
if self.finished_at is not None:
|
||||
_parse_timestamp(self.finished_at)
|
||||
if self.attempts < 1:
|
||||
raise ValueError("attempts must be >= 1")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RuntimeHealth:
|
||||
runtime: str
|
||||
runtime_id: str
|
||||
status: str
|
||||
version: str
|
||||
heartbeat_at: str
|
||||
active_tasks: int = 0
|
||||
workspace_isolated: bool = False
|
||||
credential_ref_configured: bool = False
|
||||
details: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for field_name in (
|
||||
"runtime",
|
||||
"runtime_id",
|
||||
"status",
|
||||
"version",
|
||||
"heartbeat_at",
|
||||
):
|
||||
object.__setattr__(
|
||||
self, field_name, _required(getattr(self, field_name), field_name)
|
||||
)
|
||||
_parse_timestamp(self.heartbeat_at)
|
||||
if self.active_tasks < 0:
|
||||
raise ValueError("active_tasks must be >= 0")
|
||||
object.__setattr__(self, "details", _safe_mapping(self.details))
|
||||
|
||||
|
||||
def canonical_command_bytes(command: OpenClawCommand) -> bytes:
|
||||
return json.dumps(
|
||||
command.unsigned_dict(),
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _as_signer(command: OpenClawCommand, value: Any) -> CommandSigner:
|
||||
if hasattr(value, "sign") and hasattr(value, "fingerprint") and hasattr(value, "key_id"):
|
||||
return value
|
||||
return Ed25519CommandSigner.from_bytes(value, key_id=command.key_id)
|
||||
|
||||
|
||||
def _as_verifier(command: OpenClawCommand, value: Any) -> CommandVerifier:
|
||||
if isinstance(value, Ed25519CommandSigner):
|
||||
return value.verifier()
|
||||
if hasattr(value, "verify") and hasattr(value, "fingerprint") and hasattr(value, "key_id"):
|
||||
return value
|
||||
return Ed25519CommandVerifier.from_bytes(value, key_id=command.key_id)
|
||||
|
||||
|
||||
def sign_command(command: OpenClawCommand, signing_key: Any) -> OpenClawCommand:
|
||||
signer = _as_signer(command, signing_key)
|
||||
if signer.key_id != command.key_id:
|
||||
raise ValueError("command key_id does not match signer")
|
||||
prepared = replace(
|
||||
command,
|
||||
signature_algorithm=SIGNATURE_ALGORITHM,
|
||||
public_key_fingerprint=signer.fingerprint,
|
||||
signature="",
|
||||
)
|
||||
signature = base64.b64encode(
|
||||
signer.sign(canonical_command_bytes(prepared))
|
||||
).decode("ascii")
|
||||
return replace(prepared, signature=signature)
|
||||
|
||||
|
||||
def verify_command_signature(
|
||||
command: OpenClawCommand,
|
||||
verification_key: Any,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
clock_skew_seconds: int = DEFAULT_CLOCK_SKEW_SECONDS,
|
||||
) -> bool:
|
||||
if (
|
||||
not command.signature
|
||||
or command.contract_version != CONTRACT_VERSION
|
||||
or command.signature_algorithm != SIGNATURE_ALGORITHM
|
||||
):
|
||||
return False
|
||||
try:
|
||||
verifier = _as_verifier(command, verification_key)
|
||||
signature = base64.b64decode(command.signature, validate=True)
|
||||
except Exception:
|
||||
return False
|
||||
if verifier.key_id != command.key_id:
|
||||
return False
|
||||
if not command.public_key_fingerprint or not hmac.compare_digest(
|
||||
command.public_key_fingerprint, verifier.fingerprint
|
||||
):
|
||||
return False
|
||||
if not verifier.verify(canonical_command_bytes(command), signature):
|
||||
return False
|
||||
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||
created = _parse_timestamp(command.created_at)
|
||||
if created.timestamp() - current.timestamp() > clock_skew_seconds:
|
||||
return False
|
||||
if command.expires_at is not None:
|
||||
expires = _parse_timestamp(command.expires_at)
|
||||
if current.timestamp() - expires.timestamp() > clock_skew_seconds:
|
||||
return False
|
||||
return True
|
||||
@@ -1,103 +0,0 @@
|
||||
"""Health policy and self-healing decisions for OpenClaw runtimes."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Mapping
|
||||
|
||||
from .contract import RuntimeHealth
|
||||
|
||||
|
||||
def _parse(value: str) -> datetime:
|
||||
normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
|
||||
parsed = datetime.fromisoformat(normalized)
|
||||
if parsed.tzinfo is None:
|
||||
raise ValueError("heartbeat timestamp must include timezone")
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HealthPolicy:
|
||||
max_heartbeat_age_seconds: int = 90
|
||||
require_workspace_isolation: bool = True
|
||||
require_credential_ref: bool = True
|
||||
accepted_statuses: frozenset[str] = field(
|
||||
default_factory=lambda: frozenset({"healthy", "ready"})
|
||||
)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.max_heartbeat_age_seconds < 5:
|
||||
raise ValueError("max_heartbeat_age_seconds must be >= 5")
|
||||
if not self.accepted_statuses:
|
||||
raise ValueError("accepted_statuses must not be empty")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class HealthAssessment:
|
||||
healthy: bool
|
||||
reasons: tuple[str, ...]
|
||||
recoverable: bool
|
||||
recommended_action: str
|
||||
details: Mapping[str, object] = field(default_factory=dict)
|
||||
|
||||
|
||||
class RuntimeHealthMonitor:
|
||||
"""Evaluate runtime health and recommend bounded recovery actions."""
|
||||
|
||||
def __init__(self, policy: HealthPolicy | None = None) -> None:
|
||||
self.policy = policy or HealthPolicy()
|
||||
|
||||
def assess(
|
||||
self,
|
||||
health: RuntimeHealth,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> HealthAssessment:
|
||||
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||
heartbeat = _parse(health.heartbeat_at)
|
||||
age = max(0.0, current.timestamp() - heartbeat.timestamp())
|
||||
reasons: list[str] = []
|
||||
|
||||
if health.status.lower() not in self.policy.accepted_statuses:
|
||||
reasons.append("runtime_status_unhealthy")
|
||||
if age > self.policy.max_heartbeat_age_seconds:
|
||||
reasons.append("heartbeat_stale")
|
||||
if self.policy.require_workspace_isolation and not health.workspace_isolated:
|
||||
reasons.append("workspace_not_isolated")
|
||||
if self.policy.require_credential_ref and not health.credential_ref_configured:
|
||||
reasons.append("credential_reference_missing")
|
||||
|
||||
if not reasons:
|
||||
return HealthAssessment(
|
||||
healthy=True,
|
||||
reasons=(),
|
||||
recoverable=True,
|
||||
recommended_action="none",
|
||||
details={"heartbeat_age_seconds": age},
|
||||
)
|
||||
|
||||
non_recoverable = {
|
||||
"workspace_not_isolated",
|
||||
"credential_reference_missing",
|
||||
}.intersection(reasons)
|
||||
if non_recoverable:
|
||||
action = "quarantine_runtime"
|
||||
recoverable = False
|
||||
elif "heartbeat_stale" in reasons:
|
||||
action = "restart_runtime"
|
||||
recoverable = True
|
||||
else:
|
||||
action = "drain_and_restart"
|
||||
recoverable = True
|
||||
|
||||
return HealthAssessment(
|
||||
healthy=False,
|
||||
reasons=tuple(reasons),
|
||||
recoverable=recoverable,
|
||||
recommended_action=action,
|
||||
details={
|
||||
"heartbeat_age_seconds": age,
|
||||
"runtime_id": health.runtime_id,
|
||||
"active_tasks": health.active_tasks,
|
||||
},
|
||||
)
|
||||
@@ -1,214 +0,0 @@
|
||||
"""Runtime transport implementations for the OpenClaw adapter."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any, Callable, Mapping, Protocol
|
||||
|
||||
from .contract import (
|
||||
CommandState,
|
||||
OpenClawCommand,
|
||||
OpenClawResult,
|
||||
RuntimeHealth,
|
||||
utc_now,
|
||||
)
|
||||
|
||||
|
||||
class RuntimeTransport(Protocol):
|
||||
def execute(self, command: OpenClawCommand, timeout_seconds: float) -> OpenClawResult: ...
|
||||
def cancel(self, command_id: str) -> bool: ...
|
||||
def health(self) -> RuntimeHealth: ...
|
||||
|
||||
|
||||
class TransportError(RuntimeError):
|
||||
"""Runtime transport or protocol failure."""
|
||||
|
||||
|
||||
RuntimeHandler = Callable[[OpenClawCommand], Mapping[str, Any] | OpenClawResult]
|
||||
|
||||
|
||||
class InProcessRuntimeTransport:
|
||||
"""Deterministic runtime used by tests and controlled local integrations."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
handler: RuntimeHandler,
|
||||
*,
|
||||
runtime_id: str = "openclaw-in-process",
|
||||
version: str = "v1",
|
||||
workspace_isolated: bool = True,
|
||||
credential_ref_configured: bool = True,
|
||||
) -> None:
|
||||
self.handler = handler
|
||||
self.runtime_id = runtime_id
|
||||
self.version = version
|
||||
self.workspace_isolated = workspace_isolated
|
||||
self.credential_ref_configured = credential_ref_configured
|
||||
self._lock = threading.RLock()
|
||||
self._running: set[str] = set()
|
||||
self._cancelled: set[str] = set()
|
||||
|
||||
def execute(self, command: OpenClawCommand, timeout_seconds: float) -> OpenClawResult:
|
||||
if timeout_seconds <= 0:
|
||||
raise ValueError("timeout_seconds must be positive")
|
||||
with self._lock:
|
||||
if command.command_id in self._cancelled:
|
||||
return OpenClawResult(
|
||||
command_id=command.command_id,
|
||||
tenant_id=command.tenant_id,
|
||||
task_id=command.task_id,
|
||||
trace_id=command.trace_id,
|
||||
state=CommandState.CANCELLED,
|
||||
error={"code": "cancelled", "message": "cancelled before execution"},
|
||||
finished_at=utc_now(),
|
||||
runtime_id=self.runtime_id,
|
||||
)
|
||||
self._running.add(command.command_id)
|
||||
try:
|
||||
raw = self.handler(command)
|
||||
if isinstance(raw, OpenClawResult):
|
||||
return raw
|
||||
if not isinstance(raw, Mapping):
|
||||
raise TransportError("runtime handler must return a mapping or OpenClawResult")
|
||||
return OpenClawResult(
|
||||
command_id=command.command_id,
|
||||
tenant_id=command.tenant_id,
|
||||
task_id=command.task_id,
|
||||
trace_id=command.trace_id,
|
||||
state=CommandState(str(raw.get("state", "COMPLETED"))),
|
||||
output=raw.get("output"),
|
||||
error=raw.get("error"),
|
||||
started_at=str(raw.get("started_at") or utc_now()),
|
||||
finished_at=str(raw.get("finished_at") or utc_now()),
|
||||
runtime_id=str(raw.get("runtime_id") or self.runtime_id),
|
||||
attempts=int(raw.get("attempts", 1)),
|
||||
)
|
||||
finally:
|
||||
with self._lock:
|
||||
self._running.discard(command.command_id)
|
||||
|
||||
def cancel(self, command_id: str) -> bool:
|
||||
with self._lock:
|
||||
self._cancelled.add(command_id)
|
||||
return command_id in self._running
|
||||
|
||||
def health(self) -> RuntimeHealth:
|
||||
with self._lock:
|
||||
active = len(self._running)
|
||||
return RuntimeHealth(
|
||||
runtime="openclaw",
|
||||
runtime_id=self.runtime_id,
|
||||
status="healthy",
|
||||
version=self.version,
|
||||
heartbeat_at=utc_now(),
|
||||
active_tasks=active,
|
||||
workspace_isolated=self.workspace_isolated,
|
||||
credential_ref_configured=self.credential_ref_configured,
|
||||
details={"transport": "in_process"},
|
||||
)
|
||||
|
||||
|
||||
class HTTPRuntimeTransport:
|
||||
"""Private HTTP transport for an OpenClaw runtime service."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str,
|
||||
*,
|
||||
runtime_id: str = "openclaw-http",
|
||||
credential_ref: str | None = None,
|
||||
max_response_bytes: int = 2_097_152,
|
||||
) -> None:
|
||||
normalized = str(base_url).rstrip("/")
|
||||
if not normalized.startswith(("http://", "https://")):
|
||||
raise ValueError("base_url must use http or https")
|
||||
self.base_url = normalized
|
||||
self.runtime_id = runtime_id
|
||||
self.credential_ref = credential_ref
|
||||
self.max_response_bytes = int(max_response_bytes)
|
||||
|
||||
def _request(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
payload: Mapping[str, Any] | None,
|
||||
timeout_seconds: float,
|
||||
) -> Mapping[str, Any]:
|
||||
body = None
|
||||
headers = {"Accept": "application/json", "User-Agent": "AIOS-OpenClaw/1"}
|
||||
if payload is not None:
|
||||
body = json.dumps(dict(payload), separators=(",", ":")).encode("utf-8")
|
||||
headers["Content-Type"] = "application/json"
|
||||
if self.credential_ref:
|
||||
headers["X-AIOS-Credential-Ref"] = self.credential_ref
|
||||
request = urllib.request.Request(
|
||||
f"{self.base_url}{path}",
|
||||
data=body,
|
||||
method=method,
|
||||
headers=headers,
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(request, timeout=timeout_seconds) as response:
|
||||
raw = response.read(self.max_response_bytes + 1)
|
||||
except urllib.error.HTTPError as exc:
|
||||
raise TransportError(f"runtime returned HTTP {exc.code}") from exc
|
||||
except urllib.error.URLError as exc:
|
||||
raise TransportError(f"runtime unavailable: {exc.reason}") from exc
|
||||
if len(raw) > self.max_response_bytes:
|
||||
raise TransportError("runtime response exceeded size limit")
|
||||
decoded = json.loads(raw.decode("utf-8"))
|
||||
if not isinstance(decoded, Mapping):
|
||||
raise TransportError("runtime response must be a JSON object")
|
||||
return decoded
|
||||
|
||||
@staticmethod
|
||||
def _command_payload(command: OpenClawCommand) -> dict[str, Any]:
|
||||
return {**command.unsigned_dict(), "signature": command.signature}
|
||||
|
||||
def execute(self, command: OpenClawCommand, timeout_seconds: float) -> OpenClawResult:
|
||||
value = self._request(
|
||||
"POST",
|
||||
"/v1/execute",
|
||||
payload=self._command_payload(command),
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
return OpenClawResult(
|
||||
command_id=str(value["command_id"]),
|
||||
tenant_id=str(value["tenant_id"]),
|
||||
task_id=str(value["task_id"]),
|
||||
trace_id=str(value["trace_id"]),
|
||||
state=CommandState(str(value["state"])),
|
||||
output=value.get("output"),
|
||||
error=value.get("error"),
|
||||
started_at=value.get("started_at"),
|
||||
finished_at=value.get("finished_at"),
|
||||
runtime_id=str(value.get("runtime_id") or self.runtime_id),
|
||||
attempts=int(value.get("attempts", 1)),
|
||||
)
|
||||
|
||||
def cancel(self, command_id: str) -> bool:
|
||||
value = self._request(
|
||||
"POST",
|
||||
f"/v1/commands/{urllib.parse.quote(command_id, safe='')}/cancel",
|
||||
payload={},
|
||||
timeout_seconds=10.0,
|
||||
)
|
||||
return bool(value.get("accepted"))
|
||||
|
||||
def health(self) -> RuntimeHealth:
|
||||
value = self._request("GET", "/v1/health", payload=None, timeout_seconds=5.0)
|
||||
return RuntimeHealth(
|
||||
runtime=str(value.get("runtime") or "openclaw"),
|
||||
runtime_id=str(value.get("runtime_id") or self.runtime_id),
|
||||
status=str(value.get("status") or "unknown"),
|
||||
version=str(value.get("version") or "unknown"),
|
||||
heartbeat_at=str(value.get("heartbeat_at") or utc_now()),
|
||||
active_tasks=int(value.get("active_tasks", 0)),
|
||||
workspace_isolated=bool(value.get("workspace_isolated", False)),
|
||||
credential_ref_configured=bool(value.get("credential_ref_configured", False)),
|
||||
details=value.get("details") if isinstance(value.get("details"), Mapping) else {},
|
||||
)
|
||||
@@ -0,0 +1,144 @@
|
||||
"""HTTP adapter for audited Owner authorization and writer provisioning.
|
||||
|
||||
This module layers on top of ``aios_authorized_task_writer_api`` so existing
|
||||
routes and the legacy exact ``writer:provision`` route remain available. The
|
||||
new endpoint does not mint or modify bearer credentials. It authenticates an
|
||||
existing, non-wildcard, explicitly allow-listed Owner credential and then
|
||||
invokes the audited broker.
|
||||
|
||||
Endpoint:
|
||||
POST /api/governance/owner-authorizations
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import urllib.parse
|
||||
from typing import Any, Mapping
|
||||
|
||||
import aios_authorized_task_writer_api as bridge_api
|
||||
from authorized_task_writer_provisioning import ProvisioningError
|
||||
from owner_authorization_broker import (
|
||||
OwnerAuthorizationBroker,
|
||||
OwnerAuthorizationError,
|
||||
OwnerPrincipal,
|
||||
)
|
||||
|
||||
|
||||
api = bridge_api.api
|
||||
_original_do_post = api.H.do_POST
|
||||
_ENDPOINT = "/api/governance/owner-authorizations"
|
||||
|
||||
|
||||
def _enabled() -> bool:
|
||||
return os.getenv(
|
||||
"AIOS_GITOPS_OWNER_AUTHORIZATION_BROKER_ENABLED", ""
|
||||
).strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _configured_intake_scope() -> str:
|
||||
scopes = [
|
||||
item.strip()
|
||||
for item in os.getenv(
|
||||
"AIOS_GITOPS_OWNER_AUTHORIZATION_INTAKE_SCOPES", ""
|
||||
).split(",")
|
||||
if item.strip()
|
||||
]
|
||||
if len(scopes) != 1:
|
||||
raise OwnerAuthorizationError(
|
||||
"EXACTLY_ONE_HTTP_INTAKE_SCOPE_REQUIRED", 503
|
||||
)
|
||||
scope = scopes[0]
|
||||
if scope == "writer:provision" or scope == "*" or scope.endswith(":*"):
|
||||
raise OwnerAuthorizationError("HTTP_INTAKE_SCOPE_REJECTED", 503)
|
||||
return scope
|
||||
|
||||
|
||||
def _scope_set(auth: Mapping[str, Any]) -> frozenset[str]:
|
||||
raw = auth.get("scopes")
|
||||
if raw is None:
|
||||
raw = auth.get("scope")
|
||||
if raw is None:
|
||||
return frozenset()
|
||||
if isinstance(raw, str):
|
||||
candidates = raw.replace(",", " ").split()
|
||||
elif isinstance(raw, (list, tuple, set, frozenset)):
|
||||
candidates = [str(item) for item in raw]
|
||||
else:
|
||||
raise OwnerAuthorizationError("OWNER_SCOPE_EVIDENCE_INVALID", 403)
|
||||
return frozenset(item.strip() for item in candidates if item.strip())
|
||||
|
||||
|
||||
def _credential_id(auth: Mapping[str, Any]) -> str:
|
||||
for name in (
|
||||
"credential_id",
|
||||
"token_id",
|
||||
"key_id",
|
||||
"auth_id",
|
||||
):
|
||||
value = str(auth.get(name) or "").strip()
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def _authenticate_existing_owner(handler) -> OwnerPrincipal | None:
|
||||
scope = _configured_intake_scope()
|
||||
auth = handler.need(scope)
|
||||
if not auth:
|
||||
return None
|
||||
if auth.get("role") != "owner":
|
||||
handler.sendj({"error": "OWNER_ROLE_REQUIRED"}, 403)
|
||||
return None
|
||||
identity = str(auth.get("account") or auth.get("identity") or "").strip()
|
||||
if not identity:
|
||||
handler.sendj({"error": "OWNER_IDENTITY_REQUIRED"}, 403)
|
||||
return None
|
||||
scopes = _scope_set(auth)
|
||||
if any(item == "*" or item.endswith(":*") for item in scopes):
|
||||
handler.sendj(
|
||||
{"error": "WILDCARD_OWNER_CREDENTIAL_REJECTED"}, 403
|
||||
)
|
||||
return None
|
||||
return OwnerPrincipal(
|
||||
identity=identity,
|
||||
role="owner",
|
||||
authenticated_scope=scope,
|
||||
token_scopes=scopes,
|
||||
credential_id=_credential_id(auth),
|
||||
)
|
||||
|
||||
|
||||
def _broker_do_post(self):
|
||||
path = urllib.parse.urlparse(self.path).path.rstrip("/") or "/"
|
||||
if path != _ENDPOINT:
|
||||
return _original_do_post(self)
|
||||
if not _enabled():
|
||||
return self.sendj(
|
||||
{"error": "OWNER_AUTHORIZATION_BROKER_DISABLED"}, 503
|
||||
)
|
||||
try:
|
||||
principal = _authenticate_existing_owner(self)
|
||||
if principal is None:
|
||||
return
|
||||
data = self.body()
|
||||
result = OwnerAuthorizationBroker().authorize_and_provision(
|
||||
data, principal
|
||||
)
|
||||
return self.sendj(result)
|
||||
except (OwnerAuthorizationError, ProvisioningError) as exc:
|
||||
body = {"error": exc.code}
|
||||
if exc.details:
|
||||
body["details"] = exc.details
|
||||
return self.sendj(body, exc.status)
|
||||
except Exception:
|
||||
# Never expose registry data, bearer tokens, signer output or traceback.
|
||||
return self.sendj(
|
||||
{"error": "OWNER_AUTHORIZATION_BROKER_INTERNAL_ERROR"}, 500
|
||||
)
|
||||
|
||||
|
||||
api.H.do_POST = _broker_do_post
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
api.main()
|
||||
@@ -0,0 +1,527 @@
|
||||
"""Owner-final, structured authorization for one governed pull-request merge.
|
||||
|
||||
The Owner approval is the policy decision. Signing and public verification
|
||||
prove that decision but cannot change its repository, pull request, CAS base,
|
||||
technical-gate contract, or lifetime. This module deliberately exposes no
|
||||
generic sign-bytes API and does not publish or alter technical check results.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import secrets
|
||||
import sqlite3
|
||||
import time
|
||||
from typing import Any, Callable, Mapping, Protocol
|
||||
|
||||
from aios_single_host_authority import (
|
||||
OWNER_NAMESPACE,
|
||||
LocalFileSigner,
|
||||
SignedArtifact,
|
||||
canonical_json_bytes,
|
||||
sha256_json,
|
||||
)
|
||||
|
||||
|
||||
POLICY_SCHEMA = "aios.owner-merge-approval-policy.v2"
|
||||
APPROVAL_SCHEMA = "aios.owner-merge-approval.v2"
|
||||
RECEIPT_SCHEMA = "aios.owner-merge-admission-receipt.v2"
|
||||
OPERATION = "merge.pull-request"
|
||||
|
||||
OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
DIGEST_RE = re.compile(r"^[0-9a-f]{64}$")
|
||||
REPOSITORY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}/[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
|
||||
IDENTITY_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/+:-]{2,255}$")
|
||||
CHECK_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{1,127}$")
|
||||
TOKEN_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_-]{11,255}$")
|
||||
|
||||
|
||||
class OwnerMergeApprovalError(RuntimeError):
|
||||
"""Stable fail-closed error for the Owner merge approval contract."""
|
||||
|
||||
def __init__(self, code: str):
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
|
||||
|
||||
def _fail(code: str) -> None:
|
||||
raise OwnerMergeApprovalError(code)
|
||||
|
||||
|
||||
def _require_oid(value: object, code: str) -> str:
|
||||
if not isinstance(value, str) or OID_RE.fullmatch(value) is None:
|
||||
_fail(code)
|
||||
return value
|
||||
|
||||
|
||||
def _require_digest(value: object, code: str) -> str:
|
||||
if not isinstance(value, str) or DIGEST_RE.fullmatch(value) is None:
|
||||
_fail(code)
|
||||
return value
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OwnerMergeApprovalPolicy:
|
||||
"""Immutable policy input for one exact protected merge approval."""
|
||||
|
||||
repository: str
|
||||
pr_number: int
|
||||
pr_head_oid: str
|
||||
expected_main_oid: str
|
||||
operation: str
|
||||
required_check_contract_digest: str
|
||||
required_checks: tuple[str, ...]
|
||||
owner_identity: str
|
||||
audit_lineage_digest: str
|
||||
maximum_ttl_seconds: int
|
||||
policy_version: str = "v2"
|
||||
schema: str = POLICY_SCHEMA
|
||||
|
||||
def validate(self) -> None:
|
||||
if (
|
||||
self.schema != POLICY_SCHEMA
|
||||
or self.policy_version != "v2"
|
||||
or not isinstance(self.repository, str)
|
||||
or REPOSITORY_RE.fullmatch(self.repository) is None
|
||||
or type(self.pr_number) is not int
|
||||
or not 1 <= self.pr_number < 2**31
|
||||
or self.operation != OPERATION
|
||||
or not isinstance(self.owner_identity, str)
|
||||
or IDENTITY_RE.fullmatch(self.owner_identity) is None
|
||||
or type(self.maximum_ttl_seconds) is not int
|
||||
or not 60 <= self.maximum_ttl_seconds <= 900
|
||||
):
|
||||
_fail("OWNER_MERGE_POLICY_INVALID")
|
||||
_require_oid(self.pr_head_oid, "OWNER_MERGE_POLICY_HEAD_INVALID")
|
||||
_require_oid(self.expected_main_oid, "OWNER_MERGE_POLICY_BASE_INVALID")
|
||||
_require_digest(
|
||||
self.required_check_contract_digest,
|
||||
"OWNER_MERGE_POLICY_CHECK_CONTRACT_INVALID",
|
||||
)
|
||||
_require_digest(self.audit_lineage_digest, "OWNER_MERGE_POLICY_LINEAGE_INVALID")
|
||||
if (
|
||||
not isinstance(self.required_checks, tuple)
|
||||
or not self.required_checks
|
||||
or len(set(self.required_checks)) != len(self.required_checks)
|
||||
or any(not isinstance(item, str) or CHECK_RE.fullmatch(item) is None for item in self.required_checks)
|
||||
):
|
||||
_fail("OWNER_MERGE_POLICY_REQUIRED_CHECKS_INVALID")
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
self.validate()
|
||||
value = asdict(self)
|
||||
value["required_checks"] = list(self.required_checks)
|
||||
return value
|
||||
|
||||
@property
|
||||
def digest(self) -> str:
|
||||
return sha256_json(self.as_dict())
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OwnerMergeLiveState:
|
||||
"""Read-only live evidence; this object has no status publication method."""
|
||||
|
||||
repository: str
|
||||
pr_number: int
|
||||
pr_head_oid: str
|
||||
main_oid: str
|
||||
required_check_contract_digest: str
|
||||
check_statuses: Mapping[str, bool]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OwnerMergeOidReadback:
|
||||
authority_main_oid: str
|
||||
gitea_main_oid: str
|
||||
api_main_oid: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OwnerMergeAdmissionReceipt:
|
||||
approval_id: str
|
||||
nonce: str
|
||||
owner_identity: str
|
||||
policy_digest: str
|
||||
proof_path: str
|
||||
merge_oid: str
|
||||
schema: str = RECEIPT_SCHEMA
|
||||
|
||||
|
||||
class ApprovalProofVerifier(Protocol):
|
||||
"""Public proof verifier; it is intentionally unable to issue approvals."""
|
||||
|
||||
def verify(
|
||||
self,
|
||||
*,
|
||||
namespace: str,
|
||||
payload: bytes,
|
||||
signature: str,
|
||||
key_id: str,
|
||||
) -> bool:
|
||||
...
|
||||
|
||||
|
||||
class GovernedMergeExecutor(Protocol):
|
||||
def execute(self, approval: Mapping[str, object]) -> str:
|
||||
...
|
||||
|
||||
|
||||
class MergeOidReconciler(Protocol):
|
||||
def readback(self, merge_oid: str) -> OwnerMergeOidReadback:
|
||||
...
|
||||
|
||||
|
||||
APPROVAL_FIELDS = frozenset(
|
||||
{
|
||||
"schema",
|
||||
"approval_id",
|
||||
"nonce",
|
||||
"repository",
|
||||
"pr_number",
|
||||
"pr_head_oid",
|
||||
"expected_main_oid",
|
||||
"operation",
|
||||
"required_check_contract_digest",
|
||||
"required_checks",
|
||||
"owner_identity",
|
||||
"audit_lineage_digest",
|
||||
"policy_version",
|
||||
"policy_digest",
|
||||
"issued_at",
|
||||
"not_before",
|
||||
"expires_at",
|
||||
"signature_namespace",
|
||||
"signature_key_id",
|
||||
"canonical_payload_digest",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class OwnerMergeApprovalAuthority:
|
||||
"""Structured Owner signer for an immutable merge policy.
|
||||
|
||||
``issue`` has no caller-controlled payload parameter. The immutable policy
|
||||
selects the complete authorization scope; only approval IDs and nonces are
|
||||
generated inside the authority.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
key_path: Path,
|
||||
policy: OwnerMergeApprovalPolicy,
|
||||
*,
|
||||
require_root_owner: bool = True,
|
||||
clock: Callable[[], float] = time.time,
|
||||
token_urlsafe: Callable[[int], str] = secrets.token_urlsafe,
|
||||
) -> None:
|
||||
policy.validate()
|
||||
self.policy = policy
|
||||
self.signer = LocalFileSigner(
|
||||
Path(key_path),
|
||||
signer_id="local-owner-authority",
|
||||
namespace=OWNER_NAMESPACE,
|
||||
require_root_owner=require_root_owner,
|
||||
)
|
||||
self.clock = clock
|
||||
self.token_urlsafe = token_urlsafe
|
||||
|
||||
def _token(self, prefix: str) -> str:
|
||||
value = prefix + self.token_urlsafe(24)
|
||||
if TOKEN_RE.fullmatch(value) is None:
|
||||
_fail("OWNER_MERGE_AUTHORITY_GENERATED_TOKEN_INVALID")
|
||||
return value
|
||||
|
||||
def issue(self) -> SignedArtifact:
|
||||
now = int(self.clock())
|
||||
approval_id = self._token("owner-merge-approval-")
|
||||
nonce = self._token("owner-merge-nonce-")
|
||||
policy = self.policy
|
||||
payload: dict[str, Any] = {
|
||||
"schema": APPROVAL_SCHEMA,
|
||||
"approval_id": approval_id,
|
||||
"nonce": nonce,
|
||||
"repository": policy.repository,
|
||||
"pr_number": policy.pr_number,
|
||||
"pr_head_oid": policy.pr_head_oid,
|
||||
"expected_main_oid": policy.expected_main_oid,
|
||||
"operation": policy.operation,
|
||||
"required_check_contract_digest": policy.required_check_contract_digest,
|
||||
"required_checks": list(policy.required_checks),
|
||||
"owner_identity": policy.owner_identity,
|
||||
"audit_lineage_digest": policy.audit_lineage_digest,
|
||||
"policy_version": policy.policy_version,
|
||||
"policy_digest": policy.digest,
|
||||
"issued_at": now,
|
||||
"not_before": max(0, now - 5),
|
||||
"expires_at": now + policy.maximum_ttl_seconds,
|
||||
"signature_namespace": OWNER_NAMESPACE,
|
||||
"signature_key_id": self.signer.fingerprint,
|
||||
}
|
||||
payload["canonical_payload_digest"] = sha256_json(payload)
|
||||
return self.signer._sign_canonical(payload)
|
||||
|
||||
|
||||
class OwnerMergeApprovalVerifier:
|
||||
"""Verify Owner proof, preferring normal verification then recovery proof.
|
||||
|
||||
The recovery verifier validates a pre-existing approval with the registered
|
||||
public Owner root. It cannot issue an approval and is therefore not a
|
||||
second policy principal.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
primary: ApprovalProofVerifier,
|
||||
recovery: ApprovalProofVerifier | None = None,
|
||||
clock: Callable[[], float] = time.time,
|
||||
) -> None:
|
||||
self.primary = primary
|
||||
self.recovery = recovery
|
||||
self.clock = clock
|
||||
|
||||
@staticmethod
|
||||
def _artifact(artifact: SignedArtifact | Mapping[str, Any]) -> tuple[dict[str, Any], str, str, str]:
|
||||
if isinstance(artifact, SignedArtifact):
|
||||
return dict(artifact.payload), artifact.signature, artifact.key_id, artifact.namespace
|
||||
if not isinstance(artifact, Mapping) or set(artifact) != {"payload", "signature", "key_id", "namespace"}:
|
||||
_fail("OWNER_MERGE_APPROVAL_ARTIFACT_INVALID")
|
||||
payload = artifact.get("payload")
|
||||
signature = artifact.get("signature")
|
||||
key_id = artifact.get("key_id")
|
||||
namespace = artifact.get("namespace")
|
||||
if not isinstance(payload, Mapping) or not all(isinstance(item, str) for item in (signature, key_id, namespace)):
|
||||
_fail("OWNER_MERGE_APPROVAL_ARTIFACT_INVALID")
|
||||
return dict(payload), signature, key_id, namespace
|
||||
|
||||
@staticmethod
|
||||
def _validate_scope(payload: Mapping[str, Any], policy: OwnerMergeApprovalPolicy, *, now: int, key_id: str, namespace: str) -> None:
|
||||
if set(payload) != APPROVAL_FIELDS:
|
||||
_fail("OWNER_MERGE_APPROVAL_FIELDS_INVALID")
|
||||
expected = {
|
||||
"schema": APPROVAL_SCHEMA,
|
||||
"repository": policy.repository,
|
||||
"pr_number": policy.pr_number,
|
||||
"pr_head_oid": policy.pr_head_oid,
|
||||
"expected_main_oid": policy.expected_main_oid,
|
||||
"operation": policy.operation,
|
||||
"required_check_contract_digest": policy.required_check_contract_digest,
|
||||
"required_checks": list(policy.required_checks),
|
||||
"owner_identity": policy.owner_identity,
|
||||
"audit_lineage_digest": policy.audit_lineage_digest,
|
||||
"policy_version": policy.policy_version,
|
||||
"policy_digest": policy.digest,
|
||||
"signature_namespace": OWNER_NAMESPACE,
|
||||
"signature_key_id": key_id,
|
||||
}
|
||||
if payload.get("pr_head_oid") != policy.pr_head_oid:
|
||||
_fail("OWNER_MERGE_APPROVAL_HEAD_BINDING_INVALID")
|
||||
if payload.get("expected_main_oid") != policy.expected_main_oid:
|
||||
_fail("OWNER_MERGE_APPROVAL_BASE_BINDING_INVALID")
|
||||
if payload.get("policy_digest") != policy.digest:
|
||||
_fail("OWNER_MERGE_POLICY_DIGEST_DRIFT")
|
||||
if namespace != OWNER_NAMESPACE or any(payload.get(key) != value for key, value in expected.items()):
|
||||
_fail("OWNER_MERGE_APPROVAL_SCOPE_INVALID")
|
||||
for field in ("approval_id", "nonce"):
|
||||
if not isinstance(payload.get(field), str) or TOKEN_RE.fullmatch(payload[field]) is None:
|
||||
_fail("OWNER_MERGE_APPROVAL_TOKEN_INVALID")
|
||||
for field in ("issued_at", "not_before", "expires_at"):
|
||||
if type(payload.get(field)) is not int:
|
||||
_fail("OWNER_MERGE_APPROVAL_TIME_INVALID")
|
||||
if not payload["not_before"] <= now < payload["expires_at"]:
|
||||
_fail("OWNER_MERGE_APPROVAL_EXPIRED")
|
||||
if payload["issued_at"] > now + 5 or payload["expires_at"] - payload["issued_at"] != policy.maximum_ttl_seconds:
|
||||
_fail("OWNER_MERGE_APPROVAL_TIME_INVALID")
|
||||
supplied = payload.get("canonical_payload_digest")
|
||||
body = dict(payload)
|
||||
body.pop("canonical_payload_digest", None)
|
||||
if supplied != sha256_json(body):
|
||||
_fail("OWNER_MERGE_APPROVAL_CANONICAL_DIGEST_INVALID")
|
||||
|
||||
def verify(
|
||||
self,
|
||||
artifact: SignedArtifact | Mapping[str, Any],
|
||||
policy: OwnerMergeApprovalPolicy,
|
||||
) -> tuple[dict[str, Any], str]:
|
||||
policy.validate()
|
||||
payload, signature, key_id, namespace = self._artifact(artifact)
|
||||
canonical = canonical_json_bytes(payload)
|
||||
proof_path: str | None = None
|
||||
for path, verifier in (("primary", self.primary), ("recovery", self.recovery)):
|
||||
if verifier is None:
|
||||
continue
|
||||
try:
|
||||
verified = verifier.verify(
|
||||
namespace=namespace,
|
||||
payload=canonical,
|
||||
signature=signature,
|
||||
key_id=key_id,
|
||||
)
|
||||
except Exception:
|
||||
verified = False
|
||||
if verified is True:
|
||||
proof_path = path
|
||||
break
|
||||
if proof_path is None:
|
||||
_fail("OWNER_MERGE_APPROVAL_SIGNATURE_INVALID")
|
||||
self._validate_scope(payload, policy, now=int(self.clock()), key_id=key_id, namespace=namespace)
|
||||
return payload, proof_path
|
||||
|
||||
|
||||
class SQLiteOwnerMergeNonceLedger:
|
||||
"""Durable one-shot fence. Unknown effects are intentionally not retried."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = Path(path)
|
||||
|
||||
def _connection(self) -> sqlite3.Connection:
|
||||
if self.path.is_symlink() or self.path.parent.is_symlink():
|
||||
_fail("OWNER_MERGE_NONCE_LEDGER_PATH_INVALID")
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
connection = sqlite3.connect(self.path, timeout=5, isolation_level=None)
|
||||
connection.execute(
|
||||
"CREATE TABLE IF NOT EXISTS owner_merge_nonce ("
|
||||
"nonce TEXT PRIMARY KEY, approval_digest TEXT NOT NULL, state TEXT NOT NULL, claimed_at INTEGER NOT NULL)"
|
||||
)
|
||||
return connection
|
||||
|
||||
def claim(self, *, nonce: str, approval_digest: str, now: int) -> None:
|
||||
connection = self._connection()
|
||||
try:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
existing = connection.execute(
|
||||
"SELECT state FROM owner_merge_nonce WHERE nonce=?", (nonce,)
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
connection.rollback()
|
||||
_fail("OWNER_MERGE_NONCE_REPLAY")
|
||||
connection.execute(
|
||||
"INSERT INTO owner_merge_nonce(nonce,approval_digest,state,claimed_at) VALUES(?,?,?,?)",
|
||||
(nonce, approval_digest, "PENDING", now),
|
||||
)
|
||||
connection.commit()
|
||||
except OwnerMergeApprovalError:
|
||||
raise
|
||||
except sqlite3.Error as exc:
|
||||
try:
|
||||
connection.rollback()
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
raise OwnerMergeApprovalError("OWNER_MERGE_NONCE_LEDGER_UNAVAILABLE") from exc
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def mark(self, *, nonce: str, state: str) -> None:
|
||||
if state not in {"COMPLETED", "UNKNOWN"}:
|
||||
_fail("OWNER_MERGE_NONCE_STATE_INVALID")
|
||||
connection = self._connection()
|
||||
try:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
updated = connection.execute(
|
||||
"UPDATE owner_merge_nonce SET state=? WHERE nonce=? AND state='PENDING'",
|
||||
(state, nonce),
|
||||
).rowcount
|
||||
if updated != 1:
|
||||
connection.rollback()
|
||||
_fail("OWNER_MERGE_NONCE_STATE_CONFLICT")
|
||||
connection.commit()
|
||||
except OwnerMergeApprovalError:
|
||||
raise
|
||||
except sqlite3.Error as exc:
|
||||
try:
|
||||
connection.rollback()
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
raise OwnerMergeApprovalError("OWNER_MERGE_NONCE_LEDGER_UNAVAILABLE") from exc
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
class GovernedOwnerMergeAdmission:
|
||||
"""Admit one protected merge without owning any policy or technical gate."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
policy: OwnerMergeApprovalPolicy,
|
||||
verifier: OwnerMergeApprovalVerifier,
|
||||
live_state_reader: Callable[[], OwnerMergeLiveState],
|
||||
executor: GovernedMergeExecutor,
|
||||
reconciler: MergeOidReconciler,
|
||||
nonce_ledger: SQLiteOwnerMergeNonceLedger,
|
||||
clock: Callable[[], float] = time.time,
|
||||
) -> None:
|
||||
policy.validate()
|
||||
self.policy = policy
|
||||
self.verifier = verifier
|
||||
self.live_state_reader = live_state_reader
|
||||
self.executor = executor
|
||||
self.reconciler = reconciler
|
||||
self.nonce_ledger = nonce_ledger
|
||||
self.clock = clock
|
||||
|
||||
def _validate_live_state(self, live: OwnerMergeLiveState) -> None:
|
||||
if not isinstance(live, OwnerMergeLiveState):
|
||||
_fail("OWNER_MERGE_LIVE_STATE_INVALID")
|
||||
policy = self.policy
|
||||
if live.repository != policy.repository:
|
||||
_fail("OWNER_MERGE_REPOSITORY_DRIFT")
|
||||
if live.pr_number != policy.pr_number:
|
||||
_fail("OWNER_MERGE_PR_BINDING_DRIFT")
|
||||
if live.pr_head_oid != policy.pr_head_oid:
|
||||
_fail("OWNER_MERGE_HEAD_DRIFT")
|
||||
if live.main_oid != policy.expected_main_oid:
|
||||
_fail("OWNER_MERGE_BASE_DRIFT")
|
||||
if live.required_check_contract_digest != policy.required_check_contract_digest:
|
||||
_fail("OWNER_MERGE_REQUIRED_CHECK_CONTRACT_DRIFT")
|
||||
if not isinstance(live.check_statuses, Mapping) or set(live.check_statuses) != set(policy.required_checks):
|
||||
_fail("OWNER_MERGE_REQUIRED_CHECK_CONTRACT_DRIFT")
|
||||
if any(value is not True for value in live.check_statuses.values()):
|
||||
_fail("OWNER_MERGE_REQUIRED_CHECKS_FAILED")
|
||||
|
||||
def admit(self, artifact: SignedArtifact | Mapping[str, Any]) -> OwnerMergeAdmissionReceipt:
|
||||
approval, proof_path = self.verifier.verify(artifact, self.policy)
|
||||
self._validate_live_state(self.live_state_reader())
|
||||
nonce = str(approval["nonce"])
|
||||
approval_digest = hashlib.sha256(canonical_json_bytes(approval)).hexdigest()
|
||||
self.nonce_ledger.claim(nonce=nonce, approval_digest=approval_digest, now=int(self.clock()))
|
||||
try:
|
||||
merge_oid = self.executor.execute(dict(approval))
|
||||
_require_oid(merge_oid, "OWNER_MERGE_EXECUTOR_RESULT_INVALID")
|
||||
readback = self.reconciler.readback(merge_oid)
|
||||
if (
|
||||
not isinstance(readback, OwnerMergeOidReadback)
|
||||
or readback.authority_main_oid != merge_oid
|
||||
or readback.gitea_main_oid != merge_oid
|
||||
or readback.api_main_oid != merge_oid
|
||||
):
|
||||
self.nonce_ledger.mark(nonce=nonce, state="UNKNOWN")
|
||||
_fail("OWNER_MERGE_OID_RECONCILIATION_REQUIRED")
|
||||
except OwnerMergeApprovalError:
|
||||
# Every failure after a durable claim represents an operation that
|
||||
# is either known failed or needs reconciliation. Preserve the
|
||||
# one-shot fence rather than exposing a retryable PENDING state.
|
||||
try:
|
||||
self.nonce_ledger.mark(nonce=nonce, state="UNKNOWN")
|
||||
except OwnerMergeApprovalError:
|
||||
pass
|
||||
raise
|
||||
except Exception as exc:
|
||||
try:
|
||||
self.nonce_ledger.mark(nonce=nonce, state="UNKNOWN")
|
||||
except OwnerMergeApprovalError:
|
||||
pass
|
||||
raise OwnerMergeApprovalError("OWNER_MERGE_EXECUTION_UNKNOWN") from exc
|
||||
self.nonce_ledger.mark(nonce=nonce, state="COMPLETED")
|
||||
return OwnerMergeAdmissionReceipt(
|
||||
approval_id=str(approval["approval_id"]),
|
||||
nonce=nonce,
|
||||
owner_identity=str(approval["owner_identity"]),
|
||||
policy_digest=str(approval["policy_digest"]),
|
||||
proof_path=proof_path,
|
||||
merge_oid=merge_oid,
|
||||
)
|
||||
@@ -0,0 +1,287 @@
|
||||
"""Restricted issuance service for ``aios.owner-merge-approval.v2``.
|
||||
|
||||
The service has one Owner-only operation. It accepts an empty request payload,
|
||||
loads one immutable policy, generates approval identifiers and nonces itself,
|
||||
and stores one exact issuance response. It deliberately does not expose a
|
||||
generic signing, payload, or policy-selection API.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import stat
|
||||
import time
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
from aios_owner_merge_authorization import (
|
||||
OwnerMergeApprovalAuthority,
|
||||
OwnerMergeApprovalError,
|
||||
OwnerMergeApprovalPolicy,
|
||||
OwnerMergeApprovalVerifier,
|
||||
)
|
||||
from aios_single_host_authority import RegistryOwnerApprovalVerifier, SignedArtifact
|
||||
|
||||
|
||||
ISSUE_OPERATION = "issue_owner_merge_approval"
|
||||
|
||||
|
||||
def _fail(code: str) -> None:
|
||||
raise OwnerMergeApprovalError(code)
|
||||
|
||||
|
||||
def _secure_regular(path: Path, *, require_root_owner: bool) -> None:
|
||||
try:
|
||||
metadata = path.lstat()
|
||||
except OSError as exc:
|
||||
raise OwnerMergeApprovalError("OWNER_MERGE_ISSUER_FILE_MISSING") from exc
|
||||
if path.is_symlink() or not stat.S_ISREG(metadata.st_mode):
|
||||
_fail("OWNER_MERGE_ISSUER_FILE_INVALID")
|
||||
if stat.S_IMODE(metadata.st_mode) & 0o022:
|
||||
_fail("OWNER_MERGE_ISSUER_FILE_WRITABLE")
|
||||
if require_root_owner and metadata.st_uid != 0:
|
||||
_fail("OWNER_MERGE_ISSUER_FILE_NOT_ROOT_OWNED")
|
||||
|
||||
|
||||
def _read_policy(path: Path, *, require_root_owner: bool) -> OwnerMergeApprovalPolicy:
|
||||
_secure_regular(path, require_root_owner=require_root_owner)
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise OwnerMergeApprovalError("OWNER_MERGE_ISSUER_POLICY_INVALID") from exc
|
||||
if not isinstance(value, Mapping):
|
||||
_fail("OWNER_MERGE_ISSUER_POLICY_INVALID")
|
||||
expected = set(OwnerMergeApprovalPolicy.__dataclass_fields__)
|
||||
if set(value) != expected or not isinstance(value.get("required_checks"), list):
|
||||
_fail("OWNER_MERGE_ISSUER_POLICY_INVALID")
|
||||
try:
|
||||
policy = OwnerMergeApprovalPolicy(
|
||||
repository=value["repository"],
|
||||
pr_number=value["pr_number"],
|
||||
pr_head_oid=value["pr_head_oid"],
|
||||
expected_main_oid=value["expected_main_oid"],
|
||||
operation=value["operation"],
|
||||
required_check_contract_digest=value["required_check_contract_digest"],
|
||||
required_checks=tuple(value["required_checks"]),
|
||||
owner_identity=value["owner_identity"],
|
||||
audit_lineage_digest=value["audit_lineage_digest"],
|
||||
maximum_ttl_seconds=value["maximum_ttl_seconds"],
|
||||
policy_version=value["policy_version"],
|
||||
schema=value["schema"],
|
||||
)
|
||||
policy.validate()
|
||||
except (KeyError, TypeError, OwnerMergeApprovalError) as exc:
|
||||
if isinstance(exc, OwnerMergeApprovalError):
|
||||
raise OwnerMergeApprovalError("OWNER_MERGE_ISSUER_POLICY_INVALID") from exc
|
||||
raise OwnerMergeApprovalError("OWNER_MERGE_ISSUER_POLICY_INVALID") from exc
|
||||
return policy
|
||||
|
||||
|
||||
def _artifact_dict(artifact: SignedArtifact) -> dict[str, Any]:
|
||||
return {
|
||||
"payload": dict(artifact.payload),
|
||||
"signature": artifact.signature,
|
||||
"key_id": artifact.key_id,
|
||||
"namespace": artifact.namespace,
|
||||
}
|
||||
|
||||
|
||||
class SQLiteOwnerMergeIssuanceLedger:
|
||||
"""Durably replay a current approval and retain expired issuance history."""
|
||||
|
||||
def __init__(self, path: Path) -> None:
|
||||
self.path = Path(path)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
if self.path.is_symlink() or self.path.parent.is_symlink():
|
||||
_fail("OWNER_MERGE_ISSUER_LEDGER_PATH_INVALID")
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
||||
connection = sqlite3.connect(self.path, timeout=5, isolation_level=None)
|
||||
connection.execute(
|
||||
"CREATE TABLE IF NOT EXISTS owner_merge_issuance ("
|
||||
"nonce TEXT PRIMARY KEY, policy_digest TEXT NOT NULL, response_json TEXT NOT NULL, "
|
||||
"issued_at INTEGER NOT NULL, expires_at INTEGER NOT NULL)"
|
||||
)
|
||||
connection.execute(
|
||||
"CREATE INDEX IF NOT EXISTS owner_merge_issuance_policy_expiry "
|
||||
"ON owner_merge_issuance(policy_digest, expires_at)"
|
||||
)
|
||||
return connection
|
||||
|
||||
def issue_once(
|
||||
self,
|
||||
*,
|
||||
policy_digest: str,
|
||||
now: int,
|
||||
issuer: Callable[[], dict[str, Any]],
|
||||
validate_current: Callable[[dict[str, Any]], None],
|
||||
expiry_of: Callable[[dict[str, Any]], int],
|
||||
) -> tuple[dict[str, Any], bool]:
|
||||
connection = self._connect()
|
||||
try:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
rows = connection.execute(
|
||||
"SELECT response_json, expires_at FROM owner_merge_issuance "
|
||||
"WHERE policy_digest=? AND expires_at>? ORDER BY issued_at DESC",
|
||||
(policy_digest, now),
|
||||
).fetchall()
|
||||
if len(rows) > 1:
|
||||
connection.rollback()
|
||||
_fail("OWNER_MERGE_ISSUER_LEDGER_INVALID")
|
||||
if rows:
|
||||
try:
|
||||
response = json.loads(rows[0][0])
|
||||
except (TypeError, json.JSONDecodeError) as exc:
|
||||
connection.rollback()
|
||||
raise OwnerMergeApprovalError("OWNER_MERGE_ISSUER_LEDGER_INVALID") from exc
|
||||
if not isinstance(response, dict):
|
||||
connection.rollback()
|
||||
_fail("OWNER_MERGE_ISSUER_LEDGER_INVALID")
|
||||
if type(rows[0][1]) is not int or expiry_of(response) != rows[0][1]:
|
||||
connection.rollback()
|
||||
_fail("OWNER_MERGE_ISSUER_LEDGER_INVALID")
|
||||
validate_current(response)
|
||||
connection.rollback()
|
||||
return response, True
|
||||
response = issuer()
|
||||
expires_at = expiry_of(response)
|
||||
if expires_at <= now:
|
||||
connection.rollback()
|
||||
_fail("OWNER_MERGE_ISSUER_RESPONSE_EXPIRED")
|
||||
artifact = response.get("artifact")
|
||||
if not isinstance(artifact, Mapping):
|
||||
connection.rollback()
|
||||
_fail("OWNER_MERGE_ISSUER_LEDGER_INVALID")
|
||||
payload = artifact.get("payload")
|
||||
if not isinstance(payload, Mapping) or not isinstance(payload.get("nonce"), str):
|
||||
connection.rollback()
|
||||
_fail("OWNER_MERGE_ISSUER_LEDGER_INVALID")
|
||||
encoded = json.dumps(
|
||||
response,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO owner_merge_issuance(nonce,policy_digest,response_json,issued_at,expires_at) "
|
||||
"VALUES(?,?,?,?,?)",
|
||||
(payload["nonce"], policy_digest, encoded, now, expires_at),
|
||||
)
|
||||
connection.commit()
|
||||
return response, False
|
||||
except OwnerMergeApprovalError:
|
||||
raise
|
||||
except sqlite3.IntegrityError as exc:
|
||||
try:
|
||||
connection.rollback()
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
raise OwnerMergeApprovalError("OWNER_MERGE_ISSUER_NONCE_CONFLICT") from exc
|
||||
except sqlite3.Error as exc:
|
||||
try:
|
||||
connection.rollback()
|
||||
except sqlite3.Error:
|
||||
pass
|
||||
raise OwnerMergeApprovalError("OWNER_MERGE_ISSUER_LEDGER_UNAVAILABLE") from exc
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
class OwnerMergeApprovalService:
|
||||
"""A formal structured adapter, not an arbitrary signing oracle."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
policy_path: Path,
|
||||
registry_path: Path,
|
||||
owner_key_path: Path,
|
||||
issuance_ledger_path: Path,
|
||||
allowed_uids: tuple[int, ...],
|
||||
require_root_owner: bool = True,
|
||||
clock: Callable[[], float] = time.time,
|
||||
token_urlsafe: Callable[[int], str] | None = None,
|
||||
) -> None:
|
||||
if not allowed_uids or any(type(uid) is not int or uid < 0 for uid in allowed_uids):
|
||||
_fail("OWNER_MERGE_ISSUER_ALLOWED_UIDS_INVALID")
|
||||
self.policy_path = Path(policy_path)
|
||||
self.registry_path = Path(registry_path)
|
||||
self.owner_key_path = Path(owner_key_path)
|
||||
self.allowed_uids = tuple(allowed_uids)
|
||||
self.require_root_owner = require_root_owner
|
||||
self.clock = clock
|
||||
self.token_urlsafe = token_urlsafe
|
||||
self.ledger = SQLiteOwnerMergeIssuanceLedger(issuance_ledger_path)
|
||||
|
||||
def handle(self, request: Mapping[str, Any], *, peer_uid: int) -> dict[str, Any]:
|
||||
if peer_uid not in self.allowed_uids:
|
||||
_fail("OWNER_MERGE_ISSUER_PEER_UID_FORBIDDEN")
|
||||
if not isinstance(request, Mapping) or set(request) != {"operation", "payload"}:
|
||||
_fail("OWNER_MERGE_ISSUER_REQUEST_INVALID")
|
||||
if request.get("operation") != ISSUE_OPERATION:
|
||||
_fail("OWNER_MERGE_ISSUER_OPERATION_FORBIDDEN")
|
||||
if request.get("payload") != {}:
|
||||
_fail("OWNER_MERGE_ISSUER_REQUEST_FIELDS_INVALID")
|
||||
|
||||
policy = _read_policy(
|
||||
self.policy_path,
|
||||
require_root_owner=self.require_root_owner,
|
||||
)
|
||||
_secure_regular(
|
||||
self.owner_key_path,
|
||||
require_root_owner=self.require_root_owner,
|
||||
)
|
||||
primary = RegistryOwnerApprovalVerifier(
|
||||
self.registry_path,
|
||||
require_root_owner=self.require_root_owner,
|
||||
)
|
||||
verifier = OwnerMergeApprovalVerifier(primary=primary, clock=self.clock)
|
||||
|
||||
def expiry_of(response: dict[str, Any]) -> int:
|
||||
if set(response) != {"status", "artifact"} or response.get("status") != "PASS":
|
||||
_fail("OWNER_MERGE_ISSUER_LEDGER_INVALID")
|
||||
artifact = response.get("artifact")
|
||||
if not isinstance(artifact, Mapping):
|
||||
_fail("OWNER_MERGE_ISSUER_LEDGER_INVALID")
|
||||
payload = artifact.get("payload")
|
||||
if not isinstance(payload, Mapping) or type(payload.get("expires_at")) is not int:
|
||||
_fail("OWNER_MERGE_ISSUER_LEDGER_INVALID")
|
||||
return payload["expires_at"]
|
||||
|
||||
def validate_current(response: dict[str, Any]) -> None:
|
||||
expiry_of(response)
|
||||
artifact = response["artifact"]
|
||||
if not isinstance(artifact, Mapping):
|
||||
_fail("OWNER_MERGE_ISSUER_LEDGER_INVALID")
|
||||
try:
|
||||
verifier.verify(artifact, policy)
|
||||
except OwnerMergeApprovalError as exc:
|
||||
raise OwnerMergeApprovalError("OWNER_MERGE_ISSUER_LEDGER_INVALID") from exc
|
||||
|
||||
def issue() -> dict[str, Any]:
|
||||
kwargs: dict[str, Any] = {}
|
||||
if self.token_urlsafe is not None:
|
||||
kwargs["token_urlsafe"] = self.token_urlsafe
|
||||
artifact = OwnerMergeApprovalAuthority(
|
||||
self.owner_key_path,
|
||||
policy,
|
||||
require_root_owner=self.require_root_owner,
|
||||
clock=self.clock,
|
||||
**kwargs,
|
||||
).issue()
|
||||
value = _artifact_dict(artifact)
|
||||
verifier.verify(value, policy)
|
||||
return {"status": "PASS", "artifact": value}
|
||||
|
||||
response, idempotent = self.ledger.issue_once(
|
||||
policy_digest=policy.digest,
|
||||
now=int(self.clock()),
|
||||
issuer=issue,
|
||||
validate_current=validate_current,
|
||||
expiry_of=expiry_of,
|
||||
)
|
||||
if set(response) != {"status", "artifact"} or response.get("status") != "PASS":
|
||||
_fail("OWNER_MERGE_ISSUER_LEDGER_INVALID")
|
||||
return {**response, "idempotent": idempotent}
|
||||
@@ -0,0 +1,967 @@
|
||||
"""Bounded, read-only discovery over a verified immutable Git repository.
|
||||
|
||||
The module is deliberately independent from Task, lease, credential, worktree,
|
||||
and write code. Every public operation verifies the exact repository identity,
|
||||
resolves a floating ref once to a full commit OID, and then reads only that
|
||||
immutable snapshot.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from collections.abc import Iterable, Iterator, Mapping
|
||||
import fnmatch
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
from authoritative_git import AuthoritativeGitError, verify_repository
|
||||
|
||||
|
||||
_OID_RE = re.compile(r"^[0-9a-f]{40}$")
|
||||
_REF_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,239}$")
|
||||
_IDENTITY_RE = re.compile(
|
||||
r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}/[A-Za-z0-9][A-Za-z0-9._-]{0,127}$"
|
||||
)
|
||||
_READ_ONLY_COMMANDS = frozenset({"cat-file", "diff", "ls-tree", "rev-parse"})
|
||||
_MAX_COMMAND_OUTPUT = 1_048_576
|
||||
_MAX_RECORD_BYTES = 1_048_576
|
||||
_MAX_LIMIT = 1_000
|
||||
_MAX_DEPTH = 32
|
||||
_MAX_BYTES = 1_048_576
|
||||
_MAX_GLOBS = 16
|
||||
_MAX_QUERY_LENGTH = 512
|
||||
_MAX_CURSOR_LENGTH = 4_096
|
||||
_GIT_TIMEOUT_SECONDS = 8.0
|
||||
|
||||
|
||||
class RepositoryDiscoveryError(RuntimeError):
|
||||
"""A fail-closed repository discovery request error."""
|
||||
|
||||
def __init__(self, code: str, *, status_code: int | None = None) -> None:
|
||||
self.code = str(code)
|
||||
self.status_code = (
|
||||
status_code if status_code is not None else self._status_for(self.code)
|
||||
)
|
||||
super().__init__(self.code)
|
||||
|
||||
@staticmethod
|
||||
def _status_for(code: str) -> int:
|
||||
if "OUTPUT_LIMIT" in code:
|
||||
return 413
|
||||
if code.endswith("_NOT_FOUND"):
|
||||
return 404
|
||||
if code in {"REPOSITORY_NOT_ALLOWED", "REPOSITORY_IDENTITY_MISMATCH"}:
|
||||
return 403
|
||||
if code in {
|
||||
"REPOSITORY_DISCOVERY_UNAVAILABLE",
|
||||
"REPOSITORY_DISCOVERY_GIT_FAILED",
|
||||
"REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID",
|
||||
"REPOSITORY_DISCOVERY_CURSOR_SECRET_REQUIRED",
|
||||
}:
|
||||
return 503
|
||||
return 400
|
||||
|
||||
|
||||
class RepositoryDiscoveryNotFound(RepositoryDiscoveryError):
|
||||
"""A validated immutable object or path was not found."""
|
||||
|
||||
|
||||
class RepositoryDiscoveryOutputTooLarge(RepositoryDiscoveryError):
|
||||
"""A fixed read-only Git query exceeded its collection boundary."""
|
||||
|
||||
|
||||
def _error(code: str) -> RepositoryDiscoveryError:
|
||||
return RepositoryDiscoveryError(code)
|
||||
|
||||
|
||||
def _request_mapping(request: Mapping[str, object]) -> Mapping[str, object]:
|
||||
if not isinstance(request, Mapping):
|
||||
raise _error("REPOSITORY_DISCOVERY_REQUEST_INVALID")
|
||||
return request
|
||||
|
||||
|
||||
def _required_request_value(request: Mapping[str, object], field: str) -> object:
|
||||
value = request.get(field)
|
||||
if value is None:
|
||||
raise _error("REPOSITORY_DISCOVERY_REQUEST_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _canonical(value: Mapping[str, object]) -> bytes:
|
||||
return json.dumps(
|
||||
value, sort_keys=True, separators=(",", ":"), ensure_ascii=True
|
||||
).encode("ascii")
|
||||
|
||||
|
||||
def _positive_int(value: object, field: str, maximum: int) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= maximum:
|
||||
raise _error(f"REPOSITORY_DISCOVERY_INVALID_{field.upper()}")
|
||||
return value
|
||||
|
||||
|
||||
def _safe_relative_path(
|
||||
value: object,
|
||||
field: str,
|
||||
*,
|
||||
allow_empty: bool,
|
||||
allow_glob: bool = False,
|
||||
) -> str:
|
||||
if not isinstance(value, str) or value != value.strip() or "\x00" in value:
|
||||
raise _error("REPOSITORY_DISCOVERY_PATH_INVALID")
|
||||
if "\\" in value:
|
||||
raise _error("REPOSITORY_DISCOVERY_PATH_INVALID")
|
||||
if value in ("", "."):
|
||||
if allow_empty:
|
||||
return ""
|
||||
raise _error("REPOSITORY_DISCOVERY_PATH_INVALID")
|
||||
if value.startswith(("/", "-")) or "//" in value or ":" in value:
|
||||
raise _error("REPOSITORY_DISCOVERY_PATH_INVALID")
|
||||
if not allow_glob and any(character in value for character in "*?["):
|
||||
raise _error("REPOSITORY_DISCOVERY_PATH_INVALID")
|
||||
parts = value.split("/")
|
||||
if any(part in ("", ".", "..") for part in parts):
|
||||
raise _error("REPOSITORY_DISCOVERY_PATH_INVALID")
|
||||
if len(value) > 512:
|
||||
raise _error("REPOSITORY_DISCOVERY_PATH_INVALID")
|
||||
return value
|
||||
|
||||
|
||||
def _safe_glob(value: object) -> str:
|
||||
return _safe_relative_path(value, "path_glob", allow_empty=False, allow_glob=True)
|
||||
|
||||
|
||||
def _safe_ref(value: str) -> bool:
|
||||
return bool(
|
||||
_REF_RE.fullmatch(value)
|
||||
and ".." not in value
|
||||
and "//" not in value
|
||||
and "@{" not in value
|
||||
and not value.endswith(("/", ".", ".lock"))
|
||||
and not any(part in ("", ".", "..") for part in value.split("/"))
|
||||
)
|
||||
|
||||
|
||||
def _parse_tree_record(record: bytes) -> dict[str, str] | None:
|
||||
try:
|
||||
header, raw_path = record.split(b"\t", 1)
|
||||
mode, kind, oid = header.decode("ascii").split(" ", 2)
|
||||
path = raw_path.decode("utf-8")
|
||||
except (UnicodeDecodeError, ValueError) as exc:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID") from exc
|
||||
if kind not in {"blob", "tree", "commit"} or not _OID_RE.fullmatch(oid.lower()):
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID")
|
||||
try:
|
||||
_safe_relative_path(path, "tree_path", allow_empty=False)
|
||||
except RepositoryDiscoveryError:
|
||||
# Git permits filenames outside the public POSIX-relative contract.
|
||||
# They are not exposed as usable discovery paths.
|
||||
return None
|
||||
return {"mode": mode, "type": kind, "object_oid": oid.lower(), "path": path}
|
||||
|
||||
|
||||
def _decode_output_path(raw: bytes) -> str:
|
||||
try:
|
||||
value = raw.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID") from exc
|
||||
return _safe_relative_path(value, "git_output_path", allow_empty=False)
|
||||
|
||||
|
||||
class RepositoryDiscovery:
|
||||
"""Repository-scoped immutable Git discovery with signed cursors."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
allowed_repositories: Iterable[str] | None = None,
|
||||
git_dir: str | Path | None = None,
|
||||
cursor_secret: str | bytes | None = None,
|
||||
) -> None:
|
||||
configured = allowed_repositories
|
||||
if configured is None:
|
||||
configured = (os.getenv("AIOS_GITOPS_REPOSITORY_IDENTITY", ""),)
|
||||
identities = tuple(configured)
|
||||
if not identities or any(
|
||||
not isinstance(item, str) or not _IDENTITY_RE.fullmatch(item)
|
||||
for item in identities
|
||||
):
|
||||
raise _error("REPOSITORY_DISCOVERY_ALLOWLIST_INVALID")
|
||||
self._allowed_repositories = frozenset(identities)
|
||||
self._git_dir = git_dir
|
||||
secret = (
|
||||
cursor_secret
|
||||
if cursor_secret is not None
|
||||
else os.getenv("AIOS_REPOSITORY_DISCOVERY_CURSOR_SECRET", "")
|
||||
)
|
||||
if isinstance(secret, str):
|
||||
secret = secret.encode("utf-8")
|
||||
if not isinstance(secret, bytes) or len(secret) < 16:
|
||||
raise _error("REPOSITORY_DISCOVERY_CURSOR_SECRET_REQUIRED")
|
||||
self._cursor_secret = secret
|
||||
|
||||
def _repository_path(self, repository: object) -> Path:
|
||||
if not isinstance(repository, str) or repository not in self._allowed_repositories:
|
||||
raise _error("REPOSITORY_NOT_ALLOWED")
|
||||
try:
|
||||
path, actual_identity = verify_repository(
|
||||
git_dir=self._git_dir,
|
||||
repository_identity=repository,
|
||||
)
|
||||
except AuthoritativeGitError as exc:
|
||||
code = (
|
||||
"REPOSITORY_IDENTITY_MISMATCH"
|
||||
if "REPOSITORY_IDENTITY_MISMATCH" in str(exc)
|
||||
else "REPOSITORY_DISCOVERY_UNAVAILABLE"
|
||||
)
|
||||
raise _error(code) from exc
|
||||
if actual_identity != repository:
|
||||
raise _error("REPOSITORY_IDENTITY_MISMATCH")
|
||||
return path
|
||||
|
||||
@staticmethod
|
||||
def _git_env() -> dict[str, str]:
|
||||
return {
|
||||
**os.environ,
|
||||
"GIT_OPTIONAL_LOCKS": "0",
|
||||
"GIT_CONFIG_NOSYSTEM": "1",
|
||||
"GIT_TERMINAL_PROMPT": "0",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _run(
|
||||
path: Path,
|
||||
args: list[str],
|
||||
*,
|
||||
max_output: int = _MAX_COMMAND_OUTPUT,
|
||||
) -> bytes:
|
||||
if not args or args[0] not in _READ_ONLY_COMMANDS:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_COMMAND_REJECTED")
|
||||
output_limit = min(max_output, _MAX_COMMAND_OUTPUT)
|
||||
process: subprocess.Popen[bytes] | None = None
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
["git", "--git-dir", str(path), *args],
|
||||
shell=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=RepositoryDiscovery._git_env(),
|
||||
)
|
||||
assert process.stdout is not None
|
||||
output = process.stdout.read(output_limit + 1)
|
||||
if len(output) > output_limit:
|
||||
process.kill()
|
||||
process.wait(timeout=1)
|
||||
raise RepositoryDiscoveryOutputTooLarge(
|
||||
"REPOSITORY_DISCOVERY_OUTPUT_LIMIT"
|
||||
)
|
||||
returncode = process.wait(timeout=_GIT_TIMEOUT_SECONDS)
|
||||
except RepositoryDiscoveryError:
|
||||
raise
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
if process is not None:
|
||||
try:
|
||||
process.kill()
|
||||
process.wait(timeout=1)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_FAILED") from exc
|
||||
if returncode != 0:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_FAILED")
|
||||
return output
|
||||
|
||||
@staticmethod
|
||||
def _read_blob_prefix(path: Path, oid: str, maximum: int) -> tuple[bytes, bool]:
|
||||
"""Read at most maximum bytes plus one sentinel byte."""
|
||||
process: subprocess.Popen[bytes] | None = None
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
["git", "--git-dir", str(path), "cat-file", "blob", oid],
|
||||
shell=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=RepositoryDiscovery._git_env(),
|
||||
)
|
||||
assert process.stdout is not None
|
||||
content = process.stdout.read(maximum + 1)
|
||||
truncated = len(content) > maximum
|
||||
if truncated:
|
||||
process.kill()
|
||||
process.wait(timeout=1)
|
||||
return content[:maximum], True
|
||||
if process.wait(timeout=_GIT_TIMEOUT_SECONDS) != 0:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_FAILED")
|
||||
return content, False
|
||||
except RepositoryDiscoveryError:
|
||||
raise
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
if process is not None:
|
||||
try:
|
||||
process.kill()
|
||||
process.wait(timeout=1)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_FAILED") from exc
|
||||
|
||||
def _iter_nul_records(self, path: Path, args: list[str]) -> Iterator[bytes]:
|
||||
if not args or args[0] not in _READ_ONLY_COMMANDS:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_COMMAND_REJECTED")
|
||||
process: subprocess.Popen[bytes] | None = None
|
||||
buffer = b""
|
||||
started = time.monotonic()
|
||||
try:
|
||||
process = subprocess.Popen(
|
||||
["git", "--git-dir", str(path), *args],
|
||||
shell=False,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.DEVNULL,
|
||||
env=self._git_env(),
|
||||
)
|
||||
assert process.stdout is not None
|
||||
while True:
|
||||
if time.monotonic() - started > _GIT_TIMEOUT_SECONDS:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_FAILED")
|
||||
chunk = process.stdout.read(64 * 1024)
|
||||
if not chunk:
|
||||
break
|
||||
buffer += chunk
|
||||
if len(buffer) > _MAX_RECORD_BYTES and b"\0" not in buffer:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID")
|
||||
while b"\0" in buffer:
|
||||
record, buffer = buffer.split(b"\0", 1)
|
||||
if len(record) > _MAX_RECORD_BYTES:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID")
|
||||
yield record
|
||||
if buffer:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID")
|
||||
if process.wait(
|
||||
timeout=max(0.1, _GIT_TIMEOUT_SECONDS - (time.monotonic() - started))
|
||||
) != 0:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_FAILED")
|
||||
except RepositoryDiscoveryError:
|
||||
raise
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_FAILED") from exc
|
||||
finally:
|
||||
if process is not None and process.poll() is None:
|
||||
try:
|
||||
process.kill()
|
||||
process.wait(timeout=1)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
pass
|
||||
|
||||
def _resolve_at(self, path: Path, revision: object, field: str) -> str:
|
||||
if (
|
||||
not isinstance(revision, str)
|
||||
or revision != revision.strip()
|
||||
or not revision
|
||||
or revision.startswith("-")
|
||||
):
|
||||
raise _error("REPOSITORY_DISCOVERY_INVALID_REVISION")
|
||||
if _OID_RE.fullmatch(revision.lower()):
|
||||
candidates = [revision.lower()]
|
||||
elif revision == "HEAD":
|
||||
candidates = ["HEAD"]
|
||||
elif _safe_ref(revision) and not any(
|
||||
token in revision for token in ("^", "~", ":", "\\")
|
||||
):
|
||||
if revision.startswith("refs/"):
|
||||
if not revision.startswith(("refs/heads/", "refs/tags/")):
|
||||
raise _error("REPOSITORY_DISCOVERY_INVALID_REVISION")
|
||||
candidates = [revision]
|
||||
else:
|
||||
candidates = [f"refs/heads/{revision}", f"refs/tags/{revision}"]
|
||||
else:
|
||||
raise _error("REPOSITORY_DISCOVERY_INVALID_REVISION")
|
||||
resolved: list[str] = []
|
||||
for candidate in candidates:
|
||||
try:
|
||||
output = self._run(
|
||||
path,
|
||||
["rev-parse", "--verify", f"{candidate}^{{commit}}"],
|
||||
max_output=256,
|
||||
)
|
||||
except RepositoryDiscoveryError:
|
||||
continue
|
||||
oid = output.decode("ascii", errors="ignore").strip().lower()
|
||||
if _OID_RE.fullmatch(oid):
|
||||
resolved.append(oid)
|
||||
if not resolved:
|
||||
raise RepositoryDiscoveryNotFound(
|
||||
f"REPOSITORY_DISCOVERY_{field.upper()}_NOT_FOUND"
|
||||
)
|
||||
if len(set(resolved)) != 1:
|
||||
raise _error("REPOSITORY_DISCOVERY_AMBIGUOUS_REVISION")
|
||||
return resolved[0]
|
||||
|
||||
def resolve_revision(self, repository: str, revision: str) -> str:
|
||||
return self._resolve_at(self._repository_path(repository), revision, "revision")
|
||||
|
||||
def _cursor_offset(
|
||||
self,
|
||||
cursor: str | None,
|
||||
*,
|
||||
operation: str,
|
||||
repository: str,
|
||||
resolved_oid: str,
|
||||
query: Mapping[str, object],
|
||||
) -> int:
|
||||
if cursor is None:
|
||||
return 0
|
||||
if (
|
||||
not isinstance(cursor, str)
|
||||
or not cursor
|
||||
or len(cursor) > _MAX_CURSOR_LENGTH
|
||||
or "." not in cursor
|
||||
):
|
||||
raise _error("REPOSITORY_DISCOVERY_CURSOR_INVALID")
|
||||
encoded, supplied_signature = cursor.split(".", 1)
|
||||
try:
|
||||
padded = encoded + "=" * (-len(encoded) % 4)
|
||||
payload = json.loads(
|
||||
base64.urlsafe_b64decode(padded.encode("ascii")).decode("ascii")
|
||||
)
|
||||
except (UnicodeError, ValueError, json.JSONDecodeError) as exc:
|
||||
raise _error("REPOSITORY_DISCOVERY_CURSOR_INVALID") from exc
|
||||
expected_signature = hmac.new(
|
||||
self._cursor_secret, encoded.encode("ascii"), hashlib.sha256
|
||||
).hexdigest()
|
||||
if not hmac.compare_digest(supplied_signature, expected_signature):
|
||||
raise _error("REPOSITORY_DISCOVERY_CURSOR_INVALID")
|
||||
expected = {
|
||||
"operation": operation,
|
||||
"repository": repository,
|
||||
"resolved_oid": resolved_oid,
|
||||
"query": dict(query),
|
||||
}
|
||||
if (
|
||||
not isinstance(payload, dict)
|
||||
or payload.get("v") != 1
|
||||
or any(payload.get(key) != value for key, value in expected.items())
|
||||
):
|
||||
raise _error("REPOSITORY_DISCOVERY_CURSOR_INVALID")
|
||||
offset = payload.get("offset")
|
||||
if isinstance(offset, bool) or not isinstance(offset, int) or offset < 0:
|
||||
raise _error("REPOSITORY_DISCOVERY_CURSOR_INVALID")
|
||||
return offset
|
||||
|
||||
def _next_cursor(
|
||||
self,
|
||||
*,
|
||||
operation: str,
|
||||
repository: str,
|
||||
resolved_oid: str,
|
||||
query: Mapping[str, object],
|
||||
offset: int,
|
||||
) -> str:
|
||||
payload = {
|
||||
"v": 1,
|
||||
"operation": operation,
|
||||
"repository": repository,
|
||||
"resolved_oid": resolved_oid,
|
||||
"query": dict(query),
|
||||
"offset": offset,
|
||||
}
|
||||
encoded = base64.urlsafe_b64encode(_canonical(payload)).decode("ascii").rstrip("=")
|
||||
signature = hmac.new(
|
||||
self._cursor_secret, encoded.encode("ascii"), hashlib.sha256
|
||||
).hexdigest()
|
||||
return f"{encoded}.{signature}"
|
||||
|
||||
def _iter_tree_entries(
|
||||
self,
|
||||
path: Path,
|
||||
oid: str,
|
||||
prefix: str,
|
||||
*,
|
||||
include_trees: bool,
|
||||
) -> Iterator[dict[str, str]]:
|
||||
args = ["ls-tree", "-z", "-r", "--full-tree"]
|
||||
if include_trees:
|
||||
args.append("-t")
|
||||
args.append(oid)
|
||||
if prefix:
|
||||
args.extend(["--", prefix])
|
||||
for record in self._iter_nul_records(path, args):
|
||||
entry = _parse_tree_record(record)
|
||||
if entry is None:
|
||||
continue
|
||||
if not prefix or entry["path"] == prefix or entry["path"].startswith(prefix + "/"):
|
||||
yield entry
|
||||
|
||||
def list_tree(
|
||||
self,
|
||||
*,
|
||||
repository: str,
|
||||
revision: str,
|
||||
prefix: str = "",
|
||||
depth: int = 8,
|
||||
limit: int = 100,
|
||||
cursor: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
prefix = _safe_relative_path(prefix, "prefix", allow_empty=True)
|
||||
depth = _positive_int(depth, "depth", _MAX_DEPTH)
|
||||
limit = _positive_int(limit, "limit", _MAX_LIMIT)
|
||||
path = self._repository_path(repository)
|
||||
resolved_oid = self._resolve_at(path, revision, "revision")
|
||||
query = {"prefix": prefix, "depth": depth, "limit": limit}
|
||||
offset = self._cursor_offset(
|
||||
cursor,
|
||||
operation="list_tree",
|
||||
repository=repository,
|
||||
resolved_oid=resolved_oid,
|
||||
query=query,
|
||||
)
|
||||
page: list[dict[str, str]] = []
|
||||
matched = 0
|
||||
for entry in self._iter_tree_entries(path, resolved_oid, prefix, include_trees=True):
|
||||
relative = entry["path"][len(prefix):].lstrip("/") if prefix else entry["path"]
|
||||
if relative.count("/") + 1 > depth:
|
||||
continue
|
||||
if matched >= offset and len(page) < limit:
|
||||
page.append(entry)
|
||||
matched += 1
|
||||
has_more = offset + len(page) < matched
|
||||
return {
|
||||
"repository": repository,
|
||||
"resolved_oid": resolved_oid,
|
||||
"entries": page,
|
||||
"truncated": has_more,
|
||||
"next_cursor": (
|
||||
self._next_cursor(
|
||||
operation="list_tree",
|
||||
repository=repository,
|
||||
resolved_oid=resolved_oid,
|
||||
query=query,
|
||||
offset=offset + len(page),
|
||||
)
|
||||
if has_more
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
def list_repository_tree(self, request: Mapping[str, object]) -> dict[str, object]:
|
||||
request = _request_mapping(request)
|
||||
return self.list_tree(
|
||||
repository=_required_request_value(request, "repository"), # type: ignore[arg-type]
|
||||
revision=request.get("revision", request.get("ref", "HEAD")), # type: ignore[arg-type]
|
||||
prefix=request.get("prefix", ""), # type: ignore[arg-type]
|
||||
depth=request.get("depth", 8), # type: ignore[arg-type]
|
||||
limit=request.get("limit", 100), # type: ignore[arg-type]
|
||||
cursor=request.get("cursor"), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
def _blob_size(self, path: Path, oid: str) -> int:
|
||||
output = self._run(path, ["cat-file", "-s", oid], max_output=128)
|
||||
try:
|
||||
size = int(output.decode("ascii").strip())
|
||||
except (UnicodeDecodeError, ValueError) as exc:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID") from exc
|
||||
if size < 0:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID")
|
||||
return size
|
||||
|
||||
def search_source(
|
||||
self,
|
||||
*,
|
||||
repository: str,
|
||||
revision: str,
|
||||
query: str,
|
||||
path_globs: Iterable[str] | None = None,
|
||||
max_matches: int = 100,
|
||||
max_bytes: int = 262_144,
|
||||
cursor: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
if (
|
||||
not isinstance(query, str)
|
||||
or not query
|
||||
or "\x00" in query
|
||||
or len(query) > _MAX_QUERY_LENGTH
|
||||
):
|
||||
raise _error("REPOSITORY_DISCOVERY_QUERY_INVALID")
|
||||
max_matches = _positive_int(max_matches, "max_matches", _MAX_LIMIT)
|
||||
max_bytes = _positive_int(max_bytes, "max_bytes", _MAX_BYTES)
|
||||
if path_globs is None:
|
||||
globs = ("*",)
|
||||
else:
|
||||
if isinstance(path_globs, (str, bytes)) or not isinstance(path_globs, Iterable):
|
||||
raise _error("REPOSITORY_DISCOVERY_PATH_INVALID")
|
||||
globs = tuple(_safe_glob(item) for item in path_globs)
|
||||
if not globs or len(globs) > _MAX_GLOBS:
|
||||
raise _error("REPOSITORY_DISCOVERY_PATH_INVALID")
|
||||
path = self._repository_path(repository)
|
||||
resolved_oid = self._resolve_at(path, revision, "revision")
|
||||
query_binding = {
|
||||
"query": query,
|
||||
"path_globs": list(globs),
|
||||
"max_matches": max_matches,
|
||||
"max_bytes": max_bytes,
|
||||
}
|
||||
offset = self._cursor_offset(
|
||||
cursor,
|
||||
operation="search_source",
|
||||
repository=repository,
|
||||
resolved_oid=resolved_oid,
|
||||
query=query_binding,
|
||||
)
|
||||
scanned_bytes = 0
|
||||
skipped_for_bound = False
|
||||
binary_blobs = 0
|
||||
matched = 0
|
||||
matches: list[dict[str, object]] = []
|
||||
has_more = False
|
||||
for entry in self._iter_tree_entries(path, resolved_oid, "", include_trees=False):
|
||||
if entry["type"] != "blob" or not any(
|
||||
fnmatch.fnmatchcase(entry["path"], pattern) for pattern in globs
|
||||
):
|
||||
continue
|
||||
blob_size = self._blob_size(path, entry["object_oid"])
|
||||
remaining = max_bytes - scanned_bytes
|
||||
if blob_size > remaining:
|
||||
skipped_for_bound = True
|
||||
continue
|
||||
raw, clipped = self._read_blob_prefix(path, entry["object_oid"], blob_size)
|
||||
if clipped:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_FAILED")
|
||||
scanned_bytes += len(raw)
|
||||
if b"\0" in raw:
|
||||
binary_blobs += 1
|
||||
continue
|
||||
text = raw.decode("utf-8", errors="replace")
|
||||
for number, line in enumerate(text.splitlines(), start=1):
|
||||
if query not in line:
|
||||
continue
|
||||
if matched < offset:
|
||||
matched += 1
|
||||
continue
|
||||
if len(matches) >= max_matches:
|
||||
has_more = True
|
||||
break
|
||||
matches.append(
|
||||
{
|
||||
"path": entry["path"],
|
||||
"line_start": number,
|
||||
"line_end": number,
|
||||
"blob_oid": entry["object_oid"],
|
||||
"snippet": line[:512],
|
||||
}
|
||||
)
|
||||
matched += 1
|
||||
if has_more:
|
||||
break
|
||||
return {
|
||||
"repository": repository,
|
||||
"resolved_oid": resolved_oid,
|
||||
"matches": matches,
|
||||
"scanned_bytes": scanned_bytes,
|
||||
"max_bytes": max_bytes,
|
||||
"binary_blobs_skipped": binary_blobs,
|
||||
"truncated": bool(has_more or skipped_for_bound),
|
||||
"next_cursor": (
|
||||
self._next_cursor(
|
||||
operation="search_source",
|
||||
repository=repository,
|
||||
resolved_oid=resolved_oid,
|
||||
query=query_binding,
|
||||
offset=offset + len(matches),
|
||||
)
|
||||
if has_more
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
def search_repository_source(self, request: Mapping[str, object]) -> dict[str, object]:
|
||||
request = _request_mapping(request)
|
||||
path_globs = request.get("path_globs")
|
||||
if path_globs is not None and (
|
||||
isinstance(path_globs, (str, bytes)) or not isinstance(path_globs, Iterable)
|
||||
):
|
||||
raise _error("REPOSITORY_DISCOVERY_PATH_INVALID")
|
||||
return self.search_source(
|
||||
repository=_required_request_value(request, "repository"), # type: ignore[arg-type]
|
||||
revision=request.get("revision", request.get("ref", "HEAD")), # type: ignore[arg-type]
|
||||
query=_required_request_value(request, "query"), # type: ignore[arg-type]
|
||||
path_globs=path_globs, # type: ignore[arg-type]
|
||||
max_matches=request.get("max_matches", 100), # type: ignore[arg-type]
|
||||
max_bytes=request.get("max_bytes", 262_144), # type: ignore[arg-type]
|
||||
cursor=request.get("cursor"), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
def _iter_diff_name_entries(
|
||||
self,
|
||||
path: Path,
|
||||
from_oid: str,
|
||||
to_oid: str,
|
||||
prefix: str,
|
||||
) -> Iterator[dict[str, object]]:
|
||||
suffix = ["--", prefix] if prefix else []
|
||||
records = self._iter_nul_records(
|
||||
path,
|
||||
[
|
||||
"diff",
|
||||
"--no-ext-diff",
|
||||
"--no-textconv",
|
||||
"--find-renames=50%",
|
||||
"--name-status",
|
||||
"-z",
|
||||
from_oid,
|
||||
to_oid,
|
||||
*suffix,
|
||||
],
|
||||
)
|
||||
iterator = iter(records)
|
||||
while True:
|
||||
try:
|
||||
status_raw = next(iterator)
|
||||
except StopIteration:
|
||||
return
|
||||
try:
|
||||
status = status_raw.decode("ascii")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID") from exc
|
||||
if not status or status[0] not in {"A", "C", "D", "M", "R", "T", "U", "X"}:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID")
|
||||
try:
|
||||
if status[0] in {"R", "C"}:
|
||||
old_path = _decode_output_path(next(iterator))
|
||||
new_path = _decode_output_path(next(iterator))
|
||||
yield {
|
||||
"status": status,
|
||||
"path": new_path,
|
||||
"old_path": old_path,
|
||||
}
|
||||
else:
|
||||
yield {
|
||||
"status": status,
|
||||
"path": _decode_output_path(next(iterator)),
|
||||
"old_path": None,
|
||||
}
|
||||
except StopIteration as exc:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID") from exc
|
||||
|
||||
@staticmethod
|
||||
def _parse_numstat_count(value: bytes) -> int | None:
|
||||
if value == b"-":
|
||||
return None
|
||||
try:
|
||||
parsed = int(value.decode("ascii"), 10)
|
||||
except (UnicodeDecodeError, ValueError) as exc:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID") from exc
|
||||
if parsed < 0:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID")
|
||||
return parsed
|
||||
|
||||
def _diff_stats(
|
||||
self,
|
||||
path: Path,
|
||||
from_oid: str,
|
||||
to_oid: str,
|
||||
prefix: str,
|
||||
wanted_paths: set[str],
|
||||
) -> dict[str, tuple[int | None, int | None]]:
|
||||
suffix = ["--", prefix] if prefix else []
|
||||
records = self._iter_nul_records(
|
||||
path,
|
||||
[
|
||||
"diff",
|
||||
"--no-ext-diff",
|
||||
"--no-textconv",
|
||||
"--find-renames=50%",
|
||||
"--numstat",
|
||||
"-z",
|
||||
from_oid,
|
||||
to_oid,
|
||||
*suffix,
|
||||
],
|
||||
)
|
||||
iterator = iter(records)
|
||||
stats: dict[str, tuple[int | None, int | None]] = {}
|
||||
for row in iterator:
|
||||
if not row:
|
||||
continue
|
||||
fields = row.split(b"\t", 2)
|
||||
if len(fields) != 3:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID")
|
||||
additions = self._parse_numstat_count(fields[0])
|
||||
deletions = self._parse_numstat_count(fields[1])
|
||||
if fields[2]:
|
||||
stat_path = _decode_output_path(fields[2])
|
||||
else:
|
||||
try:
|
||||
next(iterator) # old path
|
||||
stat_path = _decode_output_path(next(iterator))
|
||||
except StopIteration as exc:
|
||||
raise _error("REPOSITORY_DISCOVERY_GIT_OUTPUT_INVALID") from exc
|
||||
if stat_path in wanted_paths:
|
||||
stats[stat_path] = (additions, deletions)
|
||||
if wanted_paths <= set(stats):
|
||||
break
|
||||
return stats
|
||||
|
||||
def diff_index(
|
||||
self,
|
||||
*,
|
||||
repository: str,
|
||||
from_revision: str,
|
||||
to_revision: str,
|
||||
prefix: str = "",
|
||||
limit: int = 100,
|
||||
cursor: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
prefix = _safe_relative_path(prefix, "prefix", allow_empty=True)
|
||||
limit = _positive_int(limit, "limit", _MAX_LIMIT)
|
||||
path = self._repository_path(repository)
|
||||
from_oid = self._resolve_at(path, from_revision, "from")
|
||||
to_oid = self._resolve_at(path, to_revision, "to")
|
||||
query = {
|
||||
"from_oid": from_oid,
|
||||
"to_oid": to_oid,
|
||||
"prefix": prefix,
|
||||
"limit": limit,
|
||||
}
|
||||
offset = self._cursor_offset(
|
||||
cursor,
|
||||
operation="diff_index",
|
||||
repository=repository,
|
||||
resolved_oid=to_oid,
|
||||
query=query,
|
||||
)
|
||||
page: list[dict[str, object]] = []
|
||||
total = 0
|
||||
for entry in self._iter_diff_name_entries(path, from_oid, to_oid, prefix):
|
||||
if total >= offset and len(page) < limit:
|
||||
page.append(entry)
|
||||
total += 1
|
||||
wanted_paths = {str(entry["path"]) for entry in page}
|
||||
stat_by_path = (
|
||||
self._diff_stats(path, from_oid, to_oid, prefix, wanted_paths)
|
||||
if page
|
||||
else {}
|
||||
)
|
||||
for entry in page:
|
||||
additions, deletions = stat_by_path.get(
|
||||
str(entry["path"]), (None, None)
|
||||
)
|
||||
entry["additions"] = additions
|
||||
entry["deletions"] = deletions
|
||||
has_more = offset + len(page) < total
|
||||
return {
|
||||
"repository": repository,
|
||||
"resolved_from_oid": from_oid,
|
||||
"resolved_to_oid": to_oid,
|
||||
"entries": page,
|
||||
"truncated": has_more,
|
||||
"next_cursor": (
|
||||
self._next_cursor(
|
||||
operation="diff_index",
|
||||
repository=repository,
|
||||
resolved_oid=to_oid,
|
||||
query=query,
|
||||
offset=offset + len(page),
|
||||
)
|
||||
if has_more
|
||||
else None
|
||||
),
|
||||
}
|
||||
|
||||
def get_diff_index(self, request: Mapping[str, object]) -> dict[str, object]:
|
||||
request = _request_mapping(request)
|
||||
from_revision = request.get("from_revision", request.get("from"))
|
||||
to_revision = request.get("to_revision", request.get("to"))
|
||||
prefix = request.get("prefix", request.get("path_prefix", ""))
|
||||
if from_revision is None or to_revision is None:
|
||||
raise _error("REPOSITORY_DISCOVERY_REQUEST_INVALID")
|
||||
return self.diff_index(
|
||||
repository=_required_request_value(request, "repository"), # type: ignore[arg-type]
|
||||
from_revision=from_revision, # type: ignore[arg-type]
|
||||
to_revision=to_revision, # type: ignore[arg-type]
|
||||
prefix=prefix, # type: ignore[arg-type]
|
||||
limit=request.get("limit", 100), # type: ignore[arg-type]
|
||||
cursor=request.get("cursor"), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
def read_file(
|
||||
self,
|
||||
*,
|
||||
repository: str,
|
||||
revision: str,
|
||||
file_path: str,
|
||||
max_bytes: int = 262_144,
|
||||
) -> dict[str, object]:
|
||||
file_path = _safe_relative_path(file_path, "file_path", allow_empty=False)
|
||||
max_bytes = _positive_int(max_bytes, "max_bytes", _MAX_BYTES)
|
||||
path = self._repository_path(repository)
|
||||
resolved_oid = self._resolve_at(path, revision, "revision")
|
||||
exact: list[dict[str, str]] = []
|
||||
for entry in self._iter_tree_entries(
|
||||
path, resolved_oid, file_path, include_trees=False
|
||||
):
|
||||
if entry["path"] == file_path and entry["type"] == "blob":
|
||||
exact.append(entry)
|
||||
if len(exact) != 1:
|
||||
raise RepositoryDiscoveryNotFound("REPOSITORY_DISCOVERY_FILE_NOT_FOUND")
|
||||
blob_oid = exact[0]["object_oid"]
|
||||
content, truncated = self._read_blob_prefix(path, blob_oid, max_bytes)
|
||||
return {
|
||||
"repository": repository,
|
||||
"resolved_oid": resolved_oid,
|
||||
"blob_oid": blob_oid,
|
||||
"path": file_path,
|
||||
"content": content.decode("utf-8", errors="replace"),
|
||||
"content_base64": base64.b64encode(content).decode("ascii"),
|
||||
"content_bytes": len(content),
|
||||
"truncated": truncated,
|
||||
}
|
||||
|
||||
def read_repository_file(self, request: Mapping[str, object]) -> dict[str, object]:
|
||||
request = _request_mapping(request)
|
||||
file_path = request.get("file_path", request.get("path"))
|
||||
if file_path is None:
|
||||
raise _error("REPOSITORY_DISCOVERY_REQUEST_INVALID")
|
||||
return self.read_file(
|
||||
repository=_required_request_value(request, "repository"), # type: ignore[arg-type]
|
||||
revision=request.get("revision", request.get("ref", "HEAD")), # type: ignore[arg-type]
|
||||
file_path=file_path, # type: ignore[arg-type]
|
||||
max_bytes=request.get("max_bytes", 262_144), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def create_repository_discovery(
|
||||
*,
|
||||
allowed_repositories: Iterable[str] | None = None,
|
||||
git_dir: str | Path | None = None,
|
||||
cursor_secret: str | bytes | None = None,
|
||||
) -> RepositoryDiscovery:
|
||||
return RepositoryDiscovery(
|
||||
allowed_repositories=allowed_repositories,
|
||||
git_dir=git_dir,
|
||||
cursor_secret=cursor_secret,
|
||||
)
|
||||
|
||||
|
||||
def list_repository_tree(
|
||||
request: Mapping[str, object], *, discovery: RepositoryDiscovery | None = None
|
||||
) -> dict[str, object]:
|
||||
return (discovery or create_repository_discovery()).list_repository_tree(request)
|
||||
|
||||
|
||||
def search_repository_source(
|
||||
request: Mapping[str, object], *, discovery: RepositoryDiscovery | None = None
|
||||
) -> dict[str, object]:
|
||||
return (discovery or create_repository_discovery()).search_repository_source(request)
|
||||
|
||||
|
||||
def get_diff_index(
|
||||
request: Mapping[str, object], *, discovery: RepositoryDiscovery | None = None
|
||||
) -> dict[str, object]:
|
||||
return (discovery or create_repository_discovery()).get_diff_index(request)
|
||||
|
||||
|
||||
def read_repository_file(
|
||||
request: Mapping[str, object], *, discovery: RepositoryDiscovery | None = None
|
||||
) -> dict[str, object]:
|
||||
return (discovery or create_repository_discovery()).read_repository_file(request)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,707 @@
|
||||
"""Single-host operational delegation for AIOS migration.
|
||||
|
||||
This module is deliberately narrow:
|
||||
* Owner signs only OperationalDelegation and ExpiredTaskRecoveryDecision.
|
||||
* Machine signer signs only WorkerRuntimeBinding and WorkerRuntimeProof.
|
||||
* Every object is bound to one immutable Task identity and a narrow capability set.
|
||||
* There is no arbitrary sign-bytes API.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import time
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
from aios_single_host_authority import (
|
||||
DEFAULT_MACHINE_SIGNER_ID,
|
||||
DEFAULT_OWNER_SIGNER_ID,
|
||||
MACHINE_NAMESPACE,
|
||||
OWNER_IDENTITY,
|
||||
OWNER_NAMESPACE,
|
||||
LocalFileSigner,
|
||||
RegistryOwnerApprovalVerifier,
|
||||
SignerRegistry,
|
||||
SingleHostAuthorityError,
|
||||
canonical_json_bytes,
|
||||
sha256_json,
|
||||
verify_sshsig,
|
||||
)
|
||||
|
||||
OPERATIONAL_POLICY_SCHEMA = "aios.single-host-operational-policy.v1"
|
||||
OPERATIONAL_DELEGATION_SCHEMA = "aios.owner-operational-delegation.v1"
|
||||
RECOVERY_DECISION_SCHEMA = "aios.expired-task-recovery-decision.v1"
|
||||
WORKER_BINDING_SCHEMA = "aios.worker-runtime-binding.v1"
|
||||
WORKER_PROOF_SCHEMA = "aios.worker-runtime-proof.v1"
|
||||
REVOCATION_SCHEMA = "aios.operational-revocations.v1"
|
||||
|
||||
ALLOWED_OPERATIONAL_CAPABILITIES = frozenset({
|
||||
"task.recovery",
|
||||
"worker.runtime.bind",
|
||||
"worker.runtime.proof",
|
||||
"task.claim.guard",
|
||||
})
|
||||
ALLOWED_EXPECTED_STATE = "EXPIRED"
|
||||
HEX40 = re.compile(r"^[0-9a-f]{40}$")
|
||||
HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/+:-]{2,511}$")
|
||||
CAP = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{1,127}$")
|
||||
|
||||
|
||||
class OperationalAuthorityError(RuntimeError):
|
||||
def __init__(self, code: str):
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
|
||||
|
||||
def fail(code: str) -> None:
|
||||
raise OperationalAuthorityError(code)
|
||||
|
||||
|
||||
def _exact_fields(value: Mapping[str, Any], fields: set[str], code: str) -> None:
|
||||
if not isinstance(value, Mapping) or set(value) != fields:
|
||||
fail(code)
|
||||
|
||||
|
||||
def _safe_id(value: str, code: str) -> str:
|
||||
if not isinstance(value, str) or not SAFE_ID.fullmatch(value):
|
||||
fail(code)
|
||||
return value
|
||||
|
||||
|
||||
def _hex(value: str, n: int, code: str) -> str:
|
||||
regex = HEX40 if n == 40 else HEX64
|
||||
if not isinstance(value, str) or not regex.fullmatch(value):
|
||||
fail(code)
|
||||
return value
|
||||
|
||||
|
||||
def _caps(value: Sequence[str], code: str) -> tuple[str, ...]:
|
||||
if not isinstance(value, (list, tuple)) or not value:
|
||||
fail(code)
|
||||
out = tuple(sorted(set(value)))
|
||||
if len(out) != len(value) or any(not isinstance(x, str) or not CAP.fullmatch(x) for x in out):
|
||||
fail(code)
|
||||
return out
|
||||
|
||||
|
||||
def task_identity_sha256(task: Mapping[str, Any]) -> str:
|
||||
"""Digest only immutable recovery/claim identity fields.
|
||||
|
||||
Revision, state, attempt and deadline are intentionally excluded.
|
||||
"""
|
||||
fields = (
|
||||
"task_id", "repository", "target_branch", "base_oid", "generation",
|
||||
"execution_plan_sha256", "external_job_identity",
|
||||
)
|
||||
try:
|
||||
value = {name: task[name] for name in fields}
|
||||
except KeyError as exc:
|
||||
raise OperationalAuthorityError("TASK_IDENTITY_FIELD_MISSING") from exc
|
||||
_safe_id(str(value["task_id"]), "TASK_ID_INVALID")
|
||||
_safe_id(str(value["repository"]), "REPOSITORY_INVALID")
|
||||
_safe_id(str(value["target_branch"]), "TARGET_BRANCH_INVALID")
|
||||
_hex(str(value["base_oid"]), 40, "BASE_OID_INVALID")
|
||||
if type(value["generation"]) is not int or value["generation"] < 1:
|
||||
fail("GENERATION_INVALID")
|
||||
_hex(str(value["execution_plan_sha256"]), 64, "EXECUTION_PLAN_DIGEST_INVALID")
|
||||
_safe_id(str(value["external_job_identity"]), "EXTERNAL_JOB_IDENTITY_INVALID")
|
||||
return hashlib.sha256(canonical_json_bytes(value)).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OperationalPolicy:
|
||||
repository: str
|
||||
task_id: str
|
||||
task_identity_sha256: str
|
||||
target_branch: str
|
||||
base_oid: str
|
||||
generation: int
|
||||
execution_plan_sha256: str
|
||||
external_job_identity: str
|
||||
allowed_capabilities: tuple[str, ...]
|
||||
allowed_worker_ids: tuple[str, ...]
|
||||
maximum_delegation_ttl_seconds: int
|
||||
maximum_recovery_decision_ttl_seconds: int
|
||||
maximum_binding_ttl_seconds: int
|
||||
maximum_proof_ttl_seconds: int
|
||||
maximum_heartbeat_age_seconds: int
|
||||
maximum_task_deadline_extension_seconds: int
|
||||
schema: str = OPERATIONAL_POLICY_SCHEMA
|
||||
|
||||
def validate(self) -> None:
|
||||
if self.schema != OPERATIONAL_POLICY_SCHEMA:
|
||||
fail("OPERATIONAL_POLICY_SCHEMA_INVALID")
|
||||
_safe_id(self.repository, "OPERATIONAL_POLICY_REPOSITORY_INVALID")
|
||||
_safe_id(self.task_id, "OPERATIONAL_POLICY_TASK_ID_INVALID")
|
||||
_hex(self.task_identity_sha256, 64, "OPERATIONAL_POLICY_TASK_IDENTITY_INVALID")
|
||||
_safe_id(self.target_branch, "OPERATIONAL_POLICY_BRANCH_INVALID")
|
||||
_hex(self.base_oid, 40, "OPERATIONAL_POLICY_BASE_OID_INVALID")
|
||||
if type(self.generation) is not int or self.generation < 1:
|
||||
fail("OPERATIONAL_POLICY_GENERATION_INVALID")
|
||||
_hex(self.execution_plan_sha256, 64, "OPERATIONAL_POLICY_PLAN_INVALID")
|
||||
_safe_id(self.external_job_identity, "OPERATIONAL_POLICY_JOB_INVALID")
|
||||
caps = _caps(self.allowed_capabilities, "OPERATIONAL_POLICY_CAPABILITIES_INVALID")
|
||||
if not set(caps).issubset(ALLOWED_OPERATIONAL_CAPABILITIES):
|
||||
fail("OPERATIONAL_POLICY_CAPABILITY_WIDENING")
|
||||
if not self.allowed_worker_ids or any(not SAFE_ID.fullmatch(x) for x in self.allowed_worker_ids):
|
||||
fail("OPERATIONAL_POLICY_WORKERS_INVALID")
|
||||
bounds = (
|
||||
(self.maximum_delegation_ttl_seconds, 300, 7 * 24 * 3600),
|
||||
(self.maximum_recovery_decision_ttl_seconds, 30, 900),
|
||||
(self.maximum_binding_ttl_seconds, 60, 24 * 3600),
|
||||
(self.maximum_proof_ttl_seconds, 15, 900),
|
||||
(self.maximum_heartbeat_age_seconds, 5, 300),
|
||||
(self.maximum_task_deadline_extension_seconds, 300, 24 * 3600),
|
||||
)
|
||||
if any(type(v) is not int or not lo <= v <= hi for v, lo, hi in bounds):
|
||||
fail("OPERATIONAL_POLICY_TTL_INVALID")
|
||||
|
||||
@property
|
||||
def digest(self) -> str:
|
||||
self.validate()
|
||||
return sha256_json(asdict(self))
|
||||
|
||||
@classmethod
|
||||
def from_mapping(cls, value: Mapping[str, Any]) -> "OperationalPolicy":
|
||||
fields = set(cls.__dataclass_fields__)
|
||||
_exact_fields(value, fields, "OPERATIONAL_POLICY_FIELDS_INVALID")
|
||||
try:
|
||||
policy = cls(
|
||||
repository=value["repository"],
|
||||
task_id=value["task_id"],
|
||||
task_identity_sha256=value["task_identity_sha256"],
|
||||
target_branch=value["target_branch"],
|
||||
base_oid=value["base_oid"],
|
||||
generation=value["generation"],
|
||||
execution_plan_sha256=value["execution_plan_sha256"],
|
||||
external_job_identity=value["external_job_identity"],
|
||||
allowed_capabilities=tuple(value["allowed_capabilities"]),
|
||||
allowed_worker_ids=tuple(value["allowed_worker_ids"]),
|
||||
maximum_delegation_ttl_seconds=value["maximum_delegation_ttl_seconds"],
|
||||
maximum_recovery_decision_ttl_seconds=value["maximum_recovery_decision_ttl_seconds"],
|
||||
maximum_binding_ttl_seconds=value["maximum_binding_ttl_seconds"],
|
||||
maximum_proof_ttl_seconds=value["maximum_proof_ttl_seconds"],
|
||||
maximum_heartbeat_age_seconds=value["maximum_heartbeat_age_seconds"],
|
||||
maximum_task_deadline_extension_seconds=value["maximum_task_deadline_extension_seconds"],
|
||||
schema=value["schema"],
|
||||
)
|
||||
except Exception as exc:
|
||||
raise OperationalAuthorityError("OPERATIONAL_POLICY_FIELDS_INVALID") from exc
|
||||
policy.validate()
|
||||
return policy
|
||||
|
||||
|
||||
def build_exact_policy(
|
||||
task: Mapping[str, Any],
|
||||
*,
|
||||
worker_id: str,
|
||||
allowed_capabilities: Sequence[str] = tuple(sorted(ALLOWED_OPERATIONAL_CAPABILITIES)),
|
||||
) -> OperationalPolicy:
|
||||
identity = task_identity_sha256(task)
|
||||
policy = OperationalPolicy(
|
||||
repository=str(task["repository"]),
|
||||
task_id=str(task["task_id"]),
|
||||
task_identity_sha256=identity,
|
||||
target_branch=str(task["target_branch"]),
|
||||
base_oid=str(task["base_oid"]),
|
||||
generation=int(task["generation"]),
|
||||
execution_plan_sha256=str(task["execution_plan_sha256"]),
|
||||
external_job_identity=str(task["external_job_identity"]),
|
||||
allowed_capabilities=tuple(sorted(allowed_capabilities)),
|
||||
allowed_worker_ids=(worker_id,),
|
||||
maximum_delegation_ttl_seconds=24 * 3600,
|
||||
maximum_recovery_decision_ttl_seconds=300,
|
||||
maximum_binding_ttl_seconds=4 * 3600,
|
||||
maximum_proof_ttl_seconds=300,
|
||||
maximum_heartbeat_age_seconds=30,
|
||||
maximum_task_deadline_extension_seconds=4 * 3600,
|
||||
)
|
||||
policy.validate()
|
||||
return policy
|
||||
|
||||
|
||||
def _signed_payload_dict(payload: Mapping[str, Any], *, key_id: str, namespace: str) -> dict[str, Any]:
|
||||
value = dict(payload)
|
||||
value["signature_namespace"] = namespace
|
||||
value["signature_key_id"] = key_id
|
||||
value["canonical_payload_digest"] = sha256_json(value)
|
||||
return value
|
||||
|
||||
|
||||
def _verify_digest_field(payload: Mapping[str, Any]) -> bool:
|
||||
if not isinstance(payload, Mapping) or "canonical_payload_digest" not in payload:
|
||||
return False
|
||||
value = dict(payload)
|
||||
supplied = value.pop("canonical_payload_digest", None)
|
||||
return isinstance(supplied, str) and supplied == sha256_json(value)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SignedOperationalArtifact:
|
||||
payload: dict[str, Any]
|
||||
signature: str
|
||||
key_id: str
|
||||
namespace: str
|
||||
|
||||
@property
|
||||
def canonical_payload(self) -> bytes:
|
||||
return canonical_json_bytes(self.payload)
|
||||
|
||||
@property
|
||||
def payload_sha256(self) -> str:
|
||||
return hashlib.sha256(self.canonical_payload).hexdigest()
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"payload": dict(self.payload),
|
||||
"signature": self.signature,
|
||||
"key_id": self.key_id,
|
||||
"namespace": self.namespace,
|
||||
}
|
||||
|
||||
|
||||
class OperationalOwnerAuthority:
|
||||
def __init__(self, key_path: Path, policy: OperationalPolicy, *, require_root_owner: bool = True, clock: Any = time.time):
|
||||
policy.validate()
|
||||
self.policy = policy
|
||||
self.signer = LocalFileSigner(
|
||||
Path(key_path),
|
||||
signer_id=DEFAULT_OWNER_SIGNER_ID,
|
||||
namespace=OWNER_NAMESPACE,
|
||||
require_root_owner=require_root_owner,
|
||||
)
|
||||
self.clock = clock
|
||||
|
||||
@property
|
||||
def fingerprint(self) -> str:
|
||||
return self.signer.fingerprint
|
||||
|
||||
def _sign(self, payload: Mapping[str, Any]) -> SignedOperationalArtifact:
|
||||
full = _signed_payload_dict(payload, key_id=self.fingerprint, namespace=OWNER_NAMESPACE)
|
||||
art = self.signer._sign_canonical(full)
|
||||
return SignedOperationalArtifact(dict(art.payload), art.signature, art.key_id, art.namespace)
|
||||
|
||||
def issue_operational_delegation(self, *, delegation_id: str, nonce: str, ttl_seconds: int) -> SignedOperationalArtifact:
|
||||
now = int(self.clock())
|
||||
p = self.policy
|
||||
if not SAFE_ID.fullmatch(delegation_id) or not SAFE_ID.fullmatch(nonce):
|
||||
fail("OPERATIONAL_DELEGATION_ID_INVALID")
|
||||
if type(ttl_seconds) is not int or not 60 <= ttl_seconds <= p.maximum_delegation_ttl_seconds:
|
||||
fail("OPERATIONAL_DELEGATION_TTL_INVALID")
|
||||
payload = {
|
||||
"schema": OPERATIONAL_DELEGATION_SCHEMA,
|
||||
"delegation_id": delegation_id,
|
||||
"nonce": nonce,
|
||||
"owner_identity": OWNER_IDENTITY,
|
||||
"repository": p.repository,
|
||||
"task_id": p.task_id,
|
||||
"task_identity_sha256": p.task_identity_sha256,
|
||||
"target_branch": p.target_branch,
|
||||
"base_oid": p.base_oid,
|
||||
"generation": p.generation,
|
||||
"execution_plan_sha256": p.execution_plan_sha256,
|
||||
"external_job_identity": p.external_job_identity,
|
||||
"allowed_capabilities": list(p.allowed_capabilities),
|
||||
"allowed_worker_ids": list(p.allowed_worker_ids),
|
||||
"policy_digest": p.digest,
|
||||
"issued_at": now,
|
||||
"not_before": max(0, now - 5),
|
||||
"expires_at": now + ttl_seconds,
|
||||
"revocation_epoch": 0,
|
||||
"status": "ACTIVE",
|
||||
}
|
||||
return self._sign(payload)
|
||||
|
||||
def issue_recovery_decision(
|
||||
self,
|
||||
delegation: Mapping[str, Any],
|
||||
*,
|
||||
decision_id: str,
|
||||
nonce: str,
|
||||
expected_revision: int,
|
||||
expected_attempt: int,
|
||||
original_deadline: int,
|
||||
new_deadline: int,
|
||||
ttl_seconds: int,
|
||||
) -> SignedOperationalArtifact:
|
||||
now = int(self.clock())
|
||||
verify_delegation_scope(delegation, self.policy, now=now)
|
||||
if "task.recovery" not in delegation["allowed_capabilities"]:
|
||||
fail("RECOVERY_CAPABILITY_NOT_DELEGATED")
|
||||
if not SAFE_ID.fullmatch(decision_id) or not SAFE_ID.fullmatch(nonce):
|
||||
fail("RECOVERY_DECISION_ID_INVALID")
|
||||
if type(expected_revision) is not int or expected_revision < 0 or type(expected_attempt) is not int or expected_attempt < 0:
|
||||
fail("RECOVERY_CAS_INVALID")
|
||||
if type(original_deadline) is not int or type(new_deadline) is not int:
|
||||
fail("RECOVERY_DEADLINE_INVALID")
|
||||
if new_deadline <= now or new_deadline <= original_deadline:
|
||||
fail("RECOVERY_DEADLINE_INVALID")
|
||||
if new_deadline - now > self.policy.maximum_task_deadline_extension_seconds:
|
||||
fail("RECOVERY_DEADLINE_WIDENING")
|
||||
if type(ttl_seconds) is not int or not 15 <= ttl_seconds <= self.policy.maximum_recovery_decision_ttl_seconds:
|
||||
fail("RECOVERY_DECISION_TTL_INVALID")
|
||||
payload = {
|
||||
"schema": RECOVERY_DECISION_SCHEMA,
|
||||
"decision_id": decision_id,
|
||||
"nonce": nonce,
|
||||
"parent_delegation_id": delegation["delegation_id"],
|
||||
"parent_delegation_digest": hashlib.sha256(canonical_json_bytes(dict(delegation))).hexdigest(),
|
||||
"task_id": self.policy.task_id,
|
||||
"task_identity_sha256": self.policy.task_identity_sha256,
|
||||
"expected_state": ALLOWED_EXPECTED_STATE,
|
||||
"expected_revision": expected_revision,
|
||||
"expected_attempt": expected_attempt,
|
||||
"original_deadline": original_deadline,
|
||||
"new_deadline": new_deadline,
|
||||
"repository": self.policy.repository,
|
||||
"target_branch": self.policy.target_branch,
|
||||
"base_oid": self.policy.base_oid,
|
||||
"generation": self.policy.generation,
|
||||
"execution_plan_sha256": self.policy.execution_plan_sha256,
|
||||
"external_job_identity": self.policy.external_job_identity,
|
||||
"policy_digest": self.policy.digest,
|
||||
"one_time_use": True,
|
||||
"issued_at": now,
|
||||
"not_before": max(0, now - 5),
|
||||
"expires_at": now + ttl_seconds,
|
||||
}
|
||||
return self._sign(payload)
|
||||
|
||||
|
||||
class OperationalMachineAuthority:
|
||||
def __init__(self, key_path: Path, policy: OperationalPolicy, *, require_root_owner: bool = True, clock: Any = time.time):
|
||||
policy.validate()
|
||||
self.policy = policy
|
||||
self.signer = LocalFileSigner(
|
||||
Path(key_path),
|
||||
signer_id=DEFAULT_MACHINE_SIGNER_ID,
|
||||
namespace=MACHINE_NAMESPACE,
|
||||
require_root_owner=require_root_owner,
|
||||
)
|
||||
self.clock = clock
|
||||
|
||||
@property
|
||||
def fingerprint(self) -> str:
|
||||
return self.signer.fingerprint
|
||||
|
||||
def _sign(self, payload: Mapping[str, Any]) -> SignedOperationalArtifact:
|
||||
full = _signed_payload_dict(payload, key_id=self.fingerprint, namespace=MACHINE_NAMESPACE)
|
||||
art = self.signer._sign_canonical(full)
|
||||
return SignedOperationalArtifact(dict(art.payload), art.signature, art.key_id, art.namespace)
|
||||
|
||||
def issue_runtime_binding(
|
||||
self,
|
||||
delegation: Mapping[str, Any],
|
||||
*,
|
||||
binding_id: str,
|
||||
nonce: str,
|
||||
worker_id: str,
|
||||
runtime_id: str,
|
||||
runtime_sha256: str,
|
||||
task_contract_version: str,
|
||||
supported_contract_versions: Sequence[str],
|
||||
required_capabilities: Sequence[str],
|
||||
worker_capabilities: Sequence[str],
|
||||
max_concurrency: int,
|
||||
ttl_seconds: int,
|
||||
) -> SignedOperationalArtifact:
|
||||
now = int(self.clock())
|
||||
p = self.policy
|
||||
verify_delegation_scope(delegation, p, now=now)
|
||||
if "worker.runtime.bind" not in delegation["allowed_capabilities"]:
|
||||
fail("RUNTIME_BIND_CAPABILITY_NOT_DELEGATED")
|
||||
if worker_id not in p.allowed_worker_ids:
|
||||
fail("WORKER_ID_NOT_DELEGATED")
|
||||
for val, code in [(binding_id, "BINDING_ID_INVALID"), (nonce, "BINDING_NONCE_INVALID"), (worker_id, "WORKER_ID_INVALID"), (runtime_id, "RUNTIME_ID_INVALID"), (task_contract_version, "CONTRACT_VERSION_INVALID")]:
|
||||
_safe_id(val, code)
|
||||
_hex(runtime_sha256, 64, "RUNTIME_SHA256_INVALID")
|
||||
supported = tuple(sorted(set(supported_contract_versions)))
|
||||
if task_contract_version not in supported or not supported or any(not SAFE_ID.fullmatch(x) for x in supported):
|
||||
fail("CONTRACT_INCOMPATIBLE")
|
||||
required = _caps(required_capabilities, "REQUIRED_CAPABILITIES_INVALID")
|
||||
worker_caps = _caps(worker_capabilities, "WORKER_CAPABILITIES_INVALID")
|
||||
if not set(required).issubset(set(worker_caps)):
|
||||
fail("CAPABILITY_MISMATCH")
|
||||
if type(max_concurrency) is not int or not 1 <= max_concurrency <= 64:
|
||||
fail("MAX_CONCURRENCY_INVALID")
|
||||
if type(ttl_seconds) is not int or not 30 <= ttl_seconds <= p.maximum_binding_ttl_seconds:
|
||||
fail("BINDING_TTL_INVALID")
|
||||
if now + ttl_seconds > int(delegation["expires_at"]):
|
||||
fail("BINDING_EXCEEDS_PARENT")
|
||||
payload = {
|
||||
"schema": WORKER_BINDING_SCHEMA,
|
||||
"binding_id": binding_id,
|
||||
"nonce": nonce,
|
||||
"parent_delegation_id": delegation["delegation_id"],
|
||||
"parent_delegation_digest": hashlib.sha256(canonical_json_bytes(dict(delegation))).hexdigest(),
|
||||
"repository": p.repository,
|
||||
"task_id": p.task_id,
|
||||
"task_identity_sha256": p.task_identity_sha256,
|
||||
"target_branch": p.target_branch,
|
||||
"base_oid": p.base_oid,
|
||||
"generation": p.generation,
|
||||
"execution_plan_sha256": p.execution_plan_sha256,
|
||||
"external_job_identity": p.external_job_identity,
|
||||
"worker_id": worker_id,
|
||||
"runtime_id": runtime_id,
|
||||
"runtime_sha256": runtime_sha256,
|
||||
"task_contract_version": task_contract_version,
|
||||
"supported_contract_versions": list(supported),
|
||||
"required_capabilities": list(required),
|
||||
"worker_capabilities": list(worker_caps),
|
||||
"max_concurrency": max_concurrency,
|
||||
"policy_digest": p.digest,
|
||||
"issued_at": now,
|
||||
"not_before": max(0, now - 5),
|
||||
"expires_at": now + ttl_seconds,
|
||||
}
|
||||
return self._sign(payload)
|
||||
|
||||
def issue_runtime_proof(
|
||||
self,
|
||||
delegation: Mapping[str, Any],
|
||||
binding: Mapping[str, Any],
|
||||
*,
|
||||
proof_id: str,
|
||||
nonce: str,
|
||||
heartbeat_at: int,
|
||||
adapter_readiness: bool,
|
||||
resource_readiness: bool,
|
||||
active_claim_count: int,
|
||||
ttl_seconds: int,
|
||||
) -> SignedOperationalArtifact:
|
||||
now = int(self.clock())
|
||||
p = self.policy
|
||||
verify_delegation_scope(delegation, p, now=now)
|
||||
verify_binding_scope(binding, delegation, p, now=now)
|
||||
if "worker.runtime.proof" not in delegation["allowed_capabilities"]:
|
||||
fail("RUNTIME_PROOF_CAPABILITY_NOT_DELEGATED")
|
||||
_safe_id(proof_id, "PROOF_ID_INVALID")
|
||||
_safe_id(nonce, "PROOF_NONCE_INVALID")
|
||||
if type(heartbeat_at) is not int or heartbeat_at > now + 5 or now - heartbeat_at > p.maximum_heartbeat_age_seconds:
|
||||
fail("HEARTBEAT_STALE")
|
||||
if adapter_readiness is not True or resource_readiness is not True:
|
||||
fail("RUNTIME_NOT_READY")
|
||||
if type(active_claim_count) is not int or active_claim_count < 0 or active_claim_count >= int(binding["max_concurrency"]):
|
||||
fail("MAX_CONCURRENCY_REACHED")
|
||||
if type(ttl_seconds) is not int or not 10 <= ttl_seconds <= p.maximum_proof_ttl_seconds:
|
||||
fail("PROOF_TTL_INVALID")
|
||||
if now + ttl_seconds > min(int(binding["expires_at"]), int(delegation["expires_at"])):
|
||||
fail("PROOF_EXCEEDS_PARENT")
|
||||
payload = {
|
||||
"schema": WORKER_PROOF_SCHEMA,
|
||||
"proof_id": proof_id,
|
||||
"nonce": nonce,
|
||||
"parent_delegation_id": delegation["delegation_id"],
|
||||
"parent_delegation_digest": hashlib.sha256(canonical_json_bytes(dict(delegation))).hexdigest(),
|
||||
"binding_id": binding["binding_id"],
|
||||
"binding_digest": hashlib.sha256(canonical_json_bytes(dict(binding))).hexdigest(),
|
||||
"repository": p.repository,
|
||||
"task_id": p.task_id,
|
||||
"task_identity_sha256": p.task_identity_sha256,
|
||||
"generation": p.generation,
|
||||
"execution_plan_sha256": p.execution_plan_sha256,
|
||||
"worker_id": binding["worker_id"],
|
||||
"runtime_id": binding["runtime_id"],
|
||||
"runtime_sha256": binding["runtime_sha256"],
|
||||
"heartbeat_at": heartbeat_at,
|
||||
"adapter_readiness": True,
|
||||
"resource_readiness": True,
|
||||
"active_claim_count": active_claim_count,
|
||||
"max_concurrency": binding["max_concurrency"],
|
||||
"policy_digest": p.digest,
|
||||
"issued_at": now,
|
||||
"expires_at": now + ttl_seconds,
|
||||
}
|
||||
return self._sign(payload)
|
||||
|
||||
|
||||
DELEGATION_FIELDS = {
|
||||
"schema","delegation_id","nonce","owner_identity","repository","task_id",
|
||||
"task_identity_sha256","target_branch","base_oid","generation","execution_plan_sha256",
|
||||
"external_job_identity","allowed_capabilities","allowed_worker_ids","policy_digest",
|
||||
"issued_at","not_before","expires_at","revocation_epoch","status",
|
||||
"signature_namespace","signature_key_id","canonical_payload_digest",
|
||||
}
|
||||
BINDING_FIELDS = {
|
||||
"schema","binding_id","nonce","parent_delegation_id","parent_delegation_digest",
|
||||
"repository","task_id","task_identity_sha256","target_branch","base_oid","generation",
|
||||
"execution_plan_sha256","external_job_identity","worker_id","runtime_id","runtime_sha256",
|
||||
"task_contract_version","supported_contract_versions","required_capabilities","worker_capabilities",
|
||||
"max_concurrency","policy_digest","issued_at","not_before","expires_at",
|
||||
"signature_namespace","signature_key_id","canonical_payload_digest",
|
||||
}
|
||||
PROOF_FIELDS = {
|
||||
"schema","proof_id","nonce","parent_delegation_id","parent_delegation_digest",
|
||||
"binding_id","binding_digest","repository","task_id","task_identity_sha256","generation",
|
||||
"execution_plan_sha256","worker_id","runtime_id","runtime_sha256","heartbeat_at",
|
||||
"adapter_readiness","resource_readiness","active_claim_count","max_concurrency",
|
||||
"policy_digest","issued_at","expires_at","signature_namespace","signature_key_id",
|
||||
"canonical_payload_digest",
|
||||
}
|
||||
RECOVERY_FIELDS = {
|
||||
"schema","decision_id","nonce","parent_delegation_id","parent_delegation_digest",
|
||||
"task_id","task_identity_sha256","expected_state","expected_revision","expected_attempt",
|
||||
"original_deadline","new_deadline","repository","target_branch","base_oid","generation",
|
||||
"execution_plan_sha256","external_job_identity","policy_digest","one_time_use",
|
||||
"issued_at","not_before","expires_at","signature_namespace","signature_key_id",
|
||||
"canonical_payload_digest",
|
||||
}
|
||||
|
||||
|
||||
def verify_delegation_scope(value: Mapping[str, Any], policy: OperationalPolicy, *, now: int | None = None) -> None:
|
||||
policy.validate()
|
||||
_exact_fields(value, DELEGATION_FIELDS, "OPERATIONAL_DELEGATION_FIELDS_INVALID")
|
||||
now = int(time.time()) if now is None else int(now)
|
||||
if (
|
||||
value["schema"] != OPERATIONAL_DELEGATION_SCHEMA
|
||||
or value["status"] != "ACTIVE"
|
||||
or value["revocation_epoch"] != 0
|
||||
or value["owner_identity"] != OWNER_IDENTITY
|
||||
or value["repository"] != policy.repository
|
||||
or value["task_id"] != policy.task_id
|
||||
or value["task_identity_sha256"] != policy.task_identity_sha256
|
||||
or value["target_branch"] != policy.target_branch
|
||||
or value["base_oid"] != policy.base_oid
|
||||
or value["generation"] != policy.generation
|
||||
or value["execution_plan_sha256"] != policy.execution_plan_sha256
|
||||
or value["external_job_identity"] != policy.external_job_identity
|
||||
or value["policy_digest"] != policy.digest
|
||||
or not set(value["allowed_capabilities"]).issubset(set(policy.allowed_capabilities))
|
||||
or not set(value["allowed_worker_ids"]).issubset(set(policy.allowed_worker_ids))
|
||||
or value["signature_namespace"] != OWNER_NAMESPACE
|
||||
or not _verify_digest_field(value)
|
||||
or not int(value["not_before"]) <= now < int(value["expires_at"])
|
||||
):
|
||||
fail("OPERATIONAL_DELEGATION_SCOPE_INVALID")
|
||||
|
||||
|
||||
def verify_binding_scope(binding: Mapping[str, Any], delegation: Mapping[str, Any], policy: OperationalPolicy, *, now: int | None = None) -> None:
|
||||
_exact_fields(binding, BINDING_FIELDS, "WORKER_BINDING_FIELDS_INVALID")
|
||||
now = int(time.time()) if now is None else int(now)
|
||||
expected_parent = hashlib.sha256(canonical_json_bytes(dict(delegation))).hexdigest()
|
||||
if (
|
||||
binding["schema"] != WORKER_BINDING_SCHEMA
|
||||
or binding["parent_delegation_id"] != delegation["delegation_id"]
|
||||
or binding["parent_delegation_digest"] != expected_parent
|
||||
or binding["repository"] != policy.repository
|
||||
or binding["task_id"] != policy.task_id
|
||||
or binding["task_identity_sha256"] != policy.task_identity_sha256
|
||||
or binding["target_branch"] != policy.target_branch
|
||||
or binding["base_oid"] != policy.base_oid
|
||||
or binding["generation"] != policy.generation
|
||||
or binding["execution_plan_sha256"] != policy.execution_plan_sha256
|
||||
or binding["external_job_identity"] != policy.external_job_identity
|
||||
or binding["worker_id"] not in policy.allowed_worker_ids
|
||||
or binding["policy_digest"] != policy.digest
|
||||
or binding["signature_namespace"] != MACHINE_NAMESPACE
|
||||
or not _verify_digest_field(binding)
|
||||
or not int(binding["not_before"]) <= now < int(binding["expires_at"])
|
||||
):
|
||||
fail("WORKER_BINDING_SCOPE_INVALID")
|
||||
required = set(binding["required_capabilities"])
|
||||
worker = set(binding["worker_capabilities"])
|
||||
if not required.issubset(worker) or binding["task_contract_version"] not in binding["supported_contract_versions"]:
|
||||
fail("WORKER_BINDING_COMPATIBILITY_INVALID")
|
||||
|
||||
|
||||
def verify_proof_scope(proof: Mapping[str, Any], binding: Mapping[str, Any], delegation: Mapping[str, Any], policy: OperationalPolicy, *, now: int | None = None) -> None:
|
||||
_exact_fields(proof, PROOF_FIELDS, "WORKER_PROOF_FIELDS_INVALID")
|
||||
now = int(time.time()) if now is None else int(now)
|
||||
if (
|
||||
proof["schema"] != WORKER_PROOF_SCHEMA
|
||||
or proof["parent_delegation_id"] != delegation["delegation_id"]
|
||||
or proof["parent_delegation_digest"] != hashlib.sha256(canonical_json_bytes(dict(delegation))).hexdigest()
|
||||
or proof["binding_id"] != binding["binding_id"]
|
||||
or proof["binding_digest"] != hashlib.sha256(canonical_json_bytes(dict(binding))).hexdigest()
|
||||
or proof["repository"] != policy.repository
|
||||
or proof["task_id"] != policy.task_id
|
||||
or proof["task_identity_sha256"] != policy.task_identity_sha256
|
||||
or proof["generation"] != policy.generation
|
||||
or proof["execution_plan_sha256"] != policy.execution_plan_sha256
|
||||
or proof["worker_id"] != binding["worker_id"]
|
||||
or proof["runtime_id"] != binding["runtime_id"]
|
||||
or proof["runtime_sha256"] != binding["runtime_sha256"]
|
||||
or proof["max_concurrency"] != binding["max_concurrency"]
|
||||
or proof["policy_digest"] != policy.digest
|
||||
or proof["signature_namespace"] != MACHINE_NAMESPACE
|
||||
or proof["adapter_readiness"] is not True
|
||||
or proof["resource_readiness"] is not True
|
||||
or not _verify_digest_field(proof)
|
||||
or not now < int(proof["expires_at"])
|
||||
or now - int(proof["heartbeat_at"]) > policy.maximum_heartbeat_age_seconds
|
||||
or int(proof["active_claim_count"]) >= int(proof["max_concurrency"])
|
||||
):
|
||||
fail("WORKER_PROOF_SCOPE_INVALID")
|
||||
|
||||
|
||||
def verify_recovery_scope(decision: Mapping[str, Any], delegation: Mapping[str, Any], policy: OperationalPolicy, *, now: int | None = None) -> None:
|
||||
_exact_fields(decision, RECOVERY_FIELDS, "RECOVERY_DECISION_FIELDS_INVALID")
|
||||
now = int(time.time()) if now is None else int(now)
|
||||
if (
|
||||
decision["schema"] != RECOVERY_DECISION_SCHEMA
|
||||
or decision["parent_delegation_id"] != delegation["delegation_id"]
|
||||
or decision["parent_delegation_digest"] != hashlib.sha256(canonical_json_bytes(dict(delegation))).hexdigest()
|
||||
or decision["task_id"] != policy.task_id
|
||||
or decision["task_identity_sha256"] != policy.task_identity_sha256
|
||||
or decision["expected_state"] != "EXPIRED"
|
||||
or decision["repository"] != policy.repository
|
||||
or decision["target_branch"] != policy.target_branch
|
||||
or decision["base_oid"] != policy.base_oid
|
||||
or decision["generation"] != policy.generation
|
||||
or decision["execution_plan_sha256"] != policy.execution_plan_sha256
|
||||
or decision["external_job_identity"] != policy.external_job_identity
|
||||
or decision["policy_digest"] != policy.digest
|
||||
or decision["one_time_use"] is not True
|
||||
or decision["signature_namespace"] != OWNER_NAMESPACE
|
||||
or not _verify_digest_field(decision)
|
||||
or not int(decision["not_before"]) <= now < int(decision["expires_at"])
|
||||
or int(decision["new_deadline"]) <= now
|
||||
):
|
||||
fail("RECOVERY_DECISION_SCOPE_INVALID")
|
||||
|
||||
|
||||
class OperationalVerifier:
|
||||
"""Verify signatures through the public root-owned SignerRegistry."""
|
||||
|
||||
def __init__(self, registry_path: Path, *, require_root_owner: bool = True):
|
||||
self.registry_path = Path(registry_path)
|
||||
self.require_root_owner = require_root_owner
|
||||
|
||||
def _verify(self, artifact: SignedOperationalArtifact | Mapping[str, Any], *, role: str, signer_id: str, namespace: str) -> dict[str, Any]:
|
||||
if isinstance(artifact, SignedOperationalArtifact):
|
||||
payload, signature, key_id, artifact_namespace = artifact.payload, artifact.signature, artifact.key_id, artifact.namespace
|
||||
else:
|
||||
_exact_fields(artifact, {"payload","signature","key_id","namespace"}, "SIGNED_ARTIFACT_FIELDS_INVALID")
|
||||
payload, signature, key_id, artifact_namespace = artifact["payload"], artifact["signature"], artifact["key_id"], artifact["namespace"]
|
||||
if artifact_namespace != namespace:
|
||||
fail("SIGNED_ARTIFACT_NAMESPACE_INVALID")
|
||||
registry = SignerRegistry(self.registry_path, require_root_owner=self.require_root_owner)
|
||||
rec = registry.require(signer_id, role=role)
|
||||
if rec.fingerprint != key_id:
|
||||
fail("SIGNED_ARTIFACT_KEY_ID_INVALID")
|
||||
if role == "MACHINE_OPERATIONAL":
|
||||
owner_rec = registry.require(DEFAULT_OWNER_SIGNER_ID, role="OWNER_AUTHORITY")
|
||||
if rec.parent_signer_id != owner_rec.signer_id or rec.parent_fingerprint != owner_rec.fingerprint:
|
||||
fail("MACHINE_PARENT_BINDING_INVALID")
|
||||
# RegistryOwnerApprovalVerifier handles Owner namespace; generic machine uses registry public key.
|
||||
if role == "OWNER_AUTHORITY":
|
||||
verifier = RegistryOwnerApprovalVerifier(self.registry_path, require_root_owner=self.require_root_owner, signer_id=signer_id)
|
||||
ok = verifier.verify(namespace=namespace, payload=canonical_json_bytes(payload), signature=signature, key_id=key_id)
|
||||
else:
|
||||
from aios_single_host_authority import _load_public_key_line
|
||||
pub = _load_public_key_line(rec.public_key)
|
||||
ok = verify_sshsig(pub, canonical_json_bytes(payload), signature, namespace=namespace, expected_key_id=key_id)
|
||||
if not ok:
|
||||
fail("SIGNED_ARTIFACT_SIGNATURE_INVALID")
|
||||
return dict(payload)
|
||||
|
||||
def owner(self, artifact: SignedOperationalArtifact | Mapping[str, Any]) -> dict[str, Any]:
|
||||
return self._verify(artifact, role="OWNER_AUTHORITY", signer_id=DEFAULT_OWNER_SIGNER_ID, namespace=OWNER_NAMESPACE)
|
||||
|
||||
def machine(self, artifact: SignedOperationalArtifact | Mapping[str, Any]) -> dict[str, Any]:
|
||||
return self._verify(artifact, role="MACHINE_OPERATIONAL", signer_id=DEFAULT_MACHINE_SIGNER_ID, namespace=MACHINE_NAMESPACE)
|
||||
@@ -0,0 +1,410 @@
|
||||
"""Unix-domain fixed-schema service for operational migration authority."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
import socket
|
||||
import sqlite3
|
||||
import struct
|
||||
import stat
|
||||
import sys
|
||||
import time
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_single_host_authority import (
|
||||
DEFAULT_REGISTRY_PATH,
|
||||
OWNER_NAMESPACE,
|
||||
MACHINE_NAMESPACE,
|
||||
SingleHostAuthorityError,
|
||||
)
|
||||
from aios_single_host_operational_authority import (
|
||||
OperationalAuthorityError,
|
||||
OperationalMachineAuthority,
|
||||
OperationalOwnerAuthority,
|
||||
OperationalPolicy,
|
||||
OperationalVerifier,
|
||||
SignedOperationalArtifact,
|
||||
verify_binding_scope,
|
||||
verify_delegation_scope,
|
||||
)
|
||||
|
||||
DEFAULT_POLICY = Path("/etc/aios/single-host-operational-policy.json")
|
||||
DEFAULT_OWNER_KEY = Path("/var/lib/aios-owner-authority/owner-authority.key")
|
||||
DEFAULT_MACHINE_KEY = Path("/var/lib/aios-machine-signer/machine-operational.key")
|
||||
DEFAULT_OWNER_SOCKET = Path("/run/aios/owner-authority.sock")
|
||||
DEFAULT_MACHINE_SOCKET = Path("/run/aios/machine-signer.sock")
|
||||
DEFAULT_LEDGER = Path("/var/lib/aios-operational-authority/issuance-ledger.sqlite3")
|
||||
MAX_REQUEST = 128 * 1024
|
||||
READINESS_SCHEMA = "aios.signer-readiness-challenge.v1"
|
||||
|
||||
|
||||
class OperationalServiceError(RuntimeError):
|
||||
def __init__(self, code: str):
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
|
||||
|
||||
def fail(code: str) -> None:
|
||||
raise OperationalServiceError(code)
|
||||
|
||||
|
||||
def _read_policy(path: Path, *, require_root_owner: bool = True) -> OperationalPolicy:
|
||||
path=Path(path)
|
||||
if not path.is_absolute():
|
||||
fail("OPERATIONAL_POLICY_PATH_INVALID")
|
||||
flags=os.O_RDONLY
|
||||
if hasattr(os,"O_NOFOLLOW"):
|
||||
flags |= os.O_NOFOLLOW
|
||||
try:
|
||||
fd=os.open(path,flags)
|
||||
except OSError as exc:
|
||||
raise OperationalServiceError("OPERATIONAL_POLICY_UNAVAILABLE") from exc
|
||||
try:
|
||||
st=os.fstat(fd)
|
||||
if not stat.S_ISREG(st.st_mode):
|
||||
fail("OPERATIONAL_POLICY_NOT_REGULAR")
|
||||
mode=stat.S_IMODE(st.st_mode)
|
||||
if mode & 0o022:
|
||||
fail("OPERATIONAL_POLICY_WRITABLE_BY_GROUP_OR_OTHER")
|
||||
if require_root_owner and st.st_uid != 0:
|
||||
fail("OPERATIONAL_POLICY_NOT_ROOT_OWNED")
|
||||
raw=bytearray()
|
||||
while len(raw) <= MAX_REQUEST:
|
||||
chunk=os.read(fd,min(65536,MAX_REQUEST+1-len(raw)))
|
||||
if not chunk: break
|
||||
raw.extend(chunk)
|
||||
if not raw or len(raw)>MAX_REQUEST:
|
||||
fail("OPERATIONAL_POLICY_UNAVAILABLE")
|
||||
finally:
|
||||
os.close(fd)
|
||||
try:
|
||||
obj=json.loads(bytes(raw).decode("utf-8"))
|
||||
except Exception as exc:
|
||||
raise OperationalServiceError("OPERATIONAL_POLICY_INVALID") from exc
|
||||
return OperationalPolicy.from_mapping(obj)
|
||||
|
||||
|
||||
def _artifact(value: Mapping[str, Any]) -> SignedOperationalArtifact:
|
||||
if not isinstance(value, Mapping) or set(value) != {"payload","signature","key_id","namespace"}:
|
||||
fail("SIGNED_ARTIFACT_INVALID")
|
||||
if not isinstance(value["payload"], Mapping):
|
||||
fail("SIGNED_ARTIFACT_INVALID")
|
||||
return SignedOperationalArtifact(
|
||||
payload=dict(value["payload"]),
|
||||
signature=str(value["signature"]),
|
||||
key_id=str(value["key_id"]),
|
||||
namespace=str(value["namespace"]),
|
||||
)
|
||||
|
||||
|
||||
def _peer_uid(conn: socket.socket) -> int:
|
||||
if not hasattr(socket, "SO_PEERCRED"):
|
||||
fail("PEER_CREDENTIALS_UNAVAILABLE")
|
||||
pid, uid, gid = struct.unpack("3i", conn.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize("3i")))
|
||||
return uid
|
||||
|
||||
|
||||
|
||||
class DurableIssuanceLedger:
|
||||
"""Root-owned exact-idempotent structured signing ledger."""
|
||||
|
||||
def __init__(self, path: Path):
|
||||
self.path = Path(path)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if os.name == "posix": os.chmod(self.path.parent, 0o700)
|
||||
if self.path.exists() and self.path.is_symlink():
|
||||
fail("ISSUANCE_LEDGER_SYMLINK_FORBIDDEN")
|
||||
conn = sqlite3.connect(self.path)
|
||||
try:
|
||||
conn.execute("PRAGMA journal_mode=DELETE")
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS issuance (
|
||||
role TEXT NOT NULL,
|
||||
operation TEXT NOT NULL,
|
||||
nonce TEXT NOT NULL,
|
||||
request_sha256 TEXT NOT NULL,
|
||||
response_json TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
PRIMARY KEY(role, operation, nonce)
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
os.chmod(self.path, 0o600)
|
||||
|
||||
def issue(self, *, role: str, operation: str, nonce: str, request: Mapping[str, Any], issuer: Any) -> dict[str, Any]:
|
||||
request_sha = hashlib.sha256(
|
||||
json.dumps(request, sort_keys=True, separators=(",",":"), ensure_ascii=False).encode("utf-8")
|
||||
).hexdigest()
|
||||
conn = sqlite3.connect(self.path, timeout=5, isolation_level=None)
|
||||
try:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
row = conn.execute(
|
||||
"SELECT request_sha256,response_json FROM issuance WHERE role=? AND operation=? AND nonce=?",
|
||||
(role, operation, nonce),
|
||||
).fetchone()
|
||||
if row is not None:
|
||||
if row[0] != request_sha:
|
||||
conn.rollback()
|
||||
fail("NONCE_REUSE_WITH_DIFFERENT_REQUEST")
|
||||
result = json.loads(row[1])
|
||||
conn.commit()
|
||||
return result
|
||||
result = issuer()
|
||||
encoded = json.dumps(result, sort_keys=True, separators=(",",":"), ensure_ascii=False)
|
||||
conn.execute(
|
||||
"INSERT INTO issuance(role,operation,nonce,request_sha256,response_json,created_at) VALUES(?,?,?,?,?,?)",
|
||||
(role, operation, nonce, request_sha, encoded, int(time.time())),
|
||||
)
|
||||
conn.commit()
|
||||
return result
|
||||
except sqlite3.IntegrityError:
|
||||
conn.rollback()
|
||||
fail("ISSUANCE_LEDGER_RACE")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
class OperationalAuthorityService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
role: str,
|
||||
policy_path: Path = DEFAULT_POLICY,
|
||||
registry_path: Path = DEFAULT_REGISTRY_PATH,
|
||||
owner_key_path: Path = DEFAULT_OWNER_KEY,
|
||||
machine_key_path: Path = DEFAULT_MACHINE_KEY,
|
||||
socket_path: Path | None = None,
|
||||
ledger_path: Path = DEFAULT_LEDGER,
|
||||
allowed_uids: tuple[int, ...] = (0,),
|
||||
clock: Any = time.time,
|
||||
require_root_owner: bool = True,
|
||||
):
|
||||
if role not in {"owner","machine"}:
|
||||
fail("ROLE_INVALID")
|
||||
self.role = role
|
||||
self.policy_path = Path(policy_path)
|
||||
self.registry_path = Path(registry_path)
|
||||
self.owner_key_path = Path(owner_key_path)
|
||||
self.machine_key_path = Path(machine_key_path)
|
||||
self.socket_path = Path(socket_path or (DEFAULT_OWNER_SOCKET if role == "owner" else DEFAULT_MACHINE_SOCKET))
|
||||
self.allowed_uids = tuple(allowed_uids)
|
||||
self.ledger = DurableIssuanceLedger(ledger_path)
|
||||
self.clock = clock
|
||||
self.require_root_owner = require_root_owner
|
||||
|
||||
def _components(self):
|
||||
policy = _read_policy(self.policy_path, require_root_owner=self.require_root_owner)
|
||||
verifier = OperationalVerifier(self.registry_path, require_root_owner=self.require_root_owner)
|
||||
if self.role == "owner":
|
||||
signer = OperationalOwnerAuthority(self.owner_key_path, policy, require_root_owner=self.require_root_owner, clock=self.clock)
|
||||
else:
|
||||
signer = OperationalMachineAuthority(self.machine_key_path, policy, require_root_owner=self.require_root_owner, clock=self.clock)
|
||||
return policy, verifier, signer
|
||||
|
||||
def handle(self, request: Mapping[str, Any], *, peer_uid: int) -> dict[str, Any]:
|
||||
if peer_uid not in self.allowed_uids:
|
||||
fail("PEER_UID_FORBIDDEN")
|
||||
if not isinstance(request, Mapping) or set(request) != {"operation","payload"} or not isinstance(request["payload"], Mapping):
|
||||
fail("REQUEST_INVALID")
|
||||
op = request["operation"]
|
||||
p = request["payload"]
|
||||
policy, verifier, signer = self._components()
|
||||
|
||||
if op == "identity":
|
||||
return {
|
||||
"status":"PASS",
|
||||
"role":self.role,
|
||||
"policy_digest":policy.digest,
|
||||
"key_id":signer.fingerprint,
|
||||
"socket":str(self.socket_path),
|
||||
"arbitrary_signing":False,
|
||||
}
|
||||
|
||||
if op == "readiness_challenge":
|
||||
if set(p) != {"challenge_id","challenge_sha256"}:
|
||||
fail("REQUEST_FIELDS_INVALID")
|
||||
challenge_id = str(p["challenge_id"])
|
||||
challenge_sha256 = str(p["challenge_sha256"])
|
||||
if not challenge_id or len(challenge_id) > 160:
|
||||
fail("READINESS_CHALLENGE_ID_INVALID")
|
||||
if not isinstance(challenge_sha256, str) or not re.fullmatch(r"[0-9a-f]{64}", challenge_sha256):
|
||||
fail("READINESS_CHALLENGE_SHA256_INVALID")
|
||||
namespace = OWNER_NAMESPACE if self.role == "owner" else MACHINE_NAMESPACE
|
||||
def issue():
|
||||
art = signer._sign({
|
||||
"schema": READINESS_SCHEMA,
|
||||
"role": self.role,
|
||||
"challenge_id": challenge_id,
|
||||
"challenge_sha256": challenge_sha256,
|
||||
"policy_digest": policy.digest,
|
||||
"issued_at": int(self.clock()),
|
||||
})
|
||||
return {"status":"PASS","artifact":art.to_dict()}
|
||||
return self.ledger.issue(
|
||||
role=self.role,
|
||||
operation=op,
|
||||
nonce=challenge_id,
|
||||
request=p,
|
||||
issuer=issue,
|
||||
)
|
||||
|
||||
if self.role == "owner":
|
||||
if op == "issue_operational_delegation":
|
||||
if set(p) != {"delegation_id","nonce","ttl_seconds"}:
|
||||
fail("REQUEST_FIELDS_INVALID")
|
||||
def issue():
|
||||
art = signer.issue_operational_delegation(
|
||||
delegation_id=str(p["delegation_id"]),
|
||||
nonce=str(p["nonce"]),
|
||||
ttl_seconds=p["ttl_seconds"],
|
||||
)
|
||||
return {"status":"PASS","artifact":art.to_dict()}
|
||||
return self.ledger.issue(role=self.role, operation=op, nonce=str(p["nonce"]), request=p, issuer=issue)
|
||||
if op == "issue_expired_task_recovery":
|
||||
if set(p) != {"delegation","decision_id","nonce","expected_revision","expected_attempt","original_deadline","new_deadline","ttl_seconds"}:
|
||||
fail("REQUEST_FIELDS_INVALID")
|
||||
parent_art = _artifact(p["delegation"])
|
||||
parent = verifier.owner(parent_art)
|
||||
verify_delegation_scope(parent, policy, now=int(self.clock()))
|
||||
def issue():
|
||||
art = signer.issue_recovery_decision(
|
||||
parent,
|
||||
decision_id=str(p["decision_id"]),
|
||||
nonce=str(p["nonce"]),
|
||||
expected_revision=p["expected_revision"],
|
||||
expected_attempt=p["expected_attempt"],
|
||||
original_deadline=p["original_deadline"],
|
||||
new_deadline=p["new_deadline"],
|
||||
ttl_seconds=p["ttl_seconds"],
|
||||
)
|
||||
return {"status":"PASS","artifact":art.to_dict()}
|
||||
return self.ledger.issue(role=self.role, operation=op, nonce=str(p["nonce"]), request=p, issuer=issue)
|
||||
fail("OWNER_OPERATION_FORBIDDEN")
|
||||
|
||||
if op == "issue_worker_runtime_binding":
|
||||
if set(p) != {
|
||||
"delegation","binding_id","nonce","worker_id","runtime_id","runtime_sha256",
|
||||
"task_contract_version","supported_contract_versions","required_capabilities",
|
||||
"worker_capabilities","max_concurrency","ttl_seconds"
|
||||
}:
|
||||
fail("REQUEST_FIELDS_INVALID")
|
||||
parent_art = _artifact(p["delegation"])
|
||||
parent = verifier.owner(parent_art)
|
||||
verify_delegation_scope(parent, policy, now=int(self.clock()))
|
||||
def issue():
|
||||
art = signer.issue_runtime_binding(
|
||||
parent,
|
||||
binding_id=str(p["binding_id"]), nonce=str(p["nonce"]),
|
||||
worker_id=str(p["worker_id"]), runtime_id=str(p["runtime_id"]),
|
||||
runtime_sha256=str(p["runtime_sha256"]),
|
||||
task_contract_version=str(p["task_contract_version"]),
|
||||
supported_contract_versions=tuple(p["supported_contract_versions"]),
|
||||
required_capabilities=tuple(p["required_capabilities"]),
|
||||
worker_capabilities=tuple(p["worker_capabilities"]),
|
||||
max_concurrency=p["max_concurrency"], ttl_seconds=p["ttl_seconds"],
|
||||
)
|
||||
return {"status":"PASS","artifact":art.to_dict()}
|
||||
return self.ledger.issue(role=self.role, operation=op, nonce=str(p["nonce"]), request=p, issuer=issue)
|
||||
|
||||
if op == "issue_worker_runtime_proof":
|
||||
if set(p) != {
|
||||
"delegation","binding","proof_id","nonce","heartbeat_at","adapter_readiness",
|
||||
"resource_readiness","active_claim_count","ttl_seconds"
|
||||
}:
|
||||
fail("REQUEST_FIELDS_INVALID")
|
||||
parent_art = _artifact(p["delegation"])
|
||||
binding_art = _artifact(p["binding"])
|
||||
parent = verifier.owner(parent_art)
|
||||
binding = verifier.machine(binding_art)
|
||||
verify_delegation_scope(parent, policy, now=int(self.clock()))
|
||||
verify_binding_scope(binding, parent, policy, now=int(self.clock()))
|
||||
def issue():
|
||||
art = signer.issue_runtime_proof(
|
||||
parent, binding,
|
||||
proof_id=str(p["proof_id"]), nonce=str(p["nonce"]),
|
||||
heartbeat_at=p["heartbeat_at"],
|
||||
adapter_readiness=p["adapter_readiness"],
|
||||
resource_readiness=p["resource_readiness"],
|
||||
active_claim_count=p["active_claim_count"],
|
||||
ttl_seconds=p["ttl_seconds"],
|
||||
)
|
||||
return {"status":"PASS","artifact":art.to_dict()}
|
||||
return self.ledger.issue(role=self.role, operation=op, nonce=str(p["nonce"]), request=p, issuer=issue)
|
||||
|
||||
fail("MACHINE_OPERATION_FORBIDDEN")
|
||||
|
||||
def serve_forever(self) -> None:
|
||||
path = self.socket_path
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if path.exists() or path.is_symlink():
|
||||
if path.is_socket():
|
||||
path.unlink()
|
||||
else:
|
||||
fail("SOCKET_PATH_OCCUPIED")
|
||||
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
try:
|
||||
sock.bind(str(path))
|
||||
os.chmod(path, 0o600)
|
||||
sock.listen(16)
|
||||
while True:
|
||||
conn, _ = sock.accept()
|
||||
with conn:
|
||||
try:
|
||||
uid = _peer_uid(conn)
|
||||
chunks = []
|
||||
total = 0
|
||||
while True:
|
||||
data = conn.recv(65536)
|
||||
if not data:
|
||||
break
|
||||
total += len(data)
|
||||
if total > MAX_REQUEST:
|
||||
fail("REQUEST_TOO_LARGE")
|
||||
chunks.append(data)
|
||||
if b"\n" in data:
|
||||
break
|
||||
raw = b"".join(chunks)
|
||||
line = raw.split(b"\n",1)[0]
|
||||
req = json.loads(line.decode("utf-8"))
|
||||
result = self.handle(req, peer_uid=uid)
|
||||
out = {"ok":True,"result":result}
|
||||
except (OperationalServiceError, OperationalAuthorityError, SingleHostAuthorityError) as exc:
|
||||
out = {"ok":False,"error":getattr(exc,"code",str(exc))}
|
||||
except Exception:
|
||||
out = {"ok":False,"error":"INTERNAL_FAIL_CLOSED"}
|
||||
conn.sendall(json.dumps(out, sort_keys=True, separators=(",",":")).encode("utf-8")+b"\n")
|
||||
finally:
|
||||
sock.close()
|
||||
try:
|
||||
path.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--role", choices=("owner","machine"), required=True)
|
||||
ap.add_argument("--policy", default=str(DEFAULT_POLICY))
|
||||
ap.add_argument("--registry", default=str(DEFAULT_REGISTRY_PATH))
|
||||
ap.add_argument("--owner-key", default=str(DEFAULT_OWNER_KEY))
|
||||
ap.add_argument("--machine-key", default=str(DEFAULT_MACHINE_KEY))
|
||||
ap.add_argument("--socket")
|
||||
ns = ap.parse_args()
|
||||
service = OperationalAuthorityService(
|
||||
role=ns.role,
|
||||
policy_path=Path(ns.policy),
|
||||
registry_path=Path(ns.registry),
|
||||
owner_key_path=Path(ns.owner_key),
|
||||
machine_key_path=Path(ns.machine_key),
|
||||
socket_path=Path(ns.socket) if ns.socket else None,
|
||||
)
|
||||
service.serve_forever()
|
||||
return 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,87 @@
|
||||
"""Bridge contract surface for Task Admission Bootstrap.
|
||||
|
||||
This module is intentionally transport-agnostic. The live Bridge may merge
|
||||
these routes/operations into its existing authenticated/HTTPS surface, but the
|
||||
authorization proof for the authorize operation is the verified delegation
|
||||
epoch or Owner signature carried by the request itself.
|
||||
|
||||
No handler here can issue TaskPrincipal assertions or WriterLeases. Those remain
|
||||
strictly downstream of a real admitted/claimed Agent Task.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_task_admission_bootstrap import TaskAdmissionBootstrapService
|
||||
|
||||
|
||||
TASK_ADMISSION_ROUTES = {
|
||||
"/v2/task-admission/envelope": "getTaskAdmissionEnvelope",
|
||||
"/v2/task-admission/authorize": "submitTaskAdmissionAuthorization",
|
||||
"/v2/task-admission/status": "getTaskAdmissionStatus",
|
||||
}
|
||||
|
||||
TASK_ADMISSION_OPERATION_REQUIRED_FIELDS = {
|
||||
"getTaskAdmissionEnvelope": (
|
||||
"idempotency_key",
|
||||
"task_id",
|
||||
"repository",
|
||||
"authority_base_oid",
|
||||
"target_branch",
|
||||
"generation",
|
||||
"execution_digest",
|
||||
"requested_capabilities",
|
||||
"file_allowlist",
|
||||
"risk",
|
||||
),
|
||||
"submitTaskAdmissionAuthorization": (
|
||||
"envelope_id",
|
||||
"authorization_kind",
|
||||
),
|
||||
"getTaskAdmissionStatus": ("envelope_id",),
|
||||
}
|
||||
|
||||
TASK_ADMISSION_OPERATION_OPTIONAL_FIELDS = {
|
||||
"getTaskAdmissionEnvelope": ("expires_in_seconds",),
|
||||
"submitTaskAdmissionAuthorization": (
|
||||
"delegation_epoch_id",
|
||||
"owner_key_id",
|
||||
"signature_base64",
|
||||
),
|
||||
"getTaskAdmissionStatus": (),
|
||||
}
|
||||
|
||||
|
||||
class TaskAdmissionBridgeContract:
|
||||
def __init__(self, service: TaskAdmissionBootstrapService) -> None:
|
||||
self.service = service
|
||||
|
||||
@staticmethod
|
||||
def _validate_fields(operation: str, request: Mapping[str, Any]) -> None:
|
||||
required = set(TASK_ADMISSION_OPERATION_REQUIRED_FIELDS[operation])
|
||||
allowed = required | set(TASK_ADMISSION_OPERATION_OPTIONAL_FIELDS[operation])
|
||||
if not required.issubset(request):
|
||||
raise ValueError("TASK_ADMISSION_BRIDGE_REQUIRED_FIELDS_MISSING")
|
||||
if set(request) - allowed:
|
||||
raise ValueError("TASK_ADMISSION_BRIDGE_UNKNOWN_FIELDS")
|
||||
|
||||
def dispatch(self, operation: str, request: Mapping[str, Any]) -> dict[str, Any]:
|
||||
if operation not in TASK_ADMISSION_OPERATION_REQUIRED_FIELDS:
|
||||
raise ValueError("TASK_ADMISSION_BRIDGE_OPERATION_NOT_FOUND")
|
||||
self._validate_fields(operation, request)
|
||||
if operation == "getTaskAdmissionEnvelope":
|
||||
return self.service.create_envelope(request)
|
||||
if operation == "submitTaskAdmissionAuthorization":
|
||||
return self.service.authorize_and_admit(request)
|
||||
if operation == "getTaskAdmissionStatus":
|
||||
return self.service.status(str(request["envelope_id"]))
|
||||
raise ValueError("TASK_ADMISSION_BRIDGE_OPERATION_NOT_FOUND")
|
||||
|
||||
def handlers(self) -> dict[str, Any]:
|
||||
return {
|
||||
operation: (
|
||||
lambda request, operation=operation: self.dispatch(operation, request)
|
||||
)
|
||||
for operation in TASK_ADMISSION_OPERATION_REQUIRED_FIELDS
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,394 @@
|
||||
"""Append-only ClaimGuard binding for the superseded Single-Host child Task."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
from aios_task_lineage_supersession_initial_projection import (
|
||||
PROJECTION_RECEIPT_SCHEMA,
|
||||
PROJECTION_TABLE,
|
||||
SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
)
|
||||
|
||||
SOURCE_TASK_ID = "TASK-SINGLEHOST-TRUSTROOT-P0-20260904"
|
||||
CHILD_TASK_ID = SOURCE_TASK_ID + "-S1"
|
||||
WORKER_ID = "p0-task-principal-v15-20260830"
|
||||
REPOSITORY = "aios/aios"
|
||||
TARGET_BRANCH = "agent/single-host-trust-root-p0-20260904"
|
||||
GENERATION = 1
|
||||
EXTERNAL_JOB_ID = "job-4e10f92a6a5742518a9f1d8745c845ab"
|
||||
BINDING_SCHEMA = "aios.task-claim-guard-binding.v2"
|
||||
RECEIPT_SCHEMA = "aios.task-claim-guard-binding-receipt.v2"
|
||||
HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
|
||||
|
||||
class ClaimGuardBindingError(RuntimeError):
|
||||
def __init__(self, code: str, http_status: int = 409):
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.http_status = int(http_status)
|
||||
|
||||
|
||||
def _fail(code: str, status: int = 409) -> None:
|
||||
raise ClaimGuardBindingError(code, status)
|
||||
|
||||
|
||||
def canonical_json(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
||||
|
||||
|
||||
def _sha_text(value: str) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _sha_json(value: Any) -> str:
|
||||
return _sha_text(canonical_json(value))
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _row_dict(row: Any) -> dict[str, Any]:
|
||||
if row is None:
|
||||
return {}
|
||||
if hasattr(row, "keys"):
|
||||
return {str(key): row[key] for key in row.keys()}
|
||||
_fail("TASK_ROW_MAPPING_UNAVAILABLE", 500)
|
||||
|
||||
|
||||
def _json(raw: Any, code: str) -> Any:
|
||||
if not isinstance(raw, str):
|
||||
_fail(code)
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ClaimGuardBindingError(code) from exc
|
||||
|
||||
|
||||
def _default_policy_factory(value: Mapping[str, Any]):
|
||||
try:
|
||||
from aios_single_host_operational_authority import OperationalPolicy
|
||||
except ImportError as exc:
|
||||
raise ClaimGuardBindingError("OPERATIONAL_POLICY_RUNTIME_UNAVAILABLE", 503) from exc
|
||||
return OperationalPolicy.from_mapping(value)
|
||||
|
||||
|
||||
def _policy_value(policy: Any, name: str) -> Any:
|
||||
try:
|
||||
return getattr(policy, name)
|
||||
except AttributeError as exc:
|
||||
raise ClaimGuardBindingError("OPERATIONAL_POLICY_FIELDS_INVALID") from exc
|
||||
|
||||
|
||||
def _load_projection_evidence(connection, relation) -> dict[str, Any]:
|
||||
try:
|
||||
row = connection.execute(
|
||||
f"SELECT * FROM {PROJECTION_TABLE} WHERE task_id=? AND source_task_id=? "
|
||||
"AND supersession_ordinal=?",
|
||||
(CHILD_TASK_ID, SOURCE_TASK_ID, 1),
|
||||
).fetchone()
|
||||
except sqlite3.OperationalError as exc:
|
||||
raise ClaimGuardBindingError(
|
||||
"FORMAL_CHILD_INITIAL_PROJECTION_SCHEMA_UNAVAILABLE", 503
|
||||
) from exc
|
||||
if row is None:
|
||||
_fail("FORMAL_CHILD_INITIAL_PROJECTION_REQUIRED")
|
||||
value = _row_dict(row)
|
||||
receipt = _json(value.get("receipt_json"), "CHILD_PROJECTION_RECEIPT_INVALID")
|
||||
canonical_digest = value.get("canonical_child_execution_digest")
|
||||
source_digest = value.get("source_business_execution_digest")
|
||||
exact = {
|
||||
"task_id": CHILD_TASK_ID,
|
||||
"source_task_id": SOURCE_TASK_ID,
|
||||
"supersession_ordinal": 1,
|
||||
"source_business_execution_digest": SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
"external_job_id": EXTERNAL_JOB_ID,
|
||||
"repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH,
|
||||
"generation": GENERATION,
|
||||
"owner_decision_sha256": relation["owner_decision_sha256"],
|
||||
}
|
||||
if any(value.get(key) != expected for key, expected in exact.items()):
|
||||
_fail("CHILD_PROJECTION_LINEAGE_MISMATCH")
|
||||
if (
|
||||
not isinstance(canonical_digest, str)
|
||||
or HEX64.fullmatch(canonical_digest) is None
|
||||
or canonical_digest == SOURCE_BUSINESS_EXECUTION_DIGEST
|
||||
or source_digest != SOURCE_BUSINESS_EXECUTION_DIGEST
|
||||
or _sha_text(value["receipt_json"]) != value.get("receipt_sha256")
|
||||
or not isinstance(receipt, Mapping)
|
||||
or receipt.get("schema") != PROJECTION_RECEIPT_SCHEMA
|
||||
or receipt.get("canonical_child_execution_digest") != canonical_digest
|
||||
or receipt.get("source_business_execution_digest") != source_digest
|
||||
or receipt.get("owner_supersession_decision_sha256")
|
||||
!= relation["owner_decision_sha256"]
|
||||
):
|
||||
_fail("CHILD_PROJECTION_RECEIPT_MISMATCH")
|
||||
return {
|
||||
**value,
|
||||
"canonical_child_execution_digest": canonical_digest,
|
||||
"source_business_execution_digest": source_digest,
|
||||
}
|
||||
|
||||
|
||||
def install_child_binding(
|
||||
connection,
|
||||
*,
|
||||
policy: Mapping[str, Any],
|
||||
runtime_id: str,
|
||||
runtime_observation_digest: str,
|
||||
task_contract_version: str,
|
||||
policy_factory: Callable[[Mapping[str, Any]], Any] = _default_policy_factory,
|
||||
now: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Generate and persist the only permitted child binding from current DB state."""
|
||||
if not isinstance(policy, Mapping):
|
||||
_fail("OPERATIONAL_POLICY_INVALID")
|
||||
if not isinstance(runtime_id, str) or not runtime_id or len(runtime_id) > 512:
|
||||
_fail("RUNTIME_ID_INVALID")
|
||||
if not isinstance(runtime_observation_digest, str) or HEX64.fullmatch(runtime_observation_digest) is None:
|
||||
_fail("RUNTIME_OBSERVATION_DIGEST_INVALID")
|
||||
if not isinstance(task_contract_version, str) or not task_contract_version or len(task_contract_version) > 128:
|
||||
_fail("TASK_CONTRACT_VERSION_INVALID")
|
||||
relation = connection.execute(
|
||||
"SELECT * FROM task_lineage_supersessions WHERE source_task_id=? AND supersession_ordinal=1 AND child_task_id=?",
|
||||
(SOURCE_TASK_ID, CHILD_TASK_ID),
|
||||
).fetchone()
|
||||
if relation is None:
|
||||
_fail("FORMAL_SUPERSESSION_REQUIRED")
|
||||
projection = _load_projection_evidence(connection, relation)
|
||||
canonical_execution_digest = projection["canonical_child_execution_digest"]
|
||||
task_row = connection.execute("SELECT * FROM tasks WHERE task_id=?", (CHILD_TASK_ID,)).fetchone()
|
||||
if task_row is None:
|
||||
_fail("CHILD_TASK_NOT_FOUND", 404)
|
||||
task = _row_dict(task_row)
|
||||
if (
|
||||
task.get("status") != "QUEUED" or int(task.get("revision", -1)) < 2
|
||||
or int(task.get("attempt", -1)) != 0 or task.get("claimed_by") is not None
|
||||
or task.get("lease_token_hash") is not None or task.get("lease_expires_at") is not None
|
||||
or task.get("result_json") is not None or task.get("verification_json") is not None
|
||||
):
|
||||
_fail("CHILD_PROJECTED_CLAIMABLE_STATE_REQUIRED")
|
||||
event_rows = connection.execute(
|
||||
"SELECT event_type,payload_json FROM events WHERE task_id=? ORDER BY event_id",
|
||||
(CHILD_TASK_ID,),
|
||||
).fetchall()
|
||||
if [row[0] for row in event_rows] != [
|
||||
"TASK_CREATED",
|
||||
"TASK_INITIAL_EXECUTION_PROJECTION_AUTHORIZED",
|
||||
]:
|
||||
_fail("CANONICAL_INITIAL_PROJECTION_REQUIRED")
|
||||
projection_event = _json(
|
||||
event_rows[-1][1], "CANONICAL_INITIAL_PROJECTION_EVENT_INVALID"
|
||||
)
|
||||
if (
|
||||
projection_event.get("source_business_execution_digest")
|
||||
!= SOURCE_BUSINESS_EXECUTION_DIGEST
|
||||
or projection_event.get("canonical_child_execution_digest")
|
||||
!= canonical_execution_digest
|
||||
or projection_event.get("owner_supersession_decision_sha256")
|
||||
!= relation["owner_decision_sha256"]
|
||||
or _sha_json(projection_event) != projection["projection_event_sha256"]
|
||||
):
|
||||
_fail("CANONICAL_INITIAL_PROJECTION_EVENT_MISMATCH")
|
||||
metadata_raw = task.get("metadata_json")
|
||||
capabilities_raw = task.get("required_capabilities_json")
|
||||
metadata = _json(metadata_raw, "CHILD_METADATA_INVALID")
|
||||
capabilities = _json(capabilities_raw, "CHILD_CAPABILITIES_INVALID")
|
||||
if (
|
||||
_sha_text(metadata_raw) != projection["metadata_sha256"]
|
||||
or _sha_text(capabilities_raw)
|
||||
!= projection["required_capabilities_sha256"]
|
||||
):
|
||||
_fail("CHILD_PROJECTION_TASK_DIGEST_MISMATCH")
|
||||
if not isinstance(metadata, Mapping) or not isinstance(capabilities, list) or not capabilities:
|
||||
_fail("CHILD_PROJECTION_INVALID")
|
||||
if any(not isinstance(value, str) or not value for value in capabilities) or len(set(capabilities)) != len(capabilities):
|
||||
_fail("CHILD_CAPABILITIES_INVALID")
|
||||
execution = metadata.get("execution")
|
||||
if not isinstance(execution, Mapping):
|
||||
_fail("CHILD_EXECUTION_PROJECTION_MISSING")
|
||||
exact_execution = {
|
||||
"task_id": CHILD_TASK_ID, "repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH, "generation": GENERATION,
|
||||
"execution_digest": canonical_execution_digest, "job_id": EXTERNAL_JOB_ID,
|
||||
}
|
||||
if any(execution.get(key) != value for key, value in exact_execution.items()):
|
||||
_fail("CHILD_EXECUTION_BINDING_MISMATCH")
|
||||
parsed_policy = policy_factory(dict(policy))
|
||||
if hasattr(parsed_policy, "validate"):
|
||||
parsed_policy.validate()
|
||||
policy_exact = {
|
||||
"task_id": CHILD_TASK_ID, "repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH, "generation": GENERATION,
|
||||
"execution_plan_sha256": canonical_execution_digest,
|
||||
"external_job_identity": EXTERNAL_JOB_ID,
|
||||
}
|
||||
if any(_policy_value(parsed_policy, key) != value for key, value in policy_exact.items()):
|
||||
_fail("OPERATIONAL_POLICY_SCOPE_MISMATCH")
|
||||
if tuple(_policy_value(parsed_policy, "allowed_worker_ids")) != (WORKER_ID,):
|
||||
_fail("OPERATIONAL_POLICY_WORKER_MISMATCH")
|
||||
expected_oid = execution.get("expected_branch_oid", execution.get("base_oid"))
|
||||
if expected_oid is not None and _policy_value(parsed_policy, "base_oid") != expected_oid:
|
||||
_fail("OPERATIONAL_POLICY_BASE_OID_MISMATCH")
|
||||
policy_json = canonical_json(dict(policy))
|
||||
policy_sha = _sha_text(policy_json)
|
||||
policy_digest = str(_policy_value(parsed_policy, "digest"))
|
||||
if policy_digest != policy_sha:
|
||||
_fail("OPERATIONAL_POLICY_DIGEST_MISMATCH")
|
||||
binding = {
|
||||
"schema": BINDING_SCHEMA, "task_id": CHILD_TASK_ID,
|
||||
"source_task_id": SOURCE_TASK_ID, "supersession_ordinal": 1,
|
||||
"expected_revision": int(task["revision"]),
|
||||
"metadata_sha256": _sha_text(metadata_raw),
|
||||
"required_capabilities_sha256": _sha_json(sorted(capabilities)),
|
||||
"worker_id": WORKER_ID,
|
||||
"execution_digest": canonical_execution_digest,
|
||||
"canonical_child_execution_digest": canonical_execution_digest,
|
||||
"source_business_execution_digest": SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
"repository": REPOSITORY, "target_branch": TARGET_BRANCH, "generation": GENERATION,
|
||||
"external_job_id": EXTERNAL_JOB_ID, "policy_sha256": policy_sha,
|
||||
"policy_digest": policy_digest, "runtime_id": runtime_id,
|
||||
"runtime_observation_digest": runtime_observation_digest,
|
||||
"task_contract_version": task_contract_version,
|
||||
"owner_supersession_decision_sha256": relation["owner_decision_sha256"],
|
||||
}
|
||||
binding_json = canonical_json(binding)
|
||||
binding_sha = _sha_text(binding_json)
|
||||
binding_id = "sha256:" + binding_sha
|
||||
existing = connection.execute(
|
||||
"SELECT binding_sha256 FROM task_claim_guard_bindings_v2 WHERE task_id=?", (CHILD_TASK_ID,)
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
if existing[0] != binding_sha:
|
||||
_fail("CLAIM_GUARD_BINDING_ALREADY_EXISTS")
|
||||
receipt = connection.execute(
|
||||
"SELECT receipt_json FROM task_claim_guard_binding_receipts_v2 WHERE binding_id=?", (binding_id,)
|
||||
).fetchone()
|
||||
if receipt is None:
|
||||
_fail("CLAIM_GUARD_BINDING_RECEIPT_MISSING", 500)
|
||||
return {**json.loads(receipt[0]), "idempotent": True}
|
||||
created_at = now or _now()
|
||||
connection.execute(
|
||||
"""INSERT INTO task_claim_guard_bindings_v2(
|
||||
binding_id,task_id,source_task_id,supersession_ordinal,expected_revision,
|
||||
metadata_sha256,required_capabilities_sha256,worker_id,execution_digest,
|
||||
repository,target_branch,generation,policy_json,policy_sha256,binding_json,
|
||||
binding_sha256,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(binding_id, CHILD_TASK_ID, SOURCE_TASK_ID, 1, binding["expected_revision"],
|
||||
binding["metadata_sha256"], binding["required_capabilities_sha256"], WORKER_ID,
|
||||
canonical_execution_digest, REPOSITORY, TARGET_BRANCH, GENERATION, policy_json, policy_sha,
|
||||
binding_json, binding_sha, created_at),
|
||||
)
|
||||
receipt = {
|
||||
"schema": RECEIPT_SCHEMA, "status": "BOUND", "binding_id": binding_id,
|
||||
"binding_sha256": binding_sha, "task_id": CHILD_TASK_ID,
|
||||
"expected_revision": binding["expected_revision"],
|
||||
"metadata_sha256": binding["metadata_sha256"], "worker_id": WORKER_ID,
|
||||
"execution_digest": canonical_execution_digest,
|
||||
"canonical_child_execution_digest": canonical_execution_digest,
|
||||
"source_business_execution_digest": SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
"repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH, "generation": GENERATION,
|
||||
"owner_supersession_decision_sha256": relation["owner_decision_sha256"],
|
||||
}
|
||||
receipt_sha = _sha_json(receipt)
|
||||
receipt_id = "sha256:" + receipt_sha
|
||||
connection.execute(
|
||||
"INSERT INTO task_claim_guard_binding_receipts_v2 VALUES(?,?,?,?,?,?)",
|
||||
(receipt_id, binding_id, CHILD_TASK_ID, canonical_json(receipt), receipt_sha, created_at),
|
||||
)
|
||||
return {**receipt, "receipt_id": receipt_id, "receipt_sha256": receipt_sha, "idempotent": False}
|
||||
|
||||
|
||||
def _load_child_binding(connection) -> tuple[dict[str, Any], dict[str, Any]]:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM task_claim_guard_bindings_v2 WHERE task_id=?", (CHILD_TASK_ID,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
_fail("CHILD_CLAIM_GUARD_BINDING_MISSING")
|
||||
value = _row_dict(row)
|
||||
binding = _json(value["binding_json"], "CLAIM_GUARD_BINDING_INVALID")
|
||||
policy = _json(value["policy_json"], "OPERATIONAL_POLICY_INVALID")
|
||||
relation = connection.execute(
|
||||
"SELECT * FROM task_lineage_supersessions WHERE source_task_id=? "
|
||||
"AND supersession_ordinal=1 AND child_task_id=?",
|
||||
(SOURCE_TASK_ID, CHILD_TASK_ID),
|
||||
).fetchone()
|
||||
if relation is None:
|
||||
_fail("FORMAL_SUPERSESSION_REQUIRED")
|
||||
projection = _load_projection_evidence(connection, relation)
|
||||
canonical_execution_digest = projection["canonical_child_execution_digest"]
|
||||
if (
|
||||
_sha_text(value["binding_json"]) != value["binding_sha256"]
|
||||
or binding.get("schema") != BINDING_SCHEMA
|
||||
or value.get("execution_digest") != canonical_execution_digest
|
||||
or binding.get("execution_digest") != canonical_execution_digest
|
||||
or binding.get("canonical_child_execution_digest")
|
||||
!= canonical_execution_digest
|
||||
or binding.get("source_business_execution_digest")
|
||||
!= SOURCE_BUSINESS_EXECUTION_DIGEST
|
||||
or binding.get("owner_supersession_decision_sha256")
|
||||
!= relation["owner_decision_sha256"]
|
||||
or policy.get("execution_plan_sha256") != canonical_execution_digest
|
||||
):
|
||||
_fail("CLAIM_GUARD_BINDING_DIGEST_MISMATCH")
|
||||
task = connection.execute("SELECT * FROM tasks WHERE task_id=?", (CHILD_TASK_ID,)).fetchone()
|
||||
task = _row_dict(task)
|
||||
if (
|
||||
task.get("status") != "QUEUED" or int(task.get("revision", -1)) != int(binding["expected_revision"])
|
||||
or int(task.get("attempt", -1)) != 0 or task.get("claimed_by") is not None
|
||||
or task.get("lease_token_hash") is not None or task.get("lease_expires_at") is not None
|
||||
or _sha_text(task.get("metadata_json", "")) != binding["metadata_sha256"]
|
||||
or task.get("result_json") is not None or task.get("verification_json") is not None
|
||||
):
|
||||
_fail("CHILD_CLAIM_GUARD_TASK_CAS_MISMATCH")
|
||||
return binding, policy
|
||||
|
||||
|
||||
def _run_child_guard(connection, binding, policy_value, agent_id):
|
||||
try:
|
||||
from aios_single_host_operational_authority import OperationalPolicy, OperationalVerifier
|
||||
from aios_singlehost_claim_guard_stage1 import _LiveReader, REGISTRY_PATH
|
||||
from aios_task_claim_guard import ClaimGuard, ClaimGuardError as CoreError
|
||||
except ImportError as exc:
|
||||
raise ClaimGuardBindingError("CLAIM_GUARD_RUNTIME_UNAVAILABLE", 503) from exc
|
||||
policy = OperationalPolicy.from_mapping(policy_value)
|
||||
cfg = {
|
||||
"task_id": CHILD_TASK_ID, "expected_claim_revision": binding["expected_revision"],
|
||||
"task_metadata_sha256": binding["metadata_sha256"],
|
||||
"required_capabilities_sha256": binding["required_capabilities_sha256"],
|
||||
"task_contract_version": binding["task_contract_version"], "worker_id": WORKER_ID,
|
||||
"policy_digest": binding["policy_digest"], "runtime_id": binding["runtime_id"],
|
||||
"runtime_observation_digest": binding["runtime_observation_digest"],
|
||||
}
|
||||
reader = _LiveReader(connection, policy, cfg, now=int(datetime.now(timezone.utc).timestamp()))
|
||||
try:
|
||||
ClaimGuard(policy, OperationalVerifier(REGISTRY_PATH, require_root_owner=True)).validate_in_transaction(
|
||||
reader, task_id=CHILD_TASK_ID,
|
||||
expected_revision=int(binding["expected_revision"]), worker_id=agent_id,
|
||||
)
|
||||
except CoreError as exc:
|
||||
raise ClaimGuardBindingError(exc.code) from exc
|
||||
|
||||
|
||||
def enforce_claim_guard(connection, *, task_id: str, agent_id: str) -> None:
|
||||
"""Exact two-lineage dispatcher; unrelated Tasks retain their prior behavior."""
|
||||
if task_id == SOURCE_TASK_ID:
|
||||
try:
|
||||
from aios_singlehost_claim_guard_stage1 import enforce_claim_guard as frozen_guard
|
||||
except ImportError as exc:
|
||||
raise ClaimGuardBindingError("FROZEN_SOURCE_CLAIM_GUARD_UNAVAILABLE", 503) from exc
|
||||
frozen_guard(connection, task_id=task_id, agent_id=agent_id)
|
||||
return
|
||||
if task_id != CHILD_TASK_ID:
|
||||
return
|
||||
if agent_id != WORKER_ID:
|
||||
_fail("WORKER_ID_MISMATCH", 403)
|
||||
binding, policy = _load_child_binding(connection)
|
||||
_run_child_guard(connection, binding, policy, agent_id)
|
||||
@@ -0,0 +1,148 @@
|
||||
"""Fail-closed signed deterministic execution amendment for R7."""
|
||||
from datetime import datetime, timezone
|
||||
import hashlib, json, sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Mapping
|
||||
from aios_task_terminal_recovery import DeterministicPlan, RecoveryError, canonical_json
|
||||
|
||||
AMENDMENT_SCHEMA="aios.deterministic-execution-amendment.v1"
|
||||
APPLY_SCHEMA="aios.deterministic-execution-amendment-apply.v1"
|
||||
NAMESPACE="aios-owner-approval-v1"
|
||||
EVENT="TASK_RECOVERY_EXECUTION_AMENDMENT_APPLIED"
|
||||
APPLY_STATES=frozenset({"FAILED", "QUEUED"})
|
||||
|
||||
|
||||
def _err(code): raise RecoveryError(code)
|
||||
def _sha(v): return hashlib.sha256(canonical_json(v).encode()).hexdigest()
|
||||
def amendment_body(x):
|
||||
return {k:v for k,v in x.items() if k not in {"signature","amendment_id"}}
|
||||
def amendment_id(x): return "sha256:"+_sha(amendment_body(x))
|
||||
def signed_payload(x):
|
||||
return canonical_json({"amendment_id":x["amendment_id"],**amendment_body(x)}).encode()
|
||||
|
||||
|
||||
def parse_amendment(raw):
|
||||
x=dict(raw)
|
||||
required={"schema","amendment_id","nonce","task_id","job_id","generation",
|
||||
"repository","branch","expected_branch_oid","recovery_epoch","decision_id",
|
||||
"plan_id","plan_sha256","expected_apply_status","expected_apply_revision",
|
||||
"expected_apply_attempt","expected_current_capabilities","envelope",
|
||||
"owner_identity","key_id","issued_at","not_before","expires_at",
|
||||
"one_time_use","signature"}
|
||||
if set(x)<required or x.get("schema")!=AMENDMENT_SCHEMA: _err("AMENDMENT_SCHEMA_INVALID")
|
||||
if x["expected_apply_status"] not in APPLY_STATES: _err("AMENDMENT_APPLY_STATUS_INVALID")
|
||||
if x.get("one_time_use") is not True: _err("AMENDMENT_ONE_TIME_REQUIRED")
|
||||
if not isinstance(x["envelope"],Mapping): _err("AMENDMENT_ENVELOPE_INVALID")
|
||||
if not isinstance(x["expected_current_capabilities"],list): _err("AMENDMENT_CAPABILITIES_INVALID")
|
||||
for k in ("generation","expected_apply_revision","expected_apply_attempt"):
|
||||
if isinstance(x[k],bool) or not isinstance(x[k],int) or x[k]<0: _err("AMENDMENT_CAS_INVALID")
|
||||
for k in ("issued_at","not_before","expires_at"):
|
||||
try:d=datetime.fromisoformat(str(x[k]).replace("Z","+00:00"))
|
||||
except ValueError as e: raise RecoveryError("AMENDMENT_TIME_INVALID") from e
|
||||
if d.tzinfo is None:_err("AMENDMENT_TIME_INVALID")
|
||||
x[k]=d.astimezone(timezone.utc).isoformat().replace("+00:00","Z")
|
||||
x["expected_current_capabilities"]=sorted(set(x["expected_current_capabilities"]))
|
||||
x["envelope"]=dict(x["envelope"])
|
||||
return x
|
||||
|
||||
|
||||
def _validate_envelope(env,plan,x):
|
||||
try:
|
||||
from aios_online_agent_execution import GovernedExecutionBinding,validate_execution_envelope
|
||||
binding=GovernedExecutionBinding.from_mapping(env)
|
||||
e=dict(validate_execution_envelope(env,binding=binding,worker_capabilities=plan.required_capabilities))
|
||||
except Exception as z: raise RecoveryError("AMENDMENT_EXECUTION_ENVELOPE_INVALID") from z
|
||||
if e.get("action")!="deterministic" or e.get("adapter")!="deterministic":_err("AMENDMENT_EXECUTION_ACTION_INVALID")
|
||||
if tuple(sorted(e.get("capability_scope") or ()))!=plan.required_capabilities:_err("AMENDMENT_EXECUTION_CAPABILITIES_MISMATCH")
|
||||
if not e.get("change_set") or not e.get("verification"):_err("AMENDMENT_EXECUTION_OPERATIONS_REQUIRED")
|
||||
if e.get("runtime_input") not in (None,{}) or e.get("source_signals") not in (None,[]):_err("AMENDMENT_STALE_RUNTIME_FIELDS_FORBIDDEN")
|
||||
for k,w in (("task_id",x["task_id"]),("job_id",x["job_id"]),("repository",x["repository"]),
|
||||
("generation",x["generation"]),("target_branch",x["branch"]),
|
||||
("expected_branch_oid",x["expected_branch_oid"]),("base_oid",x["expected_branch_oid"]),
|
||||
("plan_id",x["plan_id"]),("plan_sha256",x["plan_sha256"]),("recovery_epoch",x["recovery_epoch"])):
|
||||
if e.get(k)!=w:_err("AMENDMENT_EXECUTION_BINDING_MISMATCH")
|
||||
g=e.get("governance") or {}
|
||||
if g.get("writer_lease") is not True:_err("AMENDMENT_WRITER_LEASE_REQUIRED")
|
||||
if any(g.get(k,False) for k in ("main_write","production_write","tag_write","baseline_write","force_push","protected_ref_bypass")):_err("AMENDMENT_PROTECTED_WRITE_FORBIDDEN")
|
||||
return e
|
||||
|
||||
|
||||
class DeterministicExecutionAmendmentController:
|
||||
def __init__(self,db_path,verifier,*,clock=None,envelope_validator=None):
|
||||
self.db_path=Path(db_path); self.verifier=verifier
|
||||
self.clock=clock or (lambda:datetime.now(timezone.utc)); self._init()
|
||||
|
||||
def _db(self):
|
||||
c=sqlite3.connect(self.db_path,timeout=10,isolation_level=None); c.row_factory=sqlite3.Row
|
||||
c.execute("PRAGMA busy_timeout=10000"); return c
|
||||
|
||||
def _now(self): return self.clock().astimezone(timezone.utc).isoformat().replace("+00:00","Z")
|
||||
|
||||
def _init(self):
|
||||
with self._db() as c:
|
||||
c.execute("DROP TRIGGER IF EXISTS aios_r7_recovery_capability_projection")
|
||||
c.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS deterministic_execution_amendments(
|
||||
amendment_id TEXT PRIMARY KEY,nonce TEXT UNIQUE NOT NULL,task_id TEXT NOT NULL,
|
||||
decision_id TEXT NOT NULL,plan_id TEXT NOT NULL,plan_sha256 TEXT NOT NULL,
|
||||
envelope_digest TEXT NOT NULL,envelope_json TEXT NOT NULL,payload_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,UNIQUE(task_id,decision_id));
|
||||
DROP TRIGGER IF EXISTS aios_r7_recovery_requires_executable_amendment;
|
||||
CREATE TRIGGER aios_r7_recovery_requires_executable_amendment
|
||||
BEFORE UPDATE OF status ON tasks
|
||||
WHEN OLD.status='FAILED' AND NEW.status='QUEUED' BEGIN
|
||||
SELECT CASE WHEN NOT EXISTS(
|
||||
SELECT 1 FROM deterministic_execution_amendments a
|
||||
WHERE a.task_id=OLD.task_id
|
||||
AND a.decision_id=json_extract(NEW.metadata_json,'$.recovery.decision_id')
|
||||
AND a.amendment_id=json_extract(OLD.metadata_json,'$.recovery.execution_amendment_id')
|
||||
AND a.envelope_digest=json_extract(OLD.metadata_json,'$.execution.execution_digest')
|
||||
) THEN RAISE(ABORT,'SIGNED_EXECUTION_AMENDMENT_REQUIRED') END;
|
||||
END;""")
|
||||
|
||||
def _plan(self,c,x):
|
||||
r=c.execute("SELECT * FROM deterministic_plans WHERE plan_id=?",(x["plan_id"],)).fetchone()
|
||||
if r is None or r["plan_sha256"]!=x["plan_sha256"]:_err("AMENDMENT_PLAN_NOT_FOUND_OR_MISMATCH")
|
||||
p=DeterministicPlan.from_dict(json.loads(r["payload_json"]))
|
||||
if (p.task_id,p.job_id,p.generation,p.repository,p.branch,p.expected_branch_oid,p.recovery_epoch)!=(x["task_id"],x["job_id"],x["generation"],x["repository"],x["branch"],x["expected_branch_oid"],x["recovery_epoch"]):_err("AMENDMENT_PLAN_BINDING_MISMATCH")
|
||||
return p
|
||||
|
||||
def persist(self,raw):
|
||||
x=parse_amendment(raw)
|
||||
if x["amendment_id"]!=amendment_id(x):_err("AMENDMENT_ID_DIGEST_MISMATCH")
|
||||
now=self._now()
|
||||
if now<x["not_before"] or now>=x["expires_at"]:_err("AMENDMENT_EXPIRED_OR_NOT_YET_VALID")
|
||||
if not self.verifier.verify(namespace=NAMESPACE,payload=signed_payload(x),signature=x["signature"],key_id=x["key_id"]):_err("AMENDMENT_SIGNATURE_INVALID")
|
||||
with self._db() as c:
|
||||
c.execute("BEGIN IMMEDIATE"); e=_validate_envelope(x["envelope"],self._plan(c,x),x)
|
||||
ej=canonical_json(e); ed=e["execution_digest"]; stored=dict(x); stored["signature"]="<present>"
|
||||
old=c.execute("SELECT * FROM deterministic_execution_amendments WHERE amendment_id=?",(x["amendment_id"],)).fetchone()
|
||||
if old:
|
||||
if old["envelope_json"]!=ej:_err("AMENDMENT_ID_REUSE_MISMATCH")
|
||||
c.commit(); return {"status":"PRESENT","amendment_id":x["amendment_id"],"task_id":x["task_id"],"decision_id":x["decision_id"],"plan_id":x["plan_id"],"execution_digest":ed}
|
||||
if c.execute("SELECT 1 FROM deterministic_execution_amendments WHERE nonce=? OR (task_id=? AND decision_id=?)",(x["nonce"],x["task_id"],x["decision_id"])).fetchone():_err("AMENDMENT_REPLAY_OR_DECISION_REUSE")
|
||||
c.execute("INSERT INTO deterministic_execution_amendments VALUES(?,?,?,?,?,?,?,?,?,?)",(x["amendment_id"],x["nonce"],x["task_id"],x["decision_id"],x["plan_id"],x["plan_sha256"],ed,ej,canonical_json(stored),now)); c.commit()
|
||||
return {"status":"PERSISTED","amendment_id":x["amendment_id"],"task_id":x["task_id"],"decision_id":x["decision_id"],"plan_id":x["plan_id"],"execution_digest":ed}
|
||||
|
||||
def apply(self,raw):
|
||||
if raw.get("schema")!=APPLY_SCHEMA:_err("AMENDMENT_APPLY_SCHEMA_INVALID")
|
||||
aid=raw.get("amendment_id"); tid=raw.get("task_id")
|
||||
with self._db() as c:
|
||||
c.execute("BEGIN IMMEDIATE"); a=c.execute("SELECT * FROM deterministic_execution_amendments WHERE amendment_id=? AND task_id=?",(aid,tid)).fetchone()
|
||||
if a is None:_err("AMENDMENT_NOT_FOUND")
|
||||
x=json.loads(a["payload_json"]); d=json.loads(a["envelope_json"])
|
||||
t=c.execute("SELECT * FROM tasks WHERE task_id=?",(tid,)).fetchone()
|
||||
if t is None:_err("TASK_NOT_FOUND")
|
||||
m=json.loads(t["metadata_json"] or "{}"); cp=tuple(sorted(json.loads(t["required_capabilities_json"] or "[]"))); dc=tuple(sorted(d.get("capability_scope") or ()))
|
||||
rec=m.get("recovery"); rec=dict(rec) if isinstance(rec,Mapping) else {}
|
||||
old=m.get("execution")
|
||||
if isinstance(old,Mapping) and old.get("execution_digest")==a["envelope_digest"] and cp==dc and rec.get("execution_amendment_id")==aid:
|
||||
c.commit(); return {"status":"PRESENT","task_id":tid,"amendment_id":aid,"execution_digest":a["envelope_digest"],"revision":int(t["revision"]),"attempt":int(t["attempt"])}
|
||||
if t["status"]!=x["expected_apply_status"] or int(t["revision"])!=x["expected_apply_revision"] or int(t["attempt"])!=x["expected_apply_attempt"] or cp!=tuple(x["expected_current_capabilities"]) or t["claimed_by"] is not None or t["lease_token_hash"] is not None:_err("AMENDMENT_APPLY_TASK_CAS_MISMATCH")
|
||||
if t["status"]=="QUEUED" and rec.get("decision_id")!=x["decision_id"]:_err("AMENDMENT_DECISION_BINDING_MISMATCH")
|
||||
m["execution"]=d; m["execution_projection"]={"schema":"aios.online-agent.task-projection.v1","task_id":tid,"job_id":d.get("job_id"),"execution_digest":a["envelope_digest"]}
|
||||
rec["execution_amendment_id"]=aid; m["recovery"]=rec; now=self._now()
|
||||
q=c.execute("""UPDATE tasks SET required_capabilities_json=?,metadata_json=?,revision=revision+1,updated_at=? WHERE task_id=? AND status=? AND revision=? AND attempt=? AND claimed_by IS NULL AND lease_token_hash IS NULL""",(canonical_json(list(dc)),canonical_json(m),now,tid,x["expected_apply_status"],x["expected_apply_revision"],x["expected_apply_attempt"]))
|
||||
if q.rowcount!=1:_err("AMENDMENT_APPLY_TASK_CAS_FAILED")
|
||||
c.execute("INSERT INTO events(task_id,agent_id,event_type,payload_json,created_at) VALUES(?,?,?,?,?)",(tid,None,EVENT,canonical_json({"amendment_id":aid,"decision_id":x["decision_id"],"plan_id":x["plan_id"],"execution_digest":a["envelope_digest"],"required_capabilities":list(dc)}),now)); c.commit()
|
||||
return {"status":"APPLIED","task_id":tid,"amendment_id":aid,"decision_id":x["decision_id"],"plan_id":x["plan_id"],"execution_digest":a["envelope_digest"],"required_capabilities":list(dc),"revision":x["expected_apply_revision"]+1,"attempt":x["expected_apply_attempt"]}
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Loopback-only HTTP API for the task identity overlay.
|
||||
|
||||
Endpoints:
|
||||
GET /healthz
|
||||
POST /api/governance/owner-authorizations
|
||||
|
||||
The API authenticates only the short-lived overlay credential. It does not read
|
||||
or modify the global bearer-token registry.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import urllib.parse
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from typing import Any
|
||||
|
||||
from authorized_task_writer_provisioning import ProvisioningError
|
||||
from owner_authorization_broker import OwnerAuthorizationError
|
||||
from task_identity_overlay import (
|
||||
MAX_BODY_BYTES,
|
||||
IdentityOverlayConfig,
|
||||
IdentityOverlayError,
|
||||
TaskIdentityOverlayService,
|
||||
)
|
||||
|
||||
|
||||
_ENDPOINT = "/api/governance/owner-authorizations"
|
||||
|
||||
|
||||
def _enabled() -> bool:
|
||||
return os.getenv(
|
||||
"AIOS_GITOPS_IDENTITY_OVERLAY_ENABLED", ""
|
||||
).strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _bearer(header: str) -> str:
|
||||
prefix = "Bearer "
|
||||
if not header.startswith(prefix):
|
||||
raise IdentityOverlayError("OVERLAY_AUTHENTICATION_REQUIRED", 401)
|
||||
token = header[len(prefix):].strip()
|
||||
if not token or len(token) > 4096:
|
||||
raise IdentityOverlayError("OVERLAY_AUTHENTICATION_REQUIRED", 401)
|
||||
return token
|
||||
|
||||
|
||||
class OverlayHandler(BaseHTTPRequestHandler):
|
||||
server_version = "aios-task-identity-overlay/1"
|
||||
sys_version = ""
|
||||
|
||||
@property
|
||||
def overlay(self) -> TaskIdentityOverlayService:
|
||||
return self.server.overlay # type: ignore[attr-defined]
|
||||
|
||||
def log_message(self, format: str, *args: Any) -> None:
|
||||
# Never log Authorization or request bodies.
|
||||
return
|
||||
|
||||
def _send(self, status: int, value: dict[str, Any]) -> None:
|
||||
payload = (
|
||||
json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
+ "\n"
|
||||
).encode("utf-8")
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("Content-Length", str(len(payload)))
|
||||
self.end_headers()
|
||||
self.wfile.write(payload)
|
||||
|
||||
def _body(self) -> dict[str, Any]:
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length", "0"))
|
||||
except ValueError:
|
||||
raise IdentityOverlayError("OVERLAY_CONTENT_LENGTH_INVALID") from None
|
||||
if length <= 0 or length > MAX_BODY_BYTES:
|
||||
raise IdentityOverlayError("OVERLAY_BODY_SIZE_REJECTED", 413)
|
||||
raw = self.rfile.read(length)
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError):
|
||||
raise IdentityOverlayError("OVERLAY_BODY_JSON_INVALID") from None
|
||||
if not isinstance(value, dict):
|
||||
raise IdentityOverlayError("OVERLAY_BODY_OBJECT_REQUIRED")
|
||||
return value
|
||||
|
||||
def do_GET(self) -> None:
|
||||
path = urllib.parse.urlparse(self.path).path.rstrip("/") or "/"
|
||||
if path != "/healthz":
|
||||
self._send(404, {"error": "NOT_FOUND"})
|
||||
return
|
||||
self._send(
|
||||
200,
|
||||
{
|
||||
"service": "aios-task-identity-overlay",
|
||||
"status": "active" if _enabled() else "disabled",
|
||||
"global_token_registry_used": False,
|
||||
"loopback_only": True,
|
||||
},
|
||||
)
|
||||
|
||||
def do_POST(self) -> None:
|
||||
path = urllib.parse.urlparse(self.path).path.rstrip("/") or "/"
|
||||
if path != _ENDPOINT:
|
||||
self._send(404, {"error": "NOT_FOUND"})
|
||||
return
|
||||
if not _enabled():
|
||||
self._send(503, {"error": "IDENTITY_OVERLAY_DISABLED"})
|
||||
return
|
||||
try:
|
||||
token = _bearer(self.headers.get("Authorization", ""))
|
||||
result = self.overlay.provision(self._body(), token)
|
||||
self._send(200, result)
|
||||
except (IdentityOverlayError, OwnerAuthorizationError, ProvisioningError) as exc:
|
||||
body: dict[str, Any] = {"error": exc.code}
|
||||
if getattr(exc, "details", None):
|
||||
body["details"] = exc.details
|
||||
self._send(exc.status, body)
|
||||
except Exception:
|
||||
self._send(500, {"error": "IDENTITY_OVERLAY_API_INTERNAL_ERROR"})
|
||||
|
||||
|
||||
class OverlayServer(ThreadingHTTPServer):
|
||||
allow_reuse_address = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
address: tuple[str, int],
|
||||
overlay: TaskIdentityOverlayService,
|
||||
) -> None:
|
||||
super().__init__(address, OverlayHandler)
|
||||
self.overlay = overlay
|
||||
|
||||
|
||||
def main() -> None:
|
||||
host = os.getenv("AIOS_GITOPS_IDENTITY_OVERLAY_HOST", "127.0.0.1")
|
||||
if host not in {"127.0.0.1", "::1", "localhost"}:
|
||||
raise SystemExit("identity overlay must listen on loopback")
|
||||
try:
|
||||
port = int(os.getenv("AIOS_GITOPS_IDENTITY_OVERLAY_PORT", "19864"))
|
||||
except ValueError:
|
||||
raise SystemExit("invalid overlay port") from None
|
||||
if not 1024 <= port <= 65535:
|
||||
raise SystemExit("invalid overlay port")
|
||||
config = IdentityOverlayConfig.from_environment()
|
||||
server = OverlayServer(
|
||||
(host, port),
|
||||
TaskIdentityOverlayService(config),
|
||||
)
|
||||
server.serve_forever(poll_interval=0.25)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Root-governed CLI for issuing and revoking task identity overlays.
|
||||
|
||||
The CLI never prints the bearer token. It returns only the root-owned secret
|
||||
reference and non-secret metadata.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from task_identity_overlay import (
|
||||
IdentityOverlayConfig,
|
||||
IdentityOverlayError,
|
||||
TaskIdentityOverlayStore,
|
||||
)
|
||||
|
||||
|
||||
def _write_result(path: str, value: dict) -> None:
|
||||
data = json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n"
|
||||
if path == "-":
|
||||
sys.stdout.write(data)
|
||||
return
|
||||
target = Path(path)
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
target.write_text(data, encoding="utf-8")
|
||||
os.chmod(target, 0o600)
|
||||
|
||||
|
||||
def parser() -> argparse.ArgumentParser:
|
||||
value = argparse.ArgumentParser(
|
||||
description="Issue/status/revoke a task-scoped identity overlay"
|
||||
)
|
||||
sub = value.add_subparsers(dest="command", required=True)
|
||||
|
||||
issue = sub.add_parser("issue")
|
||||
issue.add_argument("--ttl-seconds", type=int, default=900)
|
||||
issue.add_argument("--authorization-assertion-ref", required=True)
|
||||
issue.add_argument(
|
||||
"--operator-identity",
|
||||
default=os.getenv("SUDO_USER") or os.getenv("USER") or "root",
|
||||
)
|
||||
issue.add_argument("--result-json", default="-")
|
||||
|
||||
status = sub.add_parser("status")
|
||||
status.add_argument("--overlay-id", required=True)
|
||||
status.add_argument("--result-json", default="-")
|
||||
|
||||
revoke = sub.add_parser("revoke")
|
||||
revoke.add_argument("--overlay-id", required=True)
|
||||
revoke.add_argument("--reason", required=True)
|
||||
revoke.add_argument("--result-json", default="-")
|
||||
return value
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parser().parse_args()
|
||||
try:
|
||||
store = TaskIdentityOverlayStore(
|
||||
IdentityOverlayConfig.from_environment()
|
||||
)
|
||||
if args.command == "issue":
|
||||
result = store.issue(
|
||||
ttl_seconds=args.ttl_seconds,
|
||||
authorization_assertion_ref=args.authorization_assertion_ref,
|
||||
operator_identity=args.operator_identity,
|
||||
)
|
||||
elif args.command == "status":
|
||||
result = store.status(args.overlay_id)
|
||||
else:
|
||||
result = store.revoke(args.overlay_id, reason=args.reason)
|
||||
_write_result(args.result_json, result)
|
||||
return 0
|
||||
except IdentityOverlayError as exc:
|
||||
body = {"status": "BLOCKED", "error": exc.code}
|
||||
if exc.details:
|
||||
body["details"] = exc.details
|
||||
_write_result(getattr(args, "result_json", "-"), body)
|
||||
return 2
|
||||
except Exception:
|
||||
_write_result(
|
||||
getattr(args, "result_json", "-"),
|
||||
{"status": "FAILED", "error": "IDENTITY_OVERLAY_CLI_INTERNAL_ERROR"},
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,339 @@
|
||||
"""One-time, Owner-signed, append-only supersession for one exact Task lineage."""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import closing
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sqlite3
|
||||
from typing import Any, Callable, Mapping, Protocol
|
||||
|
||||
DECISION_SCHEMA = "aios.task-lineage-supersession-decision.v1"
|
||||
RECEIPT_SCHEMA = "aios.task-lineage-supersession-receipt.v1"
|
||||
OWNER_NAMESPACE = "aios-owner-approval-v1"
|
||||
OWNER_PRINCIPAL = "aios-task-grant-signer"
|
||||
OWNER_KEY_ID = "SHA256:rPqpxfPHtaZPM3zSWojX8Q7VxzCknsYqOokyo/aAM3A"
|
||||
SOURCE_TASK_ID = "TASK-SINGLEHOST-TRUSTROOT-P0-20260904"
|
||||
CHILD_TASK_ID = SOURCE_TASK_ID + "-S1"
|
||||
REPOSITORY = "aios/aios"
|
||||
TARGET_BRANCH = "agent/single-host-trust-root-p0-20260904"
|
||||
GENERATION = 1
|
||||
EXECUTION_DIGEST = "e99bda6a251832eb3865271eb5687af7eea44dd261144cdcdfa57104c0780536"
|
||||
EXTERNAL_JOB_ID = "job-4e10f92a6a5742518a9f1d8745c845ab"
|
||||
SUPERSESSION_ORDINAL = 1
|
||||
CANONICAL_PROFILE = "aios.task-initial-execution.v1"
|
||||
CANONICAL_PROFILE_VERSION = 1
|
||||
SUPERSESSION_REASON = "formal-lineage-supersession-after-expired-unclaimed-source"
|
||||
MAX_TTL = timedelta(minutes=5)
|
||||
HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/+:-]{2,511}$")
|
||||
|
||||
|
||||
class TaskLineageSupersessionError(RuntimeError):
|
||||
def __init__(self, code: str, http_status: int = 409):
|
||||
super().__init__(code)
|
||||
self.code = code
|
||||
self.http_status = int(http_status)
|
||||
|
||||
|
||||
class SignatureVerifier(Protocol):
|
||||
def verify(self, *, namespace: str, payload: bytes, signature: str, key_id: str) -> bool: ...
|
||||
|
||||
|
||||
def canonical_json(value: Any) -> str:
|
||||
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), allow_nan=False)
|
||||
|
||||
|
||||
def sha256_text(value: str) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _fail(code: str, status: int = 409) -> None:
|
||||
raise TaskLineageSupersessionError(code, status)
|
||||
|
||||
|
||||
def _utc(value: Any, field: str) -> datetime:
|
||||
if not isinstance(value, str) or not value or len(value) > 80:
|
||||
_fail(field.upper() + "_INVALID", 400)
|
||||
try:
|
||||
result = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except ValueError as exc:
|
||||
raise TaskLineageSupersessionError(field.upper() + "_INVALID", 400) from exc
|
||||
if result.tzinfo is None:
|
||||
_fail(field.upper() + "_INVALID", 400)
|
||||
return result.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _text(value: Any, field: str, *, maximum: int = 1024) -> str:
|
||||
if not isinstance(value, str) or not value or len(value) > maximum:
|
||||
_fail(field.upper() + "_INVALID", 400)
|
||||
return value
|
||||
|
||||
|
||||
def decision_payload(raw: Mapping[str, Any]) -> dict[str, Any]:
|
||||
return {key: raw[key] for key in raw if key != "signature"}
|
||||
|
||||
|
||||
DECISION_FIELDS = {
|
||||
"schema", "decision_id", "nonce", "source_task_id",
|
||||
"source_task_identity_sha256", "source_metadata_sha256",
|
||||
"source_expected_status", "source_expected_revision", "source_expected_attempt",
|
||||
"source_expected_claimed_by", "source_expected_lease_token_hash",
|
||||
"source_expected_lease_expires_at", "repository", "target_branch", "generation",
|
||||
"execution_digest", "external_job_id", "child_task_id", "supersession_ordinal",
|
||||
"canonical_projection_profile", "canonical_projection_version", "supersession_reason",
|
||||
"owner_identity", "signature_namespace", "signature_principal", "key_id",
|
||||
"issued_at", "not_before", "expires_at", "one_time_use", "signature",
|
||||
}
|
||||
|
||||
|
||||
def validate_decision(raw: Mapping[str, Any], *, now: datetime) -> dict[str, Any]:
|
||||
if not isinstance(raw, Mapping) or set(raw) != DECISION_FIELDS:
|
||||
_fail("DECISION_FIELDS_INVALID", 400)
|
||||
exact = {
|
||||
"schema": DECISION_SCHEMA,
|
||||
"source_task_id": SOURCE_TASK_ID,
|
||||
"source_expected_status": "EXPIRED",
|
||||
"source_expected_attempt": 0,
|
||||
"source_expected_claimed_by": None,
|
||||
"source_expected_lease_token_hash": None,
|
||||
"source_expected_lease_expires_at": None,
|
||||
"repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH,
|
||||
"generation": GENERATION,
|
||||
"execution_digest": EXECUTION_DIGEST,
|
||||
"external_job_id": EXTERNAL_JOB_ID,
|
||||
"child_task_id": CHILD_TASK_ID,
|
||||
"supersession_ordinal": SUPERSESSION_ORDINAL,
|
||||
"canonical_projection_profile": CANONICAL_PROFILE,
|
||||
"canonical_projection_version": CANONICAL_PROFILE_VERSION,
|
||||
"supersession_reason": SUPERSESSION_REASON,
|
||||
"owner_identity": OWNER_PRINCIPAL,
|
||||
"signature_namespace": OWNER_NAMESPACE,
|
||||
"signature_principal": OWNER_PRINCIPAL,
|
||||
"key_id": OWNER_KEY_ID,
|
||||
"one_time_use": True,
|
||||
}
|
||||
for field, expected in exact.items():
|
||||
if raw[field] != expected:
|
||||
_fail(field.upper() + "_MISMATCH", 403 if field.startswith(("owner", "signature", "key")) else 409)
|
||||
for field in ("decision_id", "nonce"):
|
||||
value = _text(raw[field], field, maximum=256)
|
||||
if SAFE_ID.fullmatch(value) is None:
|
||||
_fail(field.upper() + "_INVALID", 400)
|
||||
for field in ("source_task_identity_sha256", "source_metadata_sha256"):
|
||||
if not isinstance(raw[field], str) or HEX64.fullmatch(raw[field]) is None:
|
||||
_fail(field.upper() + "_INVALID", 400)
|
||||
if type(raw["source_expected_revision"]) is not int or raw["source_expected_revision"] < 1:
|
||||
_fail("SOURCE_EXPECTED_REVISION_INVALID", 400)
|
||||
_text(raw["signature"], "signature", maximum=32768)
|
||||
issued_at = _utc(raw["issued_at"], "issued_at")
|
||||
not_before = _utc(raw["not_before"], "not_before")
|
||||
expires_at = _utc(raw["expires_at"], "expires_at")
|
||||
if issued_at > expires_at or not_before > expires_at:
|
||||
_fail("DECISION_TIME_ORDER_INVALID", 403)
|
||||
if expires_at - issued_at > MAX_TTL or expires_at <= issued_at:
|
||||
_fail("DECISION_TTL_INVALID", 403)
|
||||
now = now.astimezone(timezone.utc)
|
||||
if now < not_before or now >= expires_at:
|
||||
_fail("DECISION_EXPIRED_OR_NOT_YET_VALID", 403)
|
||||
if issued_at > now + timedelta(seconds=5) or now - issued_at > MAX_TTL:
|
||||
_fail("DECISION_ISSUED_AT_INVALID", 403)
|
||||
return {**dict(raw), "issued_at_dt": issued_at, "not_before_dt": not_before, "expires_at_dt": expires_at}
|
||||
|
||||
|
||||
def _default_identity_digest(row: sqlite3.Row) -> str:
|
||||
try:
|
||||
from aios_expired_task_recovery import task_identity_digest
|
||||
except ImportError as exc:
|
||||
raise TaskLineageSupersessionError("FORMAL_TASK_IDENTITY_HELPER_UNAVAILABLE", 503) from exc
|
||||
return str(task_identity_digest(row))
|
||||
|
||||
|
||||
def _json_object(row: sqlite3.Row, field: str) -> dict[str, Any]:
|
||||
try:
|
||||
value = json.loads(row[field])
|
||||
except (TypeError, ValueError, KeyError) as exc:
|
||||
raise TaskLineageSupersessionError("SOURCE_TASK_JSON_INVALID", 409) from exc
|
||||
if not isinstance(value, Mapping):
|
||||
_fail("SOURCE_TASK_JSON_INVALID")
|
||||
return dict(value)
|
||||
|
||||
|
||||
def _verify_source(row: sqlite3.Row, d: Mapping[str, Any], identity_digest: Callable[[sqlite3.Row], str]) -> None:
|
||||
if row["task_id"] != SOURCE_TASK_ID:
|
||||
_fail("SOURCE_TASK_ID_MISMATCH")
|
||||
cas = (
|
||||
row["status"] == "EXPIRED"
|
||||
and int(row["revision"]) == d["source_expected_revision"]
|
||||
and int(row["attempt"]) == 0
|
||||
and row["claimed_by"] is None
|
||||
and row["lease_token_hash"] is None
|
||||
and row["lease_expires_at"] is None
|
||||
)
|
||||
if not cas:
|
||||
_fail("SOURCE_TASK_EXACT_CAS_MISMATCH")
|
||||
metadata_raw = row["metadata_json"]
|
||||
if not isinstance(metadata_raw, str) or sha256_text(metadata_raw) != d["source_metadata_sha256"]:
|
||||
_fail("SOURCE_METADATA_DIGEST_MISMATCH")
|
||||
if identity_digest(row) != d["source_task_identity_sha256"]:
|
||||
_fail("SOURCE_TASK_IDENTITY_DIGEST_MISMATCH")
|
||||
metadata = _json_object(row, "metadata_json")
|
||||
execution = metadata.get("execution")
|
||||
constraints = _json_object(row, "constraints_json")
|
||||
if not isinstance(execution, Mapping):
|
||||
_fail("SOURCE_EXECUTION_BINDING_INVALID")
|
||||
expected = {
|
||||
"repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH,
|
||||
"generation": GENERATION,
|
||||
"execution_digest": EXECUTION_DIGEST,
|
||||
"external_job_id": EXTERNAL_JOB_ID,
|
||||
"task_id": SOURCE_TASK_ID,
|
||||
}
|
||||
if any(execution.get(key) != value for key, value in expected.items()):
|
||||
_fail("SOURCE_EXECUTION_BINDING_MISMATCH")
|
||||
if constraints.get("generation") != GENERATION:
|
||||
_fail("SOURCE_GENERATION_CONSTRAINT_MISMATCH")
|
||||
|
||||
|
||||
class TaskLineageSupersessionController:
|
||||
def __init__(self, db_path: str | Path, verifier: SignatureVerifier, *, clock=None, identity_digest=None):
|
||||
self.db_path = Path(db_path)
|
||||
self.verifier = verifier
|
||||
self.clock = clock or (lambda: datetime.now(timezone.utc))
|
||||
self.identity_digest = identity_digest or _default_identity_digest
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self.db_path, timeout=30, isolation_level=None)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
connection.execute("PRAGMA busy_timeout=30000")
|
||||
return connection
|
||||
|
||||
def _now(self) -> datetime:
|
||||
value = self.clock()
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=timezone.utc)
|
||||
return value.astimezone(timezone.utc)
|
||||
|
||||
@staticmethod
|
||||
def _assert_schema(connection: sqlite3.Connection) -> None:
|
||||
required = {"tasks", "events", "task_lineage_supersessions", "task_lineage_supersession_receipts"}
|
||||
actual = {row[0] for row in connection.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
||||
if not required.issubset(actual):
|
||||
_fail("SUPERSESSION_SCHEMA_UNAVAILABLE", 503)
|
||||
columns = {row[1] for row in connection.execute("PRAGMA table_info(tasks)")}
|
||||
task_columns = {
|
||||
"task_id", "goal", "instructions", "success_criteria_json",
|
||||
"required_capabilities_json", "constraints_json", "metadata_json", "deadline",
|
||||
"priority", "max_attempts", "attempt", "status", "claimed_by",
|
||||
"lease_token_hash", "lease_expires_at", "result_json", "result_digest",
|
||||
"result_idempotency_key", "verification_json", "revision", "created_at", "updated_at",
|
||||
}
|
||||
if not task_columns.issubset(columns):
|
||||
_fail("TASK_SCHEMA_UNSUPPORTED", 503)
|
||||
|
||||
def supersede(self, raw: Mapping[str, Any]) -> dict[str, Any]:
|
||||
now = self._now()
|
||||
decision = validate_decision(raw, now=now)
|
||||
payload_bytes = canonical_json(decision_payload(raw)).encode("utf-8")
|
||||
if not self.verifier.verify(
|
||||
namespace=OWNER_NAMESPACE, payload=payload_bytes,
|
||||
signature=decision["signature"], key_id=decision["key_id"],
|
||||
):
|
||||
_fail("DECISION_SIGNATURE_INVALID", 403)
|
||||
decision_sha = hashlib.sha256(payload_bytes).hexdigest()
|
||||
now_text = now.isoformat()
|
||||
with closing(self._connect()) as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
self._assert_schema(connection)
|
||||
replay = connection.execute(
|
||||
"SELECT owner_decision_sha256,receipt_json FROM task_lineage_supersession_receipts WHERE decision_id=? OR nonce=?",
|
||||
(decision["decision_id"], decision["nonce"]),
|
||||
).fetchone()
|
||||
if replay is not None:
|
||||
if replay["owner_decision_sha256"] != decision_sha:
|
||||
_fail("DECISION_REPLAY_MISMATCH")
|
||||
result = json.loads(replay["receipt_json"])
|
||||
connection.commit()
|
||||
return {**result, "idempotent": True}
|
||||
source = connection.execute("SELECT * FROM tasks WHERE task_id=?", (SOURCE_TASK_ID,)).fetchone()
|
||||
if source is None:
|
||||
_fail("SOURCE_TASK_NOT_FOUND", 404)
|
||||
_verify_source(source, decision, self.identity_digest)
|
||||
if connection.execute(
|
||||
"SELECT 1 FROM task_lineage_supersessions WHERE source_task_id=? AND supersession_ordinal=?",
|
||||
(SOURCE_TASK_ID, SUPERSESSION_ORDINAL),
|
||||
).fetchone():
|
||||
_fail("SUPERSESSION_ALREADY_EXISTS")
|
||||
if connection.execute("SELECT 1 FROM tasks WHERE task_id=?", (CHILD_TASK_ID,)).fetchone():
|
||||
_fail("CHILD_TASK_ALREADY_EXISTS")
|
||||
connection.execute(
|
||||
"""INSERT INTO tasks(
|
||||
task_id,goal,instructions,success_criteria_json,required_capabilities_json,
|
||||
constraints_json,metadata_json,deadline,priority,max_attempts,attempt,status,
|
||||
claimed_by,lease_token_hash,lease_expires_at,result_json,result_digest,
|
||||
result_idempotency_key,verification_json,revision,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
CHILD_TASK_ID, source["goal"], source["instructions"], source["success_criteria_json"],
|
||||
"[]", source["constraints_json"], "{}", None, source["priority"], source["max_attempts"],
|
||||
0, "QUEUED", None, None, None, None, None, None, None, 1, now_text, now_text,
|
||||
),
|
||||
)
|
||||
connection.execute(
|
||||
"""INSERT INTO task_lineage_supersessions(
|
||||
source_task_id,supersession_ordinal,child_task_id,source_task_identity_sha256,
|
||||
source_metadata_sha256,owner_decision_sha256,decision_id,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)""",
|
||||
(SOURCE_TASK_ID, SUPERSESSION_ORDINAL, CHILD_TASK_ID,
|
||||
decision["source_task_identity_sha256"], decision["source_metadata_sha256"],
|
||||
decision_sha, decision["decision_id"], now_text),
|
||||
)
|
||||
event_payload = {
|
||||
"task_id": CHILD_TASK_ID, "supersedes_task_id": SOURCE_TASK_ID,
|
||||
"supersession_ordinal": SUPERSESSION_ORDINAL,
|
||||
"source_task_identity_sha256": decision["source_task_identity_sha256"],
|
||||
"source_metadata_sha256": decision["source_metadata_sha256"],
|
||||
"owner_supersession_decision_sha256": decision_sha,
|
||||
"canonical_projection_profile": CANONICAL_PROFILE,
|
||||
"canonical_projection_version": CANONICAL_PROFILE_VERSION,
|
||||
}
|
||||
connection.execute(
|
||||
"INSERT INTO events(task_id,agent_id,event_type,payload_json,created_at) VALUES(?,?,?,?,?)",
|
||||
(CHILD_TASK_ID, None, "TASK_CREATED", canonical_json(event_payload), now_text),
|
||||
)
|
||||
receipt = {
|
||||
"schema": RECEIPT_SCHEMA, "status": "SUPERSEDED",
|
||||
"decision_id": decision["decision_id"], "owner_decision_sha256": decision_sha,
|
||||
"source_task_id": SOURCE_TASK_ID, "source_task_identity_sha256": decision["source_task_identity_sha256"],
|
||||
"source_metadata_sha256": decision["source_metadata_sha256"],
|
||||
"source_status": "EXPIRED", "source_revision": decision["source_expected_revision"],
|
||||
"source_attempt": 0, "source_history_preserved": True,
|
||||
"child_task_id": CHILD_TASK_ID, "supersession_ordinal": SUPERSESSION_ORDINAL,
|
||||
"child_status": "QUEUED", "child_revision": 1, "child_attempt": 0,
|
||||
"canonical_projection_profile": CANONICAL_PROFILE,
|
||||
"canonical_projection_version": CANONICAL_PROFILE_VERSION,
|
||||
"exact_identity_cas": True, "one_time_use": True,
|
||||
"owner_signature_namespace": OWNER_NAMESPACE,
|
||||
"owner_signature_principal": OWNER_PRINCIPAL,
|
||||
"owner_signature_key_id": OWNER_KEY_ID,
|
||||
"owner_signature_sha256": sha256_text(decision["signature"]),
|
||||
}
|
||||
connection.execute(
|
||||
"""INSERT INTO task_lineage_supersession_receipts(
|
||||
decision_id,nonce,source_task_id,supersession_ordinal,child_task_id,
|
||||
owner_decision_sha256,decision_json,receipt_json,consumed_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?)""",
|
||||
(decision["decision_id"], decision["nonce"], SOURCE_TASK_ID, SUPERSESSION_ORDINAL,
|
||||
CHILD_TASK_ID, decision_sha, canonical_json(dict(raw)), canonical_json(receipt), now_text),
|
||||
)
|
||||
connection.commit()
|
||||
return {**receipt, "idempotent": False}
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
@@ -0,0 +1,548 @@
|
||||
"""Canonical initial projection for the exact Single-Host supersession child.
|
||||
|
||||
The source business execution digest is lineage evidence. It is deliberately
|
||||
not used as the child execution-envelope digest: the canonical envelope binds
|
||||
the child Task id and therefore has its own server-derived digest.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_agent_task_initial_execution_projection import (
|
||||
InitialExecutionProjectionError,
|
||||
InitialExecutionProjectionRequest,
|
||||
InitialExecutionProjectionStore,
|
||||
RUNTIME_CAPABILITY_RE,
|
||||
_now,
|
||||
_require_string,
|
||||
_required_authorization_id,
|
||||
)
|
||||
from aios_online_agent_execution import (
|
||||
ExecutionProjectionError,
|
||||
GovernedExecutionBinding,
|
||||
validate_execution_envelope,
|
||||
)
|
||||
from aios_task_lineage_supersession import (
|
||||
CANONICAL_PROFILE,
|
||||
CANONICAL_PROFILE_VERSION,
|
||||
CHILD_TASK_ID,
|
||||
DECISION_SCHEMA,
|
||||
EXECUTION_DIGEST as SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
EXTERNAL_JOB_ID,
|
||||
GENERATION,
|
||||
OWNER_KEY_ID,
|
||||
OWNER_NAMESPACE,
|
||||
OWNER_PRINCIPAL,
|
||||
RECEIPT_SCHEMA as SUPERSESSION_RECEIPT_SCHEMA,
|
||||
REPOSITORY,
|
||||
SOURCE_TASK_ID,
|
||||
SUPERSESSION_ORDINAL,
|
||||
TARGET_BRANCH,
|
||||
canonical_json,
|
||||
decision_payload,
|
||||
)
|
||||
|
||||
|
||||
PROJECTION_INTENT_SCHEMA = "aios.task-lineage-supersession-initial-projection-intent.v1"
|
||||
PROJECTION_RECEIPT_SCHEMA = "aios.task-lineage-supersession-initial-projection-receipt.v1"
|
||||
PROJECTION_IDEMPOTENCY_KEY = "task-lineage-supersession-s1-initial-projection-v1"
|
||||
PROJECTION_REASON = "canonical-initial-projection-for-formal-supersession-child"
|
||||
PROJECTION_TABLE = "task_lineage_supersession_initial_projections"
|
||||
|
||||
|
||||
def _sha_text(value: str) -> str:
|
||||
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _sha_json(value: Any) -> str:
|
||||
return _sha_text(canonical_json(value))
|
||||
|
||||
|
||||
def _json_object(raw: Any, code: str) -> dict[str, Any]:
|
||||
if not isinstance(raw, str):
|
||||
raise InitialExecutionProjectionError(code, 409)
|
||||
try:
|
||||
value = json.loads(raw)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise InitialExecutionProjectionError(code, 409) from exc
|
||||
if not isinstance(value, Mapping):
|
||||
raise InitialExecutionProjectionError(code, 409)
|
||||
return dict(value)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SupersessionChildProjectionIntent:
|
||||
"""Caller-selectable portion of the exact child projection.
|
||||
|
||||
Task identity, CAS values, Job identity, projection profile, reason and
|
||||
idempotency are intentionally absent from the request and server-owned.
|
||||
"""
|
||||
|
||||
adapter: str
|
||||
required_capability: str
|
||||
authorization_id: str
|
||||
|
||||
@classmethod
|
||||
def from_mapping(
|
||||
cls, value: Mapping[str, Any]
|
||||
) -> "SupersessionChildProjectionIntent":
|
||||
if not isinstance(value, Mapping) or set(value) != {
|
||||
"adapter",
|
||||
"required_capability",
|
||||
"authorization_id",
|
||||
}:
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_INITIAL_PROJECTION_FIELDS_INVALID", 400
|
||||
)
|
||||
adapter = _require_string(value["adapter"], "RUNTIME_ADAPTER_INVALID")
|
||||
capability = _require_string(
|
||||
value["required_capability"],
|
||||
"RUNTIME_CAPABILITY_INVALID",
|
||||
RUNTIME_CAPABILITY_RE,
|
||||
)
|
||||
if adapter not in {"hermes", "openclaw"} or capability != f"agent.runtime.{adapter}":
|
||||
raise InitialExecutionProjectionError("RUNTIME_CAPABILITY_MISMATCH", 400)
|
||||
authorization_id = _require_string(
|
||||
value["authorization_id"], "AUTHORIZATION_ID_INVALID"
|
||||
)
|
||||
return cls(adapter, capability, authorization_id)
|
||||
|
||||
def public(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema": PROJECTION_INTENT_SCHEMA,
|
||||
"source_task_id": SOURCE_TASK_ID,
|
||||
"child_task_id": CHILD_TASK_ID,
|
||||
"supersession_ordinal": SUPERSESSION_ORDINAL,
|
||||
"canonical_projection_profile": CANONICAL_PROFILE,
|
||||
"canonical_projection_version": CANONICAL_PROFILE_VERSION,
|
||||
"expected_status": "QUEUED",
|
||||
"expected_revision": 1,
|
||||
"expected_attempt": 0,
|
||||
"expected_metadata_sha256": _sha_json({}),
|
||||
"expected_required_capabilities_sha256": _sha_json([]),
|
||||
"expected_job_id": EXTERNAL_JOB_ID,
|
||||
"adapter": self.adapter,
|
||||
"required_capability": self.required_capability,
|
||||
"authorization_id": self.authorization_id,
|
||||
"idempotency_key": PROJECTION_IDEMPOTENCY_KEY,
|
||||
"reason": PROJECTION_REASON,
|
||||
}
|
||||
|
||||
def initial_request(self, canonical_execution_digest: str) -> InitialExecutionProjectionRequest:
|
||||
return InitialExecutionProjectionRequest.from_mapping(
|
||||
{
|
||||
"expected_status": "QUEUED",
|
||||
"expected_revision": 1,
|
||||
"expected_attempt": 0,
|
||||
"expected_metadata_sha256": _sha_json({}),
|
||||
"expected_required_capabilities_sha256": _sha_json([]),
|
||||
"expected_job_id": EXTERNAL_JOB_ID,
|
||||
"expected_execution_digest": canonical_execution_digest,
|
||||
"required_capability": self.required_capability,
|
||||
"adapter": self.adapter,
|
||||
"authorization_id": self.authorization_id,
|
||||
"idempotency_key": PROJECTION_IDEMPOTENCY_KEY,
|
||||
"reason": PROJECTION_REASON,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _table_names(connection: sqlite3.Connection) -> set[str]:
|
||||
return {
|
||||
str(row[0])
|
||||
for row in connection.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
def _load_lineage_context(
|
||||
connection: sqlite3.Connection, *, expect_projected: bool
|
||||
) -> dict[str, Any]:
|
||||
required_tables = {
|
||||
"tasks",
|
||||
"events",
|
||||
"task_lineage_supersessions",
|
||||
"task_lineage_supersession_receipts",
|
||||
PROJECTION_TABLE,
|
||||
}
|
||||
if not required_tables.issubset(_table_names(connection)):
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_INITIAL_PROJECTION_SCHEMA_UNAVAILABLE", 503
|
||||
)
|
||||
relation = connection.execute(
|
||||
"SELECT * FROM task_lineage_supersessions "
|
||||
"WHERE source_task_id=? AND supersession_ordinal=? AND child_task_id=?",
|
||||
(SOURCE_TASK_ID, SUPERSESSION_ORDINAL, CHILD_TASK_ID),
|
||||
).fetchone()
|
||||
if relation is None:
|
||||
raise InitialExecutionProjectionError("FORMAL_SUPERSESSION_REQUIRED", 409)
|
||||
receipt_row = connection.execute(
|
||||
"SELECT * FROM task_lineage_supersession_receipts "
|
||||
"WHERE decision_id=? AND source_task_id=? AND supersession_ordinal=? "
|
||||
"AND child_task_id=?",
|
||||
(
|
||||
relation["decision_id"],
|
||||
SOURCE_TASK_ID,
|
||||
SUPERSESSION_ORDINAL,
|
||||
CHILD_TASK_ID,
|
||||
),
|
||||
).fetchone()
|
||||
if receipt_row is None:
|
||||
raise InitialExecutionProjectionError("SUPERSESSION_RECEIPT_REQUIRED", 409)
|
||||
decision = _json_object(receipt_row["decision_json"], "SUPERSESSION_DECISION_INVALID")
|
||||
receipt = _json_object(receipt_row["receipt_json"], "SUPERSESSION_RECEIPT_INVALID")
|
||||
decision_sha256 = _sha_text(canonical_json(decision_payload(decision)))
|
||||
exact_decision = {
|
||||
"schema": DECISION_SCHEMA,
|
||||
"source_task_id": SOURCE_TASK_ID,
|
||||
"child_task_id": CHILD_TASK_ID,
|
||||
"supersession_ordinal": SUPERSESSION_ORDINAL,
|
||||
"repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH,
|
||||
"generation": GENERATION,
|
||||
"execution_digest": SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
"external_job_id": EXTERNAL_JOB_ID,
|
||||
"canonical_projection_profile": CANONICAL_PROFILE,
|
||||
"canonical_projection_version": CANONICAL_PROFILE_VERSION,
|
||||
"owner_identity": OWNER_PRINCIPAL,
|
||||
"signature_namespace": OWNER_NAMESPACE,
|
||||
"signature_principal": OWNER_PRINCIPAL,
|
||||
"key_id": OWNER_KEY_ID,
|
||||
"one_time_use": True,
|
||||
}
|
||||
if any(decision.get(key) != expected for key, expected in exact_decision.items()):
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_DECISION_SCOPE_MISMATCH", 409
|
||||
)
|
||||
if (
|
||||
decision_sha256 != relation["owner_decision_sha256"]
|
||||
or decision_sha256 != receipt_row["owner_decision_sha256"]
|
||||
or decision.get("source_task_identity_sha256")
|
||||
!= relation["source_task_identity_sha256"]
|
||||
or decision.get("source_metadata_sha256") != relation["source_metadata_sha256"]
|
||||
or receipt.get("schema") != SUPERSESSION_RECEIPT_SCHEMA
|
||||
or receipt.get("owner_decision_sha256") != decision_sha256
|
||||
or receipt.get("source_task_id") != SOURCE_TASK_ID
|
||||
or receipt.get("child_task_id") != CHILD_TASK_ID
|
||||
or receipt.get("supersession_ordinal") != SUPERSESSION_ORDINAL
|
||||
):
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_LINEAGE_EVIDENCE_MISMATCH", 409
|
||||
)
|
||||
events = connection.execute(
|
||||
"SELECT event_type,payload_json FROM events WHERE task_id=? ORDER BY event_id",
|
||||
(CHILD_TASK_ID,),
|
||||
).fetchall()
|
||||
expected_events = ["TASK_CREATED"]
|
||||
if expect_projected:
|
||||
expected_events.append("TASK_INITIAL_EXECUTION_PROJECTION_AUTHORIZED")
|
||||
if [str(row["event_type"]) for row in events] != expected_events:
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_CHILD_EVENT_CHAIN_INVALID", 409
|
||||
)
|
||||
created = _json_object(events[0]["payload_json"], "SUPERSESSION_CREATED_EVENT_INVALID")
|
||||
exact_created = {
|
||||
"task_id": CHILD_TASK_ID,
|
||||
"supersedes_task_id": SOURCE_TASK_ID,
|
||||
"supersession_ordinal": SUPERSESSION_ORDINAL,
|
||||
"source_task_identity_sha256": relation["source_task_identity_sha256"],
|
||||
"source_metadata_sha256": relation["source_metadata_sha256"],
|
||||
"owner_supersession_decision_sha256": decision_sha256,
|
||||
"canonical_projection_profile": CANONICAL_PROFILE,
|
||||
"canonical_projection_version": CANONICAL_PROFILE_VERSION,
|
||||
}
|
||||
if any(created.get(key) != expected for key, expected in exact_created.items()):
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_CREATED_EVENT_SCOPE_MISMATCH", 409
|
||||
)
|
||||
return {
|
||||
"source_task_identity_sha256": relation["source_task_identity_sha256"],
|
||||
"source_metadata_sha256": relation["source_metadata_sha256"],
|
||||
"owner_decision_sha256": decision_sha256,
|
||||
}
|
||||
|
||||
|
||||
def _validate_canonical_child_execution(
|
||||
resolved: Mapping[str, Any],
|
||||
*,
|
||||
intent: SupersessionChildProjectionIntent,
|
||||
runtime_input: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
binding = GovernedExecutionBinding.from_mapping(resolved)
|
||||
execution = validate_execution_envelope(resolved, binding=binding)
|
||||
except ExecutionProjectionError as exc:
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_CANONICAL_EXECUTION_INVALID", 409
|
||||
) from exc
|
||||
exact_binding = {
|
||||
"task_id": CHILD_TASK_ID,
|
||||
"job_id": EXTERNAL_JOB_ID,
|
||||
"repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH,
|
||||
"generation": GENERATION,
|
||||
}
|
||||
if any(execution.get(key) != expected for key, expected in exact_binding.items()):
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_CANONICAL_EXECUTION_BINDING_MISMATCH", 409
|
||||
)
|
||||
if (
|
||||
execution.get("action") != "agent_runtime"
|
||||
or execution.get("adapter") != intent.adapter
|
||||
or execution.get("capability_scope") != [intent.required_capability]
|
||||
or execution.get("runtime_input") != dict(runtime_input)
|
||||
or execution.get("change_set") != []
|
||||
or execution.get("verification") != []
|
||||
or execution.get("governance", {}).get("writer_lease") is not False
|
||||
):
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_AGENT_RUNTIME_ENVELOPE_INVALID", 409
|
||||
)
|
||||
if execution["execution_digest"] == SOURCE_BUSINESS_EXECUTION_DIGEST:
|
||||
raise InitialExecutionProjectionError(
|
||||
"CHILD_CANONICAL_DIGEST_COLLIDES_WITH_SOURCE_BUSINESS_DIGEST", 409
|
||||
)
|
||||
return execution
|
||||
|
||||
|
||||
class _SupersessionProjectionTransactionStore(InitialExecutionProjectionStore):
|
||||
"""Per-invocation store that adds immutable lineage evidence atomically."""
|
||||
|
||||
allows_supersession_child = True
|
||||
|
||||
def __init__(self, db_path, resolved: Mapping[str, Any], intent):
|
||||
self._resolved = dict(resolved)
|
||||
self.intent = intent
|
||||
super().__init__(db_path, self._resolve_once)
|
||||
|
||||
def _resolve_once(self, *_args):
|
||||
return dict(self._resolved)
|
||||
|
||||
def _projection_event_extension(
|
||||
self, connection, task_id, request, execution, applied_revision
|
||||
):
|
||||
if task_id != CHILD_TASK_ID:
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_CHILD_TASK_ID_MISMATCH", 409
|
||||
)
|
||||
context = _load_lineage_context(connection, expect_projected=False)
|
||||
return {
|
||||
"source_task_id": SOURCE_TASK_ID,
|
||||
"supersession_ordinal": SUPERSESSION_ORDINAL,
|
||||
"source_task_identity_sha256": context["source_task_identity_sha256"],
|
||||
"source_metadata_sha256": context["source_metadata_sha256"],
|
||||
"owner_supersession_decision_sha256": context["owner_decision_sha256"],
|
||||
"source_business_execution_digest": SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
"canonical_child_execution_digest": execution["execution_digest"],
|
||||
"canonical_projection_profile": CANONICAL_PROFILE,
|
||||
"canonical_projection_version": CANONICAL_PROFILE_VERSION,
|
||||
}
|
||||
|
||||
def _insert_projection_extension(
|
||||
self,
|
||||
connection,
|
||||
task_id,
|
||||
request,
|
||||
execution,
|
||||
event_payload,
|
||||
applied_revision,
|
||||
):
|
||||
context = _load_lineage_context(connection, expect_projected=True)
|
||||
task = connection.execute(
|
||||
"SELECT metadata_json,required_capabilities_json FROM tasks WHERE task_id=?",
|
||||
(CHILD_TASK_ID,),
|
||||
).fetchone()
|
||||
if task is None:
|
||||
raise InitialExecutionProjectionError("TASK_NOT_FOUND", 404)
|
||||
intent_json = canonical_json(self.intent.public())
|
||||
intent_sha256 = _sha_text(intent_json)
|
||||
receipt = {
|
||||
"schema": PROJECTION_RECEIPT_SCHEMA,
|
||||
"projection_id": "sha256:" + intent_sha256,
|
||||
"task_id": CHILD_TASK_ID,
|
||||
"source_task_id": SOURCE_TASK_ID,
|
||||
"supersession_ordinal": SUPERSESSION_ORDINAL,
|
||||
"source_task_identity_sha256": context["source_task_identity_sha256"],
|
||||
"source_metadata_sha256": context["source_metadata_sha256"],
|
||||
"owner_supersession_decision_sha256": context["owner_decision_sha256"],
|
||||
"source_business_execution_digest": SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
"canonical_child_execution_digest": execution["execution_digest"],
|
||||
"external_job_id": EXTERNAL_JOB_ID,
|
||||
"repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH,
|
||||
"generation": GENERATION,
|
||||
"adapter": self.intent.adapter,
|
||||
"required_capability": self.intent.required_capability,
|
||||
"expected_revision": 1,
|
||||
"applied_revision": applied_revision,
|
||||
"attempt": 0,
|
||||
"metadata_sha256": _sha_text(task["metadata_json"]),
|
||||
"required_capabilities_sha256": _sha_text(
|
||||
task["required_capabilities_json"]
|
||||
),
|
||||
"projection_event_sha256": _sha_json(dict(event_payload)),
|
||||
}
|
||||
receipt_json = canonical_json(receipt)
|
||||
receipt_sha256 = _sha_text(receipt_json)
|
||||
connection.execute(
|
||||
f"""INSERT INTO {PROJECTION_TABLE}(
|
||||
projection_id,task_id,source_task_id,supersession_ordinal,
|
||||
intent_sha256,intent_json,owner_decision_sha256,
|
||||
source_task_identity_sha256,source_metadata_sha256,
|
||||
source_business_execution_digest,canonical_child_execution_digest,
|
||||
external_job_id,repository,target_branch,generation,adapter,
|
||||
required_capability,expected_revision,applied_revision,metadata_sha256,
|
||||
required_capabilities_sha256,projection_event_sha256,receipt_json,
|
||||
receipt_sha256,created_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
receipt["projection_id"],
|
||||
CHILD_TASK_ID,
|
||||
SOURCE_TASK_ID,
|
||||
SUPERSESSION_ORDINAL,
|
||||
intent_sha256,
|
||||
intent_json,
|
||||
context["owner_decision_sha256"],
|
||||
context["source_task_identity_sha256"],
|
||||
context["source_metadata_sha256"],
|
||||
SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
execution["execution_digest"],
|
||||
EXTERNAL_JOB_ID,
|
||||
REPOSITORY,
|
||||
TARGET_BRANCH,
|
||||
GENERATION,
|
||||
self.intent.adapter,
|
||||
self.intent.required_capability,
|
||||
1,
|
||||
applied_revision,
|
||||
receipt["metadata_sha256"],
|
||||
receipt["required_capabilities_sha256"],
|
||||
receipt["projection_event_sha256"],
|
||||
receipt_json,
|
||||
receipt_sha256,
|
||||
_now(),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class SupersessionChildInitialProjectionStore:
|
||||
"""Server-owned adapter from exact supersession lineage to current ABI."""
|
||||
|
||||
def __init__(self, db_path, resolver):
|
||||
self.db_path = db_path
|
||||
self.resolver = resolver
|
||||
|
||||
def _connect(self):
|
||||
connection = sqlite3.connect(self.db_path, timeout=30.0)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
connection.execute("PRAGMA busy_timeout=30000")
|
||||
return connection
|
||||
|
||||
@staticmethod
|
||||
def _replay(connection, intent: SupersessionChildProjectionIntent):
|
||||
if PROJECTION_TABLE not in _table_names(connection):
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_INITIAL_PROJECTION_SCHEMA_UNAVAILABLE", 503
|
||||
)
|
||||
row = connection.execute(
|
||||
f"SELECT * FROM {PROJECTION_TABLE} WHERE task_id=?", (CHILD_TASK_ID,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
tables = _table_names(connection)
|
||||
if "task_initial_execution_projections" in tables:
|
||||
generic = connection.execute(
|
||||
"SELECT 1 FROM task_initial_execution_projections "
|
||||
"WHERE task_id=? AND idempotency_key=?",
|
||||
(CHILD_TASK_ID, PROJECTION_IDEMPOTENCY_KEY),
|
||||
).fetchone()
|
||||
if generic is not None:
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_PROJECTION_RECEIPT_MISSING", 500
|
||||
)
|
||||
return None
|
||||
intent_sha256 = _sha_json(intent.public())
|
||||
if row["intent_sha256"] != intent_sha256:
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_INITIAL_PROJECTION_INTENT_CONFLICT", 409
|
||||
)
|
||||
receipt = _json_object(
|
||||
row["receipt_json"], "SUPERSESSION_PROJECTION_RECEIPT_INVALID"
|
||||
)
|
||||
if (
|
||||
_sha_text(row["receipt_json"]) != row["receipt_sha256"]
|
||||
or receipt.get("schema") != PROJECTION_RECEIPT_SCHEMA
|
||||
or receipt.get("canonical_child_execution_digest")
|
||||
!= row["canonical_child_execution_digest"]
|
||||
):
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_PROJECTION_RECEIPT_DIGEST_MISMATCH", 500
|
||||
)
|
||||
_load_lineage_context(connection, expect_projected=True)
|
||||
task = connection.execute(
|
||||
"SELECT * FROM tasks WHERE task_id=?", (CHILD_TASK_ID,)
|
||||
).fetchone()
|
||||
if (
|
||||
task is None
|
||||
or task["status"] != "QUEUED"
|
||||
or int(task["revision"]) != int(row["applied_revision"])
|
||||
or int(task["attempt"]) != 0
|
||||
or task["claimed_by"] is not None
|
||||
or task["lease_token_hash"] is not None
|
||||
or task["lease_expires_at"] is not None
|
||||
or _sha_text(task["metadata_json"]) != row["metadata_sha256"]
|
||||
or _sha_text(task["required_capabilities_json"])
|
||||
!= row["required_capabilities_sha256"]
|
||||
):
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_PROJECTED_TASK_CAS_MISMATCH", 409
|
||||
)
|
||||
return {**receipt, "idempotent": True}
|
||||
|
||||
def project(self, value: Mapping[str, Any]) -> dict[str, Any]:
|
||||
intent = SupersessionChildProjectionIntent.from_mapping(value)
|
||||
if intent.authorization_id != _required_authorization_id():
|
||||
raise InitialExecutionProjectionError("AUTHORIZATION_ID_MISMATCH", 403)
|
||||
with self._connect() as connection:
|
||||
replay = self._replay(connection, intent)
|
||||
if replay is not None:
|
||||
return replay
|
||||
probe_request = intent.initial_request("0" * 64)
|
||||
probe_store = _SupersessionProjectionTransactionStore(
|
||||
self.db_path, {}, intent
|
||||
)
|
||||
task, _, _, _ = probe_store._load_and_validate(
|
||||
connection, CHILD_TASK_ID, probe_request
|
||||
)
|
||||
_load_lineage_context(connection, expect_projected=False)
|
||||
runtime_input = probe_store._runtime_input(task)
|
||||
resolved = dict(
|
||||
self.resolver(
|
||||
CHILD_TASK_ID,
|
||||
EXTERNAL_JOB_ID,
|
||||
intent.required_capability,
|
||||
intent.adapter,
|
||||
runtime_input,
|
||||
)
|
||||
)
|
||||
execution = _validate_canonical_child_execution(
|
||||
resolved, intent=intent, runtime_input=runtime_input
|
||||
)
|
||||
store = _SupersessionProjectionTransactionStore(
|
||||
self.db_path, resolved, intent
|
||||
)
|
||||
result = store.project(
|
||||
CHILD_TASK_ID, intent.initial_request(execution["execution_digest"])
|
||||
)
|
||||
with self._connect() as connection:
|
||||
receipt = self._replay(connection, intent)
|
||||
if receipt is None:
|
||||
raise InitialExecutionProjectionError(
|
||||
"SUPERSESSION_PROJECTION_RECEIPT_MISSING", 500
|
||||
)
|
||||
return {**receipt, "idempotent": bool(result["idempotent"])}
|
||||
@@ -0,0 +1,221 @@
|
||||
"""Fixed Owner operation for one exact Task-lineage supersession.
|
||||
|
||||
This staging overlay deliberately extends the installed single-host operational
|
||||
authority instead of exposing a generic signing primitive. The caller can
|
||||
only provide the three live CAS values which cannot be known at build time.
|
||||
Every authority, lineage and execution field is constructed server-side.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import secrets
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
from aios_single_host_authority import OWNER_IDENTITY, OWNER_NAMESPACE, sha256_json
|
||||
from aios_single_host_operational_authority import (
|
||||
OperationalAuthorityError,
|
||||
OperationalOwnerAuthority,
|
||||
OperationalPolicy,
|
||||
SignedOperationalArtifact,
|
||||
)
|
||||
|
||||
TASK_LINEAGE_SUPERSESSION_OPERATION = "issue_task_lineage_supersession"
|
||||
TASK_LINEAGE_SUPERSESSION_SCHEMA = "aios.task-lineage-supersession-decision.v1"
|
||||
SOURCE_TASK_ID = "TASK-SINGLEHOST-TRUSTROOT-P0-20260904"
|
||||
CHILD_TASK_ID = SOURCE_TASK_ID + "-S1"
|
||||
SUPERSESSION_ORDINAL = 1
|
||||
REPOSITORY = "aios/aios"
|
||||
TARGET_BRANCH = "agent/single-host-trust-root-p0-20260904"
|
||||
GENERATION = 1
|
||||
SOURCE_BUSINESS_EXECUTION_DIGEST = (
|
||||
"e99bda6a251832eb3865271eb5687af7eea44dd261144cdcdfa57104c0780536"
|
||||
)
|
||||
EXTERNAL_JOB_IDENTITY = "job-4e10f92a6a5742518a9f1d8745c845ab"
|
||||
CANONICAL_PROJECTION_PROFILE = "aios.task-initial-execution.v1"
|
||||
CANONICAL_PROJECTION_VERSION = 1
|
||||
SUPERSESSION_REASON = "formal-lineage-supersession-after-expired-unclaimed-source"
|
||||
DECISION_TTL_SECONDS = 300
|
||||
|
||||
HEX64 = re.compile(r"^[0-9a-f]{64}$")
|
||||
SAFE_TOKEN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:@/+:-]{2,511}$")
|
||||
|
||||
|
||||
def _fail(code: str) -> None:
|
||||
raise OperationalAuthorityError(code)
|
||||
|
||||
|
||||
def _assert_fixed_policy(policy: OperationalPolicy) -> None:
|
||||
"""Refuse issuance if the live policy is not the approved source scope."""
|
||||
policy.validate()
|
||||
exact = {
|
||||
"task_id": SOURCE_TASK_ID,
|
||||
"repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH,
|
||||
"generation": GENERATION,
|
||||
"execution_plan_sha256": SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
"external_job_identity": EXTERNAL_JOB_IDENTITY,
|
||||
}
|
||||
if any(getattr(policy, field) != expected for field, expected in exact.items()):
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_POLICY_MISMATCH")
|
||||
if policy.maximum_recovery_decision_ttl_seconds < DECISION_TTL_SECONDS:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_POLICY_TTL_TOO_NARROW")
|
||||
|
||||
|
||||
TASK_LINEAGE_SUPERSESSION_FIELDS = {
|
||||
"schema",
|
||||
"decision_id",
|
||||
"nonce",
|
||||
"source_task_id",
|
||||
"source_task_identity_sha256",
|
||||
"source_metadata_sha256",
|
||||
"source_expected_status",
|
||||
"source_expected_revision",
|
||||
"source_expected_attempt",
|
||||
"source_expected_claimed_by",
|
||||
"source_expected_lease_token_hash",
|
||||
"source_expected_lease_expires_at",
|
||||
"repository",
|
||||
"target_branch",
|
||||
"generation",
|
||||
"source_business_execution_digest",
|
||||
"external_job_identity",
|
||||
"child_task_id",
|
||||
"supersession_ordinal",
|
||||
"canonical_projection_profile",
|
||||
"canonical_projection_version",
|
||||
"supersession_reason",
|
||||
"owner_identity",
|
||||
"policy_digest",
|
||||
"one_time_use",
|
||||
"issued_at",
|
||||
"not_before",
|
||||
"expires_at",
|
||||
"signature_namespace",
|
||||
"signature_key_id",
|
||||
"canonical_payload_digest",
|
||||
}
|
||||
|
||||
|
||||
def verify_task_lineage_supersession_scope(
|
||||
decision: Mapping[str, Any],
|
||||
policy: OperationalPolicy,
|
||||
*,
|
||||
now: int,
|
||||
) -> None:
|
||||
"""Validate a signature-verified decision against the fixed source scope."""
|
||||
_assert_fixed_policy(policy)
|
||||
if not isinstance(decision, Mapping) or set(decision) != TASK_LINEAGE_SUPERSESSION_FIELDS:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_FIELDS_INVALID")
|
||||
exact = {
|
||||
"schema": TASK_LINEAGE_SUPERSESSION_SCHEMA,
|
||||
"source_task_id": SOURCE_TASK_ID,
|
||||
"source_expected_status": "EXPIRED",
|
||||
"source_expected_attempt": 0,
|
||||
"source_expected_claimed_by": None,
|
||||
"source_expected_lease_token_hash": None,
|
||||
"source_expected_lease_expires_at": None,
|
||||
"repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH,
|
||||
"generation": GENERATION,
|
||||
"source_business_execution_digest": SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
"external_job_identity": EXTERNAL_JOB_IDENTITY,
|
||||
"child_task_id": CHILD_TASK_ID,
|
||||
"supersession_ordinal": SUPERSESSION_ORDINAL,
|
||||
"canonical_projection_profile": CANONICAL_PROJECTION_PROFILE,
|
||||
"canonical_projection_version": CANONICAL_PROJECTION_VERSION,
|
||||
"supersession_reason": SUPERSESSION_REASON,
|
||||
"owner_identity": OWNER_IDENTITY,
|
||||
"policy_digest": policy.digest,
|
||||
"one_time_use": True,
|
||||
"signature_namespace": OWNER_NAMESPACE,
|
||||
}
|
||||
if any(decision.get(field) != expected for field, expected in exact.items()):
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_SCOPE_INVALID")
|
||||
if decision.get("source_task_identity_sha256") != policy.task_identity_sha256:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_SOURCE_IDENTITY_INVALID")
|
||||
for field in ("source_task_identity_sha256", "source_metadata_sha256"):
|
||||
if not isinstance(decision.get(field), str) or HEX64.fullmatch(decision[field]) is None:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_DIGEST_INVALID")
|
||||
if type(decision.get("source_expected_revision")) is not int or not 1 <= decision["source_expected_revision"] < 2**63:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_REVISION_INVALID")
|
||||
for field in ("decision_id", "nonce"):
|
||||
if not isinstance(decision.get(field), str) or SAFE_TOKEN.fullmatch(decision[field]) is None:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_GENERATED_ID_INVALID")
|
||||
for field in ("issued_at", "not_before", "expires_at"):
|
||||
if type(decision.get(field)) is not int:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_TIME_INVALID")
|
||||
if not decision["not_before"] <= now < decision["expires_at"]:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_TIME_INVALID")
|
||||
if decision["issued_at"] > now + 5 or now - decision["issued_at"] > DECISION_TTL_SECONDS:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_TIME_INVALID")
|
||||
if decision["expires_at"] - decision["issued_at"] != DECISION_TTL_SECONDS:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_TIME_INVALID")
|
||||
digest_value = dict(decision)
|
||||
supplied_digest = digest_value.pop("canonical_payload_digest", None)
|
||||
if supplied_digest != sha256_json(digest_value):
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_CANONICAL_DIGEST_INVALID")
|
||||
|
||||
|
||||
class TaskLineageSupersessionOwnerAuthority(OperationalOwnerAuthority):
|
||||
"""Owner signer with one bounded constructor and no caller-defined message."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*args: Any,
|
||||
token_hex: Callable[[int], str] = secrets.token_hex,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self._token_hex = token_hex
|
||||
|
||||
def issue_task_lineage_supersession(
|
||||
self,
|
||||
*,
|
||||
source_expected_revision: int,
|
||||
source_task_identity_sha256: str,
|
||||
source_metadata_sha256: str,
|
||||
) -> SignedOperationalArtifact:
|
||||
_assert_fixed_policy(self.policy)
|
||||
if type(source_expected_revision) is not int or not 1 <= source_expected_revision < 2**63:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_REVISION_INVALID")
|
||||
if source_task_identity_sha256 != self.policy.task_identity_sha256:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_SOURCE_IDENTITY_INVALID")
|
||||
if not isinstance(source_metadata_sha256, str) or HEX64.fullmatch(source_metadata_sha256) is None:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_METADATA_DIGEST_INVALID")
|
||||
|
||||
decision_id = "task-lineage-supersession-" + self._token_hex(16)
|
||||
nonce = "task-lineage-supersession-nonce-" + self._token_hex(16)
|
||||
if SAFE_TOKEN.fullmatch(decision_id) is None or SAFE_TOKEN.fullmatch(nonce) is None:
|
||||
_fail("TASK_LINEAGE_SUPERSESSION_GENERATED_ID_INVALID")
|
||||
now = int(self.clock())
|
||||
payload = {
|
||||
"schema": TASK_LINEAGE_SUPERSESSION_SCHEMA,
|
||||
"decision_id": decision_id,
|
||||
"nonce": nonce,
|
||||
"source_task_id": SOURCE_TASK_ID,
|
||||
"source_task_identity_sha256": source_task_identity_sha256,
|
||||
"source_metadata_sha256": source_metadata_sha256,
|
||||
"source_expected_status": "EXPIRED",
|
||||
"source_expected_revision": source_expected_revision,
|
||||
"source_expected_attempt": 0,
|
||||
"source_expected_claimed_by": None,
|
||||
"source_expected_lease_token_hash": None,
|
||||
"source_expected_lease_expires_at": None,
|
||||
"repository": REPOSITORY,
|
||||
"target_branch": TARGET_BRANCH,
|
||||
"generation": GENERATION,
|
||||
"source_business_execution_digest": SOURCE_BUSINESS_EXECUTION_DIGEST,
|
||||
"external_job_identity": EXTERNAL_JOB_IDENTITY,
|
||||
"child_task_id": CHILD_TASK_ID,
|
||||
"supersession_ordinal": SUPERSESSION_ORDINAL,
|
||||
"canonical_projection_profile": CANONICAL_PROJECTION_PROFILE,
|
||||
"canonical_projection_version": CANONICAL_PROJECTION_VERSION,
|
||||
"supersession_reason": SUPERSESSION_REASON,
|
||||
"owner_identity": OWNER_IDENTITY,
|
||||
"policy_digest": self.policy.digest,
|
||||
"one_time_use": True,
|
||||
"issued_at": now,
|
||||
"not_before": max(0, now - 5),
|
||||
"expires_at": now + DECISION_TTL_SECONDS,
|
||||
}
|
||||
return self._sign(payload)
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Staging-only server overlay for the fixed lineage Owner operation."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import time
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
from aios_single_host_authority import DEFAULT_REGISTRY_PATH
|
||||
from aios_single_host_operational_authority import (
|
||||
OperationalMachineAuthority,
|
||||
OperationalVerifier,
|
||||
)
|
||||
from aios_single_host_operational_authority_server import (
|
||||
DEFAULT_LEDGER,
|
||||
DEFAULT_MACHINE_KEY,
|
||||
DEFAULT_OWNER_KEY,
|
||||
DEFAULT_POLICY,
|
||||
DurableIssuanceLedger,
|
||||
OperationalAuthorityService as BaseOperationalAuthorityService,
|
||||
_read_policy,
|
||||
fail,
|
||||
)
|
||||
from aios_task_lineage_supersession_owner_authority import (
|
||||
TASK_LINEAGE_SUPERSESSION_OPERATION,
|
||||
TaskLineageSupersessionOwnerAuthority,
|
||||
)
|
||||
|
||||
REQUEST_FIELDS = {
|
||||
"source_expected_revision",
|
||||
"source_task_identity_sha256",
|
||||
"source_metadata_sha256",
|
||||
}
|
||||
|
||||
|
||||
class TaskLineageSupersessionIssuanceLedger(DurableIssuanceLedger):
|
||||
"""Operation-scoped one-time fence layered on the existing durable ledger."""
|
||||
|
||||
def issue_once(
|
||||
self,
|
||||
*,
|
||||
role: str,
|
||||
operation: str,
|
||||
request: Mapping[str, Any],
|
||||
issuer: Callable[[], dict[str, Any]],
|
||||
) -> dict[str, Any]:
|
||||
request_sha = hashlib.sha256(
|
||||
json.dumps(
|
||||
request,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
).hexdigest()
|
||||
connection = sqlite3.connect(self.path, timeout=5, isolation_level=None)
|
||||
try:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
existing = connection.execute(
|
||||
"SELECT 1 FROM issuance WHERE role=? AND operation=? LIMIT 1",
|
||||
(role, operation),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
connection.rollback()
|
||||
fail("TASK_LINEAGE_SUPERSESSION_ALREADY_ISSUED")
|
||||
result = issuer()
|
||||
artifact = result.get("artifact")
|
||||
nonce = artifact.get("payload", {}).get("nonce") if isinstance(artifact, Mapping) else None
|
||||
if not isinstance(nonce, str) or not nonce:
|
||||
connection.rollback()
|
||||
fail("TASK_LINEAGE_SUPERSESSION_ISSUER_RESULT_INVALID")
|
||||
encoded = json.dumps(
|
||||
result,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO issuance(role,operation,nonce,request_sha256,response_json,created_at) "
|
||||
"VALUES(?,?,?,?,?,?)",
|
||||
(role, operation, nonce, request_sha, encoded, int(time.time())),
|
||||
)
|
||||
connection.commit()
|
||||
return result
|
||||
except sqlite3.IntegrityError:
|
||||
connection.rollback()
|
||||
fail("ISSUANCE_LEDGER_RACE")
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
class OperationalAuthorityService(BaseOperationalAuthorityService):
|
||||
"""Preserve all existing operations and add one exact Owner-only operation."""
|
||||
|
||||
def __init__(self, *args: Any, token_hex=None, **kwargs: Any) -> None:
|
||||
super().__init__(*args, **kwargs)
|
||||
self.ledger = TaskLineageSupersessionIssuanceLedger(self.ledger.path)
|
||||
self._token_hex = token_hex
|
||||
|
||||
def _components(self):
|
||||
policy = _read_policy(
|
||||
self.policy_path,
|
||||
require_root_owner=self.require_root_owner,
|
||||
)
|
||||
verifier = OperationalVerifier(
|
||||
self.registry_path,
|
||||
require_root_owner=self.require_root_owner,
|
||||
)
|
||||
if self.role == "owner":
|
||||
kwargs = {}
|
||||
if self._token_hex is not None:
|
||||
kwargs["token_hex"] = self._token_hex
|
||||
signer = TaskLineageSupersessionOwnerAuthority(
|
||||
self.owner_key_path,
|
||||
policy,
|
||||
require_root_owner=self.require_root_owner,
|
||||
clock=self.clock,
|
||||
**kwargs,
|
||||
)
|
||||
else:
|
||||
signer = OperationalMachineAuthority(
|
||||
self.machine_key_path,
|
||||
policy,
|
||||
require_root_owner=self.require_root_owner,
|
||||
clock=self.clock,
|
||||
)
|
||||
return policy, verifier, signer
|
||||
|
||||
def handle(self, request: Mapping[str, Any], *, peer_uid: int) -> dict[str, Any]:
|
||||
if peer_uid not in self.allowed_uids:
|
||||
fail("PEER_UID_FORBIDDEN")
|
||||
if not isinstance(request, Mapping) or set(request) != {"operation", "payload"}:
|
||||
fail("REQUEST_INVALID")
|
||||
if request.get("operation") != TASK_LINEAGE_SUPERSESSION_OPERATION:
|
||||
return super().handle(request, peer_uid=peer_uid)
|
||||
if self.role != "owner":
|
||||
fail("MACHINE_OPERATION_FORBIDDEN")
|
||||
payload = request.get("payload")
|
||||
if not isinstance(payload, Mapping) or set(payload) != REQUEST_FIELDS:
|
||||
fail("REQUEST_FIELDS_INVALID")
|
||||
_policy, _verifier, signer = self._components()
|
||||
|
||||
def issue() -> dict[str, Any]:
|
||||
artifact = signer.issue_task_lineage_supersession(
|
||||
source_expected_revision=payload["source_expected_revision"],
|
||||
source_task_identity_sha256=payload["source_task_identity_sha256"],
|
||||
source_metadata_sha256=payload["source_metadata_sha256"],
|
||||
)
|
||||
return {"status": "PASS", "artifact": artifact.to_dict()}
|
||||
|
||||
return self.ledger.issue_once(
|
||||
role=self.role,
|
||||
operation=TASK_LINEAGE_SUPERSESSION_OPERATION,
|
||||
request=payload,
|
||||
issuer=issue,
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--role", choices=("owner", "machine"), required=True)
|
||||
parser.add_argument("--policy", default=str(DEFAULT_POLICY))
|
||||
parser.add_argument("--registry", default=str(DEFAULT_REGISTRY_PATH))
|
||||
parser.add_argument("--owner-key", default=str(DEFAULT_OWNER_KEY))
|
||||
parser.add_argument("--machine-key", default=str(DEFAULT_MACHINE_KEY))
|
||||
parser.add_argument("--socket")
|
||||
parser.add_argument("--ledger", default=str(DEFAULT_LEDGER))
|
||||
arguments = parser.parse_args()
|
||||
service = OperationalAuthorityService(
|
||||
role=arguments.role,
|
||||
policy_path=Path(arguments.policy),
|
||||
registry_path=Path(arguments.registry),
|
||||
owner_key_path=Path(arguments.owner_key),
|
||||
machine_key_path=Path(arguments.machine_key),
|
||||
socket_path=Path(arguments.socket) if arguments.socket else None,
|
||||
ledger_path=Path(arguments.ledger),
|
||||
)
|
||||
service.serve_forever()
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,958 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Fail-closed AF_UNIX builder for server-owned trusted candidate evidence."""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import socket
|
||||
import stat
|
||||
import struct
|
||||
import subprocess
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from dataclasses import dataclass, replace
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Mapping, Protocol
|
||||
|
||||
|
||||
class BuilderError(RuntimeError):
|
||||
"""A stable, non-sensitive error code returned by the builder boundary."""
|
||||
|
||||
|
||||
def _canonical_json(value: Mapping[str, Any]) -> bytes:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode("utf-8")
|
||||
|
||||
|
||||
def _sha256(*parts: str) -> str:
|
||||
return hashlib.sha256("\0".join(parts).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PeerIdentity:
|
||||
pid: int
|
||||
uid: int
|
||||
gid: int
|
||||
|
||||
|
||||
class DomainPurposeSigner(Protocol):
|
||||
"""A signer deliberately limited to the builder's one domain purpose."""
|
||||
|
||||
@property
|
||||
def key_id(self) -> str:
|
||||
...
|
||||
|
||||
@property
|
||||
def key_generation(self) -> int:
|
||||
...
|
||||
|
||||
def sign_for_purpose(self, purpose: str, payload: bytes) -> bytes:
|
||||
...
|
||||
|
||||
|
||||
class CredentialPathEd25519Signer:
|
||||
"""Reads a systemd-mounted credential path; never exports or logs key bytes."""
|
||||
|
||||
def __init__(self, credential_path: Path, *, key_id: str, key_generation: int, allowed_purpose: str):
|
||||
self._credential_path = Path(credential_path)
|
||||
self._key_id = str(key_id)
|
||||
self._key_generation = key_generation
|
||||
self._allowed_purpose = str(allowed_purpose)
|
||||
if not self._key_id or not isinstance(key_generation, int) or key_generation < 1:
|
||||
raise BuilderError("SIGNING_IDENTITY_INVALID")
|
||||
if not self._credential_path.is_file():
|
||||
raise BuilderError("SIGNING_CREDENTIAL_UNAVAILABLE")
|
||||
try:
|
||||
from cryptography.hazmat.primitives.serialization import load_pem_private_key
|
||||
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey
|
||||
|
||||
self._private_key = load_pem_private_key(self._credential_path.read_bytes(), password=None)
|
||||
if not isinstance(self._private_key, Ed25519PrivateKey):
|
||||
raise TypeError("signing key is not Ed25519")
|
||||
except Exception as exc: # do not expose parse/key material details
|
||||
raise BuilderError("SIGNING_CREDENTIAL_INVALID") from exc
|
||||
|
||||
@property
|
||||
def key_id(self) -> str:
|
||||
return self._key_id
|
||||
|
||||
@property
|
||||
def key_generation(self) -> int:
|
||||
return self._key_generation
|
||||
|
||||
def sign_for_purpose(self, purpose: str, payload: bytes) -> bytes:
|
||||
if purpose != self._allowed_purpose:
|
||||
raise BuilderError("SIGNING_PURPOSE_DENIED")
|
||||
return self._private_key.sign(payload)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BuilderKeyring:
|
||||
builder_hmac: bytes
|
||||
signing_private_key_path: Path | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if len(self.builder_hmac) < 32:
|
||||
raise BuilderError("BUILDER_HMAC_INVALID")
|
||||
|
||||
@classmethod
|
||||
def from_credentials_directory(cls, credentials_directory: Path) -> "BuilderKeyring":
|
||||
root = Path(credentials_directory)
|
||||
hmac_path = root / "builder_hmac"
|
||||
signing_path = root / "signing_private_key"
|
||||
try:
|
||||
secret = hmac_path.read_bytes().strip()
|
||||
except OSError as exc:
|
||||
raise BuilderError("BUILDER_HMAC_UNAVAILABLE") from exc
|
||||
if not signing_path.is_file():
|
||||
raise BuilderError("SIGNING_CREDENTIAL_UNAVAILABLE")
|
||||
return cls(builder_hmac=secret, signing_private_key_path=signing_path)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TrustedCandidateBuilderConfig:
|
||||
policy_schema: str
|
||||
repository: str
|
||||
request_schema: str
|
||||
response_schema: str
|
||||
operation: str
|
||||
readiness_operation: str
|
||||
maximum_request_age_seconds: int
|
||||
request_timeout_seconds: int
|
||||
allowed_peer_uid: int
|
||||
allowed_peer_cgroups: tuple[str, ...]
|
||||
allowed_peer_executable_prefix: str
|
||||
allowed_peer_runtime_prefix: str
|
||||
allowed_peer_runtime_suffix: str
|
||||
required_checks: tuple[str, ...]
|
||||
evidence_layout: Mapping[str, str]
|
||||
recipes: Mapping[str, Mapping[str, str]]
|
||||
governed_recipes: Mapping[str, Mapping[str, str]]
|
||||
policy_sha256: str
|
||||
source_admission_resolver: Callable[[str, str | None, str], Mapping[str, str]] | None = None
|
||||
migration_plan_resolver: Callable[[str, str, str], Any] | None = None
|
||||
|
||||
@classmethod
|
||||
def from_policy(cls, policy: Mapping[str, Any]) -> "TrustedCandidateBuilderConfig":
|
||||
required = {
|
||||
"schema", "repository", "request_schema", "response_schema", "operation",
|
||||
"readiness_operation", "maximum_request_age_seconds", "request_timeout_seconds",
|
||||
"allowed_peer_uid", "allowed_peer_cgroups", "allowed_peer_executable_prefix",
|
||||
"allowed_peer_runtime_prefix", "allowed_peer_runtime_suffix",
|
||||
"required_checks", "evidence_layout", "recipes", "governed_recipes",
|
||||
}
|
||||
if set(policy) != required or policy.get("schema") != "aios.trusted-candidate-builder-policy.v1":
|
||||
raise BuilderError("POLICY_SCHEMA_INVALID")
|
||||
recipes = policy.get("recipes")
|
||||
if not isinstance(recipes, Mapping) or set(recipes) != {"V2R2C2", "V2R2C3"}:
|
||||
raise BuilderError("POLICY_RECIPES_INVALID")
|
||||
c2, c3 = recipes["V2R2C2"], recipes["V2R2C3"]
|
||||
if not isinstance(c2, Mapping) or set(c2) != {"mode", "task_id", "candidate_id", "base_oid", "source_commit", "version", "purpose"} or c2.get("mode") != "fixed":
|
||||
raise BuilderError("POLICY_V2R2C2_INVALID")
|
||||
if not isinstance(c3, Mapping) or set(c3) != {"mode", "derives_from", "version", "purpose"} or c3.get("mode") != "governed-derived-only" or c3.get("derives_from") != "V2R2C2":
|
||||
raise BuilderError("POLICY_V2R2C3_INVALID")
|
||||
if (
|
||||
policy["repository"] != "aios/aios"
|
||||
or policy["operation"] != "BuildTrustedCandidateV1"
|
||||
or policy["readiness_operation"] != "ProbeTrustedCandidateBuilderV1"
|
||||
):
|
||||
raise BuilderError("POLICY_SCOPE_INVALID")
|
||||
if not isinstance(policy["maximum_request_age_seconds"], int) or not 1 <= policy["maximum_request_age_seconds"] <= 300:
|
||||
raise BuilderError("POLICY_FRESHNESS_INVALID")
|
||||
if not isinstance(policy["request_timeout_seconds"], int) or not 1 <= policy["request_timeout_seconds"] <= 120:
|
||||
raise BuilderError("POLICY_TIMEOUT_INVALID")
|
||||
if not isinstance(policy["allowed_peer_uid"], int) or policy["allowed_peer_uid"] != 1000:
|
||||
raise BuilderError("POLICY_PEER_UID_INVALID")
|
||||
cgroups, checks = policy["allowed_peer_cgroups"], policy["required_checks"]
|
||||
if not isinstance(cgroups, list) or not cgroups or not all(isinstance(value, str) and value.startswith("/") for value in cgroups):
|
||||
raise BuilderError("POLICY_CGROUPS_INVALID")
|
||||
for field in (
|
||||
"allowed_peer_executable_prefix",
|
||||
"allowed_peer_runtime_prefix",
|
||||
"allowed_peer_runtime_suffix",
|
||||
):
|
||||
if not isinstance(policy[field], str) or not policy[field].startswith("/"):
|
||||
raise BuilderError("POLICY_PROCESS_IDENTITY_INVALID")
|
||||
if not isinstance(checks, list) or not checks or len(set(checks)) != len(checks) or not all(isinstance(value, str) for value in checks):
|
||||
raise BuilderError("POLICY_CHECKS_INVALID")
|
||||
layout = policy["evidence_layout"]
|
||||
evidence_names = {
|
||||
"artifact", "manifest", "package_closure", "tests", "canary",
|
||||
"gitea", "rollback", "policy", "secret_scan",
|
||||
}
|
||||
if (
|
||||
not isinstance(layout, Mapping)
|
||||
or set(layout) != evidence_names
|
||||
or not all(
|
||||
isinstance(value, str)
|
||||
and value.startswith("{version}/")
|
||||
and ".." not in Path(value).parts
|
||||
and not Path(value).is_absolute()
|
||||
for value in layout.values()
|
||||
)
|
||||
):
|
||||
raise BuilderError("POLICY_EVIDENCE_LAYOUT_INVALID")
|
||||
return cls(
|
||||
policy_schema=str(policy["schema"]), repository=str(policy["repository"]),
|
||||
request_schema=str(policy["request_schema"]), response_schema=str(policy["response_schema"]),
|
||||
operation=str(policy["operation"]), readiness_operation=str(policy["readiness_operation"]),
|
||||
maximum_request_age_seconds=policy["maximum_request_age_seconds"],
|
||||
request_timeout_seconds=policy["request_timeout_seconds"],
|
||||
allowed_peer_uid=policy["allowed_peer_uid"], allowed_peer_cgroups=tuple(cgroups),
|
||||
allowed_peer_executable_prefix=policy["allowed_peer_executable_prefix"],
|
||||
allowed_peer_runtime_prefix=policy["allowed_peer_runtime_prefix"],
|
||||
allowed_peer_runtime_suffix=policy["allowed_peer_runtime_suffix"],
|
||||
required_checks=tuple(checks), evidence_layout=dict(layout),
|
||||
recipes={name: dict(value) for name, value in recipes.items()},
|
||||
governed_recipes={name: dict(value) for name, value in policy["governed_recipes"].items()} if isinstance(policy["governed_recipes"], Mapping) else _invalid_governed_recipes(),
|
||||
policy_sha256=hashlib.sha256(_canonical_json(dict(policy))).hexdigest(),
|
||||
)
|
||||
|
||||
def fixed_recipe(self, name: str) -> Mapping[str, str]:
|
||||
recipe = self.recipes.get(name)
|
||||
if recipe is None:
|
||||
raise BuilderError("RECIPE_UNKNOWN")
|
||||
if recipe["mode"] == "governed-derived-only":
|
||||
derived = self.governed_recipes.get(name)
|
||||
required = {"mode", "task_id", "candidate_id", "base_oid", "source_commit", "version", "purpose", "derived_from_v2r2c2_attestation", "builder_engineering_oid"}
|
||||
if not isinstance(derived, Mapping) or set(derived) != required or derived.get("mode") != "governed-derived" or derived.get("version") != name or derived.get("purpose") != recipe.get("purpose"):
|
||||
raise BuilderError("GOVERNED_V2R2C3_DERIVATION_REQUIRED")
|
||||
for name in ("base_oid", "source_commit", "builder_engineering_oid"):
|
||||
value = derived.get(name)
|
||||
if not isinstance(value, str) or len(value) != 40 or any(char not in "0123456789abcdef" for char in value.lower()):
|
||||
raise BuilderError("GOVERNED_V2R2C3_DERIVATION_REQUIRED")
|
||||
return dict(derived)
|
||||
return recipe
|
||||
|
||||
def resolve_recipe(self, version: str, candidate_id: str | None, source_commit: str) -> Mapping[str, str]:
|
||||
recipe = self.recipes.get(version)
|
||||
if recipe is not None:
|
||||
resolved = self.fixed_recipe(version)
|
||||
# Fixed/governed-derived policy recipes remain immutable. A
|
||||
# source admission can never override one of these identities.
|
||||
effective_candidate_id = str(candidate_id or resolved.get("candidate_id") or "")
|
||||
if {
|
||||
"version": resolved.get("version"),
|
||||
"candidate_id": resolved.get("candidate_id"),
|
||||
"source_commit": resolved.get("source_commit"),
|
||||
} != {
|
||||
"version": version,
|
||||
"candidate_id": effective_candidate_id,
|
||||
"source_commit": source_commit,
|
||||
}:
|
||||
raise BuilderError("REQUEST_RELEASE_BINDING_INVALID")
|
||||
return resolved
|
||||
|
||||
resolver = self.source_admission_resolver
|
||||
if resolver is None:
|
||||
raise BuilderError("RECIPE_UNKNOWN")
|
||||
try:
|
||||
admitted = dict(resolver(version, candidate_id, source_commit))
|
||||
except Exception as exc:
|
||||
raise BuilderError("SOURCE_ADMISSION_REQUIRED") from exc
|
||||
required = {
|
||||
"mode",
|
||||
"task_id",
|
||||
"candidate_id",
|
||||
"base_oid",
|
||||
"source_commit",
|
||||
"version",
|
||||
"purpose",
|
||||
}
|
||||
if (
|
||||
set(admitted) != required
|
||||
or admitted.get("mode") != "governed-source-admission"
|
||||
or admitted.get("version") != version
|
||||
or admitted.get("source_commit") != source_commit
|
||||
or not isinstance(admitted.get("candidate_id"), str)
|
||||
or not admitted.get("candidate_id")
|
||||
):
|
||||
raise BuilderError("SOURCE_ADMISSION_INVALID")
|
||||
effective_candidate_id = str(candidate_id or admitted["candidate_id"])
|
||||
if effective_candidate_id != admitted["candidate_id"]:
|
||||
raise BuilderError("REQUEST_RELEASE_BINDING_INVALID")
|
||||
for name in ("base_oid", "source_commit"):
|
||||
value = admitted.get(name)
|
||||
if (
|
||||
not isinstance(value, str)
|
||||
or len(value) != 40
|
||||
or any(char not in "0123456789abcdef" for char in value.lower())
|
||||
):
|
||||
raise BuilderError("SOURCE_ADMISSION_INVALID")
|
||||
if admitted.get("purpose") != "AIOS_TRUSTED_RELEASE_BUILDER":
|
||||
raise BuilderError("SOURCE_ADMISSION_INVALID")
|
||||
return admitted
|
||||
|
||||
|
||||
def _invalid_governed_recipes() -> Mapping[str, Mapping[str, str]]:
|
||||
raise BuilderError("POLICY_GOVERNED_RECIPES_INVALID")
|
||||
|
||||
|
||||
def load_builder_policy(path: Path) -> Mapping[str, Any]:
|
||||
try:
|
||||
decoded = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise BuilderError("POLICY_UNAVAILABLE") from exc
|
||||
if not isinstance(decoded, Mapping):
|
||||
raise BuilderError("POLICY_SCHEMA_INVALID")
|
||||
TrustedCandidateBuilderConfig.from_policy(decoded)
|
||||
return decoded
|
||||
|
||||
|
||||
class ServerOwnedEvidenceSource:
|
||||
"""Recomputes a fixed on-host evidence closure without caller-owned paths."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: TrustedCandidateBuilderConfig,
|
||||
authority_git_dir: Path,
|
||||
evidence_root: Path,
|
||||
*,
|
||||
expected_owner_uid: int | None = 0,
|
||||
git_runner: Callable[..., Any] = subprocess.run,
|
||||
):
|
||||
self._config = config
|
||||
self._authority_git_dir = Path(authority_git_dir)
|
||||
self._evidence_root = Path(evidence_root)
|
||||
self._expected_owner_uid = expected_owner_uid
|
||||
self._git_runner = git_runner
|
||||
|
||||
def _regular_path(self, relative: str) -> Path:
|
||||
root = self._evidence_root.resolve(strict=True)
|
||||
candidate = self._evidence_root / relative
|
||||
current = self._evidence_root
|
||||
for part in Path(relative).parts:
|
||||
current = current / part
|
||||
if current.is_symlink():
|
||||
raise BuilderError("COLLECTOR_SYMLINK_DENIED")
|
||||
try:
|
||||
resolved = candidate.resolve(strict=True)
|
||||
resolved.relative_to(root)
|
||||
metadata = resolved.stat(follow_symlinks=False)
|
||||
except (OSError, ValueError) as exc:
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_UNAVAILABLE") from exc
|
||||
if not stat.S_ISREG(metadata.st_mode):
|
||||
raise BuilderError("COLLECTOR_SPECIAL_FILE_DENIED")
|
||||
if os.name != "nt" and metadata.st_mode & 0o022:
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_MODE_DENIED")
|
||||
if (
|
||||
os.name != "nt"
|
||||
and
|
||||
self._expected_owner_uid is not None
|
||||
and metadata.st_uid != self._expected_owner_uid
|
||||
):
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_OWNER_DENIED")
|
||||
return resolved
|
||||
|
||||
def assert_ready(self) -> None:
|
||||
"""Validate the server-owned evidence facility without requiring recipe evidence.
|
||||
|
||||
Readiness is an infrastructure/property check. Candidate-specific evidence
|
||||
remains mandatory for ``BuildTrustedCandidateV1`` and is validated by
|
||||
:meth:`__call__`; an empty evidence root therefore no longer makes the
|
||||
builder globally unavailable.
|
||||
"""
|
||||
|
||||
for path, code in (
|
||||
(self._authority_git_dir, "COLLECTOR_AUTHORITY_GIT_UNAVAILABLE"),
|
||||
(self._evidence_root, "COLLECTOR_EVIDENCE_ROOT_UNAVAILABLE"),
|
||||
):
|
||||
try:
|
||||
metadata = path.stat(follow_symlinks=False)
|
||||
except OSError as exc:
|
||||
raise BuilderError(code) from exc
|
||||
if not stat.S_ISDIR(metadata.st_mode) or path.is_symlink():
|
||||
raise BuilderError(code)
|
||||
if os.name != "nt" and metadata.st_mode & 0o022:
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_ROOT_MODE_DENIED")
|
||||
if (
|
||||
path == self._evidence_root
|
||||
and os.name != "nt"
|
||||
and self._expected_owner_uid is not None
|
||||
and metadata.st_uid != self._expected_owner_uid
|
||||
):
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_ROOT_OWNER_DENIED")
|
||||
|
||||
def _digest(self, path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
def _json(self, path: Path) -> Mapping[str, Any]:
|
||||
try:
|
||||
if path.stat().st_size > 2 * 1024 * 1024:
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_SIZE_INVALID")
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID") from exc
|
||||
if not isinstance(value, Mapping):
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID")
|
||||
return value
|
||||
|
||||
def __call__(self, recipe_name: str, recipe: Mapping[str, str]) -> Mapping[str, Any]:
|
||||
try:
|
||||
tree_result = self._git_runner(
|
||||
["git", f"--git-dir={self._authority_git_dir}", "rev-parse", f"{recipe['source_commit']}^{{tree}}"],
|
||||
check=True, capture_output=True, text=True, timeout=15,
|
||||
env={"PATH": os.environ.get("PATH", "")},
|
||||
)
|
||||
ancestry_result = self._git_runner(
|
||||
["git", f"--git-dir={self._authority_git_dir}", "merge-base", "--is-ancestor", recipe["base_oid"], recipe["source_commit"]],
|
||||
check=False, capture_output=True, text=True, timeout=15,
|
||||
env={"PATH": os.environ.get("PATH", "")},
|
||||
)
|
||||
tree_oid = tree_result.stdout.strip().lower()
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_UNAVAILABLE") from exc
|
||||
if getattr(ancestry_result, "returncode", 1) != 0:
|
||||
raise BuilderError("COLLECTOR_GIT_MISMATCH")
|
||||
|
||||
receipt = self._json(self._regular_path(f"{recipe_name}/receipt.json"))
|
||||
if set(receipt) != {"schema", "version", "candidate", "release_plan"}:
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID")
|
||||
if (
|
||||
receipt.get("schema") != "aios.trusted-candidate-builder.evidence.v1"
|
||||
or receipt.get("version") != recipe_name
|
||||
):
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID")
|
||||
candidate = receipt.get("candidate")
|
||||
release_plan = receipt.get("release_plan")
|
||||
if not isinstance(candidate, Mapping) or not isinstance(release_plan, Mapping):
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID")
|
||||
if (
|
||||
candidate.get("candidate_id") != recipe["candidate_id"]
|
||||
or candidate.get("source_commit") != recipe["source_commit"]
|
||||
or candidate.get("tree_oid") != tree_oid
|
||||
):
|
||||
raise BuilderError("COLLECTOR_GIT_MISMATCH")
|
||||
git_consistency = candidate.get("git_consistency")
|
||||
if (
|
||||
not isinstance(git_consistency, Mapping)
|
||||
or git_consistency.get("consistent") is not True
|
||||
or git_consistency.get("source_commit") != recipe["source_commit"]
|
||||
or git_consistency.get("tree_oid") != tree_oid
|
||||
or git_consistency.get("repository") != self._config.repository
|
||||
):
|
||||
raise BuilderError("COLLECTOR_GIT_MISMATCH")
|
||||
|
||||
paths = {
|
||||
name: self._regular_path(template.format(version=recipe_name))
|
||||
for name, template in self._config.evidence_layout.items()
|
||||
}
|
||||
deployment = release_plan.get("deployment")
|
||||
if not isinstance(deployment, Mapping):
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID")
|
||||
digests = {name: self._digest(path) for name, path in paths.items()}
|
||||
if (
|
||||
digests["artifact"] != candidate.get("artifact_sha256")
|
||||
or digests["manifest"] != candidate.get("manifest_sha256")
|
||||
or digests["package_closure"] != deployment.get("package_closure_sha256")
|
||||
or digests["policy"] != release_plan.get("policy_sha256")
|
||||
or digests["policy"] != deployment.get("policy_sha256")
|
||||
):
|
||||
raise BuilderError("COLLECTOR_DIGEST_MISMATCH")
|
||||
|
||||
tests = self._json(paths["tests"])
|
||||
canary = self._json(paths["canary"])
|
||||
gitea = self._json(paths["gitea"])
|
||||
rollback = self._json(paths["rollback"])
|
||||
secret_scan = self._json(paths["secret_scan"])
|
||||
if tests != candidate.get("tests") or tests.get("passed") is not True:
|
||||
raise BuilderError("COLLECTOR_TESTS_MISMATCH")
|
||||
if canary != candidate.get("canary") or canary.get("passed") is not True:
|
||||
raise BuilderError("COLLECTOR_CANARY_MISMATCH")
|
||||
if (
|
||||
digests["tests"] != hashlib.sha256(_canonical_json(dict(tests))).hexdigest()
|
||||
or digests["canary"] != hashlib.sha256(_canonical_json(dict(canary))).hexdigest()
|
||||
):
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_CANONICALIZATION_MISMATCH")
|
||||
if (
|
||||
gitea != candidate.get("gitea_consistency")
|
||||
or gitea.get("consistent") is not True
|
||||
or gitea.get("repository") != self._config.repository
|
||||
or gitea.get("source_commit") != recipe["source_commit"]
|
||||
or gitea.get("tree_oid") != tree_oid
|
||||
or gitea.get("remote_ref_oid") != recipe["source_commit"]
|
||||
):
|
||||
raise BuilderError("COLLECTOR_GITEA_MISMATCH")
|
||||
if (
|
||||
candidate.get("rollback_ready") is not True
|
||||
or rollback.get("ready") is not True
|
||||
or rollback.get("source_commit") != recipe["source_commit"]
|
||||
or rollback.get("rollback_sha256") != deployment.get("rollback_sha256")
|
||||
):
|
||||
raise BuilderError("COLLECTOR_ROLLBACK_MISMATCH")
|
||||
if (
|
||||
digests["gitea"] != hashlib.sha256(_canonical_json(dict(gitea))).hexdigest()
|
||||
or digests["rollback"] != hashlib.sha256(_canonical_json(dict(rollback))).hexdigest()
|
||||
):
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_CANONICALIZATION_MISMATCH")
|
||||
if (
|
||||
secret_scan.get("passed") is not True
|
||||
or secret_scan.get("artifact_sha256") != candidate.get("artifact_sha256")
|
||||
):
|
||||
raise BuilderError("COLLECTOR_SECRET_SCAN_MISMATCH")
|
||||
checks = {name: True for name in self._config.required_checks}
|
||||
return {
|
||||
"candidate": dict(candidate),
|
||||
"release_plan": dict(release_plan),
|
||||
"checks": checks,
|
||||
"evidence_digests": digests,
|
||||
}
|
||||
|
||||
|
||||
class DeterministicEvidenceCollector:
|
||||
"""Validates a server-owned source; request fields never influence collection."""
|
||||
|
||||
_BUNDLE_FIELDS = {"candidate", "release_plan", "checks", "evidence_digests"}
|
||||
|
||||
def __init__(self, config: TrustedCandidateBuilderConfig, *, evidence_source: Callable[[str, Mapping[str, str]], Mapping[str, Any]] | None = None):
|
||||
self._config = config
|
||||
self._evidence_source = evidence_source
|
||||
|
||||
def assert_ready(self) -> None:
|
||||
"""Fail closed on collector configuration, not on recipe evidence presence."""
|
||||
|
||||
if self._evidence_source is None:
|
||||
raise BuilderError("COLLECTOR_UNCONFIGURED")
|
||||
checker = getattr(self._evidence_source, "assert_ready", None)
|
||||
if checker is not None:
|
||||
checker()
|
||||
|
||||
def collect(self, recipe_name: str, recipe: Mapping[str, str]) -> Mapping[str, Any]:
|
||||
self.assert_ready()
|
||||
bundle = self._evidence_source(recipe_name, recipe)
|
||||
if not isinstance(bundle, Mapping) or set(bundle) != self._BUNDLE_FIELDS:
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID")
|
||||
raw_checks = bundle.get("checks")
|
||||
if (
|
||||
not isinstance(raw_checks, Mapping)
|
||||
or set(raw_checks) != set(self._config.required_checks)
|
||||
or any(raw_checks.get(name) is not True for name in self._config.required_checks)
|
||||
):
|
||||
raise BuilderError("COLLECTOR_CHECKS_INCOMPLETE")
|
||||
evidence_digests = bundle.get("evidence_digests")
|
||||
if (
|
||||
not isinstance(evidence_digests, Mapping)
|
||||
or set(evidence_digests) != set(self._config.evidence_layout)
|
||||
or any(
|
||||
not isinstance(value, str)
|
||||
or len(value) != 64
|
||||
or any(char not in "0123456789abcdef" for char in value.lower())
|
||||
for value in evidence_digests.values()
|
||||
)
|
||||
):
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_DIGESTS_INVALID")
|
||||
try:
|
||||
from silent_release_controller import ReleaseCandidate
|
||||
from silent_release_hardening import ReleasePlan, TrustedCandidateBundle
|
||||
from release_migration_plan import (
|
||||
MigrationPlanError,
|
||||
MigrationPlanRequired,
|
||||
)
|
||||
|
||||
candidate = ReleaseCandidate.from_dict(dict(bundle["candidate"]))
|
||||
release_plan_payload = dict(bundle["release_plan"])
|
||||
migration_resolver = self._config.migration_plan_resolver
|
||||
if migration_resolver is not None:
|
||||
try:
|
||||
migration = migration_resolver(
|
||||
recipe_name,
|
||||
recipe["candidate_id"],
|
||||
recipe["source_commit"],
|
||||
)
|
||||
except MigrationPlanRequired as exc:
|
||||
raise BuilderError("MIGRATION_PLAN_REQUIRED") from exc
|
||||
except MigrationPlanError as exc:
|
||||
raise BuilderError("MIGRATION_PLAN_INVALID") from exc
|
||||
if migration is not None:
|
||||
release_plan_payload["migration"] = migration.to_dict()
|
||||
release_plan = ReleasePlan.from_dict(release_plan_payload)
|
||||
checks = dict(raw_checks)
|
||||
TrustedCandidateBundle(candidate, release_plan, checks).validate()
|
||||
except BuilderError:
|
||||
raise
|
||||
except (ImportError, KeyError, TypeError, ValueError, RuntimeError) as exc:
|
||||
raise BuilderError("COLLECTOR_EVIDENCE_INVALID") from exc
|
||||
if candidate.source_commit != recipe["source_commit"] or candidate.candidate_id != recipe["candidate_id"]:
|
||||
raise BuilderError("COLLECTOR_SOURCE_MISMATCH")
|
||||
return {
|
||||
"candidate": candidate.to_dict(),
|
||||
"release_plan": release_plan.to_dict(),
|
||||
"checks": checks,
|
||||
"evidence_digests": dict(evidence_digests),
|
||||
}
|
||||
|
||||
|
||||
def _extract_peer_identity(connection: socket.socket) -> PeerIdentity:
|
||||
if not hasattr(socket, "SO_PEERCRED"):
|
||||
raise BuilderError("SO_PEERCRED_UNAVAILABLE")
|
||||
raw = connection.getsockopt(socket.SOL_SOCKET, socket.SO_PEERCRED, struct.calcsize("3i"))
|
||||
if len(raw) != struct.calcsize("3i"):
|
||||
raise BuilderError("SO_PEERCRED_INVALID")
|
||||
return PeerIdentity(*struct.unpack("3i", raw))
|
||||
|
||||
|
||||
def _resolve_cgroups(pid: int) -> tuple[str, ...]:
|
||||
try:
|
||||
return tuple(
|
||||
line.rsplit(":", 1)[1].strip()
|
||||
for line in Path(f"/proc/{pid}/cgroup").read_text(encoding="utf-8").splitlines()
|
||||
if ":" in line and line.rsplit(":", 1)[1].strip().startswith("/")
|
||||
)
|
||||
except OSError as exc:
|
||||
raise BuilderError("PEER_CGROUP_UNAVAILABLE") from exc
|
||||
|
||||
|
||||
def _resolve_process_identity(pid: int) -> tuple[str, tuple[str, ...]]:
|
||||
try:
|
||||
executable = os.path.realpath(f"/proc/{pid}/exe")
|
||||
argv = tuple(
|
||||
item.decode("utf-8", "strict")
|
||||
for item in Path(f"/proc/{pid}/cmdline").read_bytes().split(b"\0")
|
||||
if item
|
||||
)
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
raise BuilderError("PEER_PROCESS_UNAVAILABLE") from exc
|
||||
if not executable or not argv:
|
||||
raise BuilderError("PEER_PROCESS_UNAVAILABLE")
|
||||
return executable, argv
|
||||
|
||||
|
||||
class TrustedCandidateBuilder:
|
||||
def __init__(
|
||||
self, config: TrustedCandidateBuilderConfig, keyring: BuilderKeyring, *,
|
||||
collector: DeterministicEvidenceCollector, signer: DomainPurposeSigner,
|
||||
peer_identity_resolver: Callable[[socket.socket], PeerIdentity] = _extract_peer_identity,
|
||||
cgroup_resolver: Callable[[int], tuple[str, ...] | str] = _resolve_cgroups,
|
||||
process_identity_resolver: Callable[[int], tuple[str, tuple[str, ...]]] = _resolve_process_identity,
|
||||
clock: Callable[[], float] = time.time, nonce_ledger_path: Path | None = None,
|
||||
) -> None:
|
||||
self.config, self.keyring, self.collector, self.signer = config, keyring, collector, signer
|
||||
self.peer_identity_resolver, self.cgroup_resolver, self.clock = peer_identity_resolver, cgroup_resolver, clock
|
||||
self.process_identity_resolver = process_identity_resolver
|
||||
self._nonce_ledger_path = Path(nonce_ledger_path) if nonce_ledger_path else None
|
||||
self._nonce_ledger: dict[str, tuple[bytes, dict[str, Any]]] = self._load_nonce_ledger()
|
||||
|
||||
def _load_nonce_ledger(self) -> dict[str, tuple[bytes, dict[str, Any]]]:
|
||||
if self._nonce_ledger_path is None or not self._nonce_ledger_path.exists():
|
||||
return {}
|
||||
if self._nonce_ledger_path.is_symlink():
|
||||
raise BuilderError("NONCE_LEDGER_INVALID")
|
||||
try:
|
||||
raw = json.loads(self._nonce_ledger_path.read_text(encoding="utf-8"))
|
||||
return {nonce: (bytes.fromhex(value["request_sha256"]), dict(value["result"])) for nonce, value in raw.items()}
|
||||
except (OSError, ValueError, TypeError, KeyError, json.JSONDecodeError) as exc:
|
||||
raise BuilderError("NONCE_LEDGER_INVALID") from exc
|
||||
|
||||
def _persist_nonce_ledger(self) -> None:
|
||||
if self._nonce_ledger_path is None:
|
||||
return
|
||||
try:
|
||||
self._nonce_ledger_path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
temporary = self._nonce_ledger_path.with_suffix(".tmp")
|
||||
payload = {nonce: {"request_sha256": digest.hex(), "result": result} for nonce, (digest, result) in self._nonce_ledger.items()}
|
||||
with temporary.open("wb") as handle:
|
||||
handle.write(_canonical_json(payload))
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.chmod(temporary, 0o600)
|
||||
os.replace(temporary, self._nonce_ledger_path)
|
||||
if os.name != "nt":
|
||||
descriptor = os.open(self._nonce_ledger_path.parent, os.O_RDONLY)
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
except OSError as exc:
|
||||
raise BuilderError("NONCE_LEDGER_PERSIST_FAILED") from exc
|
||||
|
||||
def _verify_peer(self, connection: socket.socket) -> None:
|
||||
peer = self.peer_identity_resolver(connection)
|
||||
if peer.uid != self.config.allowed_peer_uid or peer.pid <= 0:
|
||||
raise BuilderError("PEER_IDENTITY_DENIED")
|
||||
resolved = self.cgroup_resolver(peer.pid)
|
||||
cgroups = (resolved,) if isinstance(resolved, str) else tuple(resolved)
|
||||
if not cgroups or not any(item in self.config.allowed_peer_cgroups for item in cgroups):
|
||||
raise BuilderError("PEER_CGROUP_DENIED")
|
||||
executable, argv = self.process_identity_resolver(peer.pid)
|
||||
if not executable.startswith(self.config.allowed_peer_executable_prefix):
|
||||
raise BuilderError("PEER_PROCESS_DENIED")
|
||||
runtime_arguments = [
|
||||
value
|
||||
for value in argv
|
||||
if value.startswith(self.config.allowed_peer_runtime_prefix)
|
||||
and value.endswith(self.config.allowed_peer_runtime_suffix)
|
||||
]
|
||||
if len(runtime_arguments) != 1:
|
||||
raise BuilderError("PEER_PROCESS_DENIED")
|
||||
|
||||
def handle_request(self, request: Mapping[str, Any], *, connection: socket.socket) -> dict[str, Any]:
|
||||
if not isinstance(request, Mapping):
|
||||
raise BuilderError("REQUEST_SCHEMA_INVALID")
|
||||
expected_keys = {"schema", "operation", "recipe", "repository", "task_id", "candidate_id", "base_oid", "source_commit", "version", "purpose", "issued_at", "nonce", "hmac"}
|
||||
if (
|
||||
set(request) != expected_keys
|
||||
or request.get("schema") != self.config.request_schema
|
||||
or request.get("operation") not in {
|
||||
self.config.operation,
|
||||
self.config.readiness_operation,
|
||||
}
|
||||
):
|
||||
raise BuilderError("REQUEST_SCHEMA_INVALID")
|
||||
supplied_mac = request.get("hmac")
|
||||
unsigned = {key: value for key, value in request.items() if key != "hmac"}
|
||||
if not isinstance(supplied_mac, str) or not hmac.compare_digest(hmac.new(self.keyring.builder_hmac, _canonical_json(unsigned), hashlib.sha256).hexdigest(), supplied_mac):
|
||||
raise BuilderError("REQUEST_HMAC_INVALID")
|
||||
issued_at, nonce = request.get("issued_at"), request.get("nonce")
|
||||
if not isinstance(issued_at, int) or not isinstance(nonce, str) or len(nonce) < 16 or len(nonce) > 256:
|
||||
raise BuilderError("REQUEST_SCHEMA_INVALID")
|
||||
self._verify_peer(connection)
|
||||
request_bytes = _canonical_json(dict(request))
|
||||
replay = self._nonce_ledger.get(nonce)
|
||||
if replay is not None:
|
||||
if hmac.compare_digest(replay[0], hashlib.sha256(request_bytes).digest()):
|
||||
return dict(replay[1])
|
||||
raise BuilderError("REPLAY_CONFLICT")
|
||||
if abs(int(self.clock()) - issued_at) > self.config.maximum_request_age_seconds:
|
||||
raise BuilderError("REQUEST_STALE")
|
||||
recipe_name = request.get("recipe")
|
||||
if not isinstance(recipe_name, str):
|
||||
raise BuilderError("REQUEST_SCHEMA_INVALID")
|
||||
recipe = self.config.resolve_recipe(str(request.get("version") or ""), str(request.get("candidate_id") or ""), str(request.get("source_commit") or ""))
|
||||
if recipe_name != recipe.get("version"):
|
||||
raise BuilderError("REQUEST_RELEASE_BINDING_INVALID")
|
||||
bound = {"repository": self.config.repository, "task_id": recipe["task_id"], "candidate_id": recipe["candidate_id"], "base_oid": recipe["base_oid"], "source_commit": recipe["source_commit"], "version": recipe["version"], "purpose": recipe["purpose"]}
|
||||
if {key: request.get(key) for key in bound} != bound:
|
||||
raise BuilderError("REQUEST_SCOPE_INVALID")
|
||||
if request["operation"] == self.config.readiness_operation:
|
||||
self.collector.assert_ready()
|
||||
# Accessing the identity properties validates that the signer is
|
||||
# configured and usable without invoking the private key.
|
||||
builder_key_id = self.signer.key_id
|
||||
builder_key_generation = self.signer.key_generation
|
||||
result = {
|
||||
"schema": self.config.response_schema,
|
||||
"status": "READY",
|
||||
"repository": self.config.repository,
|
||||
"recipe": recipe_name,
|
||||
"candidate_id": recipe["candidate_id"],
|
||||
"source_commit": recipe["source_commit"],
|
||||
"builder_key_id": builder_key_id,
|
||||
"builder_key_generation": builder_key_generation,
|
||||
"signature_algorithm": "Ed25519",
|
||||
"signature_purpose": "AIOS_TRUSTED_RELEASE_BUILDER",
|
||||
"policy_sha256": self.config.policy_sha256,
|
||||
"collector_ready": True,
|
||||
"signer_ready": True,
|
||||
}
|
||||
if len(self._nonce_ledger) >= 2048:
|
||||
self._nonce_ledger.pop(next(iter(self._nonce_ledger)))
|
||||
self._nonce_ledger[nonce] = (
|
||||
hashlib.sha256(request_bytes).digest(),
|
||||
dict(result),
|
||||
)
|
||||
self._persist_nonce_ledger()
|
||||
return result
|
||||
|
||||
bundle = dict(self.collector.collect(recipe_name, recipe))
|
||||
candidate = bundle["candidate"]
|
||||
release_plan = bundle["release_plan"]
|
||||
checks = bundle.get("checks")
|
||||
evidence_digests = bundle["evidence_digests"]
|
||||
if not isinstance(checks, Mapping) or set(checks) != set(self.config.required_checks) or not all(value is True for value in checks.values()):
|
||||
raise BuilderError("COLLECTOR_CHECKS_INCOMPLETE")
|
||||
|
||||
issued = datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
|
||||
attestation = {
|
||||
"builder_attestation_id": "builder-attestation-" + _sha256(recipe_name, recipe["task_id"], candidate["artifact_sha256"])[:24],
|
||||
"builder_key_id": self.signer.key_id,
|
||||
"builder_key_generation": self.signer.key_generation,
|
||||
"signature_algorithm": "Ed25519",
|
||||
"signature_purpose": "AIOS_TRUSTED_RELEASE_BUILDER",
|
||||
"source_commit": candidate["source_commit"], "tree_oid": candidate["tree_oid"],
|
||||
"artifact_sha256": candidate["artifact_sha256"], "manifest_sha256": candidate["manifest_sha256"],
|
||||
"package_closure_sha256": release_plan["deployment"]["package_closure_sha256"],
|
||||
"test_evidence_sha256": evidence_digests["tests"],
|
||||
"canary_evidence_sha256": evidence_digests["canary"],
|
||||
"gitea_evidence_sha256": evidence_digests["gitea"],
|
||||
"rollback_evidence_sha256": evidence_digests["rollback"],
|
||||
"policy_evidence_sha256": evidence_digests["policy"],
|
||||
"secret_scan_evidence_sha256": evidence_digests["secret_scan"],
|
||||
"issued_at": issued, "trust_domain": "aios.trusted-builder.attestation.v1",
|
||||
}
|
||||
canonical_attestation = _canonical_json(attestation)
|
||||
fingerprint = hashlib.sha256(canonical_attestation).hexdigest()
|
||||
signature = base64.b64encode(self.signer.sign_for_purpose("AIOS_TRUSTED_RELEASE_BUILDER", canonical_attestation)).decode("ascii")
|
||||
result = {
|
||||
"schema": self.config.response_schema, "status": "BUILT", "recipe": recipe_name,
|
||||
"repository": self.config.repository, "task_id": recipe["task_id"], "base_oid": recipe["base_oid"],
|
||||
"purpose": recipe["purpose"], "checks": dict(checks),
|
||||
"evidence": {"candidate_id": candidate["candidate_id"], "source_commit": candidate["source_commit"], "tree_oid": candidate["tree_oid"], "artifact_sha256": candidate["artifact_sha256"], "manifest_sha256": candidate["manifest_sha256"], "package_closure_sha256": release_plan["deployment"]["package_closure_sha256"], "test_evidence_sha256": attestation["test_evidence_sha256"], "canary_evidence_sha256": attestation["canary_evidence_sha256"]},
|
||||
"attestation": {**attestation, "fingerprint": fingerprint, "signature": signature},
|
||||
}
|
||||
result["silent_release_bundle"] = {"candidate": candidate, "release_plan": release_plan, "checks": dict(checks), "attestation": result["attestation"]}
|
||||
if len(self._nonce_ledger) >= 2048:
|
||||
self._nonce_ledger.pop(next(iter(self._nonce_ledger)))
|
||||
self._nonce_ledger[nonce] = (hashlib.sha256(request_bytes).digest(), dict(result))
|
||||
self._persist_nonce_ledger()
|
||||
return result
|
||||
|
||||
def serve_forever(self, listener: socket.socket) -> None:
|
||||
while True:
|
||||
connection, _ = listener.accept()
|
||||
with connection:
|
||||
try:
|
||||
raw = connection.makefile("rb").readline(65537)
|
||||
if not raw or len(raw) > 65536:
|
||||
raise BuilderError("REQUEST_SIZE_INVALID")
|
||||
result = self.handle_request(json.loads(raw), connection=connection)
|
||||
except (BuilderError, UnicodeDecodeError, json.JSONDecodeError):
|
||||
result = {"schema": self.config.response_schema, "status": "REJECTED", "error_code": "REQUEST_REJECTED"}
|
||||
connection.sendall(_canonical_json(result) + b"\n")
|
||||
|
||||
|
||||
class UnixTrustedCandidateBuilderClient:
|
||||
def __init__(self, config: TrustedCandidateBuilderConfig, keyring: BuilderKeyring, *, socket_path: Path | None = None, clock: Callable[[], float] = time.time) -> None:
|
||||
self.config, self.keyring, self.socket_path, self.clock = config, keyring, socket_path, clock
|
||||
|
||||
def create_request(self, version: str, candidate_id: str | None, source_commit: str, *, nonce: str, operation: str | None = None) -> dict[str, Any]:
|
||||
selected = self.config.resolve_recipe(version, candidate_id, source_commit)
|
||||
selected_operation = operation or self.config.operation
|
||||
if selected_operation not in {self.config.operation, self.config.readiness_operation}:
|
||||
raise BuilderError("REQUEST_OPERATION_INVALID")
|
||||
request = {"schema": self.config.request_schema, "operation": selected_operation, "recipe": version, "repository": self.config.repository, "task_id": selected["task_id"], "candidate_id": selected["candidate_id"], "base_oid": selected["base_oid"], "source_commit": selected["source_commit"], "version": selected["version"], "purpose": selected["purpose"], "issued_at": int(self.clock()), "nonce": nonce}
|
||||
request["hmac"] = hmac.new(self.keyring.builder_hmac, _canonical_json(request), hashlib.sha256).hexdigest()
|
||||
return request
|
||||
|
||||
def _exchange(self, request: Mapping[str, Any]) -> Mapping[str, Any]:
|
||||
if self.socket_path is None:
|
||||
raise BuilderError("BUILDER_SOCKET_UNCONFIGURED")
|
||||
try:
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as connection:
|
||||
connection.settimeout(self.config.request_timeout_seconds)
|
||||
connection.connect(os.fspath(self.socket_path))
|
||||
connection.sendall(_canonical_json(dict(request)) + b"\n")
|
||||
raw = connection.makefile("rb").readline(65537)
|
||||
except (OSError, TimeoutError) as exc:
|
||||
raise BuilderError("BUILDER_TRANSPORT_UNAVAILABLE") from exc
|
||||
try:
|
||||
result = json.loads(raw)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise BuilderError("BUILDER_RESPONSE_INVALID") from exc
|
||||
if not isinstance(result, Mapping) or result.get("schema") != self.config.response_schema:
|
||||
raise BuilderError("BUILDER_RESPONSE_INVALID")
|
||||
return result
|
||||
|
||||
def build(self, version: str, candidate_id: str | None, source_commit: str, *, nonce: str) -> Mapping[str, Any]:
|
||||
result = self._exchange(
|
||||
self.create_request(version, candidate_id, source_commit, nonce=nonce)
|
||||
)
|
||||
if result.get("status") != "BUILT":
|
||||
raise BuilderError("BUILDER_REQUEST_REJECTED")
|
||||
return result
|
||||
|
||||
def probe(self, *, nonce: str | None = None) -> Mapping[str, Any]:
|
||||
recipe = self.config.fixed_recipe("V2R2C2")
|
||||
result = self._exchange(
|
||||
self.create_request(
|
||||
"V2R2C2",
|
||||
recipe["candidate_id"],
|
||||
recipe["source_commit"],
|
||||
nonce=nonce or secrets.token_hex(32),
|
||||
operation=self.config.readiness_operation,
|
||||
)
|
||||
)
|
||||
if result.get("status") != "READY":
|
||||
raise BuilderError("BUILDER_NOT_READY")
|
||||
return result
|
||||
|
||||
|
||||
def readiness_probe(client: UnixTrustedCandidateBuilderClient) -> Mapping[str, Any]:
|
||||
result = dict(client.probe())
|
||||
required_true = {"collector_ready", "signer_ready"}
|
||||
if any(result.get(name) is not True for name in required_true):
|
||||
raise BuilderError("BUILDER_NOT_READY")
|
||||
return result
|
||||
|
||||
|
||||
def _main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--socket-fd", required=True, type=int)
|
||||
parser.add_argument("--policy", required=True, type=Path)
|
||||
arguments = parser.parse_args()
|
||||
config = TrustedCandidateBuilderConfig.from_policy(load_builder_policy(arguments.policy))
|
||||
credentials = os.environ.get("CREDENTIALS_DIRECTORY")
|
||||
if not credentials:
|
||||
raise SystemExit("CREDENTIALS_DIRECTORY is required")
|
||||
keyring = BuilderKeyring.from_credentials_directory(Path(credentials))
|
||||
source_admission_root = os.environ.get(
|
||||
"AIOS_TRUSTED_BUILDER_SOURCE_ADMISSION_ROOT", ""
|
||||
).strip()
|
||||
if source_admission_root:
|
||||
from release_source_admission import open_source_admission_store
|
||||
|
||||
try:
|
||||
source_admission_hmac = (
|
||||
Path(credentials) / "source_admission_hmac"
|
||||
).read_bytes().strip()
|
||||
except OSError as exc:
|
||||
raise BuilderError("SOURCE_ADMISSION_HMAC_UNAVAILABLE") from exc
|
||||
source_store = open_source_admission_store(
|
||||
Path(source_admission_root),
|
||||
repository=config.repository,
|
||||
policy=arguments.policy,
|
||||
hmac_key=source_admission_hmac,
|
||||
expected_owner_uid=0,
|
||||
writable=False,
|
||||
)
|
||||
config = replace(
|
||||
config,
|
||||
source_admission_resolver=source_store.resolve_recipe,
|
||||
)
|
||||
migration_plan_root = os.environ.get(
|
||||
"AIOS_TRUSTED_BUILDER_MIGRATION_PLAN_ROOT", ""
|
||||
).strip()
|
||||
migration_profile_root = os.environ.get(
|
||||
"AIOS_TRUSTED_BUILDER_MIGRATION_PROFILE_ROOT", ""
|
||||
).strip()
|
||||
if bool(migration_plan_root) != bool(migration_profile_root):
|
||||
raise BuilderError("MIGRATION_PLAN_CONFIGURATION_INVALID")
|
||||
if migration_plan_root:
|
||||
from release_migration_plan import FileMigrationPlanResolver
|
||||
|
||||
migration_resolver = FileMigrationPlanResolver(
|
||||
Path(migration_plan_root),
|
||||
profile_root=Path(migration_profile_root),
|
||||
expected_owner_uid=0,
|
||||
)
|
||||
config = replace(
|
||||
config,
|
||||
migration_plan_resolver=migration_resolver.resolve,
|
||||
)
|
||||
signer = CredentialPathEd25519Signer(
|
||||
keyring.signing_private_key_path,
|
||||
key_id=os.environ["AIOS_TRUSTED_BUILDER_KEY_ID"],
|
||||
key_generation=int(os.environ["AIOS_TRUSTED_BUILDER_KEY_GENERATION"]),
|
||||
allowed_purpose="AIOS_TRUSTED_RELEASE_BUILDER",
|
||||
)
|
||||
listener = socket.fromfd(arguments.socket_fd, socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
collector = DeterministicEvidenceCollector(
|
||||
config,
|
||||
evidence_source=ServerOwnedEvidenceSource(
|
||||
config,
|
||||
Path(os.environ["AIOS_TRUSTED_BUILDER_GIT_DIR"]),
|
||||
Path(os.environ["AIOS_TRUSTED_BUILDER_EVIDENCE_ROOT"]),
|
||||
),
|
||||
)
|
||||
TrustedCandidateBuilder(config, keyring, collector=collector, signer=signer, nonce_ledger_path=Path(os.environ["AIOS_TRUSTED_BUILDER_NONCE_LEDGER"])).serve_forever(listener)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_main()
|
||||
@@ -1,110 +0,0 @@
|
||||
"""AIOS V2 控制面、执行编排与可信运行来源证明。"""
|
||||
|
||||
from .control_plane import (
|
||||
AgentRecord,
|
||||
AuditEvent,
|
||||
ControlPlaneError,
|
||||
ControlPlaneService,
|
||||
IdempotencyConflict,
|
||||
InMemoryControlPlaneStore,
|
||||
InvalidTransition,
|
||||
NotFound,
|
||||
PolicyDenied,
|
||||
RequestContext,
|
||||
RevisionConflict,
|
||||
TaskRecord,
|
||||
TaskState,
|
||||
TenantIsolationError,
|
||||
TenantRecord,
|
||||
Unauthorized,
|
||||
)
|
||||
from .execution_tracking import ExecutionEvidence, ExecutionTracker
|
||||
from .orchestrator import DispatchRecord, ExecutionOrchestrator, OrchestrationError
|
||||
from .provenance import (
|
||||
Ed25519PrivateKeySigner,
|
||||
Ed25519PublicKeyVerifier,
|
||||
ExecutionPath,
|
||||
GitOperation,
|
||||
PRODUCTION_POLICY_VERSION,
|
||||
ProvenanceAuthorization,
|
||||
ProvenanceControlledWriter,
|
||||
ProvenanceDenied,
|
||||
ProvenanceError,
|
||||
ProvenanceExpired,
|
||||
ProvenanceIssuer,
|
||||
ProvenanceNotFound,
|
||||
ProvenanceReplay,
|
||||
ProvenanceRevoked,
|
||||
ProvenanceSignatureError,
|
||||
ProvenanceStatus,
|
||||
ProvenanceVerifier,
|
||||
RunProvenance,
|
||||
SQLiteProvenanceStore,
|
||||
digest_payload,
|
||||
)
|
||||
from .provenance_git import (
|
||||
GitCommandResult,
|
||||
GitExecutionContext,
|
||||
ProvenanceGitError,
|
||||
ProvenanceGitWriter,
|
||||
)
|
||||
from .provenance_operations import (
|
||||
OperationContext,
|
||||
OperationReceipt,
|
||||
ProvenanceOperationGate,
|
||||
build_provenance_writer_from_env,
|
||||
context_from_environment,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AgentRecord",
|
||||
"AuditEvent",
|
||||
"ControlPlaneError",
|
||||
"ControlPlaneService",
|
||||
"DispatchRecord",
|
||||
"Ed25519PrivateKeySigner",
|
||||
"Ed25519PublicKeyVerifier",
|
||||
"ExecutionEvidence",
|
||||
"ExecutionOrchestrator",
|
||||
"ExecutionPath",
|
||||
"ExecutionTracker",
|
||||
"GitCommandResult",
|
||||
"GitExecutionContext",
|
||||
"GitOperation",
|
||||
"IdempotencyConflict",
|
||||
"InMemoryControlPlaneStore",
|
||||
"InvalidTransition",
|
||||
"NotFound",
|
||||
"OperationContext",
|
||||
"OperationReceipt",
|
||||
"OrchestrationError",
|
||||
"PRODUCTION_POLICY_VERSION",
|
||||
"PolicyDenied",
|
||||
"ProvenanceAuthorization",
|
||||
"ProvenanceControlledWriter",
|
||||
"ProvenanceDenied",
|
||||
"ProvenanceError",
|
||||
"ProvenanceExpired",
|
||||
"ProvenanceGitError",
|
||||
"ProvenanceGitWriter",
|
||||
"ProvenanceIssuer",
|
||||
"ProvenanceNotFound",
|
||||
"ProvenanceOperationGate",
|
||||
"ProvenanceReplay",
|
||||
"ProvenanceRevoked",
|
||||
"ProvenanceSignatureError",
|
||||
"ProvenanceStatus",
|
||||
"ProvenanceVerifier",
|
||||
"RequestContext",
|
||||
"RevisionConflict",
|
||||
"RunProvenance",
|
||||
"SQLiteProvenanceStore",
|
||||
"TaskRecord",
|
||||
"TaskState",
|
||||
"TenantIsolationError",
|
||||
"TenantRecord",
|
||||
"Unauthorized",
|
||||
"build_provenance_writer_from_env",
|
||||
"context_from_environment",
|
||||
"digest_payload",
|
||||
]
|
||||
@@ -1,807 +0,0 @@
|
||||
"""AIOS V2 production-candidate application entrypoint."""
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import urllib.parse
|
||||
from enum import Enum
|
||||
from http import HTTPStatus
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping
|
||||
|
||||
from aios_hermes import HermesAdapter, HermesJobState, HermesResult, SQLiteHermesStore
|
||||
from aios_openclaw import (
|
||||
Ed25519CommandSigner,
|
||||
HTTPRuntimeTransport,
|
||||
OpenClawAdapter,
|
||||
)
|
||||
from aios_openclaw.callback import AuditCallbackSink, EventPublisher
|
||||
|
||||
from .console import CONSOLE_HTML
|
||||
from .control_plane import AuditEvent, RequestContext, Unauthorized
|
||||
from .dispatch_store import SQLiteDispatchRepository
|
||||
from .execution_tracking import ExecutionEvidence, ExecutionTracker
|
||||
from .gateway import GatewayApplication, error_response
|
||||
from .gitops_status import collect_gitops_status
|
||||
from .orchestrator import ExecutionOrchestrator
|
||||
from .persistence import DurableControlPlaneService, SQLiteSnapshotRepository
|
||||
from .provenance_operations import build_provenance_writer_from_env
|
||||
|
||||
|
||||
def _json_value(value: Any) -> Any:
|
||||
if dataclasses.is_dataclass(value):
|
||||
return {
|
||||
item.name: _json_value(getattr(value, item.name))
|
||||
for item in dataclasses.fields(value)
|
||||
}
|
||||
if isinstance(value, Enum):
|
||||
return value.value
|
||||
if isinstance(value, Mapping):
|
||||
return {str(key): _json_value(item) for key, item in value.items()}
|
||||
if isinstance(value, (list, tuple, set, frozenset)):
|
||||
return [_json_value(item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _enabled(value: str | None) -> bool:
|
||||
return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _header(headers: Mapping[str, str], name: str) -> str | None:
|
||||
target = name.lower()
|
||||
for key, value in headers.items():
|
||||
if str(key).lower() == target:
|
||||
normalized = str(value or "").strip()
|
||||
return normalized or None
|
||||
return None
|
||||
|
||||
|
||||
def _provenance_context(
|
||||
headers: Mapping[str, str],
|
||||
payload: Mapping[str, Any],
|
||||
) -> tuple[str | None, str | None]:
|
||||
header_provenance = _header(headers, "X-AIOS-Provenance-ID")
|
||||
header_run = _header(headers, "X-AIOS-Run-ID")
|
||||
body_provenance = str(payload.get("provenance_id") or "").strip() or None
|
||||
body_run = str(payload.get("run_id") or "").strip() or None
|
||||
if header_provenance and body_provenance and header_provenance != body_provenance:
|
||||
raise ValueError("provenance_id header/body mismatch")
|
||||
if header_run and body_run and header_run != body_run:
|
||||
raise ValueError("run_id header/body mismatch")
|
||||
return header_provenance or body_provenance, header_run or body_run
|
||||
|
||||
|
||||
def _read_protected_file(path: Path, *, private: bool) -> bytes:
|
||||
flags = os.O_RDONLY
|
||||
if hasattr(os, "O_NOFOLLOW"):
|
||||
flags |= os.O_NOFOLLOW
|
||||
descriptor = os.open(str(path), flags)
|
||||
try:
|
||||
metadata = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(metadata.st_mode):
|
||||
raise RuntimeError("key reference is not a regular file")
|
||||
denied = 0o077 if private else 0o022
|
||||
if metadata.st_mode & denied:
|
||||
raise RuntimeError(
|
||||
"private key file permissions must be 0600 or stricter"
|
||||
if private
|
||||
else "public key file must not be group/world writable"
|
||||
)
|
||||
chunks: list[bytes] = []
|
||||
while True:
|
||||
chunk = os.read(descriptor, 65536)
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
value = b"".join(chunks)
|
||||
if not value:
|
||||
raise RuntimeError("key file is empty")
|
||||
return value
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
class FileCommandSignerResolver:
|
||||
"""Resolve tenant/key-id scoped OpenClaw Ed25519 signing material."""
|
||||
|
||||
def __init__(self, path: str | Path, *, configured_key_id: str) -> None:
|
||||
self.path = Path(path).resolve()
|
||||
self.configured_key_id = str(configured_key_id or "").strip()
|
||||
if not self.configured_key_id:
|
||||
raise ValueError("configured_key_id is required")
|
||||
|
||||
def __call__(self, tenant_id: str, key_id: str) -> Ed25519CommandSigner:
|
||||
if not str(tenant_id or "").strip():
|
||||
raise RuntimeError("tenant_id is required for command signing")
|
||||
if key_id != self.configured_key_id:
|
||||
raise RuntimeError("OpenClaw signing key_id mismatch")
|
||||
return Ed25519CommandSigner.from_bytes(
|
||||
_read_protected_file(self.path, private=True),
|
||||
key_id=key_id,
|
||||
)
|
||||
|
||||
|
||||
class ControlPlaneEventPublisher(EventPublisher):
|
||||
"""Append Runtime callback events to the tenant audit stream.
|
||||
|
||||
Callback audit remains informational. Authoritative task reconciliation is
|
||||
performed only by the orchestrator after dispatch/result validation.
|
||||
"""
|
||||
|
||||
def __init__(self, service: DurableControlPlaneService) -> None:
|
||||
self.service = service
|
||||
|
||||
def publish(self, event: Mapping[str, Any]) -> None:
|
||||
tenant_id = str(event.get("tenant_id") or "").strip()
|
||||
trace_id = str(event.get("trace_id") or "").strip()
|
||||
command_id = str(event.get("command_id") or "").strip()
|
||||
event_type = str(event.get("event_type") or "OPENCLAW_EVENT").strip()
|
||||
if not tenant_id or not trace_id or not command_id:
|
||||
raise ValueError("runtime audit event is missing tenant identity")
|
||||
state_value = str(event.get("state") or "unknown").lower()
|
||||
outcome = "allowed" if state_value == "completed" else state_value
|
||||
runtime = str(event.get("runtime") or "openclaw").strip().lower()
|
||||
principal_id = str(
|
||||
event.get("principal_id") or f"{runtime}-runtime"
|
||||
).strip()
|
||||
audit = AuditEvent(
|
||||
event_id=f"runtime_{command_id}_{len(self.service.store.audit_events) + 1}",
|
||||
tenant_id=tenant_id,
|
||||
principal_id=principal_id,
|
||||
trace_id=trace_id,
|
||||
action=event_type,
|
||||
resource_type=f"{runtime}_execution",
|
||||
resource_id=command_id,
|
||||
outcome=outcome,
|
||||
policy_version="runtime-v2",
|
||||
details=dict(event.get("details") or {}),
|
||||
)
|
||||
with self.service.store.lock:
|
||||
self.service.store.audit_events.append(audit)
|
||||
self.service.repository.save(self.service._snapshot())
|
||||
|
||||
|
||||
class CandidateApplication:
|
||||
def __init__(
|
||||
self,
|
||||
gateway: GatewayApplication,
|
||||
orchestrator: ExecutionOrchestrator,
|
||||
tracker: ExecutionTracker,
|
||||
hermes: HermesAdapter,
|
||||
openclaw: OpenClawAdapter | None,
|
||||
*,
|
||||
hermes_lifecycle_api_enabled: bool = False,
|
||||
) -> None:
|
||||
self.gateway = gateway
|
||||
self.orchestrator = orchestrator
|
||||
self.tracker = tracker
|
||||
self.hermes = hermes
|
||||
self.openclaw = openclaw
|
||||
self.hermes_lifecycle_api_enabled = bool(hermes_lifecycle_api_enabled)
|
||||
self.runtime_events = ControlPlaneEventPublisher(orchestrator.control_plane)
|
||||
|
||||
def context(self, headers: Mapping[str, str]) -> RequestContext:
|
||||
return self.gateway.context_from_headers(headers)
|
||||
|
||||
@staticmethod
|
||||
def _require_runtime_role(context: RequestContext) -> None:
|
||||
if not ({"runtime", "platform_admin"} & set(context.roles)):
|
||||
raise Unauthorized("runtime reconciliation role is required")
|
||||
|
||||
@staticmethod
|
||||
def _require_control_plane_cancel(context: RequestContext) -> None:
|
||||
required = {"runtime", "platform_admin"}
|
||||
if not required.issubset(set(context.roles)):
|
||||
raise Unauthorized(
|
||||
"Hermes cancellation requires trusted AIOS control-plane authority"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _required_identity(payload: Mapping[str, Any], name: str) -> str:
|
||||
value = str(payload.get(name) or "").strip()
|
||||
if not value:
|
||||
raise ValueError(f"{name} is required")
|
||||
return value
|
||||
|
||||
def _record_hermes_lifecycle(
|
||||
self,
|
||||
context: RequestContext,
|
||||
dispatch: Any,
|
||||
job: Any,
|
||||
*,
|
||||
action: str,
|
||||
worker_id: str | None,
|
||||
provenance_id: str,
|
||||
run_id: str,
|
||||
) -> None:
|
||||
executor = worker_id or "aios-control-plane"
|
||||
evidence = ExecutionEvidence(
|
||||
execution_id=job.job_id,
|
||||
tenant_id=job.tenant_id,
|
||||
task_id=job.task_id,
|
||||
agent_id=dispatch.agent_id,
|
||||
executor=executor,
|
||||
branch="runtime/hermes",
|
||||
status=job.state.value.lower(),
|
||||
progress_completed=0,
|
||||
progress_total=3,
|
||||
current_step=action,
|
||||
provenance_id=provenance_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
current_evidence = self.tracker.claim(
|
||||
evidence,
|
||||
principal_id=context.principal_id,
|
||||
provenance_id=provenance_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
if job.state in {
|
||||
HermesJobState.COMPLETED,
|
||||
HermesJobState.FAILED,
|
||||
HermesJobState.CANCELLED,
|
||||
}:
|
||||
if current_evidence.completed_at is None:
|
||||
self.tracker.complete(
|
||||
tenant_id=job.tenant_id,
|
||||
execution_id=job.job_id,
|
||||
status=job.state.value.lower(),
|
||||
test_status="runtime_terminal",
|
||||
test_summary={"lifecycle_action": action},
|
||||
result_summary={
|
||||
"state": job.state.value,
|
||||
"worker_id": worker_id,
|
||||
"dispatch_id": dispatch.dispatch_id,
|
||||
},
|
||||
principal_id=context.principal_id,
|
||||
provenance_id=provenance_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
else:
|
||||
self.tracker.heartbeat(
|
||||
tenant_id=job.tenant_id,
|
||||
execution_id=job.job_id,
|
||||
status=job.state.value.lower(),
|
||||
current_step=action,
|
||||
progress_completed=(1 if job.state == HermesJobState.RUNNING else 0),
|
||||
progress_total=3,
|
||||
principal_id=context.principal_id,
|
||||
provenance_id=provenance_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
self.runtime_events.publish(
|
||||
{
|
||||
"runtime": "hermes",
|
||||
"tenant_id": job.tenant_id,
|
||||
"principal_id": context.principal_id,
|
||||
"trace_id": job.trace_id,
|
||||
"command_id": job.job_id,
|
||||
"event_type": f"HERMES_{action.upper()}",
|
||||
"state": job.state.value,
|
||||
"details": {
|
||||
"dispatch_id": dispatch.dispatch_id,
|
||||
"task_id": job.task_id,
|
||||
"worker_id": worker_id,
|
||||
"cell_id": job.cell_id,
|
||||
"profile_id": job.profile_id,
|
||||
"revision": job.revision,
|
||||
"provenance_id": provenance_id,
|
||||
"run_id": run_id,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
def handle_json(
|
||||
self,
|
||||
method: str,
|
||||
raw_path: str,
|
||||
headers: Mapping[str, str],
|
||||
body: Any,
|
||||
) -> tuple[int, Mapping[str, Any]]:
|
||||
parsed = urllib.parse.urlparse(raw_path)
|
||||
path = parsed.path.rstrip("/") or "/"
|
||||
method = method.upper()
|
||||
|
||||
if method == "GET" and path == "/api/v2/gitops":
|
||||
context = self.context(headers)
|
||||
return HTTPStatus.OK, {
|
||||
"tenant_id": context.tenant_id,
|
||||
"gitops": collect_gitops_status().as_dict(),
|
||||
}
|
||||
|
||||
if method == "GET" and path == "/api/v2/runtime":
|
||||
context = self.context(headers)
|
||||
runtimes: list[dict[str, Any]] = []
|
||||
if self.openclaw is None:
|
||||
runtimes.append(
|
||||
{
|
||||
"name": "openclaw",
|
||||
"status": "disabled",
|
||||
"details": {"reason": "runtime_not_configured"},
|
||||
}
|
||||
)
|
||||
else:
|
||||
try:
|
||||
health = self.openclaw.health()
|
||||
runtimes.append(
|
||||
{
|
||||
"name": "openclaw",
|
||||
"status": health.status,
|
||||
"details": _json_value(health),
|
||||
}
|
||||
)
|
||||
except Exception as exc:
|
||||
runtimes.append(
|
||||
{
|
||||
"name": "openclaw",
|
||||
"status": "unavailable",
|
||||
"details": {"message": str(exc)},
|
||||
}
|
||||
)
|
||||
hermes_health = self.hermes.health()
|
||||
runtimes.append(
|
||||
{
|
||||
"name": "hermes",
|
||||
"status": hermes_health.status,
|
||||
"details": _json_value(hermes_health),
|
||||
}
|
||||
)
|
||||
return HTTPStatus.OK, {
|
||||
"tenant_id": context.tenant_id,
|
||||
"runtimes": runtimes,
|
||||
}
|
||||
|
||||
if method == "GET" and path == "/api/v2/executions":
|
||||
context = self.context(headers)
|
||||
return HTTPStatus.OK, {
|
||||
"executions": _json_value(
|
||||
self.tracker.list(tenant_id=context.tenant_id)
|
||||
)
|
||||
}
|
||||
|
||||
dispatch_match = __import__("re").fullmatch(
|
||||
r"/api/v2/tasks/([^/]+)/dispatch", path
|
||||
)
|
||||
if method == "POST" and dispatch_match:
|
||||
context = self.context(headers)
|
||||
task_id = urllib.parse.unquote(dispatch_match.group(1))
|
||||
payload = body if isinstance(body, Mapping) else {}
|
||||
timeout = payload.get("timeout_seconds")
|
||||
provenance_id, run_id = _provenance_context(headers, payload)
|
||||
record = self.orchestrator.dispatch(
|
||||
context,
|
||||
task_id=task_id,
|
||||
timeout_seconds=float(timeout) if timeout is not None else None,
|
||||
provenance_id=provenance_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
return HTTPStatus.ACCEPTED, {"dispatch": _json_value(record)}
|
||||
|
||||
lifecycle_match = __import__("re").fullmatch(
|
||||
r"/api/v2/internal/runtime/hermes/lifecycle/"
|
||||
r"(claim|start|heartbeat|complete|fail|cancel|status)",
|
||||
path,
|
||||
)
|
||||
if method == "POST" and lifecycle_match:
|
||||
if not self.hermes_lifecycle_api_enabled:
|
||||
return HTTPStatus.NOT_FOUND, {
|
||||
"error": "not_found",
|
||||
"message": "route not found",
|
||||
}
|
||||
context = self.context(headers)
|
||||
self._require_runtime_role(context)
|
||||
if not isinstance(body, Mapping):
|
||||
raise ValueError("JSON body must be an object")
|
||||
payload = body
|
||||
action = lifecycle_match.group(1)
|
||||
identity = {
|
||||
name: self._required_identity(payload, name)
|
||||
for name in (
|
||||
"dispatch_id",
|
||||
"job_id",
|
||||
"tenant_id",
|
||||
"task_id",
|
||||
"trace_id",
|
||||
"principal_id",
|
||||
"provenance_id",
|
||||
"run_id",
|
||||
"cell_id",
|
||||
"profile_id",
|
||||
)
|
||||
}
|
||||
header_provenance, header_run = _provenance_context(headers, payload)
|
||||
if header_provenance != identity["provenance_id"]:
|
||||
raise ValueError("provenance_id context mismatch")
|
||||
if header_run != identity["run_id"]:
|
||||
raise ValueError("run_id context mismatch")
|
||||
requeued_job_ids = self.hermes.requeue_expired()
|
||||
dispatch, job = self.orchestrator.hermes_lifecycle_context(
|
||||
context,
|
||||
dispatch_id=identity["dispatch_id"],
|
||||
job_id=identity["job_id"],
|
||||
tenant_id=identity["tenant_id"],
|
||||
task_id=identity["task_id"],
|
||||
trace_id=identity["trace_id"],
|
||||
principal_id=identity["principal_id"],
|
||||
provenance_id=identity["provenance_id"],
|
||||
run_id=identity["run_id"],
|
||||
)
|
||||
if job.cell_id != identity["cell_id"]:
|
||||
raise ValueError("cell_id mismatch")
|
||||
if job.profile_id != identity["profile_id"]:
|
||||
raise ValueError("profile_id mismatch")
|
||||
|
||||
if action == "status":
|
||||
if job.job_id in requeued_job_ids:
|
||||
self._record_hermes_lifecycle(
|
||||
context,
|
||||
dispatch,
|
||||
job,
|
||||
action="lease_expired_requeue",
|
||||
worker_id=None,
|
||||
provenance_id=identity["provenance_id"],
|
||||
run_id=identity["run_id"],
|
||||
)
|
||||
task = self.orchestrator.control_plane.get_task(
|
||||
context, dispatch.task_id
|
||||
)
|
||||
response: dict[str, Any] = {
|
||||
"job": _json_value(job),
|
||||
"dispatch": _json_value(dispatch),
|
||||
"task": _json_value(task),
|
||||
}
|
||||
if job.state in {
|
||||
HermesJobState.COMPLETED,
|
||||
HermesJobState.FAILED,
|
||||
HermesJobState.CANCELLED,
|
||||
}:
|
||||
response["result"] = _json_value(
|
||||
self.hermes.result(job.job_id, tenant_id=job.tenant_id)
|
||||
)
|
||||
return HTTPStatus.OK, response
|
||||
|
||||
expected_revision = payload.get("expected_revision")
|
||||
expected = (
|
||||
int(expected_revision) if expected_revision is not None else None
|
||||
)
|
||||
if action == "cancel":
|
||||
self._require_control_plane_cancel(context)
|
||||
if payload.get("worker_id") is not None:
|
||||
raise Unauthorized("Hermes workers may observe but not initiate cancel")
|
||||
if payload.get("cancellation_source") != "aios-control-plane":
|
||||
raise ValueError(
|
||||
"cancellation_source must be aios-control-plane"
|
||||
)
|
||||
result = self.hermes.cancel(
|
||||
job.job_id,
|
||||
tenant_id=job.tenant_id,
|
||||
expected_revision=expected,
|
||||
)
|
||||
else:
|
||||
worker_id = self._required_identity(payload, "worker_id")
|
||||
if action == "claim":
|
||||
claimed = self.hermes.claim(
|
||||
tenant_id=job.tenant_id,
|
||||
cell_id=job.cell_id,
|
||||
profile_id=job.profile_id,
|
||||
worker_id=worker_id,
|
||||
lease_seconds=(
|
||||
int(payload["lease_seconds"])
|
||||
if payload.get("lease_seconds") is not None
|
||||
else None
|
||||
),
|
||||
job_id=job.job_id,
|
||||
)
|
||||
if claimed is None:
|
||||
raise ValueError("Hermes job is not claimable")
|
||||
bound = self.orchestrator.bind_hermes_worker(
|
||||
context,
|
||||
claimed,
|
||||
dispatch_id=dispatch.dispatch_id,
|
||||
provenance_id=identity["provenance_id"],
|
||||
run_id=identity["run_id"],
|
||||
worker_id=worker_id,
|
||||
)
|
||||
self._record_hermes_lifecycle(
|
||||
context,
|
||||
bound,
|
||||
claimed,
|
||||
action="claim",
|
||||
worker_id=worker_id,
|
||||
provenance_id=identity["provenance_id"],
|
||||
run_id=identity["run_id"],
|
||||
)
|
||||
return HTTPStatus.OK, {
|
||||
"job": _json_value(claimed),
|
||||
"dispatch": _json_value(bound),
|
||||
}
|
||||
if action == "start":
|
||||
started = self.hermes.start(
|
||||
job.job_id,
|
||||
tenant_id=job.tenant_id,
|
||||
worker_id=worker_id,
|
||||
expected_revision=expected,
|
||||
)
|
||||
self._record_hermes_lifecycle(
|
||||
context,
|
||||
dispatch,
|
||||
started,
|
||||
action="start",
|
||||
worker_id=worker_id,
|
||||
provenance_id=identity["provenance_id"],
|
||||
run_id=identity["run_id"],
|
||||
)
|
||||
return HTTPStatus.OK, {"job": _json_value(started)}
|
||||
if action == "heartbeat":
|
||||
heartbeat = self.hermes.heartbeat(
|
||||
job.job_id,
|
||||
tenant_id=job.tenant_id,
|
||||
worker_id=worker_id,
|
||||
lease_seconds=(
|
||||
int(payload["lease_seconds"])
|
||||
if payload.get("lease_seconds") is not None
|
||||
else None
|
||||
),
|
||||
)
|
||||
self._record_hermes_lifecycle(
|
||||
context,
|
||||
dispatch,
|
||||
heartbeat,
|
||||
action="heartbeat",
|
||||
worker_id=worker_id,
|
||||
provenance_id=identity["provenance_id"],
|
||||
run_id=identity["run_id"],
|
||||
)
|
||||
return HTTPStatus.OK, {"job": _json_value(heartbeat)}
|
||||
if action == "complete":
|
||||
output = payload.get("output")
|
||||
if not isinstance(output, Mapping):
|
||||
raise ValueError("output must be an object")
|
||||
result = self.hermes.complete(
|
||||
job.job_id,
|
||||
tenant_id=job.tenant_id,
|
||||
worker_id=worker_id,
|
||||
output=output,
|
||||
expected_revision=expected,
|
||||
)
|
||||
elif action == "fail":
|
||||
error = payload.get("error")
|
||||
if not isinstance(error, Mapping):
|
||||
raise ValueError("error must be an object")
|
||||
result = self.hermes.fail(
|
||||
job.job_id,
|
||||
tenant_id=job.tenant_id,
|
||||
worker_id=worker_id,
|
||||
error=error,
|
||||
expected_revision=expected,
|
||||
)
|
||||
else:
|
||||
raise ValueError("unsupported Hermes lifecycle action")
|
||||
|
||||
task = self.orchestrator.reconcile_hermes(
|
||||
context,
|
||||
result,
|
||||
dispatch_id=dispatch.dispatch_id,
|
||||
provenance_id=identity["provenance_id"],
|
||||
run_id=identity["run_id"],
|
||||
)
|
||||
updated_dispatch = self.orchestrator.dispatch_repository.get(
|
||||
dispatch.dispatch_id, tenant_id=dispatch.tenant_id
|
||||
)
|
||||
terminal_job = self.hermes.get(job.job_id, tenant_id=job.tenant_id)
|
||||
self._record_hermes_lifecycle(
|
||||
context,
|
||||
updated_dispatch,
|
||||
terminal_job,
|
||||
action=action,
|
||||
worker_id=result.worker_id,
|
||||
provenance_id=identity["provenance_id"],
|
||||
run_id=identity["run_id"],
|
||||
)
|
||||
return HTTPStatus.OK, {
|
||||
"result": _json_value(result),
|
||||
"task": _json_value(task),
|
||||
"dispatch": _json_value(updated_dispatch),
|
||||
}
|
||||
|
||||
if method == "POST" and path == "/api/v2/internal/runtime/hermes/reconcile":
|
||||
context = self.context(headers)
|
||||
self._require_runtime_role(context)
|
||||
if not isinstance(body, Mapping):
|
||||
raise ValueError("JSON body must be an object")
|
||||
provenance_id, run_id = _provenance_context(headers, body)
|
||||
if not provenance_id or not run_id:
|
||||
raise ValueError("provenance_id and run_id are required")
|
||||
dispatch_id = str(body.get("dispatch_id") or "").strip()
|
||||
if not dispatch_id:
|
||||
raise ValueError("dispatch_id is required")
|
||||
result = HermesResult(
|
||||
job_id=str(body["job_id"]),
|
||||
tenant_id=str(body["tenant_id"]),
|
||||
task_id=str(body["task_id"]),
|
||||
trace_id=str(body["trace_id"]),
|
||||
state=HermesJobState(str(body["state"])),
|
||||
worker_id=(
|
||||
str(body["worker_id"])
|
||||
if body.get("worker_id") is not None
|
||||
else None
|
||||
),
|
||||
output=body.get("output"),
|
||||
error=body.get("error"),
|
||||
started_at=body.get("started_at"),
|
||||
finished_at=body.get("finished_at"),
|
||||
)
|
||||
task = self.orchestrator.reconcile_hermes(
|
||||
context,
|
||||
result,
|
||||
dispatch_id=dispatch_id,
|
||||
provenance_id=provenance_id,
|
||||
run_id=run_id,
|
||||
)
|
||||
return HTTPStatus.OK, {"task": _json_value(task)}
|
||||
|
||||
if method == "POST" and path == "/api/v2/hermes/results/reconcile":
|
||||
return HTTPStatus.GONE, {
|
||||
"error": "RuntimeReconcileEndpointMoved",
|
||||
"message": "use /api/v2/internal/runtime/hermes/reconcile",
|
||||
}
|
||||
|
||||
return self.gateway.handle(method, raw_path, headers, body)
|
||||
|
||||
|
||||
class CandidateRequestHandler(BaseHTTPRequestHandler):
|
||||
server_version = "AIOSV2Candidate/2.0"
|
||||
max_body_bytes = 1_048_576
|
||||
|
||||
@property
|
||||
def application(self) -> CandidateApplication:
|
||||
return self.server.application # type: ignore[attr-defined]
|
||||
|
||||
def _read_body(self) -> Any:
|
||||
length = int(self.headers.get("Content-Length", "0") or "0")
|
||||
if length < 0 or length > self.max_body_bytes:
|
||||
raise ValueError("request body is too large")
|
||||
if length == 0:
|
||||
return {}
|
||||
content_type = self.headers.get("Content-Type", "").split(";", 1)[0].strip()
|
||||
if content_type != "application/json":
|
||||
raise ValueError("Content-Type must be application/json")
|
||||
return json.loads(self.rfile.read(length).decode("utf-8"))
|
||||
|
||||
def _security_headers(self) -> None:
|
||||
self.send_header("Cache-Control", "no-store")
|
||||
self.send_header("X-Content-Type-Options", "nosniff")
|
||||
self.send_header("X-Frame-Options", "DENY")
|
||||
self.send_header("Referrer-Policy", "no-referrer")
|
||||
self.send_header(
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'",
|
||||
)
|
||||
|
||||
def _send_json(self, status_code: int, payload: Mapping[str, Any]) -> None:
|
||||
encoded = json.dumps(
|
||||
_json_value(payload), ensure_ascii=False, separators=(",", ":")
|
||||
).encode("utf-8")
|
||||
self.send_response(int(status_code))
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(encoded)))
|
||||
self._security_headers()
|
||||
self.end_headers()
|
||||
self.wfile.write(encoded)
|
||||
|
||||
def _send_console(self) -> None:
|
||||
encoded = CONSOLE_HTML.encode("utf-8")
|
||||
self.send_response(HTTPStatus.OK)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(encoded)))
|
||||
self._security_headers()
|
||||
self.end_headers()
|
||||
self.wfile.write(encoded)
|
||||
|
||||
def _dispatch(self, method: str) -> None:
|
||||
path = urllib.parse.urlparse(self.path).path.rstrip("/") or "/"
|
||||
if method == "GET" and path in {"/", "/console"}:
|
||||
self._send_console()
|
||||
return
|
||||
try:
|
||||
body = self._read_body() if method == "POST" else {}
|
||||
status_code, payload = self.application.handle_json(
|
||||
method,
|
||||
self.path,
|
||||
{key: value for key, value in self.headers.items()},
|
||||
body,
|
||||
)
|
||||
except Exception as exc:
|
||||
status_code, payload = error_response(exc)
|
||||
self._send_json(status_code, payload)
|
||||
|
||||
def do_GET(self) -> None: # noqa: N802
|
||||
self._dispatch("GET")
|
||||
|
||||
def do_POST(self) -> None: # noqa: N802
|
||||
self._dispatch("POST")
|
||||
|
||||
|
||||
class CandidateHTTPServer(ThreadingHTTPServer):
|
||||
daemon_threads = True
|
||||
allow_reuse_address = True
|
||||
|
||||
def __init__(self, address: tuple[str, int], application: CandidateApplication) -> None:
|
||||
self.application = application
|
||||
super().__init__(address, CandidateRequestHandler)
|
||||
|
||||
|
||||
def build_application() -> CandidateApplication:
|
||||
state_dir = Path(os.getenv("AIOS_V2_STATE_DIR", "/var/lib/aios-v2")).resolve()
|
||||
state_dir.mkdir(parents=True, exist_ok=True)
|
||||
repository = SQLiteSnapshotRepository(state_dir / "control-plane.sqlite3")
|
||||
dispatch_repository = SQLiteDispatchRepository(state_dir / "dispatches.sqlite3")
|
||||
service = DurableControlPlaneService(repository)
|
||||
require_provenance = _enabled(os.getenv("AIOS_REQUIRE_RUN_PROVENANCE", "0"))
|
||||
provenance_writer = build_provenance_writer_from_env(
|
||||
state_dir=state_dir, required=require_provenance
|
||||
)
|
||||
tracker = ExecutionTracker(
|
||||
state_dir / "executions",
|
||||
provenance_writer=provenance_writer,
|
||||
require_provenance=require_provenance,
|
||||
)
|
||||
hermes = HermesAdapter(store=SQLiteHermesStore(state_dir / "hermes.sqlite3"))
|
||||
|
||||
openclaw_url = os.getenv("AIOS_OPENCLAW_URL", "").strip()
|
||||
signing_file = os.getenv("AIOS_OPENCLAW_SIGNING_MATERIAL_FILE", "").strip()
|
||||
signing_key_id = os.getenv(
|
||||
"AIOS_OPENCLAW_SIGNING_KEY_ID", "openclaw-v2r1-candidate-ed25519"
|
||||
).strip()
|
||||
openclaw = None
|
||||
resolver = None
|
||||
if openclaw_url and signing_file:
|
||||
resolver = FileCommandSignerResolver(
|
||||
signing_file,
|
||||
configured_key_id=signing_key_id,
|
||||
)
|
||||
transport = HTTPRuntimeTransport(
|
||||
openclaw_url,
|
||||
credential_ref=os.getenv("AIOS_OPENCLAW_CREDENTIAL_REF") or None,
|
||||
)
|
||||
openclaw = OpenClawAdapter(
|
||||
transport,
|
||||
resolver,
|
||||
callback_sink=AuditCallbackSink(ControlPlaneEventPublisher(service)),
|
||||
)
|
||||
|
||||
gateway = GatewayApplication(service)
|
||||
orchestrator = ExecutionOrchestrator(
|
||||
service,
|
||||
openclaw=openclaw,
|
||||
hermes=hermes,
|
||||
openclaw_signing_material_resolver=resolver,
|
||||
openclaw_key_id=signing_key_id,
|
||||
provenance_writer=provenance_writer,
|
||||
require_provenance=require_provenance,
|
||||
dispatch_repository=dispatch_repository,
|
||||
)
|
||||
return CandidateApplication(
|
||||
gateway,
|
||||
orchestrator,
|
||||
tracker,
|
||||
hermes,
|
||||
openclaw,
|
||||
hermes_lifecycle_api_enabled=_enabled(
|
||||
os.getenv("AIOS_HERMES_LIFECYCLE_API_ENABLED", "0")
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
host = os.getenv("AIOS_V2_HOST", "127.0.0.1")
|
||||
port = int(os.getenv("AIOS_V2_PORT", "18920"))
|
||||
server = CandidateHTTPServer((host, port), build_application())
|
||||
print(f"AIOS V2 candidate listening on {host}:{port}")
|
||||
server.serve_forever()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,83 +0,0 @@
|
||||
"""Dependency-free, role-aware AIOS unified portal.
|
||||
|
||||
The browser receives only server-authorized capabilities and tenant-scoped data.
|
||||
It never selects a tenant, signs Provenance, mutates Git tags, or calls Runtime
|
||||
services directly.
|
||||
"""
|
||||
|
||||
CONSOLE_HTML = r'''<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<title>AIOS Unified Portal</title>
|
||||
<style>
|
||||
:root{font-family:Inter,"PingFang SC","Microsoft YaHei",ui-sans-serif,system-ui,sans-serif;color:#172033;background:#eef2f7;--nav:#0f172a;--card:#fff;--line:#dfe6ef;--muted:#667085;--blue:#2563eb;--green:#047857;--red:#b42318;--amber:#b54708}
|
||||
*{box-sizing:border-box}body{margin:0;min-height:100vh}button,input,textarea,select{font:inherit}
|
||||
.shell{display:grid;grid-template-columns:250px minmax(0,1fr);min-height:100vh}.sidebar{background:var(--nav);color:#e2e8f0;padding:22px 16px;position:sticky;top:0;height:100vh;overflow:auto}.brand{font-weight:800;font-size:20px;letter-spacing:.2px}.version{font-size:12px;color:#94a3b8;margin:5px 0 22px}.identity{padding:12px;background:#182338;border:1px solid #2b3950;border-radius:12px;margin-bottom:18px}.identity strong{display:block;color:#fff}.identity span{font-size:12px;color:#aebbd0;display:block;margin-top:3px;overflow-wrap:anywhere}.nav button{width:100%;display:flex;gap:10px;align-items:center;border:0;background:transparent;color:#cbd5e1;padding:11px 12px;border-radius:9px;text-align:left;cursor:pointer;margin:2px 0}.nav button:hover,.nav button.active{background:#24324b;color:#fff}.nav .dot{width:8px;height:8px;border-radius:50%;background:#60a5fa}.main{min-width:0}.topbar{height:70px;background:#fff;border-bottom:1px solid var(--line);display:flex;align-items:center;justify-content:space-between;padding:0 26px;position:sticky;top:0;z-index:4}.topbar h1{font-size:19px;margin:0}.top-actions{display:flex;align-items:center;gap:10px}.timestamp{font-size:12px;color:var(--muted)}.content{padding:24px;max-width:1680px;margin:auto}.notice{background:#eff6ff;border:1px solid #bfdbfe;border-radius:12px;padding:12px 14px;color:#1e3a8a;font-size:13px;margin-bottom:18px}.error{background:#fef3f2;border:1px solid #fecdca;color:var(--red);border-radius:10px;padding:10px 12px;white-space:pre-wrap;margin-bottom:14px;display:none}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:14px;margin-bottom:18px}.card,.panel{background:var(--card);border:1px solid var(--line);border-radius:14px;box-shadow:0 1px 3px rgba(16,24,40,.05)}.card{padding:16px}.card small{color:var(--muted);font-weight:600}.card b{display:block;font-size:28px;margin-top:7px}.panel{padding:17px;margin-bottom:16px;overflow:auto}.panel h2{font-size:16px;margin:0}.panel-head{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-bottom:13px}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(410px,1fr));gap:16px}.wide{grid-column:1/-1}.muted{color:var(--muted)}.good{color:var(--green);font-weight:700}.bad{color:var(--red);font-weight:700}.warn{color:var(--amber);font-weight:700}.pill{display:inline-block;border:1px solid #cbd5e1;border-radius:999px;padding:3px 8px;font-size:11px;margin:2px;background:#f8fafc}.pill.good{background:#ecfdf3;border-color:#a6f4c5}.pill.bad{background:#fef3f2;border-color:#fecdca}.toolbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap}.btn{border:0;border-radius:9px;padding:9px 12px;background:var(--blue);color:#fff;cursor:pointer;font-weight:650}.btn.secondary{background:#fff;color:#344054;border:1px solid #d0d5dd}.btn.danger{background:#d92d20}.btn:disabled{opacity:.45;cursor:not-allowed}.icon-btn{padding:7px 10px}.view{display:none}.view.active{display:block}table{width:100%;border-collapse:collapse;font-size:12px}th,td{text-align:left;padding:9px 8px;border-bottom:1px solid #edf0f4;vertical-align:top}th{color:#667085;font-weight:700;white-space:nowrap}td{overflow-wrap:anywhere}.empty{padding:24px;text-align:center;color:var(--muted)}.timeline{border-left:2px solid #dbe4f0;margin-left:8px;padding-left:20px}.event{position:relative;padding:0 0 17px}.event:before{content:"";position:absolute;left:-27px;top:4px;width:11px;height:11px;border-radius:50%;background:#3b82f6;border:3px solid #fff;box-shadow:0 0 0 1px #93c5fd}.event strong{font-size:13px}.event p{margin:4px 0;color:var(--muted);font-size:12px}.chain{display:flex;align-items:stretch;gap:7px;overflow:auto;padding:5px 0}.chain-item{min-width:135px;border:1px solid var(--line);border-radius:10px;padding:10px;background:#f8fafc}.chain-arrow{display:flex;align-items:center;color:#94a3b8}.form-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(230px,1fr));gap:12px}.field label{font-size:12px;color:#475467;font-weight:650;display:block;margin-bottom:5px}.field input,.field textarea,.field select{width:100%;border:1px solid #d0d5dd;border-radius:9px;padding:9px;background:#fff}.field textarea{min-height:90px;resize:vertical}.dialog{border:0;border-radius:14px;padding:0;max-width:650px;width:calc(100% - 32px);box-shadow:0 20px 60px rgba(15,23,42,.28)}.dialog::backdrop{background:rgba(15,23,42,.6)}.dialog-head{padding:16px 18px;border-bottom:1px solid var(--line);font-weight:750}.dialog-body{padding:18px}.dialog-actions{padding:14px 18px;border-top:1px solid var(--line);display:flex;justify-content:flex-end;gap:8px}.kv{display:grid;grid-template-columns:150px minmax(0,1fr);gap:6px 12px;font-size:12px}.kv div:nth-child(odd){color:var(--muted)}pre{white-space:pre-wrap;word-break:break-word;font-size:11px;background:#0f172a;color:#dbeafe;border-radius:10px;padding:12px;max-height:300px;overflow:auto}
|
||||
@media(max-width:900px){.shell{grid-template-columns:1fr}.sidebar{height:auto;position:static}.nav{display:flex;overflow:auto}.nav button{min-width:max-content}.topbar{position:static}.grid{grid-template-columns:1fr}.content{padding:16px}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">AIOS Unified Portal</div><div class="version">V2R1C1 · Role-aware Control Plane</div>
|
||||
<div class="identity"><strong id="principal">加载身份…</strong><span id="tenant"></span><span id="roles"></span></div>
|
||||
<nav id="navigation" class="nav"></nav>
|
||||
</aside>
|
||||
<section class="main">
|
||||
<header class="topbar"><h1 id="pageTitle">系统总览</h1><div class="top-actions"><span id="updated" class="timestamp">尚未加载</span><button class="btn secondary icon-btn" id="refresh">刷新</button></div></header>
|
||||
<main class="content">
|
||||
<div class="notice">身份、租户和角色由可信反向代理注入。门户只调用 AIOS Control Plane;Provenance 签发、Tag、生产部署与 Runtime 权威状态仍由受控流程执行。</div>
|
||||
<div id="error" class="error"></div>
|
||||
<section id="overview" class="view active"></section>
|
||||
<section id="tasks" class="view"></section>
|
||||
<section id="authorization" class="view"></section>
|
||||
<section id="runtime" class="view"></section>
|
||||
<section id="evidence" class="view"></section>
|
||||
<section id="audit" class="view"></section>
|
||||
<section id="release" class="view"></section>
|
||||
</main>
|
||||
</section>
|
||||
</div>
|
||||
<dialog id="taskDialog" class="dialog">
|
||||
<div class="dialog-head">创建任务</div><form id="taskForm"><div class="dialog-body form-grid">
|
||||
<div class="field"><label>Agent ID</label><input name="agent_id" required></div>
|
||||
<div class="field"><label>Action</label><input name="action" required></div>
|
||||
<div class="field"><label>Idempotency Key</label><input name="idempotency_key" required></div>
|
||||
<div class="field wide"><label>Payload JSON</label><textarea name="payload">{}</textarea></div>
|
||||
</div><div class="dialog-actions"><button type="button" class="btn secondary" data-close="taskDialog">取消</button><button class="btn">创建</button></div></form>
|
||||
</dialog>
|
||||
<dialog id="confirmDialog" class="dialog"><div class="dialog-head" id="confirmTitle">确认操作</div><form id="confirmForm"><div class="dialog-body"><p id="confirmText"></p><div class="field"><label>原因</label><textarea id="confirmReason" required></textarea></div></div><div class="dialog-actions"><button type="button" class="btn secondary" data-close="confirmDialog">取消</button><button class="btn danger">确认</button></div></form></dialog>
|
||||
<script>
|
||||
const $=s=>document.querySelector(s), esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||
const state={identity:null,current:'overview',cache:{},confirm:null};
|
||||
const labels={overview:'系统总览',tasks:'任务中心',authorization:'授权中心',runtime:'运行与运维',evidence:'执行证据',audit:'审计中心',release:'GitOps 与发布'};
|
||||
function has(c){return !!state.identity?.capabilities?.includes(c)}
|
||||
async function api(path,options={}){const r=await fetch(path,{credentials:'same-origin',headers:{'Accept':'application/json','Content-Type':'application/json',...(options.headers||{})},...options});const d=await r.json().catch(()=>({}));if(!r.ok)throw new Error(`${r.status} ${d.message||d.error||path}`);return d}
|
||||
function err(e){const el=$('#error');el.textContent=e?.message||String(e);el.style.display='block'}function clearErr(){$('#error').style.display='none';$('#error').textContent=''}
|
||||
function pill(v){const s=String(v??'unknown'),c=/complete|pass|healthy|active|synced|approved|used/i.test(s)?'good':/fail|deny|reject|revoke|expire|cancel|unavailable/i.test(s)?'bad':'';return `<span class="pill ${c}">${esc(s)}</span>`}
|
||||
function table(cols,rows){if(!rows?.length)return '<div class="empty">暂无数据</div>';return `<table><thead><tr>${cols.map(x=>`<th>${esc(x[0])}</th>`).join('')}</tr></thead><tbody>${rows.map(r=>`<tr>${cols.map(x=>`<td>${x[1](r)}</td>`).join('')}</tr>`).join('')}</tbody></table>`}
|
||||
function card(label,value,detail=''){return `<div class="card"><small>${esc(label)}</small><b>${esc(value)}</b>${detail?`<span class="muted">${esc(detail)}</span>`:''}</div>`}
|
||||
function json(v){return `<pre>${esc(JSON.stringify(v,null,2))}</pre>`}
|
||||
function show(view){state.current=view;document.querySelectorAll('.view').forEach(x=>x.classList.toggle('active',x.id===view));document.querySelectorAll('.nav button').forEach(x=>x.classList.toggle('active',x.dataset.view===view));$('#pageTitle').textContent=labels[view]||view;load(view)}
|
||||
function navigation(){const nav=$('#navigation');nav.innerHTML=(state.identity.navigation||[]).map(x=>`<button data-view="${esc(x.id)}"><span class="dot"></span>${esc(x.label)}</button>`).join('');nav.querySelectorAll('button').forEach(b=>b.onclick=()=>show(b.dataset.view));}
|
||||
async function identity(){state.identity=await api('/api/v2/me/capabilities');$('#principal').textContent=state.identity.principal_id;$('#tenant').textContent=`租户:${state.identity.tenant_id}`;$('#roles').textContent=`角色:${state.identity.roles.join(', ')||'无'}`;navigation();if(!state.identity.navigation.some(x=>x.id===state.current))state.current=state.identity.navigation[0]?.id||'overview';show(state.current)}
|
||||
async function load(view){clearErr();try{if(view==='overview')await overview();if(view==='tasks')await tasks();if(view==='authorization')await authorizations();if(view==='runtime')await runtime();if(view==='evidence')await evidence();if(view==='audit')await audit();if(view==='release')await release();$('#updated').textContent=`更新于 ${new Date().toLocaleString()}`}catch(e){err(e)}}
|
||||
async function overview(){const d=await api('/api/v2/portal/overview');state.cache.overview=d;const s=d.summary||{},rt=d.sections?.runtime?.runtimes||[],rel=d.sections?.release||{};$('#overview').innerHTML=`<div class="cards">${card('任务',s.tasks||0)}${card('活动 Dispatch',s.active_dispatches||0,`总数 ${s.dispatches||0}`)}${card('执行证据',s.evidence||0)}${card('审计事件',s.audit_events||0)}${card('当前版本',rel.version||'—',rel.status||'')}${card('授权 Grant',d.sections?.authorization?.active_grants||0,`总数 ${d.sections?.authorization?.grants||0}`)}</div><div class="grid"><div class="panel"><div class="panel-head"><h2>Runtime 健康</h2></div>${table([['Runtime',x=>esc(x.name)],['状态',x=>pill(x.status)],['版本/详情',x=>esc(x.details?.version||x.details?.runtime_id||'—')]],rt)}</div><div class="panel"><div class="panel-head"><h2>任务状态</h2></div>${table([['状态',x=>pill(x[0])],['数量',x=>esc(x[1])]],Object.entries(s.task_states||{}))}</div><div class="panel wide"><div class="panel-head"><h2>最近任务</h2></div>${table([['任务',x=>esc(x.task_id)],['Agent',x=>esc(x.agent_id)],['动作',x=>esc(x.action)],['状态',x=>pill(x.state)],['更新时间',x=>esc(x.updated_at)]],d.recent?.tasks||[])}</div></div>`}
|
||||
async function tasks(){const d=await api('/api/v2/portal/tasks');state.cache.tasks=d.items||[];const create=has('task.create')?'<button class="btn" id="newTask">创建任务</button>':'';$('#tasks').innerHTML=`<div class="panel"><div class="panel-head"><h2>任务全链路</h2><div class="toolbar">${create}</div></div>${(d.items||[]).map(taskBlock).join('')||'<div class="empty">暂无任务</div>'}</div>`;$('#newTask')?.addEventListener('click',()=>$('#taskDialog').showModal());document.querySelectorAll('[data-task-action]').forEach(b=>b.onclick=()=>taskAction(b.dataset.taskId,b.dataset.taskAction,b.dataset.revision))}
|
||||
function taskBlock(x){const t=x.task||{},d=x.dispatch,e=x.evidence||[],a=x.audit||[],actions=(x.available_actions||[]).map(v=>`<button class="btn ${v==='cancel'?'danger':'secondary'}" data-task-action="${v}" data-task-id="${esc(t.task_id)}" data-revision="${esc(t.revision)}">${{approve:'审批',cancel:'取消',dispatch:'调度'}[v]||v}</button>`).join('');return `<div class="panel" style="background:#fbfcfe"><div class="panel-head"><h2>${esc(t.task_id)} · ${esc(t.action)}</h2><div class="toolbar">${pill(t.state)}${actions}</div></div><div class="chain"><div class="chain-item"><b>Task</b><div>${esc(t.agent_id)}</div><small>${esc(t.created_by)}</small></div><span class="chain-arrow">→</span><div class="chain-item"><b>Approval</b><div>${esc(t.approved_by||'待审批')}</div><small>revision ${esc(t.revision)}</small></div><span class="chain-arrow">→</span><div class="chain-item"><b>Provenance</b><div>${esc(d?.provenance_id||'未调度')}</div><small>${esc(d?.authorization_id||'')}</small></div><span class="chain-arrow">→</span><div class="chain-item"><b>Dispatch</b><div>${esc(d?.dispatch_id||'—')}</div><small>${pill(d?.state||'not_created')}</small></div><span class="chain-arrow">→</span><div class="chain-item"><b>Runtime</b><div>${esc(d?.runtime||'—')}</div><small>${esc(d?.runtime_execution_id||'')}</small></div><span class="chain-arrow">→</span><div class="chain-item"><b>Evidence</b><div>${e.length} 条</div><small>Audit ${a.length} 条</small></div></div></div>`}
|
||||
async function taskAction(id,action,revision){clearErr();try{let body={expected_revision:Number(revision)};if(action==='dispatch'){body={};const p=prompt('Provenance ID(生产调度必填)','');const r=prompt('Run ID(生产调度必填)','');if(p)body.provenance_id=p;if(r)body.run_id=r}await api(`/api/v2/portal/tasks/${encodeURIComponent(id)}/${action}`,{method:'POST',body:JSON.stringify(body)});await tasks()}catch(e){err(e)}}
|
||||
async function authorizations(){const d=await api('/api/v2/portal/authorizations');state.cache.authorization=d;const workflows=(d.workflows||[]),grants=(d.grants||[]);$('#authorization').innerHTML=`<div class="cards">${card('Workflow',d.summary?.workflows||0)}${card('Grant',d.summary?.grants||0)}${card('有效 Grant',d.summary?.active_grants||0)}${card('已撤销',d.summary?.revoked_grants||0)}</div><div class="panel"><div class="panel-head"><h2>Workflow Provenance</h2></div>${table([['ID',x=>esc(x.provenance_id)],['Run / Task',x=>`${esc(x.run_id)}<br>${esc(x.task_id)}`],['Principal / Agent',x=>`${esc(x.principal_id)}<br>${esc(x.agent_id)}`],['状态',x=>pill(x.status)],['有效期',x=>esc(x.expires_at)],['使用',x=>esc(x.usage_count)],['操作',x=>has('provenance.revoke')&&x.status!=='revoked'?`<button class="btn danger" data-revoke-workflow="${esc(x.provenance_id)}">撤销</button>`:'—']],workflows)}</div><div class="panel"><div class="panel-head"><h2>Child Grants</h2></div>${table([['Grant',x=>esc(x.grant_id)],['类型 / Scope',x=>`${esc(x.grant_type)}<br>${esc(x.execution_path)}`],['Git 操作',x=>esc((x.allowed_git_operations||[]).join(', ')||'—')],['状态',x=>pill(x.status)],['有效期',x=>esc(x.expires_at)],['使用',x=>esc(x.usage_count)],['操作',x=>has('provenance.revoke')&&x.status!=='revoked'?`<button class="btn danger" data-revoke-grant="${esc(x.grant_id)}">撤销</button>`:'—']],grants)}</div>`;document.querySelectorAll('[data-revoke-workflow]').forEach(b=>b.onclick=()=>confirmOp('撤销 Workflow',`撤销 ${b.dataset.revokeWorkflow} 将使所有子 Grant 失效。`,reason=>api(`/api/v2/portal/provenance/${encodeURIComponent(b.dataset.revokeWorkflow)}/revoke`,{method:'POST',body:JSON.stringify({reason})}).then(authorizations)));document.querySelectorAll('[data-revoke-grant]').forEach(b=>b.onclick=()=>confirmOp('撤销 Grant',`撤销 ${b.dataset.revokeGrant}。`,reason=>api(`/api/v2/portal/grants/${encodeURIComponent(b.dataset.revokeGrant)}/revoke`,{method:'POST',body:JSON.stringify({reason})}).then(authorizations)))}
|
||||
async function runtime(){const d=await api('/api/v2/portal/runtime');const runtimes=d.runtimes||[],dispatches=d.dispatches||[],jobs=d.hermes_jobs||[];$('#runtime').innerHTML=`<div class="grid"><div class="panel"><div class="panel-head"><h2>Runtime</h2></div>${table([['名称',x=>esc(x.name)],['状态',x=>pill(x.status)],['详情',x=>esc(JSON.stringify(x.details||{}))]],runtimes)}</div><div class="panel"><div class="panel-head"><h2>Hermes Queue / Lease</h2></div>${table([['Job',x=>esc(x.job_id)],['Cell / Profile',x=>`${esc(x.cell_id)} / ${esc(x.profile_id)}`],['Worker',x=>esc(x.assigned_worker_id||'—')],['状态',x=>pill(x.state)],['Lease',x=>esc(x.lease_expires_at||'—')]],jobs)}</div><div class="panel wide"><div class="panel-head"><h2>Dispatch</h2></div>${table([['Dispatch',x=>esc(x.dispatch_id)],['Task',x=>esc(x.task_id)],['Runtime',x=>esc(x.runtime)],['执行 ID',x=>esc(x.runtime_execution_id)],['Principal',x=>esc(x.principal_id)],['状态',x=>pill(x.state)]],dispatches)}</div></div>`}
|
||||
async function evidence(){const d=await api('/api/v2/portal/evidence');$('#evidence').innerHTML=`<div class="panel"><div class="panel-head"><h2>Execution Evidence</h2></div>${table([['Execution',x=>esc(x.execution_id)],['Task / Agent',x=>`${esc(x.task_id)}<br>${esc(x.agent_id)}`],['Executor',x=>esc(x.executor)],['状态',x=>pill(x.status)],['进度',x=>`${esc(x.progress_completed)}/${esc(x.progress_total)}`],['Test',x=>pill(x.test_status)],['Commit',x=>esc(x.last_commit||'—')],['授权',x=>esc(x.last_authorization_id||'—')]],d.executions||[])}</div>`}
|
||||
async function audit(){const d=await api('/api/v2/portal/audit');const events=(d.events||[]).slice().reverse();$('#audit').innerHTML=`<div class="panel"><div class="panel-head"><h2>统一审计时间线</h2></div><div class="timeline">${events.map(x=>`<div class="event"><strong>${esc(x.action)} · ${pill(x.outcome)}</strong><p>${esc(x.created_at)} · ${esc(x.principal_id)} · ${esc(x.resource_type)}/${esc(x.resource_id)}</p><div class="muted">Trace ${esc(x.trace_id)} · Policy ${esc(x.policy_version)}</div></div>`).join('')||'<div class="empty">暂无审计事件</div>'}</div></div>`}
|
||||
async function release(){const d=await api('/api/v2/portal/release');const g=d.gitops||{};$('#release').innerHTML=`<div class="cards">${card('版本',d.version||'—')}${card('Baseline',d.baseline||'—')}${card('发布状态',d.status||'—')}${card('Git 同步',g.sync_status||'unknown')}</div><div class="grid"><div class="panel"><div class="panel-head"><h2>当前 Release</h2></div><div class="kv"><div>Release OID</div><div>${esc(d.release_oid)}</div><div>Branch</div><div>${esc(g.workspace_branch||g.branch||'—')}</div><div>Workspace</div><div>${pill(g.workspace_clean===true?'clean':g.workspace_clean===false?'dirty':'unknown')}</div><div>Local Sync</div><div>${pill(g.local_sync||'unknown')}</div><div>Gitea Sync</div><div>${pill(g.gitea_sync||'unknown')}</div></div></div><div class="panel"><div class="panel-head"><h2>治理操作</h2></div><p class="muted">门户不直接移动 Tag、合并主线或部署生产。以下仅为可发起的 GitOps 请求目录。</p>${(d.operations||[]).map(x=>pill(x)).join('')||'<span class="muted">当前角色只读</span>'}</div><div class="panel wide"><div class="panel-head"><h2>原始 GitOps 状态</h2></div>${json(g)}</div></div>`}
|
||||
function confirmOp(title,text,fn){state.confirm=fn;$('#confirmTitle').textContent=title;$('#confirmText').textContent=text;$('#confirmReason').value='';$('#confirmDialog').showModal()}
|
||||
$('#taskForm').onsubmit=async e=>{e.preventDefault();clearErr();try{const fd=new FormData(e.target),payload=JSON.parse(fd.get('payload')||'{}');await api('/api/v2/portal/tasks',{method:'POST',body:JSON.stringify({agent_id:fd.get('agent_id'),action:fd.get('action'),idempotency_key:fd.get('idempotency_key'),payload})});$('#taskDialog').close();e.target.reset();await tasks()}catch(e){err(e)}};
|
||||
$('#confirmForm').onsubmit=async e=>{e.preventDefault();const fn=state.confirm;$('#confirmDialog').close();state.confirm=null;try{await fn?.($('#confirmReason').value)}catch(e){err(e)}};
|
||||
document.querySelectorAll('[data-close]').forEach(b=>b.onclick=()=>document.getElementById(b.dataset.close).close());$('#refresh').onclick=()=>load(state.current);
|
||||
identity().catch(err);setInterval(()=>load(state.current),30000);
|
||||
</script>
|
||||
</body></html>'''
|
||||
@@ -1,627 +0,0 @@
|
||||
"""Tenant-isolated AIOS V2 control-plane domain service.
|
||||
|
||||
This module deliberately contains no OpenClaw or Hermes implementation details.
|
||||
It owns authoritative tenant, agent, task-lifecycle, idempotency and audit state.
|
||||
Runtime adapters consume approved tasks through a separate boundary.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import threading
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from typing import Any, Callable, Iterable, Mapping
|
||||
|
||||
|
||||
def _utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _new_id(prefix: str) -> str:
|
||||
return f"{prefix}_{uuid.uuid4().hex}"
|
||||
|
||||
|
||||
def _canonical_digest(value: Mapping[str, Any]) -> str:
|
||||
encoded = json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
class ControlPlaneError(RuntimeError):
|
||||
"""Base error returned by the control-plane domain."""
|
||||
|
||||
|
||||
class Unauthorized(ControlPlaneError):
|
||||
"""The caller is not authenticated or lacks the required role."""
|
||||
|
||||
|
||||
class TenantIsolationError(ControlPlaneError):
|
||||
"""A request attempted to cross a tenant boundary."""
|
||||
|
||||
|
||||
class NotFound(ControlPlaneError):
|
||||
"""A tenant-scoped resource does not exist or is not visible."""
|
||||
|
||||
|
||||
class InvalidTransition(ControlPlaneError):
|
||||
"""A task state transition violates the lifecycle contract."""
|
||||
|
||||
|
||||
class IdempotencyConflict(ControlPlaneError):
|
||||
"""An idempotency key was reused for a different command."""
|
||||
|
||||
|
||||
class RevisionConflict(ControlPlaneError):
|
||||
"""Optimistic task revision does not match current state."""
|
||||
|
||||
|
||||
class PolicyDenied(ControlPlaneError):
|
||||
"""The policy gate denied a control-plane operation."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RequestContext:
|
||||
"""Authenticated request identity propagated across all AIOS layers."""
|
||||
|
||||
tenant_id: str
|
||||
principal_id: str
|
||||
trace_id: str
|
||||
roles: frozenset[str] = field(default_factory=frozenset)
|
||||
session_id: str | None = None
|
||||
policy_version: str = "v1"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in ("tenant_id", "principal_id", "trace_id"):
|
||||
if not str(getattr(self, name, "")).strip():
|
||||
raise ValueError(f"{name} is required")
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
principal_id: str,
|
||||
roles: Iterable[str] = (),
|
||||
trace_id: str | None = None,
|
||||
session_id: str | None = None,
|
||||
policy_version: str = "v1",
|
||||
) -> "RequestContext":
|
||||
return cls(
|
||||
tenant_id=tenant_id.strip(),
|
||||
principal_id=principal_id.strip(),
|
||||
trace_id=trace_id or _new_id("trace"),
|
||||
roles=frozenset(str(role).strip() for role in roles if str(role).strip()),
|
||||
session_id=session_id,
|
||||
policy_version=policy_version,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TenantRecord:
|
||||
tenant_id: str
|
||||
display_name: str
|
||||
status: str = "active"
|
||||
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||
created_at: str = field(default_factory=_utc_now)
|
||||
updated_at: str = field(default_factory=_utc_now)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AgentRecord:
|
||||
agent_id: str
|
||||
tenant_id: str
|
||||
display_name: str
|
||||
runtime: str
|
||||
capabilities: tuple[str, ...] = field(default_factory=tuple)
|
||||
enabled: bool = True
|
||||
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||
created_at: str = field(default_factory=_utc_now)
|
||||
updated_at: str = field(default_factory=_utc_now)
|
||||
|
||||
|
||||
class TaskState(str, Enum):
|
||||
CREATED = "CREATED"
|
||||
APPROVED = "APPROVED"
|
||||
RUNNING = "RUNNING"
|
||||
COMPLETED = "COMPLETED"
|
||||
FAILED = "FAILED"
|
||||
CANCELLED = "CANCELLED"
|
||||
|
||||
|
||||
TERMINAL_TASK_STATES = frozenset(
|
||||
{TaskState.COMPLETED, TaskState.FAILED, TaskState.CANCELLED}
|
||||
)
|
||||
|
||||
ALLOWED_TASK_TRANSITIONS: Mapping[TaskState, frozenset[TaskState]] = {
|
||||
TaskState.CREATED: frozenset({TaskState.APPROVED, TaskState.CANCELLED}),
|
||||
TaskState.APPROVED: frozenset({TaskState.RUNNING, TaskState.CANCELLED}),
|
||||
TaskState.RUNNING: frozenset(
|
||||
{TaskState.COMPLETED, TaskState.FAILED, TaskState.CANCELLED}
|
||||
),
|
||||
TaskState.COMPLETED: frozenset(),
|
||||
TaskState.FAILED: frozenset(),
|
||||
TaskState.CANCELLED: frozenset(),
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskRecord:
|
||||
task_id: str
|
||||
tenant_id: str
|
||||
agent_id: str
|
||||
action: str
|
||||
payload: Mapping[str, Any]
|
||||
state: TaskState = TaskState.CREATED
|
||||
revision: int = 1
|
||||
idempotency_key: str | None = None
|
||||
request_digest: str = ""
|
||||
result: Mapping[str, Any] | None = None
|
||||
error: Mapping[str, Any] | None = None
|
||||
created_by: str = ""
|
||||
approved_by: str | None = None
|
||||
created_at: str = field(default_factory=_utc_now)
|
||||
updated_at: str = field(default_factory=_utc_now)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AuditEvent:
|
||||
event_id: str
|
||||
tenant_id: str
|
||||
principal_id: str
|
||||
trace_id: str
|
||||
action: str
|
||||
resource_type: str
|
||||
resource_id: str
|
||||
outcome: str
|
||||
policy_version: str
|
||||
details: Mapping[str, Any] = field(default_factory=dict)
|
||||
created_at: str = field(default_factory=_utc_now)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PolicyDecision:
|
||||
allowed: bool
|
||||
reason: str
|
||||
policy_version: str
|
||||
|
||||
|
||||
PolicyEvaluator = Callable[[RequestContext, str, Mapping[str, Any]], PolicyDecision]
|
||||
|
||||
|
||||
class InMemoryControlPlaneStore:
|
||||
"""Thread-safe reference store used by tests and the first gateway slice.
|
||||
|
||||
Production backends must preserve the same tenant-scoped interface and add
|
||||
database row-level security, migrations, backup and HA evidence.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.lock = threading.RLock()
|
||||
self.tenants: dict[str, TenantRecord] = {}
|
||||
self.agents: dict[str, AgentRecord] = {}
|
||||
self.tasks: dict[str, TaskRecord] = {}
|
||||
self.idempotency: dict[tuple[str, str], tuple[str, str]] = {}
|
||||
self.audit_events: list[AuditEvent] = []
|
||||
|
||||
|
||||
class ControlPlaneService:
|
||||
"""Authoritative tenant, agent and task lifecycle service."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
store: InMemoryControlPlaneStore | None = None,
|
||||
*,
|
||||
policy_evaluator: PolicyEvaluator | None = None,
|
||||
) -> None:
|
||||
self.store = store or InMemoryControlPlaneStore()
|
||||
self.policy_evaluator = policy_evaluator or self._default_policy
|
||||
|
||||
@staticmethod
|
||||
def _default_policy(
|
||||
context: RequestContext,
|
||||
action: str,
|
||||
resource: Mapping[str, Any],
|
||||
) -> PolicyDecision:
|
||||
del resource
|
||||
role_requirements = {
|
||||
"tenant.register": {"platform_admin"},
|
||||
"tenant.read": {"tenant_admin", "operator", "auditor", "platform_admin"},
|
||||
"agent.register": {"tenant_admin", "operator", "platform_admin"},
|
||||
"agent.read": {"tenant_admin", "operator", "auditor", "platform_admin"},
|
||||
"task.create": {"tenant_admin", "operator", "platform_admin"},
|
||||
"task.read": {"tenant_admin", "operator", "auditor", "platform_admin"},
|
||||
"task.approve": {"tenant_admin", "approver", "platform_admin"},
|
||||
"task.start": {"runtime", "operator", "platform_admin"},
|
||||
"task.complete": {"runtime", "operator", "platform_admin"},
|
||||
"task.fail": {"runtime", "operator", "platform_admin"},
|
||||
"task.cancel": {"tenant_admin", "operator", "approver", "platform_admin"},
|
||||
"audit.read": {"tenant_admin", "auditor", "platform_admin"},
|
||||
}
|
||||
required = role_requirements.get(action, {"platform_admin"})
|
||||
allowed = bool(context.roles.intersection(required))
|
||||
return PolicyDecision(
|
||||
allowed=allowed,
|
||||
reason="role_allowed" if allowed else "required_role_missing",
|
||||
policy_version=context.policy_version,
|
||||
)
|
||||
|
||||
def _require_context(self, context: RequestContext) -> None:
|
||||
if not context.principal_id or not context.tenant_id or not context.trace_id:
|
||||
raise Unauthorized("authenticated tenant context is required")
|
||||
|
||||
def _authorize(
|
||||
self,
|
||||
context: RequestContext,
|
||||
action: str,
|
||||
resource: Mapping[str, Any],
|
||||
) -> PolicyDecision:
|
||||
self._require_context(context)
|
||||
decision = self.policy_evaluator(context, action, resource)
|
||||
if not decision.allowed:
|
||||
self._audit(
|
||||
context,
|
||||
action=action,
|
||||
resource_type=str(resource.get("resource_type", "policy")),
|
||||
resource_id=str(resource.get("resource_id", "unknown")),
|
||||
outcome="denied",
|
||||
details={"reason": decision.reason},
|
||||
)
|
||||
raise PolicyDenied(f"{action} denied: {decision.reason}")
|
||||
return decision
|
||||
|
||||
@staticmethod
|
||||
def _assert_same_tenant(context: RequestContext, tenant_id: str) -> None:
|
||||
if context.tenant_id != tenant_id and "platform_admin" not in context.roles:
|
||||
raise TenantIsolationError("cross-tenant access denied")
|
||||
|
||||
def _audit(
|
||||
self,
|
||||
context: RequestContext,
|
||||
*,
|
||||
action: str,
|
||||
resource_type: str,
|
||||
resource_id: str,
|
||||
outcome: str,
|
||||
details: Mapping[str, Any] | None = None,
|
||||
) -> AuditEvent:
|
||||
event = AuditEvent(
|
||||
event_id=_new_id("audit"),
|
||||
tenant_id=context.tenant_id,
|
||||
principal_id=context.principal_id,
|
||||
trace_id=context.trace_id,
|
||||
action=action,
|
||||
resource_type=resource_type,
|
||||
resource_id=resource_id,
|
||||
outcome=outcome,
|
||||
policy_version=context.policy_version,
|
||||
details=copy.deepcopy(dict(details or {})),
|
||||
)
|
||||
self.store.audit_events.append(event)
|
||||
return event
|
||||
|
||||
def register_tenant(
|
||||
self,
|
||||
context: RequestContext,
|
||||
*,
|
||||
tenant_id: str,
|
||||
display_name: str,
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
) -> TenantRecord:
|
||||
tenant_id = tenant_id.strip()
|
||||
if not tenant_id or not display_name.strip():
|
||||
raise ValueError("tenant_id and display_name are required")
|
||||
self._authorize(
|
||||
context,
|
||||
"tenant.register",
|
||||
{"resource_type": "tenant", "resource_id": tenant_id},
|
||||
)
|
||||
self._assert_same_tenant(context, tenant_id)
|
||||
with self.store.lock:
|
||||
if tenant_id in self.store.tenants:
|
||||
raise ControlPlaneError(f"tenant already exists: {tenant_id}")
|
||||
tenant = TenantRecord(
|
||||
tenant_id=tenant_id,
|
||||
display_name=display_name.strip(),
|
||||
metadata=copy.deepcopy(dict(metadata or {})),
|
||||
)
|
||||
self.store.tenants[tenant_id] = tenant
|
||||
self._audit(
|
||||
context,
|
||||
action="tenant.register",
|
||||
resource_type="tenant",
|
||||
resource_id=tenant_id,
|
||||
outcome="allowed",
|
||||
)
|
||||
return tenant
|
||||
|
||||
def get_tenant(self, context: RequestContext, tenant_id: str | None = None) -> TenantRecord:
|
||||
tenant_id = tenant_id or context.tenant_id
|
||||
self._authorize(
|
||||
context,
|
||||
"tenant.read",
|
||||
{"resource_type": "tenant", "resource_id": tenant_id},
|
||||
)
|
||||
self._assert_same_tenant(context, tenant_id)
|
||||
with self.store.lock:
|
||||
tenant = self.store.tenants.get(tenant_id)
|
||||
if tenant is None:
|
||||
raise NotFound("tenant not found")
|
||||
return tenant
|
||||
|
||||
def register_agent(
|
||||
self,
|
||||
context: RequestContext,
|
||||
*,
|
||||
agent_id: str,
|
||||
display_name: str,
|
||||
runtime: str,
|
||||
capabilities: Iterable[str] = (),
|
||||
metadata: Mapping[str, Any] | None = None,
|
||||
) -> AgentRecord:
|
||||
self._authorize(
|
||||
context,
|
||||
"agent.register",
|
||||
{"resource_type": "agent", "resource_id": agent_id},
|
||||
)
|
||||
tenant = self.get_tenant(context)
|
||||
if tenant.status != "active":
|
||||
raise ControlPlaneError("tenant is not active")
|
||||
normalized_capabilities = tuple(
|
||||
sorted({str(item).strip() for item in capabilities if str(item).strip()})
|
||||
)
|
||||
if not agent_id.strip() or not display_name.strip() or not runtime.strip():
|
||||
raise ValueError("agent_id, display_name and runtime are required")
|
||||
with self.store.lock:
|
||||
existing = self.store.agents.get(agent_id)
|
||||
if existing is not None and existing.tenant_id != context.tenant_id:
|
||||
raise TenantIsolationError("agent id belongs to another tenant")
|
||||
if existing is not None:
|
||||
raise ControlPlaneError(f"agent already exists: {agent_id}")
|
||||
agent = AgentRecord(
|
||||
agent_id=agent_id.strip(),
|
||||
tenant_id=context.tenant_id,
|
||||
display_name=display_name.strip(),
|
||||
runtime=runtime.strip(),
|
||||
capabilities=normalized_capabilities,
|
||||
metadata=copy.deepcopy(dict(metadata or {})),
|
||||
)
|
||||
self.store.agents[agent.agent_id] = agent
|
||||
self._audit(
|
||||
context,
|
||||
action="agent.register",
|
||||
resource_type="agent",
|
||||
resource_id=agent.agent_id,
|
||||
outcome="allowed",
|
||||
details={"runtime": agent.runtime},
|
||||
)
|
||||
return agent
|
||||
|
||||
def get_agent(self, context: RequestContext, agent_id: str) -> AgentRecord:
|
||||
self._authorize(
|
||||
context,
|
||||
"agent.read",
|
||||
{"resource_type": "agent", "resource_id": agent_id},
|
||||
)
|
||||
with self.store.lock:
|
||||
agent = self.store.agents.get(agent_id)
|
||||
if agent is None:
|
||||
raise NotFound("agent not found")
|
||||
self._assert_same_tenant(context, agent.tenant_id)
|
||||
return agent
|
||||
|
||||
def list_agents(self, context: RequestContext) -> list[AgentRecord]:
|
||||
self._authorize(
|
||||
context,
|
||||
"agent.read",
|
||||
{"resource_type": "agent_collection", "resource_id": context.tenant_id},
|
||||
)
|
||||
with self.store.lock:
|
||||
return [
|
||||
agent
|
||||
for agent in self.store.agents.values()
|
||||
if agent.tenant_id == context.tenant_id
|
||||
]
|
||||
|
||||
def create_task(
|
||||
self,
|
||||
context: RequestContext,
|
||||
*,
|
||||
agent_id: str,
|
||||
action: str,
|
||||
payload: Mapping[str, Any] | None = None,
|
||||
idempotency_key: str | None = None,
|
||||
) -> TaskRecord:
|
||||
self._authorize(
|
||||
context,
|
||||
"task.create",
|
||||
{"resource_type": "task", "resource_id": idempotency_key or "new"},
|
||||
)
|
||||
agent = self.get_agent(context, agent_id)
|
||||
if not agent.enabled:
|
||||
raise ControlPlaneError("agent is disabled")
|
||||
if action not in agent.capabilities and "*" not in agent.capabilities:
|
||||
raise PolicyDenied(f"agent does not advertise capability: {action}")
|
||||
normalized_payload = copy.deepcopy(dict(payload or {}))
|
||||
request_digest = _canonical_digest(
|
||||
{"agent_id": agent_id, "action": action, "payload": normalized_payload}
|
||||
)
|
||||
normalized_key = idempotency_key.strip() if idempotency_key else None
|
||||
with self.store.lock:
|
||||
if normalized_key:
|
||||
idempotency_ref = (context.tenant_id, normalized_key)
|
||||
existing = self.store.idempotency.get(idempotency_ref)
|
||||
if existing is not None:
|
||||
existing_task_id, existing_digest = existing
|
||||
if existing_digest != request_digest:
|
||||
raise IdempotencyConflict(
|
||||
"idempotency key was reused with a different request"
|
||||
)
|
||||
return self.store.tasks[existing_task_id]
|
||||
task = TaskRecord(
|
||||
task_id=_new_id("task"),
|
||||
tenant_id=context.tenant_id,
|
||||
agent_id=agent_id,
|
||||
action=action,
|
||||
payload=normalized_payload,
|
||||
idempotency_key=normalized_key,
|
||||
request_digest=request_digest,
|
||||
created_by=context.principal_id,
|
||||
)
|
||||
self.store.tasks[task.task_id] = task
|
||||
if normalized_key:
|
||||
self.store.idempotency[(context.tenant_id, normalized_key)] = (
|
||||
task.task_id,
|
||||
request_digest,
|
||||
)
|
||||
self._audit(
|
||||
context,
|
||||
action="task.create",
|
||||
resource_type="task",
|
||||
resource_id=task.task_id,
|
||||
outcome="allowed",
|
||||
details={"agent_id": agent_id, "action": action},
|
||||
)
|
||||
return task
|
||||
|
||||
def get_task(self, context: RequestContext, task_id: str) -> TaskRecord:
|
||||
self._authorize(
|
||||
context,
|
||||
"task.read",
|
||||
{"resource_type": "task", "resource_id": task_id},
|
||||
)
|
||||
with self.store.lock:
|
||||
task = self.store.tasks.get(task_id)
|
||||
if task is None:
|
||||
raise NotFound("task not found")
|
||||
self._assert_same_tenant(context, task.tenant_id)
|
||||
return task
|
||||
|
||||
def list_tasks(
|
||||
self,
|
||||
context: RequestContext,
|
||||
*,
|
||||
states: Iterable[TaskState | str] | None = None,
|
||||
) -> list[TaskRecord]:
|
||||
self._authorize(
|
||||
context,
|
||||
"task.read",
|
||||
{"resource_type": "task_collection", "resource_id": context.tenant_id},
|
||||
)
|
||||
state_filter = (
|
||||
{TaskState(item) for item in states}
|
||||
if states is not None
|
||||
else None
|
||||
)
|
||||
with self.store.lock:
|
||||
return [
|
||||
task
|
||||
for task in self.store.tasks.values()
|
||||
if task.tenant_id == context.tenant_id
|
||||
and (state_filter is None or task.state in state_filter)
|
||||
]
|
||||
|
||||
def transition_task(
|
||||
self,
|
||||
context: RequestContext,
|
||||
*,
|
||||
task_id: str,
|
||||
target_state: TaskState | str,
|
||||
expected_revision: int | None = None,
|
||||
result: Mapping[str, Any] | None = None,
|
||||
error: Mapping[str, Any] | None = None,
|
||||
) -> TaskRecord:
|
||||
target = TaskState(target_state)
|
||||
action_name = {
|
||||
TaskState.APPROVED: "task.approve",
|
||||
TaskState.RUNNING: "task.start",
|
||||
TaskState.COMPLETED: "task.complete",
|
||||
TaskState.FAILED: "task.fail",
|
||||
TaskState.CANCELLED: "task.cancel",
|
||||
}.get(target)
|
||||
if action_name is None:
|
||||
raise InvalidTransition(f"cannot transition to {target.value}")
|
||||
self._authorize(
|
||||
context,
|
||||
action_name,
|
||||
{"resource_type": "task", "resource_id": task_id},
|
||||
)
|
||||
with self.store.lock:
|
||||
current = self.store.tasks.get(task_id)
|
||||
if current is None:
|
||||
raise NotFound("task not found")
|
||||
self._assert_same_tenant(context, current.tenant_id)
|
||||
if expected_revision is not None and current.revision != expected_revision:
|
||||
raise RevisionConflict(
|
||||
f"expected revision {expected_revision}, current {current.revision}"
|
||||
)
|
||||
if target not in ALLOWED_TASK_TRANSITIONS[current.state]:
|
||||
raise InvalidTransition(
|
||||
f"invalid transition {current.state.value} -> {target.value}"
|
||||
)
|
||||
if target == TaskState.COMPLETED and result is None:
|
||||
raise ValueError("completed task requires result")
|
||||
if target == TaskState.FAILED and error is None:
|
||||
raise ValueError("failed task requires error")
|
||||
updated = TaskRecord(
|
||||
task_id=current.task_id,
|
||||
tenant_id=current.tenant_id,
|
||||
agent_id=current.agent_id,
|
||||
action=current.action,
|
||||
payload=current.payload,
|
||||
state=target,
|
||||
revision=current.revision + 1,
|
||||
idempotency_key=current.idempotency_key,
|
||||
request_digest=current.request_digest,
|
||||
result=copy.deepcopy(dict(result)) if result is not None else current.result,
|
||||
error=copy.deepcopy(dict(error)) if error is not None else current.error,
|
||||
created_by=current.created_by,
|
||||
approved_by=(
|
||||
context.principal_id
|
||||
if target == TaskState.APPROVED
|
||||
else current.approved_by
|
||||
),
|
||||
created_at=current.created_at,
|
||||
updated_at=_utc_now(),
|
||||
)
|
||||
self.store.tasks[task_id] = updated
|
||||
self._audit(
|
||||
context,
|
||||
action=action_name,
|
||||
resource_type="task",
|
||||
resource_id=task_id,
|
||||
outcome="allowed",
|
||||
details={
|
||||
"from": current.state.value,
|
||||
"to": target.value,
|
||||
"revision": updated.revision,
|
||||
},
|
||||
)
|
||||
return updated
|
||||
|
||||
def list_audit_events(
|
||||
self,
|
||||
context: RequestContext,
|
||||
*,
|
||||
resource_id: str | None = None,
|
||||
) -> list[AuditEvent]:
|
||||
self._authorize(
|
||||
context,
|
||||
"audit.read",
|
||||
{"resource_type": "audit", "resource_id": resource_id or context.tenant_id},
|
||||
)
|
||||
with self.store.lock:
|
||||
return [
|
||||
event
|
||||
for event in self.store.audit_events
|
||||
if event.tenant_id == context.tenant_id
|
||||
and (resource_id is None or event.resource_id == resource_id)
|
||||
]
|
||||
@@ -1,379 +0,0 @@
|
||||
"""AIOS V2 持久化 Dispatch 记录。
|
||||
|
||||
Dispatch 是 AIOS Task 与 Runtime command/job 之间的唯一可信绑定。所有结果回写
|
||||
必须匹配 tenant/task/trace/runtime_execution/provenance/run/principal。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import threading
|
||||
import uuid
|
||||
from dataclasses import asdict, dataclass, replace
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Protocol
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def _required(value: str, label: str) -> str:
|
||||
normalized = str(value or "").strip()
|
||||
if not normalized:
|
||||
raise ValueError(f"{label} is required")
|
||||
return normalized
|
||||
|
||||
|
||||
class DispatchState(str, Enum):
|
||||
PREPARED = "PREPARED"
|
||||
RUNNING = "RUNNING"
|
||||
QUEUED = "QUEUED"
|
||||
COMPLETED = "COMPLETED"
|
||||
FAILED = "FAILED"
|
||||
CANCELLED = "CANCELLED"
|
||||
|
||||
|
||||
TERMINAL_DISPATCH_STATES = frozenset(
|
||||
{DispatchState.COMPLETED, DispatchState.FAILED, DispatchState.CANCELLED}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DispatchRecord:
|
||||
dispatch_id: str
|
||||
tenant_id: str
|
||||
task_id: str
|
||||
agent_id: str
|
||||
principal_id: str
|
||||
runtime: str
|
||||
runtime_execution_id: str
|
||||
trace_id: str
|
||||
provenance_id: str
|
||||
run_id: str
|
||||
authorization_id: str
|
||||
state: DispatchState = DispatchState.PREPARED
|
||||
expected_worker_id: str | None = None
|
||||
result_digest: str | None = None
|
||||
created_at: str = ""
|
||||
updated_at: str = ""
|
||||
completed_at: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in (
|
||||
"dispatch_id",
|
||||
"tenant_id",
|
||||
"task_id",
|
||||
"agent_id",
|
||||
"principal_id",
|
||||
"runtime",
|
||||
"runtime_execution_id",
|
||||
"trace_id",
|
||||
"provenance_id",
|
||||
"run_id",
|
||||
"authorization_id",
|
||||
):
|
||||
object.__setattr__(self, name, _required(getattr(self, name), name))
|
||||
object.__setattr__(self, "state", DispatchState(self.state))
|
||||
now = utc_now()
|
||||
object.__setattr__(self, "created_at", self.created_at or now)
|
||||
object.__setattr__(self, "updated_at", self.updated_at or now)
|
||||
if self.expected_worker_id is not None:
|
||||
object.__setattr__(
|
||||
self,
|
||||
"expected_worker_id",
|
||||
_required(self.expected_worker_id, "expected_worker_id"),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
tenant_id: str,
|
||||
task_id: str,
|
||||
agent_id: str,
|
||||
principal_id: str,
|
||||
runtime: str,
|
||||
runtime_execution_id: str,
|
||||
trace_id: str,
|
||||
provenance_id: str,
|
||||
run_id: str,
|
||||
authorization_id: str,
|
||||
) -> "DispatchRecord":
|
||||
return cls(
|
||||
dispatch_id=f"dispatch_{uuid.uuid4().hex}",
|
||||
tenant_id=tenant_id,
|
||||
task_id=task_id,
|
||||
agent_id=agent_id,
|
||||
principal_id=principal_id,
|
||||
runtime=runtime,
|
||||
runtime_execution_id=runtime_execution_id,
|
||||
trace_id=trace_id,
|
||||
provenance_id=provenance_id,
|
||||
run_id=run_id,
|
||||
authorization_id=authorization_id,
|
||||
)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
value = asdict(self)
|
||||
value["state"] = self.state.value
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: Mapping[str, Any]) -> "DispatchRecord":
|
||||
return cls(**dict(value))
|
||||
|
||||
|
||||
class DispatchConflict(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class DispatchNotFound(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class DispatchRepository(Protocol):
|
||||
def create(self, record: DispatchRecord) -> DispatchRecord: ...
|
||||
def get(self, dispatch_id: str, *, tenant_id: str) -> DispatchRecord: ...
|
||||
def get_by_task(self, task_id: str, *, tenant_id: str) -> DispatchRecord | None: ...
|
||||
def get_by_runtime_execution(
|
||||
self, runtime_execution_id: str, *, tenant_id: str
|
||||
) -> DispatchRecord | None: ...
|
||||
def update(
|
||||
self,
|
||||
dispatch_id: str,
|
||||
*,
|
||||
tenant_id: str,
|
||||
state: DispatchState | str | None = None,
|
||||
expected_worker_id: str | None = None,
|
||||
result_digest: str | None = None,
|
||||
) -> DispatchRecord: ...
|
||||
def list(self, *, tenant_id: str) -> list[DispatchRecord]: ...
|
||||
|
||||
|
||||
class InMemoryDispatchRepository:
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.RLock()
|
||||
self._records: dict[str, DispatchRecord] = {}
|
||||
self._by_task: dict[tuple[str, str], str] = {}
|
||||
self._by_runtime: dict[tuple[str, str], str] = {}
|
||||
|
||||
def create(self, record: DispatchRecord) -> DispatchRecord:
|
||||
with self._lock:
|
||||
task_ref = (record.tenant_id, record.task_id)
|
||||
runtime_ref = (record.tenant_id, record.runtime_execution_id)
|
||||
if task_ref in self._by_task or runtime_ref in self._by_runtime:
|
||||
existing = self.get_by_task(record.task_id, tenant_id=record.tenant_id)
|
||||
if existing == record:
|
||||
return record
|
||||
raise DispatchConflict("task or runtime execution already has a dispatch")
|
||||
self._records[record.dispatch_id] = record
|
||||
self._by_task[task_ref] = record.dispatch_id
|
||||
self._by_runtime[runtime_ref] = record.dispatch_id
|
||||
return record
|
||||
|
||||
def get(self, dispatch_id: str, *, tenant_id: str) -> DispatchRecord:
|
||||
with self._lock:
|
||||
record = self._records.get(dispatch_id)
|
||||
if record is None or record.tenant_id != tenant_id:
|
||||
raise DispatchNotFound("dispatch not found")
|
||||
return record
|
||||
|
||||
def get_by_task(self, task_id: str, *, tenant_id: str) -> DispatchRecord | None:
|
||||
with self._lock:
|
||||
dispatch_id = self._by_task.get((tenant_id, task_id))
|
||||
return self._records.get(dispatch_id) if dispatch_id else None
|
||||
|
||||
def get_by_runtime_execution(
|
||||
self, runtime_execution_id: str, *, tenant_id: str
|
||||
) -> DispatchRecord | None:
|
||||
with self._lock:
|
||||
dispatch_id = self._by_runtime.get((tenant_id, runtime_execution_id))
|
||||
return self._records.get(dispatch_id) if dispatch_id else None
|
||||
|
||||
def update(
|
||||
self,
|
||||
dispatch_id: str,
|
||||
*,
|
||||
tenant_id: str,
|
||||
state: DispatchState | str | None = None,
|
||||
expected_worker_id: str | None = None,
|
||||
result_digest: str | None = None,
|
||||
) -> DispatchRecord:
|
||||
with self._lock:
|
||||
current = self.get(dispatch_id, tenant_id=tenant_id)
|
||||
target = DispatchState(state) if state is not None else current.state
|
||||
if current.state in TERMINAL_DISPATCH_STATES and target != current.state:
|
||||
raise DispatchConflict("terminal dispatch cannot transition")
|
||||
updated = replace(
|
||||
current,
|
||||
state=target,
|
||||
expected_worker_id=(
|
||||
expected_worker_id
|
||||
if expected_worker_id is not None
|
||||
else current.expected_worker_id
|
||||
),
|
||||
result_digest=result_digest or current.result_digest,
|
||||
updated_at=utc_now(),
|
||||
completed_at=(utc_now() if target in TERMINAL_DISPATCH_STATES else None),
|
||||
)
|
||||
self._records[dispatch_id] = updated
|
||||
return updated
|
||||
|
||||
def list(self, *, tenant_id: str) -> list[DispatchRecord]:
|
||||
with self._lock:
|
||||
return sorted(
|
||||
[item for item in self._records.values() if item.tenant_id == tenant_id],
|
||||
key=lambda item: (item.created_at, item.dispatch_id),
|
||||
)
|
||||
|
||||
|
||||
class SQLiteDispatchRepository:
|
||||
SCHEMA_VERSION = 1
|
||||
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self.path = Path(path).resolve()
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = threading.RLock()
|
||||
with self._connect() as connection:
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS dispatch_records (
|
||||
dispatch_id TEXT PRIMARY KEY,
|
||||
tenant_id TEXT NOT NULL,
|
||||
task_id TEXT NOT NULL,
|
||||
runtime_execution_id TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
state TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE(tenant_id, task_id),
|
||||
UNIQUE(tenant_id, runtime_execution_id)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_dispatch_tenant_state
|
||||
ON dispatch_records(tenant_id, state, updated_at);
|
||||
"""
|
||||
)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(str(self.path), timeout=30.0)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute("PRAGMA synchronous=FULL")
|
||||
return connection
|
||||
|
||||
@staticmethod
|
||||
def _encode(record: DispatchRecord) -> str:
|
||||
return json.dumps(
|
||||
record.as_dict(), ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _decode(row: sqlite3.Row) -> DispatchRecord:
|
||||
value = json.loads(row["payload_json"])
|
||||
if not isinstance(value, Mapping):
|
||||
raise RuntimeError("invalid dispatch record")
|
||||
return DispatchRecord.from_dict(value)
|
||||
|
||||
def create(self, record: DispatchRecord) -> DispatchRecord:
|
||||
with self._lock, self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
try:
|
||||
connection.execute(
|
||||
"INSERT INTO dispatch_records VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
(
|
||||
record.dispatch_id,
|
||||
record.tenant_id,
|
||||
record.task_id,
|
||||
record.runtime_execution_id,
|
||||
self._encode(record),
|
||||
record.state.value,
|
||||
record.updated_at,
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
return record
|
||||
except sqlite3.IntegrityError as exc:
|
||||
connection.rollback()
|
||||
existing = self.get_by_task(record.task_id, tenant_id=record.tenant_id)
|
||||
if existing is not None and existing == record:
|
||||
return existing
|
||||
raise DispatchConflict("task or runtime execution already has a dispatch") from exc
|
||||
|
||||
def get(self, dispatch_id: str, *, tenant_id: str) -> DispatchRecord:
|
||||
with self._lock, self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT payload_json FROM dispatch_records WHERE dispatch_id = ? AND tenant_id = ?",
|
||||
(_required(dispatch_id, "dispatch_id"), _required(tenant_id, "tenant_id")),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise DispatchNotFound("dispatch not found")
|
||||
return self._decode(row)
|
||||
|
||||
def get_by_task(self, task_id: str, *, tenant_id: str) -> DispatchRecord | None:
|
||||
with self._lock, self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT payload_json FROM dispatch_records WHERE task_id = ? AND tenant_id = ?",
|
||||
(_required(task_id, "task_id"), _required(tenant_id, "tenant_id")),
|
||||
).fetchone()
|
||||
return self._decode(row) if row is not None else None
|
||||
|
||||
def get_by_runtime_execution(
|
||||
self, runtime_execution_id: str, *, tenant_id: str
|
||||
) -> DispatchRecord | None:
|
||||
with self._lock, self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT payload_json FROM dispatch_records WHERE runtime_execution_id = ? AND tenant_id = ?",
|
||||
(
|
||||
_required(runtime_execution_id, "runtime_execution_id"),
|
||||
_required(tenant_id, "tenant_id"),
|
||||
),
|
||||
).fetchone()
|
||||
return self._decode(row) if row is not None else None
|
||||
|
||||
def update(
|
||||
self,
|
||||
dispatch_id: str,
|
||||
*,
|
||||
tenant_id: str,
|
||||
state: DispatchState | str | None = None,
|
||||
expected_worker_id: str | None = None,
|
||||
result_digest: str | None = None,
|
||||
) -> DispatchRecord:
|
||||
current = self.get(dispatch_id, tenant_id=tenant_id)
|
||||
target = DispatchState(state) if state is not None else current.state
|
||||
if current.state in TERMINAL_DISPATCH_STATES and target != current.state:
|
||||
raise DispatchConflict("terminal dispatch cannot transition")
|
||||
updated = replace(
|
||||
current,
|
||||
state=target,
|
||||
expected_worker_id=(
|
||||
expected_worker_id if expected_worker_id is not None else current.expected_worker_id
|
||||
),
|
||||
result_digest=result_digest or current.result_digest,
|
||||
updated_at=utc_now(),
|
||||
completed_at=(utc_now() if target in TERMINAL_DISPATCH_STATES else None),
|
||||
)
|
||||
with self._lock, self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
connection.execute(
|
||||
"UPDATE dispatch_records SET payload_json = ?, state = ?, updated_at = ? WHERE dispatch_id = ? AND tenant_id = ?",
|
||||
(
|
||||
self._encode(updated),
|
||||
updated.state.value,
|
||||
updated.updated_at,
|
||||
updated.dispatch_id,
|
||||
updated.tenant_id,
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
return updated
|
||||
|
||||
def list(self, *, tenant_id: str) -> list[DispatchRecord]:
|
||||
with self._lock, self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT payload_json FROM dispatch_records WHERE tenant_id = ? ORDER BY updated_at, dispatch_id",
|
||||
(_required(tenant_id, "tenant_id"),),
|
||||
).fetchall()
|
||||
return [self._decode(row) for row in rows]
|
||||
@@ -1,543 +0,0 @@
|
||||
"""Portable multi-provider execution plans and aggregate release gates.
|
||||
|
||||
A plan is a DAG of executor-neutral stages. Only root stages are declared at
|
||||
plan creation. A dependent stage is declared atomically after every dependency
|
||||
passes its Truth + notification gate, so its start deadline cannot expire while
|
||||
it is legitimately waiting for upstream work.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
import threading
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Mapping, Sequence
|
||||
|
||||
from .executor_protocol import ExecutorProviderRegistry, normalize_executor
|
||||
from .external_task_control import ExternalTaskCoordinator
|
||||
from .external_task_query import list_records
|
||||
from .task_execution_registry import (
|
||||
ExecutionTaskManifest,
|
||||
ExecutionTruthRecord,
|
||||
ExecutionTruthState,
|
||||
required,
|
||||
utc_now,
|
||||
)
|
||||
|
||||
PLAN_SCHEMA_VERSION = "aios.execution-plan.v2"
|
||||
WAITING_DEPENDENCY = "WAITING_DEPENDENCY"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionStageManifest:
|
||||
stage_id: str
|
||||
task_id: str
|
||||
executor_type: str
|
||||
role: str
|
||||
steps: Sequence[str]
|
||||
depends_on: Sequence[str] = ()
|
||||
required_evidence: Sequence[str] = ()
|
||||
notification_events: Sequence[str] = ("STARTED", "SUCCEEDED", "FAILED")
|
||||
owner_notification_ref: str = "AIOS_OWNER_WECHAT_TARGET_REF"
|
||||
start_deadline_seconds: int = 300
|
||||
heartbeat_timeout_seconds: int = 180
|
||||
required: bool = True
|
||||
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in ("stage_id", "task_id", "role", "owner_notification_ref"):
|
||||
object.__setattr__(self, name, required(getattr(self, name), name))
|
||||
object.__setattr__(self, "executor_type", normalize_executor(self.executor_type))
|
||||
steps = tuple(required(item, "step") for item in self.steps)
|
||||
if not steps:
|
||||
raise ValueError("execution stage requires at least one step")
|
||||
object.__setattr__(self, "steps", steps)
|
||||
object.__setattr__(
|
||||
self,
|
||||
"depends_on",
|
||||
tuple(sorted({required(item, "depends_on") for item in self.depends_on})),
|
||||
)
|
||||
object.__setattr__(
|
||||
self,
|
||||
"required_evidence",
|
||||
tuple(sorted({required(item, "required_evidence") for item in self.required_evidence})),
|
||||
)
|
||||
object.__setattr__(
|
||||
self,
|
||||
"notification_events",
|
||||
tuple(sorted({required(item, "notification_event") for item in self.notification_events})),
|
||||
)
|
||||
if self.start_deadline_seconds < 1 or self.heartbeat_timeout_seconds < 1:
|
||||
raise ValueError("stage deadlines must be positive")
|
||||
object.__setattr__(
|
||||
self,
|
||||
"metadata",
|
||||
json.loads(json.dumps(dict(self.metadata), ensure_ascii=False, default=str)),
|
||||
)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
value = asdict(self)
|
||||
value["steps"] = list(self.steps)
|
||||
value["depends_on"] = list(self.depends_on)
|
||||
value["required_evidence"] = list(self.required_evidence)
|
||||
value["notification_events"] = list(self.notification_events)
|
||||
return value
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: Mapping[str, Any]) -> "ExecutionStageManifest":
|
||||
return cls(**dict(value))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ExecutionPlanManifest:
|
||||
plan_id: str
|
||||
source_branch: str
|
||||
source_oid: str
|
||||
target_version: str
|
||||
stages: Sequence[ExecutionStageManifest]
|
||||
release_gate_policy: str = "all_required_stages"
|
||||
schema_version: str = PLAN_SCHEMA_VERSION
|
||||
metadata: Mapping[str, Any] = field(default_factory=dict)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
for name in ("plan_id", "source_branch", "source_oid", "target_version"):
|
||||
object.__setattr__(self, name, required(getattr(self, name), name))
|
||||
if self.schema_version != PLAN_SCHEMA_VERSION:
|
||||
raise ValueError("unsupported execution plan schema")
|
||||
if self.release_gate_policy != "all_required_stages":
|
||||
raise ValueError("unsupported release gate policy")
|
||||
stages = tuple(self.stages)
|
||||
if not stages:
|
||||
raise ValueError("execution plan requires stages")
|
||||
stage_ids = [item.stage_id for item in stages]
|
||||
task_ids = [item.task_id for item in stages]
|
||||
if len(stage_ids) != len(set(stage_ids)):
|
||||
raise ValueError("execution plan stage_id values must be unique")
|
||||
if len(task_ids) != len(set(task_ids)):
|
||||
raise ValueError("execution plan task_id values must be unique")
|
||||
known = set(stage_ids)
|
||||
for stage in stages:
|
||||
unknown = sorted(set(stage.depends_on) - known)
|
||||
if unknown:
|
||||
raise ValueError(f"stage {stage.stage_id} has unknown dependencies: {unknown}")
|
||||
if stage.stage_id in stage.depends_on:
|
||||
raise ValueError("stage cannot depend on itself")
|
||||
self._assert_acyclic(stages)
|
||||
object.__setattr__(self, "stages", stages)
|
||||
object.__setattr__(
|
||||
self,
|
||||
"metadata",
|
||||
json.loads(json.dumps(dict(self.metadata), ensure_ascii=False, default=str)),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _assert_acyclic(stages: Sequence[ExecutionStageManifest]) -> None:
|
||||
dependencies = {item.stage_id: set(item.depends_on) for item in stages}
|
||||
resolved: set[str] = set()
|
||||
while len(resolved) < len(stages):
|
||||
ready = {
|
||||
stage_id
|
||||
for stage_id, deps in dependencies.items()
|
||||
if stage_id not in resolved and deps.issubset(resolved)
|
||||
}
|
||||
if not ready:
|
||||
raise ValueError("execution plan contains a dependency cycle")
|
||||
resolved.update(ready)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": self.schema_version,
|
||||
"plan_id": self.plan_id,
|
||||
"source_branch": self.source_branch,
|
||||
"source_oid": self.source_oid,
|
||||
"target_version": self.target_version,
|
||||
"release_gate_policy": self.release_gate_policy,
|
||||
"stages": [item.as_dict() for item in self.stages],
|
||||
"metadata": dict(self.metadata),
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, value: Mapping[str, Any]) -> "ExecutionPlanManifest":
|
||||
raw = dict(value)
|
||||
raw["stages"] = tuple(
|
||||
ExecutionStageManifest.from_dict(item) for item in raw.get("stages") or ()
|
||||
)
|
||||
return cls(**raw)
|
||||
|
||||
def stage(self, stage_id: str) -> ExecutionStageManifest:
|
||||
normalized = required(stage_id, "stage_id")
|
||||
for stage in self.stages:
|
||||
if stage.stage_id == normalized:
|
||||
return stage
|
||||
raise KeyError(f"execution stage not found: {normalized}")
|
||||
|
||||
|
||||
class SQLiteExecutionPlanStore:
|
||||
"""Durable plan declarations; stage state remains in Truth Registry."""
|
||||
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self.path = Path(path).resolve()
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = threading.RLock()
|
||||
with self._connect() as connection:
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS execution_plans (
|
||||
plan_id TEXT PRIMARY KEY,
|
||||
manifest_json TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(str(self.path), timeout=30.0)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA journal_mode=WAL")
|
||||
connection.execute("PRAGMA synchronous=FULL")
|
||||
return connection
|
||||
|
||||
@staticmethod
|
||||
def _encode(plan: ExecutionPlanManifest) -> str:
|
||||
return json.dumps(
|
||||
plan.as_dict(),
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
|
||||
def save(
|
||||
self,
|
||||
plan: ExecutionPlanManifest,
|
||||
*,
|
||||
allow_source_oid_supersession: bool = False,
|
||||
) -> None:
|
||||
payload = self._encode(plan)
|
||||
now = utc_now()
|
||||
with self._lock, self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
existing = connection.execute(
|
||||
"SELECT manifest_json FROM execution_plans WHERE plan_id = ?",
|
||||
(plan.plan_id,),
|
||||
).fetchone()
|
||||
if existing is not None:
|
||||
if existing["manifest_json"] != payload:
|
||||
previous = json.loads(existing["manifest_json"])
|
||||
replacement = plan.as_dict()
|
||||
previous_oid = previous.pop("source_oid", None)
|
||||
replacement_oid = replacement.pop("source_oid", None)
|
||||
if (
|
||||
not allow_source_oid_supersession
|
||||
or previous != replacement
|
||||
or previous_oid == replacement_oid
|
||||
):
|
||||
connection.rollback()
|
||||
raise RuntimeError(
|
||||
"plan_id already exists with another manifest"
|
||||
)
|
||||
connection.execute(
|
||||
"UPDATE execution_plans SET manifest_json = ?, updated_at = ? WHERE plan_id = ?",
|
||||
(payload, now, plan.plan_id),
|
||||
)
|
||||
connection.commit()
|
||||
return
|
||||
connection.rollback()
|
||||
return
|
||||
connection.execute(
|
||||
"INSERT INTO execution_plans VALUES (?, ?, ?, ?)",
|
||||
(plan.plan_id, payload, now, now),
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
def get(self, plan_id: str) -> ExecutionPlanManifest:
|
||||
with self._lock, self._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT manifest_json FROM execution_plans WHERE plan_id = ?",
|
||||
(required(plan_id, "plan_id"),),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise KeyError("execution plan not found")
|
||||
value = json.loads(row["manifest_json"])
|
||||
if not isinstance(value, Mapping):
|
||||
raise ValueError("stored execution plan is invalid")
|
||||
return ExecutionPlanManifest.from_dict(value)
|
||||
|
||||
def list(self) -> list[ExecutionPlanManifest]:
|
||||
with self._lock, self._connect() as connection:
|
||||
rows = connection.execute(
|
||||
"SELECT manifest_json FROM execution_plans ORDER BY created_at, plan_id"
|
||||
).fetchall()
|
||||
return [
|
||||
ExecutionPlanManifest.from_dict(json.loads(row["manifest_json"]))
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
class ExecutionPlanCoordinator:
|
||||
"""Declares dependency-ready stages and evaluates aggregate release gates."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
task_coordinator: ExternalTaskCoordinator,
|
||||
plan_store: SQLiteExecutionPlanStore,
|
||||
providers: ExecutorProviderRegistry,
|
||||
) -> None:
|
||||
self.task_coordinator = task_coordinator
|
||||
self.plan_store = plan_store
|
||||
self.providers = providers
|
||||
|
||||
@classmethod
|
||||
def from_file(
|
||||
cls,
|
||||
path: str | Path,
|
||||
*,
|
||||
providers: ExecutorProviderRegistry,
|
||||
) -> "ExecutionPlanCoordinator":
|
||||
return cls(
|
||||
task_coordinator=ExternalTaskCoordinator.from_file(path),
|
||||
plan_store=SQLiteExecutionPlanStore(path),
|
||||
providers=providers,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _task_manifest(
|
||||
plan: ExecutionPlanManifest,
|
||||
stage: ExecutionStageManifest,
|
||||
) -> ExecutionTaskManifest:
|
||||
return ExecutionTaskManifest(
|
||||
task_id=stage.task_id,
|
||||
executor=stage.executor_type,
|
||||
source_branch=plan.source_branch,
|
||||
source_oid=plan.source_oid,
|
||||
target_version=plan.target_version,
|
||||
steps=stage.steps,
|
||||
start_deadline_seconds=stage.start_deadline_seconds,
|
||||
heartbeat_timeout_seconds=stage.heartbeat_timeout_seconds,
|
||||
required_evidence=stage.required_evidence,
|
||||
notification_events=stage.notification_events,
|
||||
owner_notification_ref=stage.owner_notification_ref,
|
||||
manifest_version="aios.external-task.v2",
|
||||
)
|
||||
|
||||
def _record(self, task_id: str) -> ExecutionTruthRecord | None:
|
||||
try:
|
||||
return self.task_coordinator.registry.get(task_id)
|
||||
except KeyError:
|
||||
return None
|
||||
|
||||
def _declare_stage(
|
||||
self,
|
||||
plan: ExecutionPlanManifest,
|
||||
stage: ExecutionStageManifest,
|
||||
) -> ExecutionTruthRecord:
|
||||
self.providers.validate_manifest(self._task_manifest(plan, stage))
|
||||
return self.task_coordinator.declare(self._task_manifest(plan, stage))
|
||||
|
||||
def declare(self, plan: ExecutionPlanManifest) -> Mapping[str, Any]:
|
||||
for stage in plan.stages:
|
||||
self.providers.get(stage.executor_type)
|
||||
allow_source_oid_supersession = False
|
||||
try:
|
||||
previous = self.plan_store.get(plan.plan_id)
|
||||
except KeyError:
|
||||
previous = None
|
||||
if previous is not None and previous.source_oid != plan.source_oid:
|
||||
previous_value = previous.as_dict()
|
||||
replacement_value = plan.as_dict()
|
||||
previous_value.pop("source_oid")
|
||||
replacement_value.pop("source_oid")
|
||||
root_records = [
|
||||
self._record(stage.task_id)
|
||||
for stage in previous.stages
|
||||
if not stage.depends_on
|
||||
]
|
||||
allow_source_oid_supersession = bool(
|
||||
previous_value == replacement_value
|
||||
and root_records
|
||||
and all(
|
||||
record is not None
|
||||
and record.state == ExecutionTruthState.NOT_STARTED
|
||||
for record in root_records
|
||||
)
|
||||
)
|
||||
self.plan_store.save(
|
||||
plan,
|
||||
allow_source_oid_supersession=allow_source_oid_supersession,
|
||||
)
|
||||
for stage in plan.stages:
|
||||
if not stage.depends_on:
|
||||
self._declare_stage(plan, stage)
|
||||
return self.snapshot(plan.plan_id)
|
||||
|
||||
def declare_file(self, path: str | Path) -> Mapping[str, Any]:
|
||||
value = json.loads(Path(path).read_text(encoding="utf-8"))
|
||||
if not isinstance(value, Mapping):
|
||||
raise ValueError("execution plan file must contain a JSON object")
|
||||
return self.declare(ExecutionPlanManifest.from_dict(value))
|
||||
|
||||
def dependency_gate(self, plan_id: str, stage_id: str) -> Mapping[str, Any]:
|
||||
plan = self.plan_store.get(plan_id)
|
||||
stage = plan.stage(stage_id)
|
||||
dependencies: list[Mapping[str, Any]] = []
|
||||
passed = True
|
||||
for dependency_id in stage.depends_on:
|
||||
dependency = plan.stage(dependency_id)
|
||||
record = self._record(dependency.task_id)
|
||||
if record is None:
|
||||
gate: Mapping[str, Any] = {
|
||||
"task_id": dependency.task_id,
|
||||
"state": WAITING_DEPENDENCY,
|
||||
"passed": False,
|
||||
"release_blocking": True,
|
||||
}
|
||||
else:
|
||||
gate = self.task_coordinator.gate(dependency.task_id)
|
||||
dependencies.append(
|
||||
{
|
||||
"stage_id": dependency.stage_id,
|
||||
"task_id": dependency.task_id,
|
||||
"gate": gate,
|
||||
}
|
||||
)
|
||||
if not gate["passed"]:
|
||||
passed = False
|
||||
return {
|
||||
"plan_id": plan.plan_id,
|
||||
"stage_id": stage.stage_id,
|
||||
"passed": passed,
|
||||
"release_blocking": not passed,
|
||||
"dependencies": dependencies,
|
||||
}
|
||||
|
||||
def activate_ready(self, plan_id: str) -> list[Mapping[str, Any]]:
|
||||
plan = self.plan_store.get(plan_id)
|
||||
activated: list[Mapping[str, Any]] = []
|
||||
for stage in plan.stages:
|
||||
if self._record(stage.task_id) is not None:
|
||||
continue
|
||||
dependency = self.dependency_gate(plan.plan_id, stage.stage_id)
|
||||
if not dependency["passed"]:
|
||||
continue
|
||||
record = self._declare_stage(plan, stage)
|
||||
activated.append(
|
||||
{
|
||||
"stage_id": stage.stage_id,
|
||||
"task_id": record.task_id,
|
||||
"executor_type": stage.executor_type,
|
||||
"state": record.state.value,
|
||||
}
|
||||
)
|
||||
return activated
|
||||
|
||||
def discover_ready(
|
||||
self,
|
||||
*,
|
||||
plan_id: str,
|
||||
executor_type: str,
|
||||
) -> list[Mapping[str, Any]]:
|
||||
plan = self.plan_store.get(plan_id)
|
||||
self.activate_ready(plan.plan_id)
|
||||
normalized = normalize_executor(executor_type)
|
||||
records = {
|
||||
item.task_id: item
|
||||
for item in list_records(
|
||||
self.task_coordinator.registry.path,
|
||||
executor=normalized,
|
||||
states=(
|
||||
ExecutionTruthState.DECLARED.value,
|
||||
ExecutionTruthState.REGISTERED.value,
|
||||
),
|
||||
)
|
||||
}
|
||||
ready: list[Mapping[str, Any]] = []
|
||||
for stage in plan.stages:
|
||||
if stage.executor_type != normalized or stage.task_id not in records:
|
||||
continue
|
||||
dependency = self.dependency_gate(plan.plan_id, stage.stage_id)
|
||||
if dependency["passed"]:
|
||||
ready.append(
|
||||
{
|
||||
"plan_id": plan.plan_id,
|
||||
"stage": stage.as_dict(),
|
||||
"record": records[stage.task_id].as_dict(),
|
||||
}
|
||||
)
|
||||
return ready
|
||||
|
||||
def snapshot(self, plan_id: str) -> Mapping[str, Any]:
|
||||
plan = self.plan_store.get(plan_id)
|
||||
stages: list[Mapping[str, Any]] = []
|
||||
for stage in plan.stages:
|
||||
record = self._record(stage.task_id)
|
||||
dependency = self.dependency_gate(plan.plan_id, stage.stage_id)
|
||||
if record is None:
|
||||
truth: Mapping[str, Any] = {
|
||||
"task_id": stage.task_id,
|
||||
"state": WAITING_DEPENDENCY,
|
||||
"passed": False,
|
||||
"release_blocking": True,
|
||||
"executor_run_id": None,
|
||||
"executor_id": None,
|
||||
"current_step": 0,
|
||||
"total_steps": len(stage.steps),
|
||||
"evidence": {},
|
||||
"error": {},
|
||||
"notifications": [],
|
||||
}
|
||||
else:
|
||||
truth = self.task_coordinator.status(stage.task_id)
|
||||
stages.append(
|
||||
{
|
||||
"stage": stage.as_dict(),
|
||||
"dependency_gate": dependency,
|
||||
"truth": truth,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"manifest": plan.as_dict(),
|
||||
"stages": stages,
|
||||
}
|
||||
|
||||
def gate(self, plan_id: str) -> Mapping[str, Any]:
|
||||
plan = self.plan_store.get(plan_id)
|
||||
self.activate_ready(plan.plan_id)
|
||||
stages: list[Mapping[str, Any]] = []
|
||||
required_passed = True
|
||||
for stage in plan.stages:
|
||||
dependency = self.dependency_gate(plan.plan_id, stage.stage_id)
|
||||
record = self._record(stage.task_id)
|
||||
if record is None:
|
||||
truth_gate: Mapping[str, Any] = {
|
||||
"task_id": stage.task_id,
|
||||
"state": WAITING_DEPENDENCY,
|
||||
"passed": False,
|
||||
"release_blocking": True,
|
||||
}
|
||||
else:
|
||||
truth_gate = self.task_coordinator.gate(stage.task_id)
|
||||
stage_passed = bool(truth_gate["passed"] and dependency["passed"])
|
||||
if stage.required and not stage_passed:
|
||||
required_passed = False
|
||||
stages.append(
|
||||
{
|
||||
"stage_id": stage.stage_id,
|
||||
"task_id": stage.task_id,
|
||||
"executor_type": stage.executor_type,
|
||||
"role": stage.role,
|
||||
"required": stage.required,
|
||||
"passed": stage_passed,
|
||||
"truth_gate": truth_gate,
|
||||
"dependency_gate": dependency,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"plan_id": plan.plan_id,
|
||||
"target_version": plan.target_version,
|
||||
"policy": plan.release_gate_policy,
|
||||
"passed": required_passed,
|
||||
"release_blocking": not required_passed,
|
||||
"stages": stages,
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user