Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a07817910 | ||
|
|
a642b04577 | ||
|
|
bb80f8cc2f | ||
|
|
0eb6e1a864 |
@@ -13,6 +13,7 @@ from hashlib import sha256
|
||||
from pathlib import Path
|
||||
import json
|
||||
from typing import Any
|
||||
from message_handoff import stable_event_id
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
@@ -55,13 +56,19 @@ class AgentEventBus:
|
||||
payload: dict[str, Any],
|
||||
correlation_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
event_id = "evt_" + sha256(
|
||||
json.dumps(
|
||||
{"kind": kind, "source": source, "summary": summary, "payload": payload},
|
||||
sort_keys=True,
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
).hexdigest()[:16]
|
||||
event_id = stable_event_id(
|
||||
source=source, target=target, message_type=kind, payload=payload,
|
||||
tenant_id=(payload.get('tenant_id') if isinstance(payload, dict) else None),
|
||||
user_id=(payload.get('user_id') if isinstance(payload, dict) else None),
|
||||
project_id=(payload.get('project_id') if isinstance(payload, dict) else None),
|
||||
conversation_id=(payload.get('conversation_id') if isinstance(payload, dict) else None),
|
||||
task_id=(payload.get('task_id') if isinstance(payload, dict) else None),
|
||||
run_id=(payload.get('run_id') if isinstance(payload, dict) else None),
|
||||
correlation_id=(payload.get('correlation_id') if isinstance(payload, dict) else None),
|
||||
causation_id=(payload.get('causation_id') if isinstance(payload, dict) else None),
|
||||
sequence=(payload.get('sequence') if isinstance(payload, dict) else None),
|
||||
idempotency_key=(payload.get('idempotency_key') if isinstance(payload, dict) else None),
|
||||
)
|
||||
event = AgentEvent(event_id, kind, source, target, summary, payload, correlation_id=correlation_id)
|
||||
self._write("pending", event.to_dict())
|
||||
self._audit("created", event.to_dict())
|
||||
|
||||
@@ -7,7 +7,7 @@ class AgentEventObservabilityBridge:
|
||||
def __init__(self, hooks: AgentObservabilityHooks | None = None):
|
||||
self.hooks = hooks or AgentObservabilityHooks()
|
||||
|
||||
def consume(self, event: dict):
|
||||
def consume(self, event: dict, *, actor=None, trace=None):
|
||||
kind = event.get("kind")
|
||||
payload = event.get("payload", {})
|
||||
if kind == "agent_started":
|
||||
@@ -16,6 +16,8 @@ class AgentEventObservabilityBridge:
|
||||
visible_scope=payload.get("scope", "self"),
|
||||
entry_agent=payload.get("agent", "unknown"),
|
||||
execution_id=event.get("id"),
|
||||
actor=actor,
|
||||
trace=trace,
|
||||
)
|
||||
if kind == "agent_delegated":
|
||||
return self.hooks.delegate(event["execution_id"], payload.get("agent", "unknown"))
|
||||
|
||||
@@ -31,6 +31,18 @@ class AgentExecutionRecord:
|
||||
status: str = "completed"
|
||||
started_at: str = field(default_factory=utc_now)
|
||||
ended_at: str = field(default_factory=utc_now)
|
||||
trace_id: str | None = None
|
||||
span_id: str | None = None
|
||||
parent_span_id: str | None = None
|
||||
correlation_id: str | None = None
|
||||
causation_id: str | None = None
|
||||
tenant_id: str | None = None
|
||||
project_id: str | None = None
|
||||
conversation_id: str | None = None
|
||||
task_id: str | None = None
|
||||
claim_id: str | None = None
|
||||
run_id: str | None = None
|
||||
message_id: str | None = None
|
||||
|
||||
def to_dict(self):
|
||||
return asdict(self)
|
||||
|
||||
@@ -9,25 +9,49 @@ try:
|
||||
except Exception:
|
||||
APIRouter = None
|
||||
|
||||
from .agent_observability import AgentExecutionRecord
|
||||
from .agent_observability import AgentExecutionRecord, AgentObservability
|
||||
from .agent_observability_dashboard import AgentObservabilityDashboard
|
||||
from .agent_observability_store import AgentObservabilityStore
|
||||
from .trace_tool_credential_governance import GovernanceActor, TraceContext, authorize_scope
|
||||
|
||||
store = AgentObservabilityStore()
|
||||
dashboard = AgentObservabilityDashboard(store.load())
|
||||
|
||||
|
||||
def record_agent_execution(payload: dict[str, Any]):
|
||||
record = AgentExecutionRecord(**payload)
|
||||
def record_agent_execution(payload: dict[str, Any], *, actor: GovernanceActor | dict[str, Any] | None = None, trace: TraceContext | None = None):
|
||||
scoped = any(payload.get(name) is not None for name in (
|
||||
"trace_id", "span_id", "tenant_id", "project_id", "conversation_id", "task_id", "run_id"
|
||||
))
|
||||
values = dict(payload)
|
||||
if scoped:
|
||||
if actor is None or trace is None:
|
||||
raise RuntimeError("TRACE_ACTOR_CONTEXT_REQUIRED")
|
||||
trusted = GovernanceActor.from_value(actor)
|
||||
authorize_scope(trusted, trace.scope)
|
||||
bound = {
|
||||
"trace_id": trace.trace_id, "span_id": trace.span_id,
|
||||
"parent_span_id": trace.parent_span_id, "correlation_id": trace.correlation_id,
|
||||
"causation_id": trace.causation_id, "tenant_id": trace.scope.tenant_id,
|
||||
"user_id": trace.scope.user_id, "project_id": trace.scope.project_id,
|
||||
"conversation_id": trace.scope.conversation_id, "task_id": trace.task_id,
|
||||
"claim_id": trace.claim_id, "run_id": trace.scope.run_id, "message_id": trace.message_id,
|
||||
}
|
||||
for name, authoritative in bound.items():
|
||||
if values.get(name) not in (None, authoritative):
|
||||
raise RuntimeError("TRACE_SCOPE_MISMATCH_DENY:" + name)
|
||||
values[name] = authoritative
|
||||
record = AgentExecutionRecord(**values)
|
||||
return {"status": "ok", "record": store.append(record)}
|
||||
|
||||
|
||||
def get_agent_observability(user_id: str | None = None, group_id: str | None = None, role: str = "user"):
|
||||
records = store.query(user_id, group_id, role)
|
||||
def get_agent_observability(user_id: str | None = None, group_id: str | None = None, role: str = "user", *, actor: GovernanceActor | dict[str, Any] | None = None):
|
||||
records = store.query(user_id, group_id, role, actor=actor)
|
||||
visible = AgentObservability()
|
||||
visible.records = [AgentExecutionRecord(**record) for record in records]
|
||||
return {
|
||||
"status": "ok",
|
||||
"records": records,
|
||||
"summary": store.load().summary(user_id if role != "owner" else None),
|
||||
"summary": visible.summary(None),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -14,6 +14,11 @@ from uuid import uuid4
|
||||
|
||||
from .agent_observability import AgentExecutionRecord
|
||||
from .agent_observability_store import AgentObservabilityStore
|
||||
from .trace_tool_credential_governance import (
|
||||
GovernanceActor,
|
||||
TraceContext,
|
||||
authorize_scope,
|
||||
)
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
@@ -36,6 +41,7 @@ class AgentRunContext:
|
||||
input_tokens: int = 0
|
||||
output_tokens: int = 0
|
||||
status: str = "running"
|
||||
trace: TraceContext | None = None
|
||||
|
||||
def delegate(self, agent_name: str) -> None:
|
||||
if agent_name not in self.path:
|
||||
@@ -59,7 +65,19 @@ class AgentObservabilityHooks:
|
||||
visible_scope: str,
|
||||
entry_agent: str,
|
||||
execution_id: str | None = None,
|
||||
*,
|
||||
actor: GovernanceActor | dict[str, Any] | None = None,
|
||||
trace: TraceContext | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if execution_id is not None and execution_id in self.active:
|
||||
raise RuntimeError("OBSERVABILITY_EXECUTION_CONFLICT")
|
||||
if trace is not None:
|
||||
if actor is None:
|
||||
raise RuntimeError("TRACE_ACTOR_CONTEXT_REQUIRED")
|
||||
trusted_actor = GovernanceActor.from_value(actor)
|
||||
authorize_scope(trusted_actor, trace.scope)
|
||||
if trace.scope.user_id is not None and trace.scope.user_id != user_id:
|
||||
raise RuntimeError("TRACE_SCOPE_MISMATCH_DENY:user_id")
|
||||
run = AgentRunContext(
|
||||
execution_id=execution_id or f"exec_{uuid4().hex[:12]}",
|
||||
user_id=user_id,
|
||||
@@ -67,6 +85,7 @@ class AgentObservabilityHooks:
|
||||
entry_agent=entry_agent,
|
||||
path=[entry_agent],
|
||||
used_hermes=entry_agent.lower() == "hermes",
|
||||
trace=trace,
|
||||
)
|
||||
self.active[run.execution_id] = run
|
||||
return {"status": "started", "execution_id": run.execution_id, "run": run.__dict__}
|
||||
@@ -106,6 +125,18 @@ class AgentObservabilityHooks:
|
||||
status=status,
|
||||
started_at=run.started_at,
|
||||
ended_at=ended_at,
|
||||
trace_id=run.trace.trace_id if run.trace else None,
|
||||
span_id=run.trace.span_id if run.trace else None,
|
||||
parent_span_id=run.trace.parent_span_id if run.trace else None,
|
||||
correlation_id=run.trace.correlation_id if run.trace else None,
|
||||
causation_id=run.trace.causation_id if run.trace else None,
|
||||
tenant_id=run.trace.scope.tenant_id if run.trace else None,
|
||||
project_id=run.trace.scope.project_id if run.trace else None,
|
||||
conversation_id=run.trace.scope.conversation_id if run.trace else None,
|
||||
task_id=run.trace.task_id if run.trace else None,
|
||||
claim_id=run.trace.claim_id if run.trace else None,
|
||||
run_id=run.trace.scope.run_id if run.trace else None,
|
||||
message_id=run.trace.message_id if run.trace else None,
|
||||
)
|
||||
return {"status": "stored", "record": self.store.append(record)}
|
||||
|
||||
|
||||
@@ -12,6 +12,12 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from .agent_observability import AgentExecutionRecord, AgentObservability
|
||||
from .trace_tool_credential_governance import (
|
||||
GovernanceActor,
|
||||
GovernanceError,
|
||||
GovernanceScope,
|
||||
authorize_scope,
|
||||
)
|
||||
|
||||
|
||||
class AgentObservabilityStore:
|
||||
@@ -23,6 +29,11 @@ class AgentObservabilityStore:
|
||||
def append(self, record: AgentExecutionRecord) -> dict[str, Any]:
|
||||
path = self.records_dir / f"{record.execution_id}.json"
|
||||
data = record.to_dict()
|
||||
if path.exists():
|
||||
existing = json.loads(path.read_text(encoding="utf-8"))
|
||||
if existing == data:
|
||||
return existing
|
||||
raise RuntimeError("OBSERVABILITY_EXECUTION_CONFLICT")
|
||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return data
|
||||
|
||||
@@ -33,8 +44,36 @@ class AgentObservabilityStore:
|
||||
observability.records.append(AgentExecutionRecord(**raw))
|
||||
return observability
|
||||
|
||||
def query(self, user_id: str | None = None, group_id: str | None = None, role: str = "user") -> list[dict[str, Any]]:
|
||||
def query(self, user_id: str | None = None, group_id: str | None = None, role: str = "user", *, actor: GovernanceActor | dict[str, Any] | None = None) -> list[dict[str, Any]]:
|
||||
records = [record.to_dict() for record in self.load().records]
|
||||
scoped = [record for record in records if record.get("tenant_id") is not None]
|
||||
legacy = [record for record in records if record.get("tenant_id") is None]
|
||||
if actor is not None:
|
||||
trusted = GovernanceActor.from_value(actor)
|
||||
visible: list[dict[str, Any]] = []
|
||||
for record in scoped:
|
||||
try:
|
||||
authorize_scope(
|
||||
trusted,
|
||||
GovernanceScope(
|
||||
"tenant",
|
||||
tenant_id=record.get("tenant_id"),
|
||||
user_id=record.get("user_id"),
|
||||
project_id=record.get("project_id"),
|
||||
conversation_id=record.get("conversation_id"),
|
||||
run_id=record.get("run_id"),
|
||||
),
|
||||
)
|
||||
except GovernanceError:
|
||||
continue
|
||||
visible.append(record)
|
||||
if trusted.privileged:
|
||||
visible.extend(legacy)
|
||||
else:
|
||||
visible.extend(record for record in legacy if record.get("user_id") == trusted.scope.user_id)
|
||||
return visible
|
||||
# Caller-supplied role remains a legacy compatibility path only.
|
||||
records = legacy
|
||||
if role == "owner":
|
||||
return records
|
||||
if role == "group_admin" and group_id:
|
||||
|
||||
@@ -94,6 +94,91 @@ class AuthorityGitExecutor:
|
||||
self._git(["worktree", "add", "--detach", str(path), base_oid])
|
||||
return path
|
||||
|
||||
def recover_failed_detached_worktree_exact(
|
||||
self,
|
||||
*,
|
||||
path: Path,
|
||||
expected_oid: str,
|
||||
stale_oid: str,
|
||||
target_ref: str,
|
||||
) -> str:
|
||||
"""Recreate one exact clean, detached, single-attempt worktree at its durable head."""
|
||||
_oid(expected_oid, "expected_oid")
|
||||
_oid(stale_oid, "stale_oid")
|
||||
if not TASK_BRANCH_RE.fullmatch(target_ref):
|
||||
raise AuthorityGitError("TASK_BRANCH_SCOPE_DENIED")
|
||||
if self.read_ref(target_ref) is not None:
|
||||
raise AuthorityGitError("AUTHORITY_TARGET_BRANCH_ALREADY_EXISTS")
|
||||
if (
|
||||
not path.is_absolute()
|
||||
or path.parent != self.worktree_root
|
||||
or path.name in {"", ".", ".."}
|
||||
or path.is_symlink()
|
||||
or not path.is_dir()
|
||||
):
|
||||
raise AuthorityGitError("WORKTREE_RECOVERY_PATH_INVALID")
|
||||
head = subprocess.run(
|
||||
["git", "-C", str(path), "rev-parse", "HEAD"],
|
||||
text=True, capture_output=True, check=False,
|
||||
)
|
||||
if head.returncode != 0 or head.stdout.strip() != stale_oid or stale_oid == expected_oid:
|
||||
raise AuthorityGitError("WORKTREE_RECOVERY_HEAD_INVALID")
|
||||
symbolic = subprocess.run(
|
||||
["git", "-C", str(path), "symbolic-ref", "-q", "HEAD"],
|
||||
text=True, capture_output=True, check=False,
|
||||
)
|
||||
if symbolic.returncode != 1:
|
||||
raise AuthorityGitError("WORKTREE_RECOVERY_NOT_DETACHED")
|
||||
status = subprocess.run(
|
||||
["git", "-C", str(path), "status", "--porcelain=v1", "--untracked-files=all"],
|
||||
text=True, capture_output=True, check=False,
|
||||
)
|
||||
if status.returncode != 0 or status.stdout:
|
||||
raise AuthorityGitError("WORKTREE_RECOVERY_NOT_CLEAN")
|
||||
parents = self._git(["rev-list", "--parents", "-n", "1", stale_oid]).split()
|
||||
if parents != [stale_oid, expected_oid]:
|
||||
raise AuthorityGitError("WORKTREE_RECOVERY_LINEAGE_INVALID")
|
||||
registered = [
|
||||
line.removeprefix("worktree ")
|
||||
for line in self._git(["worktree", "list", "--porcelain"]).splitlines()
|
||||
if line.startswith("worktree ")
|
||||
]
|
||||
if registered.count(str(path)) != 1:
|
||||
raise AuthorityGitError("WORKTREE_RECOVERY_REGISTRATION_INVALID")
|
||||
self._git(["worktree", "remove", "--force", str(path)])
|
||||
if path.exists() or path.is_symlink():
|
||||
raise AuthorityGitError("WORKTREE_RECOVERY_REMOVE_FAILED")
|
||||
recreated = self.create_detached_worktree(expected_oid, path.name, path.parent)
|
||||
readback = subprocess.run(
|
||||
["git", "-C", str(recreated), "rev-parse", "HEAD"],
|
||||
text=True, capture_output=True, check=False,
|
||||
)
|
||||
detached = subprocess.run(
|
||||
["git", "-C", str(recreated), "symbolic-ref", "-q", "HEAD"],
|
||||
text=True, capture_output=True, check=False,
|
||||
)
|
||||
if readback.returncode != 0 or readback.stdout.strip() != expected_oid or detached.returncode != 1:
|
||||
raise AuthorityGitError("WORKTREE_RECOVERY_READBACK_MISMATCH")
|
||||
return stale_oid
|
||||
|
||||
def create_task_branch_CAS(self, branch: str, expected_base_oid: str, new_oid: str) -> str:
|
||||
if not TASK_BRANCH_RE.fullmatch(branch):
|
||||
raise AuthorityGitError("TASK_BRANCH_SCOPE_DENIED")
|
||||
_oid(expected_base_oid, "expected_base_oid")
|
||||
_oid(new_oid, "new_oid")
|
||||
current = self.read_ref(branch)
|
||||
if current is not None:
|
||||
if current == new_oid:
|
||||
return new_oid
|
||||
raise AuthorityGitError("AUTHORITY_TARGET_BRANCH_ALREADY_EXISTS")
|
||||
if not self.ancestry_check(expected_base_oid, new_oid):
|
||||
raise AuthorityGitError("NEW_OID_NOT_DESCENDANT")
|
||||
self._git(["update-ref", branch, new_oid, "0" * 40])
|
||||
readback = self.read_ref(branch)
|
||||
if readback != new_oid:
|
||||
raise AuthorityGitError("AUTHORITY_CREATE_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")
|
||||
|
||||
@@ -13,6 +13,8 @@ BRIDGE_OPERATIONS = (
|
||||
"getGovernedWriteCapabilities",
|
||||
"createGovernedTask",
|
||||
"acquireWriterLease",
|
||||
"bootstrapWriterLease",
|
||||
"renewWriterLease",
|
||||
"commitFileGoverned",
|
||||
"commitFilesGoverned",
|
||||
"getGitOpsJob",
|
||||
@@ -31,6 +33,8 @@ BRIDGE_OPERATIONS = (
|
||||
OPERATION_REQUIRED_FIELDS = {
|
||||
"createGovernedTask": ("task_id", "repository", "base_oid", "target_branch", "risk", "description", "idempotency_key"),
|
||||
"acquireWriterLease": ("schema", "task_id", "repository", "branch_mode", "base_oid", "expected_branch_oid", "target_branch", "generation", "lease_seconds"),
|
||||
"bootstrapWriterLease": ("schema", "task_id", "repository", "branch_mode", "base_oid", "expected_branch_oid", "target_branch", "generation", "lease_seconds"),
|
||||
"renewWriterLease": ("schema", "task_id", "repository", "branch_mode", "base_oid", "expected_branch_oid", "target_branch", "generation", "lease_seconds"),
|
||||
"commitFileGoverned": ("task_id", "repository", "expected_branch_oid", "target_branch", "generation", "idempotency_key", "path", "content_base64", "message"),
|
||||
"commitFilesGoverned": ("task_id", "repository", "expected_branch_oid", "target_branch", "generation", "idempotency_key", "files", "message"),
|
||||
"getGitOpsJob": ("job_id",),
|
||||
@@ -46,6 +50,49 @@ OPERATION_REQUIRED_FIELDS = {
|
||||
"getControlPlaneSnapshot": (),
|
||||
}
|
||||
|
||||
BRIDGE_OPERATION_CONTRACTS = {
|
||||
"acquireWriterLease": {
|
||||
"method": "POST",
|
||||
"path": "/v2/writer-lease",
|
||||
"required_fields": OPERATION_REQUIRED_FIELDS["acquireWriterLease"],
|
||||
"response_fields": (
|
||||
"task_id", "job_id", "generation", "lease_seconds", "lease_expires_at",
|
||||
"create_target_branch", "worktree_mode", "cas_ref_update_required",
|
||||
),
|
||||
},
|
||||
"bootstrapWriterLease": {
|
||||
"method": "POST",
|
||||
"path": "/v2/writer-lease/bootstrap",
|
||||
"required_fields": OPERATION_REQUIRED_FIELDS["bootstrapWriterLease"],
|
||||
"response_fields": (
|
||||
"task_id", "job_id", "generation", "lease_seconds", "lease_expires_at",
|
||||
"create_target_branch", "worktree_mode", "cas_ref_update_required",
|
||||
"recovered", "bootstrapped", "runtime_rehydrated",
|
||||
"durable_branch_lease_present", "idempotent",
|
||||
),
|
||||
},
|
||||
"renewWriterLease": {
|
||||
"method": "POST",
|
||||
"path": "/v2/writer-lease/renew",
|
||||
"required_fields": OPERATION_REQUIRED_FIELDS["renewWriterLease"],
|
||||
"response_fields": (
|
||||
"task_id", "job_id", "generation", "lease_seconds", "lease_expires_at",
|
||||
"recovered", "bootstrapped", "runtime_rehydrated",
|
||||
"durable_branch_lease_present", "idempotent",
|
||||
),
|
||||
},
|
||||
"commitFileGoverned": {
|
||||
"method": "POST", "path": "/v2/commit-file",
|
||||
"required_fields": OPERATION_REQUIRED_FIELDS["commitFileGoverned"],
|
||||
"response_fields": ("job_id", "commit_oid", "commit_visible", "branch_mode", "target_branch", "generation"),
|
||||
},
|
||||
"commitFilesGoverned": {
|
||||
"method": "POST", "path": "/v2/commit-files",
|
||||
"required_fields": OPERATION_REQUIRED_FIELDS["commitFilesGoverned"],
|
||||
"response_fields": ("job_id", "commit_oid", "commit_visible", "branch_mode", "target_branch", "generation"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class RuntimeDriftError(RuntimeError):
|
||||
pass
|
||||
@@ -122,6 +169,14 @@ class BridgeV2Adapter:
|
||||
"legacy_writes_enabled": False,
|
||||
"governed_writes_enabled": True,
|
||||
"operations": list(BRIDGE_OPERATIONS),
|
||||
"operation_contracts": {
|
||||
operation: {
|
||||
**contract,
|
||||
"required_fields": list(contract["required_fields"]),
|
||||
"response_fields": list(contract["response_fields"]),
|
||||
}
|
||||
for operation, contract in BRIDGE_OPERATION_CONTRACTS.items()
|
||||
},
|
||||
"force_push": False,
|
||||
"main_write": False,
|
||||
"tag_write": False,
|
||||
@@ -151,6 +206,8 @@ class BridgeV2Adapter:
|
||||
|
||||
def createGovernedTask(self, request: Mapping[str, Any]) -> Any: return self._call("createGovernedTask", request)
|
||||
def acquireWriterLease(self, request: Mapping[str, Any]) -> Any: return self._call("acquireWriterLease", request)
|
||||
def bootstrapWriterLease(self, request: Mapping[str, Any]) -> Any: return self._call("bootstrapWriterLease", request)
|
||||
def renewWriterLease(self, request: Mapping[str, Any]) -> Any: return self._call("renewWriterLease", 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)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -51,11 +51,13 @@ 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 create_task_branch_cas(self, branch: str, expected_base: str, new: str) -> str: ...
|
||||
def compare_and_swap_branch(self, branch: str, expected: str, new: str) -> str: ...
|
||||
|
||||
|
||||
class RemotePort(Protocol):
|
||||
def repository_identity(self) -> str: ...
|
||||
def push_new_exact(self, branch: str, new: str) -> None: ...
|
||||
def push_exact(self, branch: str, expected: str, new: str) -> None: ...
|
||||
def read_git_ref(self, branch: str) -> str | None: ...
|
||||
def read_api_branch(self, branch: str) -> str | None: ...
|
||||
@@ -103,6 +105,9 @@ class AuthorityGitAdapter:
|
||||
def is_ancestor(self, older: str, newer: str) -> bool:
|
||||
return self.executor.ancestry_check(older, newer)
|
||||
|
||||
def create_task_branch_cas(self, branch: str, expected_base: str, new: str) -> str:
|
||||
return self.executor.create_task_branch_CAS(self._ref(branch), expected_base, new)
|
||||
|
||||
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)
|
||||
|
||||
@@ -258,6 +263,15 @@ class GiteaContinuationRemote:
|
||||
raise ContinuationExecutionError("GITEA_GIT_READBACK_INVALID")
|
||||
return oid
|
||||
|
||||
def push_new_exact(self, branch: str, new: str) -> None:
|
||||
self._require_projected()
|
||||
self.repository_identity()
|
||||
if self.read_git_ref(branch) is not None:
|
||||
raise ContinuationExecutionError("GITEA_TARGET_BRANCH_ALREADY_EXISTS")
|
||||
_run(["git", "push", self.remote_name, f"{new}:refs/heads/{branch}"], cwd=self.workspace)
|
||||
if self.read_git_ref(branch) != new:
|
||||
raise ContinuationExecutionError("GITEA_CREATE_READBACK_MISMATCH")
|
||||
|
||||
def push_exact(self, branch: str, expected: str, new: str) -> None:
|
||||
self._require_projected()
|
||||
self.repository_identity()
|
||||
@@ -329,6 +343,7 @@ class ContinuationPermissions:
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ContinuationRuntimeBinding:
|
||||
request: WriterLeaseRequestV2
|
||||
job_id: str
|
||||
workspace_path: Path
|
||||
lease_not_before: datetime
|
||||
lease_expires_at: datetime
|
||||
@@ -389,13 +404,20 @@ class ContinuationExecutor:
|
||||
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:
|
||||
def _max_generation(self, branch: str, *, exclude_job_id: str | None = None) -> 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()
|
||||
if exclude_job_id is None:
|
||||
row = connection.execute(
|
||||
"SELECT MAX(generation) AS generation FROM branch_leases WHERE branch=?",
|
||||
(branch,),
|
||||
).fetchone()
|
||||
else:
|
||||
row = connection.execute(
|
||||
"SELECT MAX(generation) AS generation FROM branch_leases "
|
||||
"WHERE branch=? AND job_id!=?",
|
||||
(branch, exclude_job_id),
|
||||
).fetchone()
|
||||
durable = int(row["generation"] or 0)
|
||||
return max(observed, durable)
|
||||
|
||||
@@ -404,6 +426,7 @@ class ContinuationExecutor:
|
||||
return {
|
||||
"schema": WriterLeaseRequestV2.schema,
|
||||
"task_id": request.task_id,
|
||||
"job_id": request.job_id,
|
||||
"repository": request.repository,
|
||||
"branch_mode": request.branch_mode,
|
||||
"base_oid": request.base_oid,
|
||||
@@ -413,10 +436,21 @@ class ContinuationExecutor:
|
||||
"lease_seconds": request.lease_seconds,
|
||||
}
|
||||
|
||||
def _validate_preflight(self, binding: ContinuationRuntimeBinding) -> None:
|
||||
def _validate_preflight(self, binding: ContinuationRuntimeBinding, *, job_id: str | None = None) -> 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}:
|
||||
request_mapping = self._request_mapping(request)
|
||||
normalized = validate_writer_lease_request_v2(request_mapping, self.authority)
|
||||
expected_normalized = {
|
||||
**request_mapping,
|
||||
"create_target_branch": request.branch_mode == "CREATE_NEW",
|
||||
"worktree_mode": (
|
||||
"detached_at_base_oid"
|
||||
if request.branch_mode == "CREATE_NEW"
|
||||
else "detached_at_expected_head"
|
||||
),
|
||||
"cas_ref_update_required": True,
|
||||
}
|
||||
if normalized != expected_normalized:
|
||||
raise ContinuationExecutionError("CONTINUATION_NORMALIZATION_INVALID")
|
||||
if not binding.original_receipt_valid or not binding.original_grant_consumed:
|
||||
raise ContinuationExecutionError("ORIGINAL_GRANT_LINEAGE_INVALID")
|
||||
@@ -426,7 +460,7 @@ class ContinuationExecutor:
|
||||
raise ContinuationExecutionError("LEASE_NOT_ACTIVE")
|
||||
if now >= binding.lease_expires_at:
|
||||
raise ContinuationExecutionError("LEASE_EXPIRED")
|
||||
if request.generation <= self._max_generation(request.target_branch):
|
||||
if request.generation <= self._max_generation(request.target_branch, exclude_job_id=job_id):
|
||||
raise ContinuationExecutionError("GENERATION_NOT_MONOTONIC")
|
||||
if not self.workspace.is_detached():
|
||||
raise ContinuationExecutionError("WORKTREE_NOT_DETACHED")
|
||||
@@ -452,57 +486,102 @@ class ContinuationExecutor:
|
||||
"generation": request.generation,
|
||||
}
|
||||
|
||||
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:
|
||||
def execute(
|
||||
self,
|
||||
binding: ContinuationRuntimeBinding,
|
||||
commit: ContinuationCommit,
|
||||
*,
|
||||
before_complete: Callable[[str], None] | None = None,
|
||||
) -> dict[str, object]:
|
||||
commit.payload()
|
||||
if not binding.job_id or binding.request.job_id != binding.job_id:
|
||||
raise ContinuationExecutionError("RUNTIME_LINEAGE_MISSING_JOB_ID")
|
||||
current = self.controller.store.read(binding.job_id)
|
||||
current_state = JobState(current["state"])
|
||||
if current_state == JobState.COMPLETED:
|
||||
return self.controller.store._receipt(current, duplicate=True).as_dict()
|
||||
self._validate_preflight(binding)
|
||||
if current_state == JobState.REMOTE_VERIFIED:
|
||||
receipt = json.loads(str(current["receipt_json"] or "{}"))
|
||||
commit_oid = str(receipt.get("authority_oid") or "")
|
||||
if not OID_RE.fullmatch(commit_oid):
|
||||
raise ContinuationExecutionError("GOVERNED_COMMIT_OID_INVALID")
|
||||
if before_complete is not None:
|
||||
before_complete(commit_oid)
|
||||
return self.controller.advance(
|
||||
binding.job_id,
|
||||
JobState.COMPLETED,
|
||||
side_effect_state="REMOTE_VERIFIED",
|
||||
receipt=receipt,
|
||||
).as_dict()
|
||||
self._validate_preflight(binding, job_id=binding.job_id)
|
||||
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:
|
||||
row = self.controller.store.read(binding.job_id)
|
||||
state = JobState(row["state"])
|
||||
if state == JobState.REQUESTED:
|
||||
self.controller.advance(
|
||||
submitted.job_id,
|
||||
binding.job_id,
|
||||
JobState.AUTHORIZED,
|
||||
side_effect_state="AUTHORIZED",
|
||||
receipt={"task_id": request.task_id, "original_grant_consumed": True},
|
||||
expected_revision=int(row["revision"]),
|
||||
)
|
||||
row = self.controller.store.read(submitted.job_id)
|
||||
if JobState(row["state"]) == JobState.AUTHORIZED:
|
||||
row = self.controller.store.read(binding.job_id)
|
||||
state = JobState(row["state"])
|
||||
if state == JobState.AUTHORIZED:
|
||||
self.controller.lease_job(
|
||||
submitted.job_id,
|
||||
lease_id=f"lease-{submitted.job_id}",
|
||||
binding.job_id,
|
||||
lease_id=f"lease-{binding.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")
|
||||
row = self.controller.store.read(binding.job_id)
|
||||
state = JobState(row["state"])
|
||||
if state == JobState.LEASED:
|
||||
self.controller.advance(binding.job_id, JobState.WORKTREE_READY,
|
||||
side_effect_state="WORKTREE_READY", expected_revision=int(row["revision"]))
|
||||
row = self.controller.store.read(binding.job_id)
|
||||
state = JobState(row["state"])
|
||||
if state == JobState.WORKTREE_READY:
|
||||
self.controller.advance(binding.job_id, JobState.EXECUTING,
|
||||
side_effect_state="IN_PROGRESS", expected_revision=int(row["revision"]))
|
||||
elif state != JobState.EXECUTING:
|
||||
raise ContinuationExecutionError("JOB_EXECUTION_STATE_INVALID")
|
||||
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 request.branch_mode == "CREATE_NEW":
|
||||
authority_oid = self.authority.create_task_branch_cas(request.target_branch, request.base_oid, new_oid)
|
||||
else:
|
||||
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,
|
||||
binding.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)
|
||||
self.controller.advance(binding.job_id, JobState.REMOTE_SYNCING, side_effect_state="IN_PROGRESS")
|
||||
if request.branch_mode == "CREATE_NEW":
|
||||
self.remote.push_new_exact(request.target_branch, new_oid)
|
||||
else:
|
||||
self.remote.push_exact(request.target_branch, request.expected_branch_oid, new_oid)
|
||||
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)
|
||||
return self._remote_pending(binding.job_id, authority_oid, new_oid)
|
||||
return self._finish_remote_verification(
|
||||
binding.job_id,
|
||||
authority_oid,
|
||||
new_oid,
|
||||
before_complete=before_complete,
|
||||
)
|
||||
finally:
|
||||
if projected:
|
||||
self.credential.revoke()
|
||||
@@ -525,7 +604,14 @@ class ContinuationExecutor:
|
||||
)
|
||||
return result.as_dict()
|
||||
|
||||
def _finish_remote_verification(self, job_id: str, authority_oid: str, new_oid: str) -> dict[str, object]:
|
||||
def _finish_remote_verification(
|
||||
self,
|
||||
job_id: str,
|
||||
authority_oid: str,
|
||||
new_oid: str,
|
||||
*,
|
||||
before_complete: Callable[[str], None] | None = None,
|
||||
) -> dict[str, object]:
|
||||
row = self.controller.store.read(job_id)
|
||||
branch = row["branch"]
|
||||
request = json.loads(row["request_json"])["binding"]
|
||||
@@ -542,8 +628,20 @@ class ContinuationExecutor:
|
||||
"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()
|
||||
self.controller.advance(
|
||||
job_id,
|
||||
JobState.REMOTE_VERIFIED,
|
||||
side_effect_state="REMOTE_VERIFIED",
|
||||
receipt=receipt,
|
||||
)
|
||||
if before_complete is not None:
|
||||
before_complete(new_oid)
|
||||
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)
|
||||
|
||||
@@ -49,6 +49,7 @@ class WriterLeaseRequestV2:
|
||||
target_branch: str
|
||||
generation: int
|
||||
lease_seconds: int = 900
|
||||
job_id: str = ""
|
||||
|
||||
schema = "aios.task-writer-lease.v2"
|
||||
accepted_schemas = frozenset({"aios.task-writer-lease.v2", "aios.task-writer-lease-request.v2"})
|
||||
@@ -56,6 +57,8 @@ class WriterLeaseRequestV2:
|
||||
def validate(self, probe: BranchProbe) -> None:
|
||||
if not self.task_id or self.repository != "aios/aios":
|
||||
raise ContinuationError("request_identity_invalid")
|
||||
if not self.job_id or not self.job_id.startswith("job-"):
|
||||
raise ContinuationError("job_id_invalid")
|
||||
if self.branch_mode not in {"CREATE_NEW", "CONTINUE_EXISTING"}:
|
||||
raise ContinuationError("branch_mode_invalid")
|
||||
_oid(self.base_oid, "base_oid")
|
||||
|
||||
@@ -6,6 +6,7 @@ from datetime import datetime, timedelta, timezone
|
||||
import os
|
||||
from pathlib import Path
|
||||
import secrets
|
||||
import re
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
|
||||
@@ -21,9 +22,14 @@ class CredentialLease:
|
||||
scopes: tuple[str, ...]
|
||||
provider: str
|
||||
state: str = "ACTIVE"
|
||||
scope_type: str = "system"
|
||||
tenant_id: str | None = None
|
||||
user_id: str | None = None
|
||||
project_id: str | None = None
|
||||
run_id: str | None = None
|
||||
|
||||
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}
|
||||
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, "scope_type": self.scope_type, "tenant_id": self.tenant_id, "user_id": self.user_id, "project_id": self.project_id, "run_id": self.run_id}
|
||||
|
||||
|
||||
class FileCredentialProvider:
|
||||
@@ -43,11 +49,19 @@ class CredentialBroker:
|
||||
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]:
|
||||
def issue(self, *, task_id: str, repository: str, target_branch: str, generation: int, scopes: tuple[str, ...] = ("git:push", "gitea:read"), lease_seconds: int = 900, scope_type: str = "system", tenant_id: str | None = None, user_id: str | None = None, project_id: str | None = None, run_id: str | None = None) -> 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")
|
||||
if scope_type == "tenant":
|
||||
if not tenant_id:
|
||||
raise ValueError("CREDENTIAL_SCOPE_DENY")
|
||||
elif scope_type in {"system", "global"}:
|
||||
if any((tenant_id, user_id, project_id, run_id)):
|
||||
raise ValueError("CREDENTIAL_SCOPE_DENY")
|
||||
else:
|
||||
raise ValueError("CREDENTIAL_SCOPE_DENY")
|
||||
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__)
|
||||
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__, "ACTIVE", scope_type, tenant_id, user_id, project_id, run_id)
|
||||
self._leases[lease.lease_id] = lease
|
||||
return lease.as_dict()
|
||||
|
||||
@@ -64,7 +78,39 @@ class CredentialBroker:
|
||||
lease = self._get(lease_id)
|
||||
if lease.state != "ACTIVE":
|
||||
raise RuntimeError("CREDENTIAL_LEASE_NOT_ACTIVE")
|
||||
return self.provider.environment(lease)
|
||||
environment = self.provider.environment(lease)
|
||||
for key, value in environment.items():
|
||||
upper = str(key).upper()
|
||||
if not isinstance(value, str):
|
||||
raise RuntimeError("CREDENTIAL_SECRET_EXPOSURE_DENY")
|
||||
sensitive = any(marker in upper for marker in ("CREDENTIAL", "TOKEN", "SECRET", "PASSWORD", "PRIVATE_KEY", "API_KEY"))
|
||||
if re.search(r"(?i)bearer\s+\S+|(?:token|secret|password|api[_-]?key)\s*[:=]", value):
|
||||
raise RuntimeError("CREDENTIAL_SECRET_EXPOSURE_DENY")
|
||||
if sensitive and upper.endswith("_FILE"):
|
||||
candidate = Path(value)
|
||||
credential_root = Path("/run/credentials")
|
||||
try:
|
||||
resolved = candidate.resolve(strict=False)
|
||||
except OSError as exc:
|
||||
raise RuntimeError("CREDENTIAL_SECRET_EXPOSURE_DENY") from exc
|
||||
if not candidate.is_absolute() or not (resolved == credential_root or credential_root in resolved.parents):
|
||||
raise RuntimeError("CREDENTIAL_SECRET_EXPOSURE_DENY")
|
||||
elif sensitive and upper.endswith("_REFERENCE"):
|
||||
if re.fullmatch(r"(?:systemd-credential|vault|secret-manager):[A-Za-z0-9][A-Za-z0-9._:/#-]{0,255}", value) is None:
|
||||
raise RuntimeError("CREDENTIAL_SECRET_EXPOSURE_DENY")
|
||||
elif sensitive:
|
||||
raise RuntimeError("CREDENTIAL_SECRET_EXPOSURE_DENY")
|
||||
return environment
|
||||
|
||||
def authorize(self, lease_id: str, *, task_id: str, operation: str, scope_type: str = "system", tenant_id: str | None = None, user_id: str | None = None, project_id: str | None = None, run_id: str | None = None) -> Mapping[str, str]:
|
||||
lease = self._get(lease_id)
|
||||
if lease.state != "ACTIVE" or lease.task_id != task_id or operation not in lease.scopes:
|
||||
raise RuntimeError("CREDENTIAL_SCOPE_DENY")
|
||||
requested = (scope_type, tenant_id, user_id, project_id, run_id)
|
||||
actual = (lease.scope_type, lease.tenant_id, lease.user_id, lease.project_id, lease.run_id)
|
||||
if requested != actual:
|
||||
raise RuntimeError("CREDENTIAL_SCOPE_DENY")
|
||||
return self.environment(lease_id)
|
||||
|
||||
def status(self, lease_id: str) -> dict[str, Any]:
|
||||
return self._get(lease_id).as_dict()
|
||||
|
||||
@@ -27,6 +27,19 @@ class ServiceUnavailable(JobError):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LeaseRenewalResult:
|
||||
"""Durable outcome of one writer-lease recovery attempt."""
|
||||
|
||||
renewed: bool
|
||||
idempotent: bool
|
||||
durable: bool
|
||||
lease_expires_at: str | None
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return self.renewed or self.idempotent
|
||||
|
||||
|
||||
class BindingConflict(JobError):
|
||||
pass
|
||||
|
||||
@@ -66,6 +79,14 @@ def utc_now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
||||
|
||||
|
||||
def parse_utc(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("LEASE_EXPIRY_INVALID")
|
||||
return parsed.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def canonical_json(value: Any) -> bytes:
|
||||
return (
|
||||
json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False) + "\n"
|
||||
@@ -404,13 +425,16 @@ class GitOpsJobController:
|
||||
self.store._mirror(result)
|
||||
return self.store._receipt(result, duplicate=True)
|
||||
|
||||
def update_lease(self, lease_id: str, *, expected_revision: int, current_head_oid: str, generation: int, state: str = "ACTIVE") -> None:
|
||||
def update_lease(self, lease_id: str, *, expected_revision: int, current_head_oid: str, generation: int, state: str = "ACTIVE", lease_expires_at: str | None = None) -> None:
|
||||
if len(current_head_oid) != 40 or generation < 1:
|
||||
raise JobError("LEASE_BINDING_INVALID")
|
||||
try:
|
||||
with self.store._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
updated = connection.execute("UPDATE branch_leases SET current_head_oid=?,generation=?,state=?,revision=revision+1 WHERE lease_id=? AND revision=?", (current_head_oid, generation, state, lease_id, expected_revision))
|
||||
if lease_expires_at is None:
|
||||
updated = connection.execute("UPDATE branch_leases SET current_head_oid=?,generation=?,state=?,revision=revision+1 WHERE lease_id=? AND revision=?", (current_head_oid, generation, state, lease_id, expected_revision))
|
||||
else:
|
||||
updated = connection.execute("UPDATE branch_leases SET current_head_oid=?,generation=?,lease_expires_at=?,state=?,revision=revision+1 WHERE lease_id=? AND revision=?", (current_head_oid, generation, lease_expires_at, state, lease_id, expected_revision))
|
||||
if updated.rowcount != 1:
|
||||
raise JobError("LEASE_REVISION_CAS_CONFLICT")
|
||||
connection.commit()
|
||||
@@ -419,6 +443,94 @@ class GitOpsJobController:
|
||||
except sqlite3.Error as exc:
|
||||
raise ServiceUnavailable("JOB_DB_UNAVAILABLE") from exc
|
||||
|
||||
def renew_lease(
|
||||
self,
|
||||
*,
|
||||
branch: str,
|
||||
lineage_anchor_oid: str,
|
||||
current_head_oid: str,
|
||||
generation: int,
|
||||
lease_expires_at: str,
|
||||
) -> LeaseRenewalResult:
|
||||
if (
|
||||
not branch
|
||||
or len(lineage_anchor_oid) != 40
|
||||
or len(current_head_oid) != 40
|
||||
or generation < 1
|
||||
or not lease_expires_at.strip()
|
||||
):
|
||||
raise JobError("LEASE_BINDING_INVALID")
|
||||
try:
|
||||
with self.store._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
rows = connection.execute(
|
||||
"SELECT lease_id, revision, generation, state, lease_expires_at "
|
||||
"FROM branch_leases "
|
||||
"WHERE branch=? AND lineage_anchor_oid=? AND current_head_oid=? "
|
||||
"AND generation=? AND state='ACTIVE' ORDER BY revision DESC",
|
||||
(branch, lineage_anchor_oid, current_head_oid, generation),
|
||||
).fetchall()
|
||||
if not rows:
|
||||
connection.commit()
|
||||
return LeaseRenewalResult(False, False, False, None)
|
||||
if len(rows) != 1:
|
||||
raise JobError("LEASE_BINDING_CONFLICT")
|
||||
row = rows[0]
|
||||
try:
|
||||
current_expires_at = parse_utc(str(row["lease_expires_at"]))
|
||||
requested_expires_at = parse_utc(lease_expires_at)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise JobError("LEASE_EXPIRY_INVALID") from exc
|
||||
now = datetime.now(timezone.utc)
|
||||
if requested_expires_at <= now:
|
||||
raise JobError("LEASE_EXPIRY_INVALID")
|
||||
if current_expires_at > now:
|
||||
connection.commit()
|
||||
return LeaseRenewalResult(
|
||||
False,
|
||||
True,
|
||||
True,
|
||||
str(row["lease_expires_at"]),
|
||||
)
|
||||
updated = connection.execute(
|
||||
"UPDATE branch_leases SET lease_expires_at=?,revision=revision+1 "
|
||||
"WHERE lease_id=? AND revision=? AND generation=? "
|
||||
"AND state='ACTIVE' AND lease_expires_at=?",
|
||||
(
|
||||
lease_expires_at,
|
||||
row["lease_id"],
|
||||
int(row["revision"]),
|
||||
generation,
|
||||
row["lease_expires_at"],
|
||||
),
|
||||
)
|
||||
if updated.rowcount != 1:
|
||||
current = connection.execute(
|
||||
"SELECT lease_expires_at, generation, state FROM branch_leases "
|
||||
"WHERE lease_id=?",
|
||||
(row["lease_id"],),
|
||||
).fetchone()
|
||||
if (
|
||||
current is not None
|
||||
and int(current["generation"]) == generation
|
||||
and current["state"] == "ACTIVE"
|
||||
and parse_utc(str(current["lease_expires_at"])) > now
|
||||
):
|
||||
connection.commit()
|
||||
return LeaseRenewalResult(
|
||||
False,
|
||||
True,
|
||||
True,
|
||||
str(current["lease_expires_at"]),
|
||||
)
|
||||
raise JobError("LEASE_REVISION_CAS_CONFLICT")
|
||||
connection.commit()
|
||||
return LeaseRenewalResult(True, False, True, lease_expires_at)
|
||||
except JobError:
|
||||
raise
|
||||
except sqlite3.Error as exc:
|
||||
raise ServiceUnavailable("JOB_DB_UNAVAILABLE") from exc
|
||||
|
||||
def advance(self, job_id: str, state: JobState, *, side_effect_state: str, receipt: Mapping[str, Any] | None = None, expected_revision: int | None = None) -> JobReceipt:
|
||||
return self._advance(
|
||||
job_id,
|
||||
|
||||
@@ -964,6 +964,7 @@ def validate_writer_lease_request_v2(raw: Mapping[str, Any], probe: Any) -> dict
|
||||
required = {
|
||||
"schema",
|
||||
"task_id",
|
||||
"job_id",
|
||||
"repository",
|
||||
"branch_mode",
|
||||
"base_oid",
|
||||
@@ -976,6 +977,7 @@ def validate_writer_lease_request_v2(raw: Mapping[str, Any], probe: Any) -> dict
|
||||
raise ProvisioningError("LEASE_REQUEST_V2_FIELDS_REJECTED", 409)
|
||||
request = WriterLeaseRequestV2(
|
||||
task_id=str(raw["task_id"]),
|
||||
job_id=str(raw["job_id"]),
|
||||
repository=str(raw["repository"]),
|
||||
branch_mode=str(raw["branch_mode"]),
|
||||
base_oid=str(raw["base_oid"]),
|
||||
@@ -988,6 +990,7 @@ def validate_writer_lease_request_v2(raw: Mapping[str, Any], probe: Any) -> dict
|
||||
return {
|
||||
"schema": WriterLeaseRequestV2.schema,
|
||||
"task_id": request.task_id,
|
||||
"job_id": request.job_id,
|
||||
"repository": request.repository,
|
||||
"branch_mode": request.branch_mode,
|
||||
"base_oid": request.base_oid,
|
||||
|
||||
@@ -0,0 +1,456 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import json
|
||||
import sqlite3
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
UTC = dt.timezone.utc
|
||||
_ALLOWED_CONFIDENTIALITY = frozenset({"public", "tenant", "project", "private", "run"})
|
||||
_PRIVILEGED = frozenset({"*", "admin", "system.admin", "message.admin", "task.admin"})
|
||||
|
||||
|
||||
class HandoffError(RuntimeError):
|
||||
code = "HANDOFF_ERROR"
|
||||
|
||||
|
||||
class MessageScopeMismatch(HandoffError):
|
||||
code = "MESSAGE_SCOPE_MISMATCH_DENY"
|
||||
|
||||
|
||||
class ConfidentialityScopeDenied(HandoffError):
|
||||
code = "MESSAGE_CONFIDENTIALITY_SCOPE_ENFORCE"
|
||||
|
||||
|
||||
class MessageExpired(HandoffError):
|
||||
code = "MESSAGE_EXPIRED"
|
||||
|
||||
|
||||
class SequenceGap(HandoffError):
|
||||
code = "MESSAGE_SEQUENCE_GAP_DETECT"
|
||||
|
||||
|
||||
class SequenceConflict(HandoffError):
|
||||
code = "MESSAGE_SEQUENCE_CONFLICT"
|
||||
|
||||
|
||||
class IdempotencyConflict(HandoffError):
|
||||
code = "MESSAGE_IDEMPOTENCY_CONFLICT"
|
||||
|
||||
|
||||
def _utcnow() -> dt.datetime:
|
||||
return dt.datetime.now(UTC)
|
||||
|
||||
|
||||
def _iso(value: dt.datetime | str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
parsed = dt.datetime.fromisoformat(value.replace("Z", "+00:00")) if isinstance(value, str) else value
|
||||
if parsed.tzinfo is None:
|
||||
parsed = parsed.replace(tzinfo=UTC)
|
||||
return parsed.astimezone(UTC).isoformat()
|
||||
|
||||
|
||||
def _canonical(value: Any) -> str:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
def _sha(value: Any) -> str:
|
||||
return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _clean(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _tuple_scope(value: Iterable[str] | str | None) -> tuple[str, ...]:
|
||||
if value is None:
|
||||
return ()
|
||||
if isinstance(value, str):
|
||||
value = value.split(",")
|
||||
return tuple(sorted({str(item).strip() for item in value if str(item).strip()}))
|
||||
|
||||
|
||||
_SECRET_KEY = re.compile(r"(?i)(?:token|secret|password|private[_-]?key|api[_-]?key)")
|
||||
_SECRET_VALUE = re.compile(r"(?i)bearer\s+[A-Za-z0-9._~+/-]{8,}|(?:token|secret|password|api[_-]?key)\s*[:=]\s*\S+")
|
||||
_SAFE_REFERENCE = re.compile(r"^(?:systemd-credential|vault|secret-manager):[A-Za-z0-9][A-Za-z0-9._:/#-]{0,255}$")
|
||||
|
||||
|
||||
def assert_payload_secret_safe(value: Any, *, key: str = "") -> None:
|
||||
if isinstance(value, Mapping):
|
||||
for child_key, child in value.items():
|
||||
assert_payload_secret_safe(child, key=str(child_key))
|
||||
return
|
||||
if isinstance(value, (list, tuple)):
|
||||
for child in value:
|
||||
assert_payload_secret_safe(child, key=key)
|
||||
return
|
||||
if isinstance(value, (bytes, bytearray)):
|
||||
raise ValueError("CREDENTIAL_SECRET_EXPOSURE_DENY")
|
||||
if isinstance(value, str):
|
||||
if _SECRET_VALUE.search(value):
|
||||
raise ValueError("CREDENTIAL_SECRET_EXPOSURE_DENY")
|
||||
if _SECRET_KEY.search(key) and value and _SAFE_REFERENCE.fullmatch(value) is None:
|
||||
raise ValueError("CREDENTIAL_SECRET_EXPOSURE_DENY")
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class DeliveryContext:
|
||||
tenant_id: str | None = None
|
||||
user_id: str | None = None
|
||||
project_id: str | None = None
|
||||
conversation_id: str | None = None
|
||||
run_id: str | None = None
|
||||
capabilities: tuple[str, ...] = ()
|
||||
|
||||
@classmethod
|
||||
def from_actor(cls, actor: Any) -> "DeliveryContext":
|
||||
def get(name: str) -> Any:
|
||||
if isinstance(actor, Mapping):
|
||||
return actor.get(name)
|
||||
return getattr(actor, name, None)
|
||||
return cls(
|
||||
tenant_id=_clean(get("tenant_id")),
|
||||
user_id=_clean(get("user_id")),
|
||||
project_id=_clean(get("project_id")),
|
||||
conversation_id=_clean(get("conversation_id")),
|
||||
run_id=_clean(get("run_id")),
|
||||
capabilities=_tuple_scope(get("capabilities") or get("capability_scope")),
|
||||
)
|
||||
|
||||
@property
|
||||
def privileged(self) -> bool:
|
||||
return bool(set(self.capabilities) & _PRIVILEGED)
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class MessageEnvelope:
|
||||
source: str
|
||||
target: str
|
||||
message_type: str
|
||||
payload: Mapping[str, Any]
|
||||
sequence: int
|
||||
idempotency_key: str
|
||||
tenant_id: str | None = None
|
||||
user_id: str | None = None
|
||||
project_id: str | None = None
|
||||
conversation_id: str | None = None
|
||||
task_id: str | None = None
|
||||
run_id: str | None = None
|
||||
correlation_id: str | None = None
|
||||
causation_id: str | None = None
|
||||
capability_scope: tuple[str, ...] = ()
|
||||
confidentiality_scope: str = "private"
|
||||
expires_at: str | None = None
|
||||
created_at: str = dataclasses.field(default_factory=lambda: _iso(_utcnow()) or "")
|
||||
envelope_id: str = ""
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
assert_payload_secret_safe(self.payload)
|
||||
for field in ("source", "target", "message_type", "idempotency_key", "tenant_id", "user_id",
|
||||
"project_id", "conversation_id", "task_id", "run_id", "correlation_id", "causation_id"):
|
||||
object.__setattr__(self, field, _clean(getattr(self, field)) if field not in {
|
||||
"source", "target", "message_type", "idempotency_key"
|
||||
} else (_clean(getattr(self, field)) or ""))
|
||||
object.__setattr__(self, "capability_scope", _tuple_scope(self.capability_scope))
|
||||
object.__setattr__(self, "confidentiality_scope", (_clean(self.confidentiality_scope) or "private").lower())
|
||||
object.__setattr__(self, "expires_at", _iso(self.expires_at))
|
||||
object.__setattr__(self, "created_at", _iso(self.created_at) or "")
|
||||
if not self.source or not self.target or not self.message_type or not self.idempotency_key:
|
||||
raise ValueError("MESSAGE_ENVELOPE_IDENTITY_REQUIRED")
|
||||
if not isinstance(self.sequence, int) or self.sequence < 1:
|
||||
raise ValueError("MESSAGE_SEQUENCE_MUST_BE_POSITIVE")
|
||||
if self.confidentiality_scope not in _ALLOWED_CONFIDENTIALITY:
|
||||
raise ValueError("MESSAGE_CONFIDENTIALITY_SCOPE_INVALID")
|
||||
if self.confidentiality_scope != "public" and self.tenant_id is None:
|
||||
raise ValueError("MESSAGE_TENANT_REQUIRED")
|
||||
if self.confidentiality_scope in {"project", "private", "run"} and self.project_id is None:
|
||||
raise ValueError("MESSAGE_PROJECT_REQUIRED")
|
||||
if self.confidentiality_scope in {"private", "run"} and self.user_id is None:
|
||||
raise ValueError("MESSAGE_USER_REQUIRED")
|
||||
if self.confidentiality_scope == "run" and self.run_id is None:
|
||||
raise ValueError("MESSAGE_RUN_REQUIRED")
|
||||
expected = stable_event_id(
|
||||
source=self.source, target=self.target, message_type=self.message_type, payload=self.payload,
|
||||
tenant_id=self.tenant_id, user_id=self.user_id, project_id=self.project_id,
|
||||
conversation_id=self.conversation_id, task_id=self.task_id, run_id=self.run_id,
|
||||
correlation_id=self.correlation_id, causation_id=self.causation_id,
|
||||
sequence=self.sequence, idempotency_key=self.idempotency_key,
|
||||
)
|
||||
if self.envelope_id and self.envelope_id != expected:
|
||||
raise ValueError("MESSAGE_ENVELOPE_ID_MISMATCH")
|
||||
object.__setattr__(self, "envelope_id", expected)
|
||||
|
||||
@property
|
||||
def payload_sha256(self) -> str:
|
||||
return _sha(self.payload)
|
||||
|
||||
@property
|
||||
def stream_key(self) -> str:
|
||||
return _sha({
|
||||
"tenant_id": self.tenant_id, "source": self.source, "target": self.target,
|
||||
"conversation_id": self.conversation_id, "task_id": self.task_id, "run_id": self.run_id,
|
||||
})
|
||||
|
||||
def is_expired(self, *, now: dt.datetime | None = None) -> bool:
|
||||
if self.expires_at is None:
|
||||
return False
|
||||
return dt.datetime.fromisoformat(self.expires_at) <= (now or _utcnow()).astimezone(UTC)
|
||||
|
||||
def as_record(self) -> dict[str, Any]:
|
||||
return {
|
||||
"envelope_id": self.envelope_id, "stream_key": self.stream_key, "source": self.source,
|
||||
"target": self.target, "message_type": self.message_type, "payload_json": _canonical(self.payload),
|
||||
"payload_sha256": self.payload_sha256, "tenant_id": self.tenant_id, "user_id": self.user_id,
|
||||
"project_id": self.project_id, "conversation_id": self.conversation_id, "task_id": self.task_id,
|
||||
"run_id": self.run_id, "correlation_id": self.correlation_id,
|
||||
"causation_id": self.causation_id, "sequence": self.sequence,
|
||||
"capability_scope_json": _canonical(self.capability_scope),
|
||||
"confidentiality_scope": self.confidentiality_scope, "idempotency_key": self.idempotency_key,
|
||||
"expires_at": self.expires_at, "created_at": self.created_at,
|
||||
}
|
||||
|
||||
|
||||
def stable_event_id(*, source: Any, target: Any, message_type: Any, payload: Any,
|
||||
tenant_id: Any = None, user_id: Any = None, project_id: Any = None,
|
||||
conversation_id: Any = None, task_id: Any = None, run_id: Any = None,
|
||||
correlation_id: Any = None, causation_id: Any = None,
|
||||
sequence: Any = None, idempotency_key: Any = None) -> str:
|
||||
assert_payload_secret_safe(payload)
|
||||
material = {
|
||||
"source": _clean(source), "target": _clean(target), "message_type": _clean(message_type),
|
||||
"payload_sha256": _sha(payload), "tenant_id": _clean(tenant_id), "user_id": _clean(user_id),
|
||||
"project_id": _clean(project_id), "conversation_id": _clean(conversation_id),
|
||||
"task_id": _clean(task_id), "run_id": _clean(run_id), "causation_id": _clean(causation_id),
|
||||
"sequence": sequence, "idempotency_key": _clean(idempotency_key),
|
||||
}
|
||||
# Preserve historical envelope IDs for legacy rows that predate correlation_id.
|
||||
if _clean(correlation_id) is not None:
|
||||
material["correlation_id"] = _clean(correlation_id)
|
||||
return _sha(material)
|
||||
|
||||
|
||||
def bind_envelope_scope(envelope: MessageEnvelope, actor: Any) -> MessageEnvelope:
|
||||
ctx = DeliveryContext.from_actor(actor)
|
||||
if ctx.privileged:
|
||||
return envelope
|
||||
if ctx.tenant_id is None:
|
||||
if envelope.tenant_id is not None:
|
||||
raise MessageScopeMismatch(MessageScopeMismatch.code)
|
||||
return envelope
|
||||
updates: dict[str, Any] = {}
|
||||
for field in ("tenant_id", "user_id", "project_id", "conversation_id", "run_id"):
|
||||
trusted = getattr(ctx, field)
|
||||
requested = getattr(envelope, field)
|
||||
if requested is not None and requested != trusted:
|
||||
raise MessageScopeMismatch(f"{MessageScopeMismatch.code}:{field}")
|
||||
if requested is None and trusted is not None:
|
||||
updates[field] = trusted
|
||||
if not updates:
|
||||
return envelope
|
||||
data = dataclasses.asdict(envelope)
|
||||
data.update(updates)
|
||||
data["envelope_id"] = ""
|
||||
return MessageEnvelope(**data)
|
||||
|
||||
|
||||
def authorize_delivery(actor: Any, envelope: MessageEnvelope) -> None:
|
||||
ctx = DeliveryContext.from_actor(actor)
|
||||
if ctx.privileged:
|
||||
return
|
||||
if not set(envelope.capability_scope).issubset(set(ctx.capabilities)):
|
||||
raise ConfidentialityScopeDenied(f"{ConfidentialityScopeDenied.code}:capability")
|
||||
if envelope.confidentiality_scope == "public":
|
||||
return
|
||||
if ctx.tenant_id != envelope.tenant_id:
|
||||
raise MessageScopeMismatch(f"{MessageScopeMismatch.code}:tenant_id")
|
||||
if envelope.confidentiality_scope in {"project", "private", "run"} and ctx.project_id != envelope.project_id:
|
||||
raise ConfidentialityScopeDenied(f"{ConfidentialityScopeDenied.code}:project_id")
|
||||
if envelope.confidentiality_scope in {"private", "run"} and ctx.user_id != envelope.user_id:
|
||||
raise ConfidentialityScopeDenied(f"{ConfidentialityScopeDenied.code}:user_id")
|
||||
if envelope.confidentiality_scope == "run" and ctx.run_id != envelope.run_id:
|
||||
raise ConfidentialityScopeDenied(f"{ConfidentialityScopeDenied.code}:run_id")
|
||||
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS message_handoff (
|
||||
envelope_id TEXT PRIMARY KEY,
|
||||
stream_key TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
message_type TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
payload_sha256 TEXT NOT NULL,
|
||||
tenant_id TEXT,
|
||||
user_id TEXT,
|
||||
project_id TEXT,
|
||||
conversation_id TEXT,
|
||||
task_id TEXT,
|
||||
run_id TEXT,
|
||||
correlation_id TEXT,
|
||||
causation_id TEXT,
|
||||
sequence INTEGER NOT NULL CHECK(sequence >= 1),
|
||||
capability_scope_json TEXT NOT NULL,
|
||||
confidentiality_scope TEXT NOT NULL,
|
||||
idempotency_key TEXT NOT NULL,
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'PENDING',
|
||||
delivered_at TEXT,
|
||||
acked_at TEXT,
|
||||
UNIQUE(stream_key, idempotency_key),
|
||||
UNIQUE(stream_key, sequence)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_message_handoff_target_status
|
||||
ON message_handoff(target, status, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_message_handoff_scope
|
||||
ON message_handoff(tenant_id, project_id, user_id, conversation_id, run_id);
|
||||
"""
|
||||
|
||||
|
||||
class DurableHandoffStore:
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self.path = Path(path)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self._connect() as conn:
|
||||
conn.executescript(_SCHEMA)
|
||||
columns = {str(row[1]) for row in conn.execute("PRAGMA table_info(message_handoff)")}
|
||||
if "correlation_id" not in columns:
|
||||
conn.execute("ALTER TABLE message_handoff ADD COLUMN correlation_id TEXT")
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_message_handoff_lineage "
|
||||
"ON message_handoff(correlation_id, causation_id)"
|
||||
)
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(self.path, timeout=30.0)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys=ON")
|
||||
conn.execute("PRAGMA busy_timeout=30000")
|
||||
return conn
|
||||
|
||||
@staticmethod
|
||||
def _from_row(row: sqlite3.Row) -> MessageEnvelope:
|
||||
return MessageEnvelope(
|
||||
source=row["source"], target=row["target"], message_type=row["message_type"],
|
||||
payload=json.loads(row["payload_json"]), tenant_id=row["tenant_id"], user_id=row["user_id"],
|
||||
project_id=row["project_id"], conversation_id=row["conversation_id"], task_id=row["task_id"],
|
||||
run_id=row["run_id"], correlation_id=row["correlation_id"],
|
||||
causation_id=row["causation_id"], sequence=int(row["sequence"]),
|
||||
capability_scope=tuple(json.loads(row["capability_scope_json"])),
|
||||
confidentiality_scope=row["confidentiality_scope"], idempotency_key=row["idempotency_key"],
|
||||
expires_at=row["expires_at"], created_at=row["created_at"], envelope_id=row["envelope_id"],
|
||||
)
|
||||
|
||||
def put(self, envelope: MessageEnvelope) -> tuple[str, MessageEnvelope]:
|
||||
if envelope.is_expired():
|
||||
raise MessageExpired(MessageExpired.code)
|
||||
record = envelope.as_record()
|
||||
with self._connect() as conn:
|
||||
conn.execute("BEGIN IMMEDIATE")
|
||||
replay = conn.execute(
|
||||
"SELECT * FROM message_handoff WHERE stream_key=? AND idempotency_key=?",
|
||||
(envelope.stream_key, envelope.idempotency_key),
|
||||
).fetchone()
|
||||
if replay is not None:
|
||||
existing = self._from_row(replay)
|
||||
if existing.envelope_id != envelope.envelope_id or existing.payload_sha256 != envelope.payload_sha256:
|
||||
raise IdempotencyConflict(IdempotencyConflict.code)
|
||||
conn.commit()
|
||||
return "REPLAY", existing
|
||||
seq = conn.execute(
|
||||
"SELECT * FROM message_handoff WHERE stream_key=? AND sequence=?",
|
||||
(envelope.stream_key, envelope.sequence),
|
||||
).fetchone()
|
||||
if seq is not None:
|
||||
existing = self._from_row(seq)
|
||||
if existing.envelope_id == envelope.envelope_id:
|
||||
conn.commit()
|
||||
return "REPLAY", existing
|
||||
raise SequenceConflict(SequenceConflict.code)
|
||||
maximum = int(conn.execute(
|
||||
"SELECT COALESCE(MAX(sequence),0) FROM message_handoff WHERE stream_key=?",
|
||||
(envelope.stream_key,),
|
||||
).fetchone()[0])
|
||||
expected = maximum + 1
|
||||
if envelope.sequence != expected:
|
||||
raise SequenceGap(f"{SequenceGap.code}:expected={expected}:actual={envelope.sequence}")
|
||||
columns = tuple(record)
|
||||
placeholders = ",".join("?" for _ in columns)
|
||||
conn.execute(
|
||||
f"INSERT INTO message_handoff ({','.join(columns)}) VALUES ({placeholders})",
|
||||
tuple(record[column] for column in columns),
|
||||
)
|
||||
conn.commit()
|
||||
return "CREATED", envelope
|
||||
|
||||
def get(self, envelope_id: str, *, actor: Any) -> MessageEnvelope:
|
||||
with self._connect() as conn:
|
||||
row = conn.execute("SELECT * FROM message_handoff WHERE envelope_id=?", (envelope_id,)).fetchone()
|
||||
if row is None:
|
||||
raise KeyError(envelope_id)
|
||||
envelope = self._from_row(row)
|
||||
if envelope.is_expired():
|
||||
conn.execute("UPDATE message_handoff SET status='EXPIRED' WHERE envelope_id=?", (envelope_id,))
|
||||
conn.commit()
|
||||
raise MessageExpired(MessageExpired.code)
|
||||
authorize_delivery(actor, envelope)
|
||||
return envelope
|
||||
|
||||
def pending(self, *, target: str, actor: Any, limit: int = 100) -> list[MessageEnvelope]:
|
||||
if not 1 <= limit <= 1000:
|
||||
raise ValueError("MESSAGE_PENDING_LIMIT_INVALID")
|
||||
visible: list[MessageEnvelope] = []
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM message_handoff WHERE target=? AND status='PENDING' ORDER BY created_at, sequence LIMIT ?",
|
||||
(target, limit),
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
envelope = self._from_row(row)
|
||||
if envelope.is_expired():
|
||||
conn.execute("UPDATE message_handoff SET status='EXPIRED' WHERE envelope_id=?", (envelope.envelope_id,))
|
||||
continue
|
||||
try:
|
||||
authorize_delivery(actor, envelope)
|
||||
except HandoffError:
|
||||
continue
|
||||
visible.append(envelope)
|
||||
conn.commit()
|
||||
return visible
|
||||
|
||||
def mark_delivered(self, envelope_id: str, *, actor: Any) -> MessageEnvelope:
|
||||
envelope = self.get(envelope_id, actor=actor)
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE message_handoff SET status='DELIVERED', delivered_at=? "
|
||||
"WHERE envelope_id=? AND status='PENDING'",
|
||||
(_iso(_utcnow()), envelope_id),
|
||||
)
|
||||
conn.commit()
|
||||
return envelope
|
||||
|
||||
def acknowledge(self, envelope_id: str, *, actor: Any) -> MessageEnvelope:
|
||||
envelope = self.get(envelope_id, actor=actor)
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"UPDATE message_handoff SET status='ACKED', acked_at=? WHERE envelope_id=?",
|
||||
(_iso(_utcnow()), envelope_id),
|
||||
)
|
||||
conn.commit()
|
||||
return envelope
|
||||
|
||||
|
||||
class HandoffFabric:
|
||||
def __init__(self, store: DurableHandoffStore) -> None:
|
||||
self.store = store
|
||||
|
||||
def publish(self, envelope: MessageEnvelope, *, actor: Any | None = None) -> tuple[str, MessageEnvelope]:
|
||||
return self.store.put(bind_envelope_scope(envelope, actor) if actor is not None else envelope)
|
||||
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Mapping
|
||||
|
||||
from message_handoff import MessageEnvelope
|
||||
|
||||
|
||||
def _get(value: Any, *names: str, default: Any = None) -> Any:
|
||||
for name in names:
|
||||
if isinstance(value, Mapping) and name in value:
|
||||
return value[name]
|
||||
if hasattr(value, name):
|
||||
return getattr(value, name)
|
||||
return default
|
||||
|
||||
|
||||
def agent_event_to_envelope(event: Any, *, target: str | None = None, sequence: int = 1,
|
||||
idempotency_key: str | None = None, **scope: Any) -> MessageEnvelope:
|
||||
payload = _get(event, "payload", "data", default={}) or {}
|
||||
target = target or _get(event, "target", "recipient")
|
||||
if not target:
|
||||
raise ValueError("MESSAGE_TARGET_REQUIRED")
|
||||
return MessageEnvelope(
|
||||
source=str(_get(event, "source", "producer", "agent_id", default="agent-event")),
|
||||
target=str(target),
|
||||
message_type=str(_get(event, "event_type", "type", "message_type", default="agent.event")),
|
||||
payload=dict(payload),
|
||||
sequence=sequence,
|
||||
idempotency_key=idempotency_key or str(_get(event, "id", "event_id", default="")) or f"agent-event:{sequence}",
|
||||
causation_id=_get(event, "causation_id"),
|
||||
**scope,
|
||||
)
|
||||
|
||||
|
||||
def taskplane_event_to_envelope(event: Any, *, target: str, sequence: int = 1,
|
||||
idempotency_key: str | None = None, **scope: Any) -> MessageEnvelope:
|
||||
data = _get(event, "data", "payload", default={}) or {}
|
||||
event_id = str(_get(event, "id", "event_id", default=""))
|
||||
return MessageEnvelope(
|
||||
source=str(_get(event, "source", default="task-plane")),
|
||||
target=target,
|
||||
message_type=str(_get(event, "type", "event_type", default="task.event")),
|
||||
payload=dict(data),
|
||||
sequence=sequence,
|
||||
idempotency_key=idempotency_key or event_id or f"task-event:{sequence}",
|
||||
causation_id=event_id or None,
|
||||
**scope,
|
||||
)
|
||||
|
||||
|
||||
def legacy_message_to_envelope(message: Any, *, target: str | None = None, sequence: int = 1,
|
||||
idempotency_key: str | None = None, **scope: Any) -> MessageEnvelope:
|
||||
payload = _get(message, "payload", "data", "body", "content", default={})
|
||||
if not isinstance(payload, Mapping):
|
||||
payload = {"value": payload}
|
||||
target = target or _get(message, "target", "recipient", "receiver")
|
||||
if not target:
|
||||
raise ValueError("MESSAGE_TARGET_REQUIRED")
|
||||
source = _get(message, "source", "sender", "agent_id", default="legacy-message-bus")
|
||||
kind = _get(message, "message_type", "type", "kind", default="legacy.message")
|
||||
identifier = _get(message, "id", "message_id", "event_id")
|
||||
return MessageEnvelope(
|
||||
source=str(source), target=str(target), message_type=str(kind), payload=dict(payload),
|
||||
sequence=sequence,
|
||||
idempotency_key=idempotency_key or (str(identifier) if identifier else f"legacy:{sequence}"),
|
||||
causation_id=str(identifier) if identifier else None,
|
||||
**scope,
|
||||
)
|
||||
@@ -11,6 +11,7 @@ from dataclasses import asdict, dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from .search_gateway_orchestrator import SearchGatewayOrchestrator
|
||||
from .trace_tool_credential_governance import GovernanceActor, TraceContext
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -43,6 +44,9 @@ class SearchGatewayAgentAdapter:
|
||||
domain_profiles: list[str] | None = None,
|
||||
user_scope: str = "public",
|
||||
correlation_id: str | None = None,
|
||||
*,
|
||||
actor: GovernanceActor | dict[str, Any] | None = None,
|
||||
trace: TraceContext | None = None,
|
||||
) -> dict[str, Any]:
|
||||
payload = {
|
||||
"query": query,
|
||||
@@ -59,7 +63,11 @@ class SearchGatewayAgentAdapter:
|
||||
"session_profile": context.session_profile,
|
||||
"correlation_id": correlation_id,
|
||||
}
|
||||
return self.orchestrator.execute(payload)
|
||||
if actor is None and trace is None:
|
||||
# Preserve adapter compatibility; the governed orchestrator itself
|
||||
# still fails closed when trusted context is absent.
|
||||
return self.orchestrator.execute(payload)
|
||||
return self.orchestrator.execute(payload, actor=actor, trace=trace)
|
||||
|
||||
|
||||
class OpenClawSearchClient(SearchGatewayAgentAdapter):
|
||||
|
||||
@@ -13,10 +13,18 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
import hashlib
|
||||
import json
|
||||
from typing import Any, Protocol
|
||||
from uuid import uuid4
|
||||
|
||||
from .search_profile_resolver import DomainQueryProfileRegistry, SearchProfile, SearchProfileResolver
|
||||
from .trace_tool_credential_governance import (
|
||||
GovernanceActor,
|
||||
GovernanceError,
|
||||
GovernanceStore,
|
||||
TraceContext,
|
||||
)
|
||||
|
||||
|
||||
def utc_now() -> str:
|
||||
@@ -156,13 +164,25 @@ class SearchPolicyGuard:
|
||||
|
||||
|
||||
class SearchGateway:
|
||||
def __init__(self, providers: list[SearchProvider] | None = None):
|
||||
def __init__(self, providers: list[SearchProvider] | None = None, *, governance_store: GovernanceStore | None = None):
|
||||
self.providers = providers or []
|
||||
self.governance_store = governance_store
|
||||
self.profile_resolver = SearchProfileResolver()
|
||||
self.planner = QueryPlanner()
|
||||
self.guard = SearchPolicyGuard()
|
||||
|
||||
def search(self, request: SearchRequest) -> SearchResponse:
|
||||
def search(self, request: SearchRequest, *, actor: GovernanceActor | dict[str, Any] | None = None, trace: TraceContext | None = None) -> SearchResponse:
|
||||
trusted_actor: GovernanceActor | None = None
|
||||
if self.governance_store is not None:
|
||||
if actor is None or trace is None:
|
||||
raise GovernanceError("TOOL_MCP_AUTHZ_DENY")
|
||||
trusted_actor = GovernanceActor.from_value(actor)
|
||||
if request.requester != trusted_actor.identity:
|
||||
raise GovernanceError("TOOL_MCP_AUTHZ_DENY", field="requester")
|
||||
if request.correlation_id is not None and request.correlation_id not in {
|
||||
trace.trace_id, trace.correlation_id
|
||||
}:
|
||||
raise GovernanceError("TOOL_SCOPE_PROPAGATION", field="correlation_id")
|
||||
request_id = request.correlation_id or f"search_{uuid4().hex[:12]}"
|
||||
profile = self.profile_resolver.resolve(request.group_profile, request.user_profile, request.session_profile)
|
||||
expanded_queries = self.planner.expand(request, profile)
|
||||
@@ -184,14 +204,60 @@ class SearchGateway:
|
||||
attempts: list[SearchProviderAttempt] = []
|
||||
providers = self._ordered_providers(profile)
|
||||
for provider in providers:
|
||||
if request.authorized_sources and provider.name not in request.authorized_sources:
|
||||
attempts.append(SearchProviderAttempt(provider=provider.name, status="denied", reason="SOURCE_AUTHZ_DENY"))
|
||||
continue
|
||||
if request.user_scope != "public" and provider.name not in request.authorized_sources:
|
||||
attempts.append(SearchProviderAttempt(provider=provider.name, status="denied", reason="SOURCE_AUTHZ_REQUIRED"))
|
||||
continue
|
||||
try:
|
||||
if self.governance_store is not None:
|
||||
assert trusted_actor is not None and trace is not None
|
||||
input_digest = hashlib.sha256(
|
||||
json.dumps(
|
||||
{"query": request.query, "intent": request.intent, "provider": provider.name},
|
||||
sort_keys=True, separators=(",", ":"), ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
).hexdigest()
|
||||
invocation_key = hashlib.sha256(
|
||||
f"{trace.trace_id}:{trace.span_id}:{provider.name}:{input_digest}".encode("utf-8")
|
||||
).hexdigest()
|
||||
authorization = self.governance_store.authorize_tool(
|
||||
actor=trusted_actor,
|
||||
tool_name=f"search.{provider.name}",
|
||||
trace=trace,
|
||||
requested_scope=trace.scope,
|
||||
input_digest=input_digest,
|
||||
invocation_key=invocation_key,
|
||||
)
|
||||
if authorization.get("idempotent"):
|
||||
raise GovernanceError("TOOL_INVOCATION_REPLAY")
|
||||
provider_evidence, attempt = provider.search(request, expanded_queries)
|
||||
if self.governance_store is not None:
|
||||
result_digest = hashlib.sha256(
|
||||
json.dumps(
|
||||
{
|
||||
"evidence": [item.to_dict() for item in provider_evidence],
|
||||
"attempt": attempt.to_dict(),
|
||||
},
|
||||
sort_keys=True, separators=(",", ":"), ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
).hexdigest()
|
||||
self.governance_store.record_tool_receipt(
|
||||
invocation_key=invocation_key,
|
||||
result_digest=result_digest,
|
||||
actor=trusted_actor,
|
||||
trace=trace,
|
||||
)
|
||||
evidence.extend(provider_evidence)
|
||||
attempts.append(attempt)
|
||||
if provider_evidence:
|
||||
break
|
||||
except Exception as exc:
|
||||
attempts.append(SearchProviderAttempt(provider=getattr(provider, "name", "unknown"), status="failed", reason=str(exc)))
|
||||
except GovernanceError:
|
||||
raise
|
||||
except Exception:
|
||||
# Provider exceptions may contain URLs, tokens, or local paths.
|
||||
attempts.append(SearchProviderAttempt(provider=getattr(provider, "name", "unknown"), status="failed", reason="PROVIDER_FAILURE"))
|
||||
|
||||
if evidence:
|
||||
return SearchResponse(
|
||||
|
||||
@@ -7,27 +7,29 @@ try:
|
||||
except Exception:
|
||||
APIRouter = None
|
||||
|
||||
from .search_gateway import SearchGateway, SearchRequest
|
||||
from .search_gateway import SearchRequest
|
||||
from .search_gateway_orchestrator import SearchGatewayOrchestrator
|
||||
from .trace_tool_credential_governance import GovernanceActor, TraceContext
|
||||
from .search_gateway_dashboard import SearchGatewayDashboard
|
||||
|
||||
|
||||
gateway = SearchGateway()
|
||||
orchestrator = SearchGatewayOrchestrator()
|
||||
dashboard = SearchGatewayDashboard()
|
||||
|
||||
|
||||
def search_gateway_request(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
request = SearchRequest(**payload)
|
||||
return gateway.search(request).to_dict()
|
||||
def search_gateway_request(payload: dict[str, Any], *, actor: GovernanceActor | dict[str, Any] | None = None, trace: TraceContext | None = None) -> dict[str, Any]:
|
||||
return orchestrator.execute(payload, actor=actor, trace=trace)
|
||||
|
||||
|
||||
def get_search_gateway_status() -> dict[str, Any]:
|
||||
return {
|
||||
"module": "search-gateway",
|
||||
"status": "ready",
|
||||
"providers_configured": len(gateway.providers),
|
||||
"providers_configured": len(orchestrator.gateway.providers),
|
||||
"policy_guard": "enabled",
|
||||
"profile_customization": "enabled",
|
||||
"fallback_required_when_provider_unavailable": True,
|
||||
"governance": "required",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -3,24 +3,49 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
import os
|
||||
|
||||
from .search_audit_store import SearchAuditStore
|
||||
from .search_event_bridge import SearchEventBridge
|
||||
from .search_gateway import SearchGateway, SearchRequest
|
||||
from .search_provider_registry import SearchProviderRegistry
|
||||
from .trace_tool_credential_governance import (
|
||||
GovernanceActor,
|
||||
GovernanceScope,
|
||||
GovernanceStore,
|
||||
ToolDescriptor,
|
||||
TraceContext,
|
||||
)
|
||||
|
||||
|
||||
class SearchGatewayOrchestrator:
|
||||
def __init__(self):
|
||||
def __init__(self, *, governance_store: GovernanceStore | None = None):
|
||||
self.registry = SearchProviderRegistry()
|
||||
self.audit = SearchAuditStore()
|
||||
self.events = SearchEventBridge()
|
||||
self.gateway = SearchGateway(self.registry.ordered())
|
||||
self.governance = governance_store or GovernanceStore(
|
||||
os.environ.get("AIOS_SEARCH_GOVERNANCE_DB", "runtime/search-governance.sqlite3")
|
||||
)
|
||||
registrar = GovernanceActor(
|
||||
"search-runtime-bootstrap", GovernanceScope("system"), ("system.admin",)
|
||||
)
|
||||
for provider in self.registry.ordered():
|
||||
self.governance.register_tool(
|
||||
ToolDescriptor(
|
||||
f"search.{provider.name}", "tool", GovernanceScope("global"), ("tool.search",)
|
||||
),
|
||||
registrar,
|
||||
)
|
||||
self.gateway = SearchGateway(self.registry.ordered(), governance_store=self.governance)
|
||||
|
||||
def execute(self, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
def execute(self, payload: dict[str, Any], *, actor: GovernanceActor | dict[str, Any] | None = None, trace: TraceContext | None = None) -> dict[str, Any]:
|
||||
if actor is None or trace is None:
|
||||
from .trace_tool_credential_governance import GovernanceError
|
||||
raise GovernanceError("TOOL_MCP_AUTHZ_DENY")
|
||||
self.governance.record_trace(trace, actor)
|
||||
request = SearchRequest(**payload)
|
||||
self.events.search_started(request.to_dict())
|
||||
response = self.gateway.search(request)
|
||||
response = self.gateway.search(request, actor=actor, trace=trace)
|
||||
result = response.to_dict()
|
||||
for attempt in result.get("attempts", []):
|
||||
self.events.provider_attempted(result["request_id"], attempt)
|
||||
@@ -35,6 +60,7 @@ class SearchGatewayOrchestrator:
|
||||
"registry": self.registry.status(),
|
||||
"audit": "enabled",
|
||||
"events": "enabled",
|
||||
"governance": "required",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ from .models import (
|
||||
)
|
||||
from .policy import PolicyEngine
|
||||
from .store import TaskPlaneStore
|
||||
from .scope import bind_task_scope
|
||||
from .worker_registry import WorkerRegistry
|
||||
|
||||
|
||||
@@ -68,28 +69,54 @@ class TaskPlaneService:
|
||||
self.delegation = DelegationEngine(store)
|
||||
self.store.probe_attestation_verifier = self.policy.probe_attestation_verifier
|
||||
|
||||
def create_task(self, body: dict[str, Any], idempotency_key: str | None = None) -> dict[str, Any]:
|
||||
def _authorized_task(self, task_id: str, actor: str, action: str) -> TaskRecord:
|
||||
task = self.store.get_task(task_id, self.policy.actor_context(actor))
|
||||
self.policy.assert_allowed(action, actor, task=task)
|
||||
return task
|
||||
|
||||
def create_task(
|
||||
self,
|
||||
body: dict[str, Any],
|
||||
idempotency_key: str | None = None,
|
||||
*,
|
||||
actor_identity: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if body.get("parent_task_id"):
|
||||
raise ForbiddenError("child tasks must use the delegate endpoint")
|
||||
issuer = body.get("issuer", {})
|
||||
actor = issuer.get("id", "anon") if isinstance(issuer, dict) else "anon"
|
||||
declared_actor = issuer.get("id", "anon") if isinstance(issuer, dict) else "anon"
|
||||
actor = actor_identity or declared_actor
|
||||
self.policy.assert_allowed("create", actor)
|
||||
try:
|
||||
plan = TaskPlan.from_dict(body)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValidationError("invalid task plan") from exc
|
||||
if actor_identity is not None:
|
||||
plan = bind_task_scope(
|
||||
plan, self.policy.actor_context(actor_identity), public_create=True
|
||||
)
|
||||
task = self.store.create_task_with_event(plan, idempotency_key=idempotency_key)
|
||||
return task.to_dict()
|
||||
|
||||
def list_tasks(self, actor: str | None = None) -> list[dict[str, Any]]:
|
||||
context = None
|
||||
if actor is not None:
|
||||
self.policy.assert_allowed("read", actor)
|
||||
return [task.to_dict(include_task=False) for task in self.store.list_tasks()]
|
||||
context = self.policy.actor_context(actor)
|
||||
return [
|
||||
task.to_dict(include_task=False)
|
||||
for task in self.store.list_tasks(context)
|
||||
]
|
||||
|
||||
def get_task(self, task_id: str, actor: str | None = None) -> dict[str, Any]:
|
||||
context = None
|
||||
if actor is not None:
|
||||
self.policy.assert_allowed("read", actor, task=self.store.get_task(task_id))
|
||||
return self.store.get_task(task_id).to_dict()
|
||||
self.policy.assert_allowed("read", actor)
|
||||
context = self.policy.actor_context(actor)
|
||||
task = self.store.get_task(task_id, context)
|
||||
if actor is not None:
|
||||
self.policy.assert_allowed("read", actor, task=task)
|
||||
return task.to_dict()
|
||||
|
||||
def get_worker(self, worker_id: str, actor: str | None = None) -> dict[str, Any]:
|
||||
if actor is not None:
|
||||
@@ -163,9 +190,8 @@ class TaskPlaneService:
|
||||
if not worker_id:
|
||||
raise ValidationError("worker_id required")
|
||||
|
||||
task = self.store.get_task(task_id)
|
||||
task = self._authorized_task(task_id, actor, "claim")
|
||||
self.policy.assert_readonly_worker_binding(actor, str(worker_id))
|
||||
self.policy.assert_allowed("claim", actor, task=task)
|
||||
lease_seconds = _bounded_int(
|
||||
body.get("lease_seconds", 30), "lease_seconds", 1, 3600
|
||||
)
|
||||
@@ -184,10 +210,9 @@ class TaskPlaneService:
|
||||
if not claim_id:
|
||||
raise ValidationError("claim_id required")
|
||||
actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
|
||||
task = self.store.get_task(task_id)
|
||||
task = self._authorized_task(task_id, actor, "heartbeat")
|
||||
claim = self.store.get_claim(claim_id)
|
||||
self.policy.assert_readonly_worker_binding(actor, claim.worker_id)
|
||||
self.policy.assert_allowed("heartbeat", actor, task=task)
|
||||
if claim.worker_identity != actor:
|
||||
raise ForbiddenError("claim identity mismatch")
|
||||
lease_seconds = _bounded_int(
|
||||
@@ -206,10 +231,9 @@ class TaskPlaneService:
|
||||
message = body.get("message")
|
||||
if progress is None:
|
||||
raise ValidationError("progress required")
|
||||
task = self.store.get_task(task_id)
|
||||
task = self._authorized_task(task_id, actor, "progress")
|
||||
claim = self.store.get_claim(claim_id)
|
||||
self.policy.assert_readonly_worker_binding(actor, claim.worker_id)
|
||||
self.policy.assert_allowed("progress", actor, task=task)
|
||||
if task.task_plan.data_classification == "credential-secret":
|
||||
if message not in {None, ""} or contains_secret_text(stage):
|
||||
raise ValidationError(
|
||||
@@ -234,7 +258,7 @@ class TaskPlaneService:
|
||||
if not claim_id:
|
||||
raise ValidationError("claim_id required")
|
||||
actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
|
||||
task = self.store.get_task(task_id)
|
||||
task = self._authorized_task(task_id, actor, "result")
|
||||
|
||||
status = body.get("status", "COMPLETED")
|
||||
outputs = body.get("outputs", [])
|
||||
@@ -247,7 +271,6 @@ class TaskPlaneService:
|
||||
raise ValidationError("evidence items must be objects")
|
||||
claim = self.store.get_claim(claim_id)
|
||||
self.policy.assert_readonly_worker_binding(actor, claim.worker_id)
|
||||
self.policy.assert_allowed("result", actor, task=task)
|
||||
if claim.worker_identity != actor:
|
||||
raise ForbiddenError("claim identity mismatch")
|
||||
if task.task_plan.data_classification == "credential-secret":
|
||||
@@ -326,8 +349,7 @@ class TaskPlaneService:
|
||||
reason = body.get("reason", "failed")
|
||||
actor = body.get("actor_identity") or body.get("worker_identity") or "worker"
|
||||
self.policy.assert_allowed("fail", actor)
|
||||
task = self.store.get_task(task_id)
|
||||
self.policy.assert_allowed("fail", actor, task=task)
|
||||
task = self._authorized_task(task_id, actor, "fail")
|
||||
claim = self.store.get_claim(claim_id)
|
||||
if claim.worker_identity != actor:
|
||||
raise ForbiddenError("claim identity mismatch")
|
||||
@@ -339,8 +361,7 @@ class TaskPlaneService:
|
||||
actor = body.get("actor_identity") or body.get("worker_identity") or "owner"
|
||||
self.policy.assert_allowed("cancel", actor)
|
||||
reason = body.get("reason", "cancelled")
|
||||
task = self.store.get_task(task_id)
|
||||
self.policy.assert_allowed("cancel", actor, task=task)
|
||||
task = self._authorized_task(task_id, actor, "cancel")
|
||||
updated = self.store.cancel_task_with_event(
|
||||
task_id, task.revision, reason=reason
|
||||
)
|
||||
@@ -349,8 +370,7 @@ class TaskPlaneService:
|
||||
def delegate(self, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||
actor = body.get("actor_identity") or "owner"
|
||||
self.policy.assert_allowed("delegate", actor)
|
||||
parent = self.store.get_task(task_id)
|
||||
self.policy.assert_allowed("delegate", actor, task=parent)
|
||||
parent = self._authorized_task(task_id, actor, "delegate")
|
||||
if "task" in body:
|
||||
try:
|
||||
child_plan = TaskPlan.from_dict(body["task"])
|
||||
@@ -364,25 +384,31 @@ class TaskPlaneService:
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValidationError("invalid child task plan") from exc
|
||||
child_plan.parent_task_id = task_id
|
||||
child_plan = bind_task_scope(
|
||||
child_plan, self.policy.actor_context(actor), public_create=False
|
||||
)
|
||||
child = self.delegation.delegate(task_id, child_plan, caller=actor)
|
||||
return child.to_dict()
|
||||
|
||||
def get_events(self, task_id: str, actor: str | None = None) -> list[dict[str, Any]]:
|
||||
if actor is not None:
|
||||
self.policy.assert_allowed("audit", actor, task=self.store.get_task(task_id))
|
||||
self.store.get_task(task_id)
|
||||
self._authorized_task(task_id, actor, "audit")
|
||||
else:
|
||||
self.store.get_task(task_id)
|
||||
return self.store.list_events(task_id)
|
||||
|
||||
def get_evidence(self, task_id: str, actor: str | None = None) -> list[dict[str, Any]]:
|
||||
if actor is not None:
|
||||
self.policy.assert_allowed("audit", actor, task=self.store.get_task(task_id))
|
||||
self.store.get_task(task_id)
|
||||
self._authorized_task(task_id, actor, "audit")
|
||||
else:
|
||||
self.store.get_task(task_id)
|
||||
return self.evidence.list(task_id)
|
||||
|
||||
def get_children(self, task_id: str, actor: str | None = None) -> list[str]:
|
||||
if actor is not None:
|
||||
self.policy.assert_allowed("read", actor, task=self.store.get_task(task_id))
|
||||
self.store.get_task(task_id)
|
||||
self._authorized_task(task_id, actor, "read")
|
||||
else:
|
||||
self.store.get_task(task_id)
|
||||
return self.store.list_children(task_id)
|
||||
|
||||
|
||||
@@ -506,7 +532,11 @@ class TaskPlaneRequestHandler(BaseHTTPRequestHandler):
|
||||
):
|
||||
raise ForbiddenError("issuer identity mismatch")
|
||||
body["issuer"] = {"type": "authenticated", "id": identity}
|
||||
return 201, self.service.create_task(body or {}, headers.get("idempotency-key"))
|
||||
return 201, self.service.create_task(
|
||||
body or {},
|
||||
headers.get("idempotency-key"),
|
||||
actor_identity=identity,
|
||||
)
|
||||
if body.get("actor_identity") not in {None, identity}:
|
||||
raise ForbiddenError("actor identity mismatch")
|
||||
body["actor_identity"] = identity
|
||||
|
||||
@@ -106,3 +106,8 @@ class DurableOutboxDispatcher:
|
||||
receipt=str(receipt) if receipt else None,
|
||||
error=None if receipt else f"{adapter.name} receipt unavailable",
|
||||
)
|
||||
|
||||
# R4.2 P0-B compatibility adapter.
|
||||
def to_handoff_envelope(event, *, target, sequence=1, idempotency_key=None, **scope):
|
||||
from message_handoff_adapters import taskplane_event_to_envelope
|
||||
return taskplane_event_to_envelope(event, target=target, sequence=sequence, idempotency_key=idempotency_key, **scope)
|
||||
|
||||
@@ -13,6 +13,8 @@ from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from .scope import normalize_task_scope
|
||||
|
||||
|
||||
SENSITIVE_KEYS = {
|
||||
"secret",
|
||||
@@ -66,6 +68,12 @@ TASK_PLAN_KEYS = {
|
||||
"allowed_input_paths",
|
||||
"allowed_output_paths",
|
||||
"output_policy",
|
||||
"scope_type",
|
||||
"tenant_id",
|
||||
"user_id",
|
||||
"project_id",
|
||||
"conversation_id",
|
||||
"run_id",
|
||||
}
|
||||
|
||||
|
||||
@@ -237,6 +245,12 @@ class TaskPlan:
|
||||
schema_version: int = 1
|
||||
task_id: str | None = None
|
||||
task_type: str = "code.change"
|
||||
scope_type: str = "legacy"
|
||||
tenant_id: str | None = None
|
||||
user_id: str | None = None
|
||||
project_id: str | None = None
|
||||
conversation_id: str | None = None
|
||||
run_id: str | None = None
|
||||
issuer: dict[str, Any] = field(default_factory=dict)
|
||||
intent: str | None = None
|
||||
required_capabilities: list[str] = field(default_factory=list)
|
||||
@@ -311,10 +325,17 @@ class TaskPlan:
|
||||
raise ValueError("credential-secret requires metadata-only logging")
|
||||
if output_policy != "hash-count-status-only":
|
||||
raise ValueError("credential-secret requires restricted output")
|
||||
scope = normalize_task_scope(data)
|
||||
return cls(
|
||||
schema_version=schema_version,
|
||||
task_id=validate_identifier(data["task_id"], "task_id"),
|
||||
task_type=validate_type(data.get("task_type", "code.change"), "task_type"),
|
||||
scope_type=scope.scope_type,
|
||||
tenant_id=scope.tenant_id,
|
||||
user_id=scope.user_id,
|
||||
project_id=scope.project_id,
|
||||
conversation_id=scope.conversation_id,
|
||||
run_id=scope.run_id,
|
||||
issuer=issuer,
|
||||
intent=data.get("intent"),
|
||||
required_capabilities=_normalize_list(data.get("required_capabilities", [])),
|
||||
@@ -348,6 +369,12 @@ class TaskPlan:
|
||||
"schema_version": self.schema_version,
|
||||
"task_id": self.task_id,
|
||||
"task_type": self.task_type,
|
||||
"scope_type": self.scope_type,
|
||||
"tenant_id": self.tenant_id,
|
||||
"user_id": self.user_id,
|
||||
"project_id": self.project_id,
|
||||
"conversation_id": self.conversation_id,
|
||||
"run_id": self.run_id,
|
||||
"issuer": self.issuer,
|
||||
"intent": self.intent,
|
||||
"required_capabilities": list(self.required_capabilities),
|
||||
|
||||
@@ -3,10 +3,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Callable
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
from .errors import ForbiddenError, LockedError
|
||||
from .models import ProbeEvidence, ResultEnvelope, TaskRecord
|
||||
from .scope import TaskActorContext, actor_context as build_actor_context
|
||||
|
||||
|
||||
RISK_RANK = {"low": 1, "medium": 2, "high": 3}
|
||||
@@ -93,6 +94,10 @@ class PolicyEngine:
|
||||
result_signature_verifier: Callable[[ResultEnvelope], bool] | None = None,
|
||||
probe_attestation_verifier: Callable[[ProbeEvidence], bool] | None = None,
|
||||
identity_capabilities: dict[str, set[str]] | None = None,
|
||||
identity_actor_contexts: Mapping[
|
||||
str, TaskActorContext | Mapping[str, Any]
|
||||
]
|
||||
| None = None,
|
||||
admin_identities: set[str] | None = None,
|
||||
readonly_worker_execution_enabled: bool = False,
|
||||
readonly_worker_profiles: list[ReadonlyWorkerProfile] | None = None,
|
||||
@@ -109,12 +114,26 @@ class PolicyEngine:
|
||||
identity: set(capabilities)
|
||||
for identity, capabilities in (identity_capabilities or {}).items()
|
||||
}
|
||||
self.identity_actor_contexts = dict(identity_actor_contexts or {})
|
||||
self.admin_identities = set(admin_identities or ())
|
||||
self.readonly_worker_execution_enabled = readonly_worker_execution_enabled
|
||||
self.readonly_worker_profiles = {
|
||||
profile.identity: profile for profile in (readonly_worker_profiles or [])
|
||||
}
|
||||
|
||||
def actor_context(self, identity: str) -> TaskActorContext:
|
||||
configured = self.identity_actor_contexts.get(
|
||||
identity, self.identity_actor_contexts.get("*")
|
||||
)
|
||||
capabilities = set(
|
||||
self.identity_capabilities.get(
|
||||
identity, self.identity_capabilities.get("*", set())
|
||||
)
|
||||
)
|
||||
if identity in self.admin_identities:
|
||||
capabilities.add("task.admin")
|
||||
return build_actor_context(identity, configured, capabilities)
|
||||
|
||||
def _readonly_worker_allows(
|
||||
self, action: str, actor: str, task: TaskRecord | None
|
||||
) -> bool:
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Trusted TaskPlane scope model and SQL authorization helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, replace
|
||||
from typing import Any, Mapping
|
||||
|
||||
from .errors import ForbiddenError, ValidationError
|
||||
|
||||
SCOPE_FIELDS = ("tenant_id", "user_id", "project_id", "conversation_id", "run_id")
|
||||
PRIVILEGED_CAPABILITIES = frozenset(
|
||||
{"*", "admin", "system.admin", "task.admin", "task.read.all"}
|
||||
)
|
||||
|
||||
|
||||
def _value(value: Any, field: str) -> Any:
|
||||
if hasattr(value, "task_plan"):
|
||||
value = value.task_plan
|
||||
if isinstance(value, Mapping):
|
||||
return value.get(field)
|
||||
return getattr(value, field, None)
|
||||
|
||||
|
||||
def _text(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
result = str(value).strip()
|
||||
return result or None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskScope:
|
||||
scope_type: str
|
||||
tenant_id: str | None = None
|
||||
user_id: str | None = None
|
||||
project_id: str | None = None
|
||||
conversation_id: str | None = None
|
||||
run_id: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TaskActorContext:
|
||||
identity: str
|
||||
scope_type: str = "legacy"
|
||||
tenant_id: str | None = None
|
||||
user_id: str | None = None
|
||||
project_id: str | None = None
|
||||
conversation_id: str | None = None
|
||||
run_id: str | None = None
|
||||
capabilities: frozenset[str] = frozenset()
|
||||
|
||||
|
||||
def normalize_task_scope(value: Any) -> TaskScope:
|
||||
fields = {field: _text(_value(value, field)) for field in SCOPE_FIELDS}
|
||||
declared = _text(_value(value, "scope_type"))
|
||||
if declared is None:
|
||||
declared = "tenant" if fields["tenant_id"] else "legacy"
|
||||
if declared == "tenant":
|
||||
if fields["tenant_id"] is None:
|
||||
raise ValidationError(
|
||||
"tenant scope requires tenant_id", code="TASK_SCOPE_TENANT_ID_REQUIRED"
|
||||
)
|
||||
elif declared == "legacy":
|
||||
if any(fields.values()):
|
||||
raise ValidationError(
|
||||
"legacy scope cannot contain scoped ownership",
|
||||
code="TASK_SCOPE_LEGACY_WITH_SCOPED_FIELDS",
|
||||
)
|
||||
elif declared == "system":
|
||||
if any(fields[field] for field in ("tenant_id", "user_id", "project_id", "conversation_id")):
|
||||
raise ValidationError(
|
||||
"system scope cannot contain tenant ownership",
|
||||
code="TASK_SCOPE_SYSTEM_WITH_TENANT_FIELDS",
|
||||
)
|
||||
else:
|
||||
raise ValidationError("invalid task scope type", code="TASK_SCOPE_TYPE_INVALID")
|
||||
return TaskScope(declared, **fields)
|
||||
|
||||
|
||||
def actor_is_privileged(actor: TaskActorContext) -> bool:
|
||||
return bool(actor.capabilities & PRIVILEGED_CAPABILITIES)
|
||||
|
||||
|
||||
def actor_context(
|
||||
identity: str,
|
||||
configured: TaskActorContext | Mapping[str, Any] | None,
|
||||
capabilities: set[str] | frozenset[str],
|
||||
) -> TaskActorContext:
|
||||
if isinstance(configured, TaskActorContext):
|
||||
context = configured
|
||||
else:
|
||||
data = dict(configured or {})
|
||||
data.pop("identity", None)
|
||||
data.pop("capabilities", None)
|
||||
context = TaskActorContext(identity=str(identity), **data)
|
||||
scope = normalize_task_scope(context)
|
||||
return replace(
|
||||
context,
|
||||
identity=str(identity),
|
||||
scope_type=scope.scope_type,
|
||||
tenant_id=scope.tenant_id,
|
||||
user_id=scope.user_id,
|
||||
project_id=scope.project_id,
|
||||
conversation_id=scope.conversation_id,
|
||||
run_id=scope.run_id,
|
||||
capabilities=frozenset(str(item) for item in capabilities),
|
||||
)
|
||||
|
||||
|
||||
def bind_task_scope(plan: Any, actor: TaskActorContext, *, public_create: bool) -> Any:
|
||||
requested = normalize_task_scope(plan)
|
||||
if actor_is_privileged(actor):
|
||||
trusted = normalize_task_scope(actor)
|
||||
if requested.scope_type == "legacy" and trusted.scope_type != "legacy":
|
||||
plan.scope_type = trusted.scope_type
|
||||
for field in SCOPE_FIELDS:
|
||||
setattr(plan, field, getattr(actor, field))
|
||||
return plan
|
||||
trusted = normalize_task_scope(actor)
|
||||
if public_create and trusted.scope_type == "legacy":
|
||||
raise ForbiddenError(
|
||||
"authenticated identity has no tenant scope",
|
||||
code="TASK_ACTOR_SCOPE_UNAVAILABLE",
|
||||
)
|
||||
if trusted.scope_type != "tenant":
|
||||
if requested.scope_type == "legacy":
|
||||
plan.scope_type = trusted.scope_type
|
||||
for field in SCOPE_FIELDS:
|
||||
setattr(plan, field, getattr(actor, field))
|
||||
return plan
|
||||
if requested.scope_type != trusted.scope_type:
|
||||
raise ForbiddenError("task scope mismatch", code="TASK_SCOPE_MISMATCH_DENY")
|
||||
return plan
|
||||
if requested.scope_type not in {"legacy", "tenant"}:
|
||||
raise ForbiddenError("task scope mismatch", code="TASK_SCOPE_MISMATCH_DENY")
|
||||
updates: dict[str, Any] = {"scope_type": "tenant"}
|
||||
for field in SCOPE_FIELDS:
|
||||
declared = getattr(requested, field)
|
||||
authoritative = getattr(actor, field)
|
||||
if declared is not None and declared != authoritative:
|
||||
raise ForbiddenError(
|
||||
"task scope mismatch",
|
||||
code="TASK_SCOPE_MISMATCH_DENY",
|
||||
details={"field": field},
|
||||
)
|
||||
updates[field] = authoritative
|
||||
for field, item in updates.items():
|
||||
setattr(plan, field, item)
|
||||
return plan
|
||||
|
||||
|
||||
def actor_scope_sql(actor: TaskActorContext | None) -> tuple[str, tuple[Any, ...]]:
|
||||
if actor is None or actor_is_privileged(actor):
|
||||
return "", ()
|
||||
scope = normalize_task_scope(actor)
|
||||
if scope.scope_type == "legacy":
|
||||
return "scope_type = 'legacy'", ()
|
||||
if scope.scope_type == "system":
|
||||
return "scope_type = 'system'", ()
|
||||
clauses = ["scope_type = 'tenant'", "tenant_id = ?"]
|
||||
params: list[Any] = [scope.tenant_id]
|
||||
for field in ("user_id", "project_id", "conversation_id", "run_id"):
|
||||
value = getattr(scope, field)
|
||||
if value is None:
|
||||
clauses.append(f"{field} IS NULL")
|
||||
else:
|
||||
clauses.append(f"({field} IS NULL OR {field} = ?)")
|
||||
params.append(value)
|
||||
return " AND ".join(clauses), tuple(params)
|
||||
|
||||
|
||||
def task_scope_values(value: Any) -> tuple[str | None, ...]:
|
||||
scope = normalize_task_scope(value)
|
||||
return (
|
||||
scope.scope_type,
|
||||
scope.tenant_id,
|
||||
scope.user_id,
|
||||
scope.project_id,
|
||||
scope.conversation_id,
|
||||
scope.run_id,
|
||||
)
|
||||
@@ -32,6 +32,7 @@ from .models import (
|
||||
redact_value,
|
||||
validate_identifier,
|
||||
)
|
||||
from .scope import TaskActorContext, actor_scope_sql, task_scope_values
|
||||
|
||||
|
||||
EventEnvelope = tuple[str, str, str, str, str, float]
|
||||
@@ -134,6 +135,12 @@ class TaskPlaneStore:
|
||||
status TEXT NOT NULL,
|
||||
task_json TEXT NOT NULL,
|
||||
task_type TEXT NOT NULL,
|
||||
scope_type TEXT NOT NULL DEFAULT 'legacy',
|
||||
tenant_id TEXT,
|
||||
user_id TEXT,
|
||||
project_id TEXT,
|
||||
conversation_id TEXT,
|
||||
run_id TEXT,
|
||||
root_task_id TEXT NOT NULL,
|
||||
parent_task_id TEXT,
|
||||
correlation_id TEXT,
|
||||
@@ -272,6 +279,21 @@ class TaskPlaneStore:
|
||||
)
|
||||
"""
|
||||
)
|
||||
self._ensure_columns(
|
||||
"tasks",
|
||||
{
|
||||
"scope_type": "TEXT NOT NULL DEFAULT 'legacy'",
|
||||
"tenant_id": "TEXT",
|
||||
"user_id": "TEXT",
|
||||
"project_id": "TEXT",
|
||||
"conversation_id": "TEXT",
|
||||
"run_id": "TEXT",
|
||||
},
|
||||
)
|
||||
self.conn.execute(
|
||||
"UPDATE tasks SET scope_type='legacy' "
|
||||
"WHERE scope_type IS NULL OR TRIM(scope_type)=''"
|
||||
)
|
||||
self._ensure_columns(
|
||||
"workers",
|
||||
{
|
||||
@@ -299,6 +321,14 @@ class TaskPlaneStore:
|
||||
)
|
||||
self.conn.execute("CREATE INDEX IF NOT EXISTS ix_tasks_status ON tasks(status)")
|
||||
self.conn.execute("CREATE INDEX IF NOT EXISTS ix_tasks_parent ON tasks(parent_task_id)")
|
||||
self.conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS ix_tasks_scope_tenant_project "
|
||||
"ON tasks(scope_type, tenant_id, project_id)"
|
||||
)
|
||||
self.conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS ix_tasks_scope_owner "
|
||||
"ON tasks(scope_type, tenant_id, user_id, conversation_id, run_id)"
|
||||
)
|
||||
self.conn.execute("CREATE INDEX IF NOT EXISTS ix_workers_status ON workers(status)")
|
||||
self.conn.execute("CREATE INDEX IF NOT EXISTS ix_claims_task ON claims(task_id)")
|
||||
self.conn.execute(
|
||||
@@ -347,6 +377,42 @@ class TaskPlaneStore:
|
||||
raise NotFoundError("task")
|
||||
task_json = json.loads(row["task_json"])
|
||||
task_plan = TaskPlan.from_dict(task_json)
|
||||
stored_scope = tuple(
|
||||
row[field]
|
||||
for field in (
|
||||
"scope_type",
|
||||
"tenant_id",
|
||||
"user_id",
|
||||
"project_id",
|
||||
"conversation_id",
|
||||
"run_id",
|
||||
)
|
||||
)
|
||||
json_has_scope = any(
|
||||
field in task_json
|
||||
for field in (
|
||||
"scope_type",
|
||||
"tenant_id",
|
||||
"user_id",
|
||||
"project_id",
|
||||
"conversation_id",
|
||||
"run_id",
|
||||
)
|
||||
)
|
||||
if json_has_scope and stored_scope != task_scope_values(task_plan):
|
||||
raise ConflictError(
|
||||
"task scope persistence mismatch",
|
||||
code="TASK_SCOPE_PERSISTENCE_MISMATCH",
|
||||
)
|
||||
if not json_has_scope:
|
||||
(
|
||||
task_plan.scope_type,
|
||||
task_plan.tenant_id,
|
||||
task_plan.user_id,
|
||||
task_plan.project_id,
|
||||
task_plan.conversation_id,
|
||||
task_plan.run_id,
|
||||
) = stored_scope
|
||||
task_plan.parent_task_id = row["parent_task_id"]
|
||||
task_plan.correlation_id = row["correlation_id"]
|
||||
return TaskRecord(
|
||||
@@ -389,6 +455,7 @@ class TaskPlaneStore:
|
||||
task_id = task_plan.task_id or f"task-{uuid.uuid4()}"
|
||||
task_plan.task_id = task_id
|
||||
task_json = redact_value(task_plan.to_dict(redact=False))
|
||||
scope_values = task_scope_values(task_plan)
|
||||
now = _now()
|
||||
with self._transaction():
|
||||
if idempotency_key:
|
||||
@@ -397,7 +464,11 @@ class TaskPlaneStore:
|
||||
(idempotency_key,),
|
||||
).fetchone()
|
||||
if existing:
|
||||
if _canonical(json.loads(existing["task_json"])) != _canonical(task_json):
|
||||
existing_plan = TaskPlan.from_dict(
|
||||
json.loads(existing["task_json"])
|
||||
)
|
||||
existing_json = redact_value(existing_plan.to_dict(redact=False))
|
||||
if _canonical(existing_json) != _canonical(task_json):
|
||||
raise ConflictError("idempotency key payload mismatch")
|
||||
return self.get_task(existing["task_id"])
|
||||
root_task_id = task_id
|
||||
@@ -412,14 +483,18 @@ class TaskPlaneStore:
|
||||
self.conn.execute(
|
||||
"""
|
||||
INSERT INTO tasks
|
||||
(task_id, revision, status, task_json, task_type, root_task_id, parent_task_id, correlation_id, idempotency_key, created_at, updated_at, failure_reason)
|
||||
VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
|
||||
(task_id, revision, status, task_json, task_type,
|
||||
scope_type, tenant_id, user_id, project_id, conversation_id, run_id,
|
||||
root_task_id, parent_task_id, correlation_id, idempotency_key,
|
||||
created_at, updated_at, failure_reason)
|
||||
VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL)
|
||||
""",
|
||||
(
|
||||
task_id,
|
||||
"CREATED",
|
||||
json.dumps(task_json, ensure_ascii=False),
|
||||
task_plan.task_type,
|
||||
*scope_values,
|
||||
root_task_id,
|
||||
task_plan.parent_task_id,
|
||||
task_plan.correlation_id,
|
||||
@@ -451,21 +526,30 @@ class TaskPlaneStore:
|
||||
)
|
||||
return self.get_task(task.task_id)
|
||||
|
||||
def get_task(self, task_id: str) -> TaskRecord:
|
||||
def get_task(
|
||||
self, task_id: str, actor_context: TaskActorContext | None = None
|
||||
) -> TaskRecord:
|
||||
task_id = _validated_id(task_id, "task_id")
|
||||
predicate, scope_params = actor_scope_sql(actor_context)
|
||||
sql = "SELECT * FROM tasks WHERE task_id=?"
|
||||
if predicate:
|
||||
sql += f" AND ({predicate})"
|
||||
with self._lock:
|
||||
row = self.conn.execute(
|
||||
"SELECT * FROM tasks WHERE task_id=?", (task_id,)
|
||||
).fetchone()
|
||||
row = self.conn.execute(sql, (task_id, *scope_params)).fetchone()
|
||||
if row is None:
|
||||
raise NotFoundError(f"task {task_id} not found")
|
||||
return self._row_to_task(row)
|
||||
|
||||
def list_tasks(self) -> list[TaskRecord]:
|
||||
def list_tasks(
|
||||
self, actor_context: TaskActorContext | None = None
|
||||
) -> list[TaskRecord]:
|
||||
predicate, scope_params = actor_scope_sql(actor_context)
|
||||
sql = "SELECT * FROM tasks"
|
||||
if predicate:
|
||||
sql += f" WHERE ({predicate})"
|
||||
sql += " ORDER BY created_at DESC"
|
||||
with self._lock:
|
||||
rows = self.conn.execute(
|
||||
"SELECT * FROM tasks ORDER BY created_at DESC"
|
||||
).fetchall()
|
||||
rows = self.conn.execute(sql, scope_params).fetchall()
|
||||
return [self._row_to_task(row) for row in rows]
|
||||
|
||||
def get_task_budget(self, task_id: str) -> Budget:
|
||||
|
||||
@@ -0,0 +1,759 @@
|
||||
"""Scope-bound governance for traces, Tool/MCP calls, and credentials.
|
||||
|
||||
This module deliberately stores references and audit metadata only. Secret
|
||||
material remains in the configured credential provider boundary.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from contextlib import contextmanager
|
||||
import hashlib
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable, Mapping
|
||||
|
||||
|
||||
SCOPE_FIELDS = ("tenant_id", "user_id", "project_id", "conversation_id", "run_id")
|
||||
PRIVILEGED_CAPABILITIES = frozenset({"*", "admin", "system.admin", "governance.admin"})
|
||||
REFERENCE_RE = re.compile(r"^(systemd-credential|vault|secret-manager):[A-Za-z0-9][A-Za-z0-9._:/#-]{0,255}$")
|
||||
IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,179}$")
|
||||
|
||||
|
||||
class GovernanceError(RuntimeError):
|
||||
def __init__(self, code: str, *, field: str | None = None) -> None:
|
||||
super().__init__(code if field is None else f"{code}:{field}")
|
||||
self.code = code
|
||||
self.field = field
|
||||
|
||||
|
||||
def _clean(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _scope_tuple(value: Iterable[str] | str | None) -> tuple[str, ...]:
|
||||
if value is None:
|
||||
return ()
|
||||
if isinstance(value, str):
|
||||
value = value.split(",")
|
||||
return tuple(sorted({_clean(item) for item in value if _clean(item)}))
|
||||
|
||||
|
||||
def _canonical(value: Any) -> str:
|
||||
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
def _sha(value: Any) -> str:
|
||||
return hashlib.sha256(_canonical(value).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _identifier(value: Any, code: str) -> str:
|
||||
text = _clean(value)
|
||||
if text is None or IDENTIFIER_RE.fullmatch(text) is None:
|
||||
raise GovernanceError(code)
|
||||
return text
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GovernanceScope:
|
||||
"""Explicit resource scope; system/global resources never get fake tenants."""
|
||||
|
||||
scope_type: str
|
||||
tenant_id: str | None = None
|
||||
user_id: str | None = None
|
||||
project_id: str | None = None
|
||||
conversation_id: str | None = None
|
||||
run_id: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "scope_type", (_clean(self.scope_type) or "").lower())
|
||||
for name in SCOPE_FIELDS:
|
||||
object.__setattr__(self, name, _clean(getattr(self, name)))
|
||||
if self.scope_type == "tenant":
|
||||
if self.tenant_id is None:
|
||||
raise GovernanceError("GOVERNANCE_TENANT_ID_REQUIRED")
|
||||
elif self.scope_type in {"system", "global"}:
|
||||
if any(getattr(self, name) is not None for name in SCOPE_FIELDS):
|
||||
raise GovernanceError("GOVERNANCE_NON_TENANT_SCOPE_INVALID")
|
||||
elif self.scope_type == "legacy":
|
||||
if any(getattr(self, name) is not None for name in SCOPE_FIELDS):
|
||||
raise GovernanceError("GOVERNANCE_LEGACY_SCOPE_INVALID")
|
||||
else:
|
||||
raise GovernanceError("GOVERNANCE_SCOPE_TYPE_INVALID")
|
||||
|
||||
@classmethod
|
||||
def from_value(cls, value: Any) -> "GovernanceScope":
|
||||
def get(name: str) -> Any:
|
||||
return value.get(name) if isinstance(value, Mapping) else getattr(value, name, None)
|
||||
|
||||
declared = _clean(get("scope_type"))
|
||||
tenant_id = _clean(get("tenant_id"))
|
||||
return cls(
|
||||
scope_type=declared or ("tenant" if tenant_id else "legacy"),
|
||||
tenant_id=tenant_id,
|
||||
user_id=get("user_id"),
|
||||
project_id=get("project_id"),
|
||||
conversation_id=get("conversation_id"),
|
||||
run_id=get("run_id"),
|
||||
)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return asdict(self)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class GovernanceActor:
|
||||
identity: str
|
||||
scope: GovernanceScope
|
||||
capabilities: tuple[str, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "identity", _identifier(self.identity, "GOVERNANCE_ACTOR_INVALID"))
|
||||
object.__setattr__(self, "capabilities", _scope_tuple(self.capabilities))
|
||||
|
||||
@classmethod
|
||||
def from_value(cls, value: Any) -> "GovernanceActor":
|
||||
if isinstance(value, cls):
|
||||
return value
|
||||
if not isinstance(value, Mapping):
|
||||
raise GovernanceError("GOVERNANCE_ACTOR_INVALID")
|
||||
return cls(
|
||||
identity=value.get("identity") or value.get("actor_id") or value.get("user_id"),
|
||||
scope=GovernanceScope.from_value(value),
|
||||
capabilities=_scope_tuple(value.get("capabilities") or value.get("capability_scope")),
|
||||
)
|
||||
|
||||
@property
|
||||
def privileged(self) -> bool:
|
||||
return bool(set(self.capabilities) & PRIVILEGED_CAPABILITIES)
|
||||
|
||||
|
||||
def authorize_scope(actor: GovernanceActor | Mapping[str, Any], resource: GovernanceScope | Mapping[str, Any]) -> None:
|
||||
actor = GovernanceActor.from_value(actor)
|
||||
resource = resource if isinstance(resource, GovernanceScope) else GovernanceScope.from_value(resource)
|
||||
if actor.privileged:
|
||||
return
|
||||
if resource.scope_type in {"system", "global"}:
|
||||
raise GovernanceError("GOVERNANCE_SYSTEM_SCOPE_AUTHZ_DENY")
|
||||
if resource.scope_type == "legacy":
|
||||
if actor.scope.scope_type != "legacy":
|
||||
raise GovernanceError("GOVERNANCE_LEGACY_SCOPE_AUTHZ_DENY")
|
||||
return
|
||||
if actor.scope.scope_type != "tenant":
|
||||
raise GovernanceError("GOVERNANCE_SCOPE_MISMATCH_DENY", field="scope_type")
|
||||
for name in SCOPE_FIELDS:
|
||||
expected = getattr(resource, name)
|
||||
actual = getattr(actor.scope, name)
|
||||
if expected is not None and expected != actual:
|
||||
raise GovernanceError("GOVERNANCE_SCOPE_MISMATCH_DENY", field=name)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TraceContext:
|
||||
trace_id: str
|
||||
span_id: str
|
||||
scope: GovernanceScope
|
||||
parent_span_id: str | None = None
|
||||
correlation_id: str | None = None
|
||||
causation_id: str | None = None
|
||||
task_id: str | None = None
|
||||
claim_id: str | None = None
|
||||
message_id: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "trace_id", _identifier(self.trace_id, "TRACE_ID_INVALID"))
|
||||
object.__setattr__(self, "span_id", _identifier(self.span_id, "TRACE_SPAN_ID_INVALID"))
|
||||
for name in ("parent_span_id", "correlation_id", "causation_id", "task_id", "claim_id", "message_id"):
|
||||
value = _clean(getattr(self, name))
|
||||
if value is not None and IDENTIFIER_RE.fullmatch(value) is None:
|
||||
raise GovernanceError("TRACE_LINEAGE_IDENTIFIER_INVALID", field=name)
|
||||
object.__setattr__(self, name, value)
|
||||
|
||||
def child(self, *, span_id: str, actor: GovernanceActor | Mapping[str, Any], **lineage: Any) -> "TraceContext":
|
||||
authorize_scope(actor, self.scope)
|
||||
for name in ("trace_id", "task_id", "claim_id", "message_id", "correlation_id"):
|
||||
supplied = _clean(lineage.get(name))
|
||||
expected = getattr(self, name)
|
||||
if supplied is not None and expected is not None and supplied != expected:
|
||||
raise GovernanceError("TRACE_LINEAGE_CONTINUITY", field=name)
|
||||
requested_scope = lineage.pop("scope", self.scope)
|
||||
requested_scope = requested_scope if isinstance(requested_scope, GovernanceScope) else GovernanceScope.from_value(requested_scope)
|
||||
if requested_scope != self.scope:
|
||||
raise GovernanceError("TRACE_SCOPE_MISMATCH_DENY")
|
||||
return TraceContext(
|
||||
trace_id=self.trace_id,
|
||||
span_id=span_id,
|
||||
parent_span_id=self.span_id,
|
||||
correlation_id=self.correlation_id,
|
||||
causation_id=_clean(lineage.get("causation_id")) or self.span_id,
|
||||
task_id=self.task_id,
|
||||
claim_id=self.claim_id,
|
||||
message_id=self.message_id,
|
||||
scope=self.scope,
|
||||
)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"trace_id": self.trace_id,
|
||||
"span_id": self.span_id,
|
||||
"parent_span_id": self.parent_span_id,
|
||||
"correlation_id": self.correlation_id,
|
||||
"causation_id": self.causation_id,
|
||||
"task_id": self.task_id,
|
||||
"claim_id": self.claim_id,
|
||||
"message_id": self.message_id,
|
||||
**self.scope.as_dict(),
|
||||
}
|
||||
|
||||
|
||||
def bind_trace_lineage(
|
||||
*,
|
||||
actor: GovernanceActor | Mapping[str, Any],
|
||||
task: Any,
|
||||
message: Any,
|
||||
trace_id: str,
|
||||
span_id: str,
|
||||
parent: TraceContext | None = None,
|
||||
) -> TraceContext:
|
||||
actor = GovernanceActor.from_value(actor)
|
||||
task_scope = GovernanceScope.from_value(task)
|
||||
message_scope = GovernanceScope.from_value(message)
|
||||
if task_scope != message_scope:
|
||||
raise GovernanceError("TRACE_SCOPE_MISMATCH_DENY")
|
||||
authorize_scope(actor, task_scope)
|
||||
|
||||
def get(value: Any, name: str) -> Any:
|
||||
return value.get(name) if isinstance(value, Mapping) else getattr(value, name, None)
|
||||
|
||||
task_id = _clean(get(task, "task_id"))
|
||||
message_task = _clean(get(message, "task_id"))
|
||||
if task_id and message_task and task_id != message_task:
|
||||
raise GovernanceError("TRACE_LINEAGE_CONTINUITY", field="task_id")
|
||||
context = TraceContext(
|
||||
trace_id=trace_id,
|
||||
span_id=span_id,
|
||||
parent_span_id=parent.span_id if parent else None,
|
||||
correlation_id=_clean(get(task, "correlation_id")) or _clean(get(message, "correlation_id")),
|
||||
causation_id=_clean(get(message, "causation_id")) or (parent.span_id if parent else None),
|
||||
task_id=task_id or message_task,
|
||||
claim_id=_clean(get(task, "claim_id")) or _clean(get(message, "claim_id")),
|
||||
message_id=_clean(get(message, "envelope_id")) or _clean(get(message, "id")),
|
||||
scope=task_scope,
|
||||
)
|
||||
if parent is not None:
|
||||
if parent.trace_id != context.trace_id or parent.scope != context.scope:
|
||||
raise GovernanceError("TRACE_LINEAGE_CONTINUITY")
|
||||
if parent.task_id and context.task_id and parent.task_id != context.task_id:
|
||||
raise GovernanceError("TRACE_LINEAGE_CONTINUITY", field="task_id")
|
||||
return context
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ToolDescriptor:
|
||||
name: str
|
||||
kind: str
|
||||
scope: GovernanceScope
|
||||
required_capabilities: tuple[str, ...] = ()
|
||||
credential_ref: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "name", _identifier(self.name, "TOOL_NAME_INVALID"))
|
||||
object.__setattr__(self, "kind", (_clean(self.kind) or "").lower())
|
||||
if self.kind not in {"tool", "mcp"}:
|
||||
raise GovernanceError("TOOL_KIND_INVALID")
|
||||
object.__setattr__(self, "required_capabilities", _scope_tuple(self.required_capabilities))
|
||||
if self.credential_ref is not None and REFERENCE_RE.fullmatch(self.credential_ref) is None:
|
||||
raise GovernanceError("CREDENTIAL_SECRET_EXPOSURE_DENY")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CredentialGrant:
|
||||
grant_id: str
|
||||
credential_ref: str
|
||||
scope: GovernanceScope
|
||||
operations: tuple[str, ...]
|
||||
task_id: str | None = None
|
||||
tool_name: str | None = None
|
||||
expires_at: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
object.__setattr__(self, "grant_id", _identifier(self.grant_id, "CREDENTIAL_GRANT_ID_INVALID"))
|
||||
if REFERENCE_RE.fullmatch(self.credential_ref or "") is None:
|
||||
raise GovernanceError("CREDENTIAL_SECRET_EXPOSURE_DENY")
|
||||
object.__setattr__(self, "operations", _scope_tuple(self.operations))
|
||||
if not self.operations:
|
||||
raise GovernanceError("CREDENTIAL_OPERATIONS_REQUIRED")
|
||||
if self.expires_at is not None:
|
||||
parsed = datetime.fromisoformat(self.expires_at.replace("Z", "+00:00"))
|
||||
if parsed.tzinfo is None:
|
||||
raise GovernanceError("CREDENTIAL_EXPIRY_INVALID")
|
||||
|
||||
def authorize(
|
||||
self,
|
||||
actor: GovernanceActor | Mapping[str, Any],
|
||||
*,
|
||||
operation: str,
|
||||
task_id: str | None = None,
|
||||
tool_name: str | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> str:
|
||||
try:
|
||||
authorize_scope(actor, self.scope)
|
||||
except GovernanceError as exc:
|
||||
raise GovernanceError("CREDENTIAL_SCOPE_DENY", field=exc.field) from exc
|
||||
if operation not in self.operations:
|
||||
raise GovernanceError("CREDENTIAL_SCOPE_DENY", field="operation")
|
||||
if self.task_id is not None and self.task_id != _clean(task_id):
|
||||
raise GovernanceError("CREDENTIAL_SCOPE_DENY", field="task_id")
|
||||
if self.tool_name is not None and self.tool_name != _clean(tool_name):
|
||||
raise GovernanceError("CREDENTIAL_SCOPE_DENY", field="tool_name")
|
||||
if self.expires_at is not None:
|
||||
current = (now or datetime.now(timezone.utc)).astimezone(timezone.utc)
|
||||
expiry = datetime.fromisoformat(self.expires_at.replace("Z", "+00:00")).astimezone(timezone.utc)
|
||||
if current >= expiry:
|
||||
raise GovernanceError("CREDENTIAL_SCOPE_DENY", field="expires_at")
|
||||
return self.credential_ref
|
||||
|
||||
def public(self) -> dict[str, Any]:
|
||||
return {
|
||||
"grant_id": self.grant_id,
|
||||
"credential_ref": self.credential_ref,
|
||||
"operations": list(self.operations),
|
||||
"task_id": self.task_id,
|
||||
"tool_name": self.tool_name,
|
||||
"expires_at": self.expires_at,
|
||||
**self.scope.as_dict(),
|
||||
}
|
||||
|
||||
|
||||
_SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS governance_schema_metadata (
|
||||
singleton INTEGER PRIMARY KEY CHECK(singleton=1),
|
||||
schema_version INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS governed_tools (
|
||||
name TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
scope_json TEXT NOT NULL,
|
||||
required_capabilities_json TEXT NOT NULL,
|
||||
credential_ref TEXT,
|
||||
descriptor_sha256 TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS governed_trace_spans (
|
||||
trace_id TEXT NOT NULL,
|
||||
span_id TEXT NOT NULL,
|
||||
parent_span_id TEXT,
|
||||
context_json TEXT NOT NULL,
|
||||
context_sha256 TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY(trace_id, span_id)
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS governed_credential_grants (
|
||||
grant_id TEXT PRIMARY KEY,
|
||||
grant_json TEXT NOT NULL,
|
||||
grant_sha256 TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS governed_tool_invocations (
|
||||
invocation_key TEXT PRIMARY KEY,
|
||||
request_sha256 TEXT NOT NULL,
|
||||
tool_name TEXT NOT NULL,
|
||||
task_id TEXT NOT NULL,
|
||||
claim_id TEXT NOT NULL,
|
||||
trace_id TEXT NOT NULL,
|
||||
span_id TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
receipt_sha256 TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS governance_audit_evidence (
|
||||
sequence INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
evidence_id TEXT NOT NULL UNIQUE,
|
||||
action TEXT NOT NULL,
|
||||
actor_identity TEXT NOT NULL,
|
||||
trace_id TEXT,
|
||||
span_id TEXT,
|
||||
resource_id TEXT NOT NULL,
|
||||
outcome TEXT NOT NULL,
|
||||
details_json TEXT NOT NULL,
|
||||
previous_sha256 TEXT NOT NULL,
|
||||
evidence_sha256 TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
"""
|
||||
|
||||
|
||||
class GovernanceStore:
|
||||
def __init__(self, path: str | Path) -> None:
|
||||
self.path = Path(path)
|
||||
self.path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self._connection() as connection:
|
||||
has_metadata = connection.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='governance_schema_metadata'"
|
||||
).fetchone()
|
||||
if has_metadata:
|
||||
row = connection.execute(
|
||||
"SELECT schema_version FROM governance_schema_metadata WHERE singleton=1"
|
||||
).fetchone()
|
||||
if row is not None and int(row[0]) != 1:
|
||||
raise GovernanceError("GOVERNANCE_SCHEMA_VERSION_UNSUPPORTED")
|
||||
connection.executescript(_SCHEMA)
|
||||
row = connection.execute(
|
||||
"SELECT schema_version FROM governance_schema_metadata WHERE singleton=1"
|
||||
).fetchone()
|
||||
if row is None:
|
||||
connection.execute(
|
||||
"INSERT INTO governance_schema_metadata(singleton,schema_version) VALUES(1,1)"
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
connection = sqlite3.connect(self.path, timeout=30.0)
|
||||
connection.row_factory = sqlite3.Row
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
connection.execute("PRAGMA busy_timeout=30000")
|
||||
return connection
|
||||
|
||||
@contextmanager
|
||||
def _connection(self):
|
||||
connection = self._connect()
|
||||
try:
|
||||
yield connection
|
||||
connection.commit()
|
||||
except Exception:
|
||||
connection.rollback()
|
||||
raise
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
def _audit(
|
||||
self,
|
||||
connection: sqlite3.Connection,
|
||||
*,
|
||||
action: str,
|
||||
actor: GovernanceActor,
|
||||
resource_id: str,
|
||||
outcome: str,
|
||||
trace: TraceContext | None,
|
||||
details: Mapping[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
previous = connection.execute(
|
||||
"SELECT evidence_sha256 FROM governance_audit_evidence ORDER BY sequence DESC LIMIT 1"
|
||||
).fetchone()
|
||||
previous_sha = str(previous[0]) if previous else ""
|
||||
created_at = _now()
|
||||
material = {
|
||||
"action": action,
|
||||
"actor_identity": actor.identity,
|
||||
"trace_id": trace.trace_id if trace else None,
|
||||
"span_id": trace.span_id if trace else None,
|
||||
"resource_id": resource_id,
|
||||
"outcome": outcome,
|
||||
"details": dict(details),
|
||||
"previous_sha256": previous_sha,
|
||||
"created_at": created_at,
|
||||
}
|
||||
digest = _sha(material)
|
||||
evidence_id = "gev-" + digest[:32]
|
||||
connection.execute(
|
||||
"""INSERT INTO governance_audit_evidence
|
||||
(evidence_id,action,actor_identity,trace_id,span_id,resource_id,outcome,
|
||||
details_json,previous_sha256,evidence_sha256,created_at)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(
|
||||
evidence_id, action, actor.identity, material["trace_id"], material["span_id"],
|
||||
resource_id, outcome, _canonical(material["details"]), previous_sha, digest, created_at,
|
||||
),
|
||||
)
|
||||
return {"evidence_id": evidence_id, "evidence_sha256": digest, **material}
|
||||
|
||||
def record_trace(self, trace: TraceContext, actor: GovernanceActor | Mapping[str, Any]) -> dict[str, Any]:
|
||||
actor = GovernanceActor.from_value(actor)
|
||||
authorize_scope(actor, trace.scope)
|
||||
context = trace.as_dict()
|
||||
digest = _sha(context)
|
||||
with self._connection() as connection:
|
||||
existing = connection.execute(
|
||||
"SELECT context_sha256 FROM governed_trace_spans WHERE trace_id=? AND span_id=?",
|
||||
(trace.trace_id, trace.span_id),
|
||||
).fetchone()
|
||||
if existing is not None and existing[0] != digest:
|
||||
raise GovernanceError("TRACE_LINEAGE_CONFLICT")
|
||||
if existing is not None:
|
||||
return {"context": context, "context_sha256": digest, "idempotent": True}
|
||||
if trace.parent_span_id:
|
||||
parent = connection.execute(
|
||||
"SELECT context_json FROM governed_trace_spans WHERE trace_id=? AND span_id=?",
|
||||
(trace.trace_id, trace.parent_span_id),
|
||||
).fetchone()
|
||||
if parent is None:
|
||||
raise GovernanceError("TRACE_LINEAGE_CONTINUITY", field="parent_span_id")
|
||||
parent_context = json.loads(parent[0])
|
||||
if GovernanceScope.from_value(parent_context) != trace.scope:
|
||||
raise GovernanceError("TRACE_SCOPE_MISMATCH_DENY")
|
||||
connection.execute(
|
||||
"INSERT OR IGNORE INTO governed_trace_spans VALUES (?,?,?,?,?,?)",
|
||||
(trace.trace_id, trace.span_id, trace.parent_span_id, _canonical(context), digest, _now()),
|
||||
)
|
||||
evidence = self._audit(
|
||||
connection, action="TRACE_RECORDED", actor=actor,
|
||||
resource_id=f"{trace.trace_id}:{trace.span_id}", outcome="ALLOW", trace=trace,
|
||||
details={"context_sha256": digest},
|
||||
)
|
||||
return {"context": context, "context_sha256": digest, "evidence": evidence}
|
||||
|
||||
def register_tool(self, descriptor: ToolDescriptor, actor: GovernanceActor | Mapping[str, Any]) -> dict[str, Any]:
|
||||
actor = GovernanceActor.from_value(actor)
|
||||
if not actor.privileged:
|
||||
raise GovernanceError("TOOL_MCP_AUTHZ_DENY")
|
||||
public = {
|
||||
"name": descriptor.name,
|
||||
"kind": descriptor.kind,
|
||||
"required_capabilities": list(descriptor.required_capabilities),
|
||||
"credential_ref": descriptor.credential_ref,
|
||||
**descriptor.scope.as_dict(),
|
||||
}
|
||||
digest = _sha(public)
|
||||
with self._connection() as connection:
|
||||
existing = connection.execute(
|
||||
"SELECT descriptor_sha256 FROM governed_tools WHERE name=?", (descriptor.name,)
|
||||
).fetchone()
|
||||
if existing is not None and existing[0] != digest:
|
||||
raise GovernanceError("TOOL_DESCRIPTOR_CONFLICT")
|
||||
if existing is not None:
|
||||
return {"descriptor": public, "descriptor_sha256": digest, "idempotent": True}
|
||||
connection.execute(
|
||||
"INSERT OR IGNORE INTO governed_tools VALUES (?,?,?,?,?,?)",
|
||||
(descriptor.name, descriptor.kind, _canonical(descriptor.scope.as_dict()),
|
||||
_canonical(descriptor.required_capabilities), descriptor.credential_ref, digest),
|
||||
)
|
||||
evidence = self._audit(
|
||||
connection, action="TOOL_REGISTERED", actor=actor, resource_id=descriptor.name,
|
||||
outcome="ALLOW", trace=None, details={"descriptor_sha256": digest},
|
||||
)
|
||||
return {"descriptor": public, "descriptor_sha256": digest, "evidence": evidence}
|
||||
|
||||
def authorize_tool(
|
||||
self,
|
||||
*,
|
||||
actor: GovernanceActor | Mapping[str, Any],
|
||||
tool_name: str,
|
||||
trace: TraceContext,
|
||||
requested_scope: GovernanceScope | Mapping[str, Any],
|
||||
input_digest: str,
|
||||
invocation_key: str,
|
||||
) -> dict[str, Any]:
|
||||
actor = GovernanceActor.from_value(actor)
|
||||
invocation_key = _identifier(invocation_key, "TOOL_INVOCATION_KEY_INVALID")
|
||||
if not re.fullmatch(r"[0-9a-f]{64}", input_digest):
|
||||
raise GovernanceError("TOOL_INPUT_DIGEST_INVALID")
|
||||
if trace.task_id is None or trace.claim_id is None:
|
||||
raise GovernanceError("TOOL_MCP_AUTHZ_DENY", field="active_claim")
|
||||
requested_scope = requested_scope if isinstance(requested_scope, GovernanceScope) else GovernanceScope.from_value(requested_scope)
|
||||
if requested_scope != trace.scope:
|
||||
raise GovernanceError("TOOL_SCOPE_PROPAGATION")
|
||||
authorize_scope(actor, requested_scope)
|
||||
with self._connection() as connection:
|
||||
persisted_trace = connection.execute(
|
||||
"SELECT context_sha256 FROM governed_trace_spans WHERE trace_id=? AND span_id=?",
|
||||
(trace.trace_id, trace.span_id),
|
||||
).fetchone()
|
||||
if persisted_trace is None or persisted_trace[0] != _sha(trace.as_dict()):
|
||||
raise GovernanceError("TOOL_MCP_AUTHZ_DENY", field="trace")
|
||||
row = connection.execute("SELECT * FROM governed_tools WHERE name=?", (tool_name,)).fetchone()
|
||||
if row is None:
|
||||
raise GovernanceError("TOOL_MCP_AUTHZ_DENY")
|
||||
descriptor_scope = GovernanceScope.from_value(json.loads(row["scope_json"]))
|
||||
if descriptor_scope.scope_type == "tenant" and descriptor_scope != requested_scope:
|
||||
raise GovernanceError("TOOL_MCP_AUTHZ_DENY")
|
||||
required = set(json.loads(row["required_capabilities_json"]))
|
||||
if not required.issubset(set(actor.capabilities)):
|
||||
raise GovernanceError("TOOL_MCP_AUTHZ_DENY")
|
||||
request_sha = _sha({
|
||||
"tool_name": tool_name, "input_digest": input_digest,
|
||||
"task_id": trace.task_id, "claim_id": trace.claim_id,
|
||||
"trace_id": trace.trace_id, "span_id": trace.span_id,
|
||||
"scope": requested_scope.as_dict(),
|
||||
})
|
||||
replay = connection.execute(
|
||||
"SELECT * FROM governed_tool_invocations WHERE invocation_key=?",
|
||||
(invocation_key,),
|
||||
).fetchone()
|
||||
if replay is not None:
|
||||
if replay["request_sha256"] != request_sha:
|
||||
raise GovernanceError("TOOL_INVOCATION_IDEMPOTENCY_CONFLICT")
|
||||
if replay["status"] != "VERIFIED":
|
||||
raise GovernanceError("TOOL_INVOCATION_RECONCILIATION_REQUIRED")
|
||||
return {
|
||||
"tool_name": tool_name, "trace_id": trace.trace_id,
|
||||
"span_id": trace.span_id, "invocation_key": invocation_key,
|
||||
"status": "VERIFIED", "receipt_sha256": replay["receipt_sha256"],
|
||||
"idempotent": True,
|
||||
}
|
||||
created_at = _now()
|
||||
connection.execute(
|
||||
"""INSERT INTO governed_tool_invocations
|
||||
(invocation_key,request_sha256,tool_name,task_id,claim_id,trace_id,span_id,
|
||||
status,receipt_sha256,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(invocation_key, request_sha, tool_name, trace.task_id, trace.claim_id,
|
||||
trace.trace_id, trace.span_id, "AUTHORIZED", None, created_at, created_at),
|
||||
)
|
||||
evidence = self._audit(
|
||||
connection, action="TOOL_AUTHORIZED", actor=actor, resource_id=tool_name,
|
||||
outcome="ALLOW", trace=trace,
|
||||
details={"input_sha256": input_digest, "scope_sha256": _sha(requested_scope.as_dict()),
|
||||
"invocation_key": invocation_key},
|
||||
)
|
||||
return {
|
||||
"tool_name": tool_name,
|
||||
"trace_id": trace.trace_id,
|
||||
"span_id": trace.span_id,
|
||||
"invocation_key": invocation_key,
|
||||
"status": "AUTHORIZED",
|
||||
"credential_required": row["credential_ref"] is not None,
|
||||
"evidence": evidence,
|
||||
}
|
||||
|
||||
def record_tool_receipt(
|
||||
self,
|
||||
*,
|
||||
invocation_key: str,
|
||||
result_digest: str,
|
||||
actor: GovernanceActor | Mapping[str, Any],
|
||||
trace: TraceContext,
|
||||
) -> dict[str, Any]:
|
||||
actor = GovernanceActor.from_value(actor)
|
||||
invocation_key = _identifier(invocation_key, "TOOL_INVOCATION_KEY_INVALID")
|
||||
if not re.fullmatch(r"[0-9a-f]{64}", result_digest):
|
||||
raise GovernanceError("TOOL_RECEIPT_DIGEST_INVALID")
|
||||
authorize_scope(actor, trace.scope)
|
||||
with self._connection() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT * FROM governed_tool_invocations WHERE invocation_key=?",
|
||||
(invocation_key,),
|
||||
).fetchone()
|
||||
if row is None or row["trace_id"] != trace.trace_id or row["span_id"] != trace.span_id:
|
||||
raise GovernanceError("TOOL_RECEIPT_BINDING_INVALID")
|
||||
if row["status"] == "VERIFIED":
|
||||
if row["receipt_sha256"] != result_digest:
|
||||
raise GovernanceError("TOOL_RECEIPT_CONFLICT")
|
||||
return {"invocation_key": invocation_key, "status": "VERIFIED", "idempotent": True}
|
||||
if row["status"] != "AUTHORIZED":
|
||||
raise GovernanceError("TOOL_INVOCATION_RECONCILIATION_REQUIRED")
|
||||
connection.execute(
|
||||
"UPDATE governed_tool_invocations SET status='VERIFIED',receipt_sha256=?,updated_at=? "
|
||||
"WHERE invocation_key=? AND status='AUTHORIZED'",
|
||||
(result_digest, _now(), invocation_key),
|
||||
)
|
||||
evidence = self._audit(
|
||||
connection, action="TOOL_RESULT_VERIFIED", actor=actor,
|
||||
resource_id=row["tool_name"], outcome="ALLOW", trace=trace,
|
||||
details={"invocation_key": invocation_key, "result_sha256": result_digest},
|
||||
)
|
||||
return {"invocation_key": invocation_key, "status": "VERIFIED", "evidence": evidence}
|
||||
|
||||
def store_grant(self, grant: CredentialGrant, actor: GovernanceActor | Mapping[str, Any]) -> dict[str, Any]:
|
||||
actor = GovernanceActor.from_value(actor)
|
||||
if not actor.privileged:
|
||||
raise GovernanceError("CREDENTIAL_SCOPE_DENY")
|
||||
public = grant.public()
|
||||
digest = _sha(public)
|
||||
with self._connection() as connection:
|
||||
existing = connection.execute(
|
||||
"SELECT grant_sha256 FROM governed_credential_grants WHERE grant_id=?", (grant.grant_id,)
|
||||
).fetchone()
|
||||
if existing is not None and existing[0] != digest:
|
||||
raise GovernanceError("CREDENTIAL_GRANT_CONFLICT")
|
||||
connection.execute(
|
||||
"INSERT OR IGNORE INTO governed_credential_grants VALUES (?,?,?,?)",
|
||||
(grant.grant_id, _canonical(public), digest, _now()),
|
||||
)
|
||||
evidence = self._audit(
|
||||
connection, action="CREDENTIAL_GRANT_STORED", actor=actor,
|
||||
resource_id=grant.grant_id, outcome="ALLOW", trace=None,
|
||||
details={"grant_sha256": digest},
|
||||
)
|
||||
return {"grant": public, "grant_sha256": digest, "evidence": evidence}
|
||||
|
||||
def authorize_credential(
|
||||
self,
|
||||
*,
|
||||
grant_id: str,
|
||||
actor: GovernanceActor | Mapping[str, Any],
|
||||
trace: TraceContext,
|
||||
operation: str,
|
||||
task_id: str | None,
|
||||
tool_name: str | None,
|
||||
now: datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
actor = GovernanceActor.from_value(actor)
|
||||
with self._connection() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT grant_json FROM governed_credential_grants WHERE grant_id=?", (grant_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise GovernanceError("CREDENTIAL_SCOPE_DENY")
|
||||
raw = json.loads(row[0])
|
||||
grant = CredentialGrant(
|
||||
grant_id=raw["grant_id"], credential_ref=raw["credential_ref"],
|
||||
scope=GovernanceScope.from_value(raw), operations=tuple(raw["operations"]),
|
||||
task_id=raw.get("task_id"), tool_name=raw.get("tool_name"),
|
||||
expires_at=raw.get("expires_at"),
|
||||
)
|
||||
if trace.scope != grant.scope:
|
||||
raise GovernanceError("CREDENTIAL_SCOPE_DENY", field="trace_scope")
|
||||
reference = grant.authorize(
|
||||
actor, operation=operation, task_id=task_id, tool_name=tool_name, now=now
|
||||
)
|
||||
evidence = self._audit(
|
||||
connection, action="CREDENTIAL_AUTHORIZED", actor=actor,
|
||||
resource_id=grant_id, outcome="ALLOW", trace=trace,
|
||||
details={"operation": operation, "task_id": task_id, "tool_name": tool_name,
|
||||
"credential_ref_sha256": _sha(reference)},
|
||||
)
|
||||
return {"credential_ref": reference, "evidence": evidence}
|
||||
|
||||
def verify_audit_chain(self, required_actions: Iterable[str] = ()) -> bool:
|
||||
ordered_actions: list[str] = []
|
||||
previous = ""
|
||||
with self._connection() as connection:
|
||||
rows = connection.execute("SELECT * FROM governance_audit_evidence ORDER BY sequence").fetchall()
|
||||
for row in rows:
|
||||
details = json.loads(row["details_json"])
|
||||
material = {
|
||||
"action": row["action"], "actor_identity": row["actor_identity"],
|
||||
"trace_id": row["trace_id"], "span_id": row["span_id"],
|
||||
"resource_id": row["resource_id"], "outcome": row["outcome"],
|
||||
"details": details, "previous_sha256": row["previous_sha256"],
|
||||
"created_at": row["created_at"],
|
||||
}
|
||||
if row["previous_sha256"] != previous or row["evidence_sha256"] != _sha(material):
|
||||
return False
|
||||
previous = row["evidence_sha256"]
|
||||
ordered_actions.append(row["action"])
|
||||
position = 0
|
||||
for required in required_actions:
|
||||
try:
|
||||
position = ordered_actions.index(required, position) + 1
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CredentialGrant", "GovernanceActor", "GovernanceError", "GovernanceScope",
|
||||
"GovernanceStore", "ToolDescriptor", "TraceContext", "authorize_scope",
|
||||
"bind_trace_lineage",
|
||||
]
|
||||
@@ -0,0 +1,36 @@
|
||||
-- R4.2 P0-B durable Message/Handoff Fabric.
|
||||
-- Forward-only. The existing 20260707 message_outbox remains a compatibility source.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS message_handoff (
|
||||
envelope_id TEXT PRIMARY KEY,
|
||||
stream_key TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
target TEXT NOT NULL,
|
||||
message_type TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
payload_sha256 TEXT NOT NULL,
|
||||
tenant_id TEXT,
|
||||
user_id TEXT,
|
||||
project_id TEXT,
|
||||
conversation_id TEXT,
|
||||
task_id TEXT,
|
||||
run_id TEXT,
|
||||
causation_id TEXT,
|
||||
sequence INTEGER NOT NULL CHECK(sequence >= 1),
|
||||
capability_scope_json TEXT NOT NULL,
|
||||
confidentiality_scope TEXT NOT NULL,
|
||||
idempotency_key TEXT NOT NULL,
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'PENDING',
|
||||
delivered_at TEXT,
|
||||
acked_at TEXT,
|
||||
UNIQUE(stream_key, idempotency_key),
|
||||
UNIQUE(stream_key, sequence)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_message_handoff_target_status
|
||||
ON message_handoff(target, status, created_at);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_message_handoff_scope
|
||||
ON message_handoff(tenant_id, project_id, user_id, conversation_id, run_id);
|
||||
@@ -240,6 +240,114 @@ components:
|
||||
additionalProperties: false
|
||||
properties:
|
||||
candidate_id: {type: string}
|
||||
BridgeWriterLeaseRecoveryRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [schema, task_id, repository, branch_mode, base_oid, expected_branch_oid, target_branch, generation, lease_seconds]
|
||||
properties:
|
||||
schema:
|
||||
type: string
|
||||
enum: [aios.task-writer-lease.v2, aios.task-writer-lease-request.v2]
|
||||
task_id: {type: string, minLength: 1, maxLength: 256}
|
||||
job_id: {type: string, pattern: '^job-[A-Za-z0-9][A-Za-z0-9._-]{7,255}$'}
|
||||
repository: {type: string, const: aios/aios}
|
||||
branch_mode: {type: string, enum: [CREATE_NEW, CONTINUE_EXISTING]}
|
||||
base_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||
expected_branch_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||
target_branch: {type: string, pattern: '^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$'}
|
||||
generation: {type: integer, minimum: 1}
|
||||
lease_seconds: {type: integer, minimum: 1, maximum: 86400}
|
||||
BridgeWriterLeaseResponse:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [schema, task_id, job_id, repository, branch_mode, base_oid, expected_branch_oid, target_branch, generation, lease_seconds, create_target_branch, worktree_mode, cas_ref_update_required]
|
||||
properties:
|
||||
schema: {type: string, enum: [aios.task-writer-lease.v2]}
|
||||
task_id: {type: string}
|
||||
job_id: {type: string, pattern: '^job-[A-Za-z0-9][A-Za-z0-9._-]{7,255}$'}
|
||||
repository: {type: string, const: aios/aios}
|
||||
branch_mode: {type: string, enum: [CREATE_NEW, CONTINUE_EXISTING]}
|
||||
base_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||
expected_branch_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||
target_branch: {type: string, pattern: '^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$'}
|
||||
generation: {type: integer, minimum: 1}
|
||||
lease_seconds: {type: integer, minimum: 1, maximum: 86400}
|
||||
create_target_branch: {type: boolean}
|
||||
worktree_mode: {type: string, enum: [detached_at_base_oid, detached_at_expected_head]}
|
||||
cas_ref_update_required: {type: boolean, const: true}
|
||||
lease_expires_at: {type: string, format: date-time}
|
||||
BridgeWriterLeaseRecoveryResponse:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [schema, task_id, job_id, repository, branch_mode, base_oid, expected_branch_oid, target_branch, generation, lease_seconds, lease_expires_at, create_target_branch, worktree_mode, cas_ref_update_required, recovered, bootstrapped, idempotent, runtime_rehydrated, durable_branch_lease_present]
|
||||
properties:
|
||||
schema: {type: string, enum: [aios.task-writer-lease.v2]}
|
||||
task_id: {type: string}
|
||||
job_id: {type: string, pattern: '^job-[A-Za-z0-9][A-Za-z0-9._-]{7,255}$'}
|
||||
repository: {type: string, const: aios/aios}
|
||||
branch_mode: {type: string, enum: [CREATE_NEW, CONTINUE_EXISTING]}
|
||||
base_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||
expected_branch_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||
target_branch: {type: string, pattern: '^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$'}
|
||||
generation: {type: integer, minimum: 1}
|
||||
lease_seconds: {type: integer, minimum: 1, maximum: 86400}
|
||||
lease_expires_at: {type: string, format: date-time}
|
||||
create_target_branch: {type: boolean, const: false}
|
||||
worktree_mode: {type: string, enum: [detached_at_base_oid, detached_at_expected_head]}
|
||||
cas_ref_update_required: {type: boolean, const: true}
|
||||
recovered: {type: boolean}
|
||||
bootstrapped: {type: boolean}
|
||||
idempotent: {type: boolean}
|
||||
runtime_rehydrated: {type: boolean}
|
||||
durable_branch_lease_present: {type: boolean}
|
||||
BridgeCommitFileRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [task_id, repository, expected_branch_oid, target_branch, generation, idempotency_key, path, content_base64, message]
|
||||
properties:
|
||||
task_id: {type: string, minLength: 1}
|
||||
job_id: {type: string, pattern: '^job-[A-Za-z0-9][A-Za-z0-9._-]{7,255}$'}
|
||||
repository: {type: string, const: aios/aios}
|
||||
expected_branch_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||
target_branch: {type: string, pattern: '^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$'}
|
||||
generation: {type: integer, minimum: 1}
|
||||
idempotency_key: {type: string, minLength: 1, maxLength: 256}
|
||||
path: {type: string, minLength: 1, maxLength: 512}
|
||||
content_base64: {type: string, format: byte}
|
||||
message: {type: string, minLength: 1, maxLength: 200}
|
||||
BridgeCommitFilesRequest:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [task_id, repository, expected_branch_oid, target_branch, generation, idempotency_key, files, message]
|
||||
properties:
|
||||
task_id: {type: string, minLength: 1}
|
||||
job_id: {type: string, pattern: '^job-[A-Za-z0-9][A-Za-z0-9._-]{7,255}$'}
|
||||
repository: {type: string, const: aios/aios}
|
||||
expected_branch_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||
target_branch: {type: string, pattern: '^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$'}
|
||||
generation: {type: integer, minimum: 1}
|
||||
idempotency_key: {type: string, minLength: 1, maxLength: 256}
|
||||
files: {type: object, additionalProperties: {type: string, format: byte}, minProperties: 1}
|
||||
message: {type: string, minLength: 1, maxLength: 200}
|
||||
BridgeCommitResponse:
|
||||
type: object
|
||||
additionalProperties: true
|
||||
required: [job_id, commit_oid, commit_visible, branch_mode, target_branch, generation]
|
||||
properties:
|
||||
job_id: {type: string, pattern: '^job-[A-Za-z0-9][A-Za-z0-9._-]{7,255}$'}
|
||||
commit_oid: {type: string, pattern: '^[0-9a-f]{40}$'}
|
||||
commit_visible: {type: boolean}
|
||||
branch_mode: {type: string, enum: [CONTINUE_EXISTING]}
|
||||
target_branch: {type: string, pattern: '^agent/[A-Za-z0-9][A-Za-z0-9._/-]{2,179}$'}
|
||||
generation: {type: integer, minimum: 1}
|
||||
BridgeGovernedCapabilities:
|
||||
type: object
|
||||
required: [bridge_schema_version, governed_writes_enabled, operations, operation_contracts]
|
||||
properties:
|
||||
bridge_schema_version: {type: string, const: aios.chatgpt-gitops-bridge.v2}
|
||||
governed_writes_enabled: {type: boolean, const: true}
|
||||
operations: {type: array, items: {type: string}}
|
||||
operation_contracts: {type: object, additionalProperties: true}
|
||||
paths:
|
||||
/createReleaseIntent:
|
||||
post:
|
||||
@@ -567,3 +675,118 @@ paths:
|
||||
'200': {description: Safe GitOps plan}
|
||||
'401': {description: Unauthorized}
|
||||
x-openai-isConsequential: true
|
||||
/v2/capabilities:
|
||||
get:
|
||||
operationId: getGovernedWriteCapabilities
|
||||
summary: Read the governed Bridge v2 capability contract
|
||||
responses:
|
||||
'200':
|
||||
description: Governed operations and their request/response contracts
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/BridgeGovernedCapabilities'}
|
||||
/v2/writer-lease:
|
||||
post:
|
||||
operationId: acquireWriterLease
|
||||
summary: Acquire the exact governed writer lease for a task generation
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/BridgeWriterLeaseRecoveryRequest'}
|
||||
responses:
|
||||
'200':
|
||||
description: Exact detached worktree lease binding
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/BridgeWriterLeaseResponse'}
|
||||
'400': {description: Invalid lease binding}
|
||||
'409': {description: Branch, base, or generation conflict}
|
||||
x-openai-isConsequential: true
|
||||
/v2/writer-lease/bootstrap:
|
||||
post:
|
||||
operationId: bootstrapWriterLease
|
||||
summary: Bootstrap a governed writer runtime without creating a durable lease
|
||||
description: >
|
||||
Staging-only runtime recovery. It validates the exact task, repository,
|
||||
base OID, expected branch OID, target branch, and generation. It must
|
||||
not create a branch, insert a branch lease, or consume a generation.
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/BridgeWriterLeaseRecoveryRequest'}
|
||||
responses:
|
||||
'200':
|
||||
description: Runtime binding bootstrapped or returned idempotently
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/BridgeWriterLeaseRecoveryResponse'}
|
||||
'400': {description: Invalid lease binding}
|
||||
'409': {description: A matching durable lease already exists or binding conflicts}
|
||||
'503': {description: Runtime or durable state unavailable}
|
||||
x-openai-isConsequential: true
|
||||
/v2/writer-lease/renew:
|
||||
post:
|
||||
operationId: renewWriterLease
|
||||
summary: Recover an expired governed writer lease without changing its binding
|
||||
description: >
|
||||
Renews only the current task generation and exact repository, base OID,
|
||||
expected branch OID, and target branch. Durable renewal is revision-CAS
|
||||
protected; runtime rehydration is reported separately.
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/BridgeWriterLeaseRecoveryRequest'}
|
||||
responses:
|
||||
'200':
|
||||
description: Lease recovered or idempotently observed as already active
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/BridgeWriterLeaseRecoveryResponse'}
|
||||
'400': {description: Invalid schema, generation, or lease duration}
|
||||
'409': {description: Task, base, repository, branch, generation, or durable lease conflict}
|
||||
'503': {description: Durable job store or runtime unavailable}
|
||||
x-openai-isConsequential: true
|
||||
/v2/commit-file:
|
||||
post:
|
||||
operationId: commitFileGoverned
|
||||
summary: Commit one file through the governed writer lease
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/BridgeCommitFileRequest'}
|
||||
responses:
|
||||
'200':
|
||||
description: Authority and remote visibility receipt
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/BridgeCommitResponse'}
|
||||
'409': {description: Lease, expected-head, idempotency, or repository conflict}
|
||||
'503': {description: Reconciliation required}
|
||||
x-openai-isConsequential: true
|
||||
/v2/commit-files:
|
||||
post:
|
||||
operationId: commitFilesGoverned
|
||||
summary: Atomically commit a bounded set of files through the governed writer lease
|
||||
security: [{bearerAuth: []}]
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/BridgeCommitFilesRequest'}
|
||||
responses:
|
||||
'200':
|
||||
description: Authority and remote visibility receipt
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/BridgeCommitResponse'}
|
||||
'409': {description: Lease, expected-head, idempotency, or repository conflict}
|
||||
'503': {description: Reconciliation required}
|
||||
x-openai-isConsequential: true
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# R4.2 P0-A TaskPlane Scope Isolation Design
|
||||
|
||||
Status: approved candidate design. This document is not evidence of a protected-main merge or production deployment.
|
||||
|
||||
## Boundary
|
||||
|
||||
- Task: `r42-p0a-taskplane-scope-isolation-20260816`
|
||||
- Governed Job: `job-3fd743b51971c6007923699c1233f84c`
|
||||
- Repository: `aios/aios`
|
||||
- Base: `0eb6e1a864edfdaeef37b438991707a110e21558`
|
||||
- Target branch: `agent/r42-p0a-taskplane-scope-isolation-20260816`
|
||||
- Generation: `1`
|
||||
|
||||
The implementation reuses this exact boundary. It does not create a replacement Task, Job, branch, generation, or idempotency key.
|
||||
|
||||
## Model and invariants
|
||||
|
||||
`TaskPlan` gains first-class `scope_type`, `tenant_id`, `user_id`, `project_id`, `conversation_id`, and `run_id` fields. The fields participate in accepted-key validation, parsing, serialization, persistence, and authorization.
|
||||
|
||||
Historical rows without scope data normalize to `scope_type=legacy`; no tenant is inferred. Tenant-scoped tasks require a non-empty `tenant_id`. System/global resources use an explicit `scope_type=system` invariant and do not receive a fabricated tenant. A task may add user, project, conversation, and run ownership within its tenant.
|
||||
|
||||
## Trusted ActorContext
|
||||
|
||||
TaskPlane defines a small immutable `TaskActorContext` used only for authorization. The HTTP authenticator returns an identity. `PolicyEngine` resolves that identity through server-owned policy configuration into the effective context and capabilities. Request fields such as `tenant_id`, `user_id`, `group_id`, and `role` are declarations only and never expand authority.
|
||||
|
||||
Public create, list, and get paths always use the resolved context. Internal compatibility calls may omit a context and retain explicit legacy behavior, but the HTTP adapter never uses that path.
|
||||
|
||||
## Persistence
|
||||
|
||||
SQLite remains the current runtime. `_create_schema()` performs an idempotent forward migration that adds the six scope columns and indexes, then backfills only missing `scope_type` values to `legacy`.
|
||||
|
||||
New writes persist scope in both the canonical `task_json` representation and first-class columns. Reads accept historical JSON without scope, using legacy columns, but reject a new-row mismatch between JSON and columns. SQL list/get predicates are parameterized and applied before row hydration.
|
||||
|
||||
PostgreSQL remains a separate migration target and is not claimed by this change.
|
||||
|
||||
## Authorization flow
|
||||
|
||||
1. HTTP bearer authentication yields an identity.
|
||||
2. `PolicyEngine` verifies capability and resolves the identity to `TaskActorContext`.
|
||||
3. Create binds the requested scope to the trusted context and rejects mismatches.
|
||||
4. List/get pass the trusted context to `TaskPlaneStore`.
|
||||
5. Store applies tenant and ownership predicates in SQL; privileged capabilities may explicitly cross scope.
|
||||
|
||||
Tenant actors cannot read legacy tasks, another tenant, or mismatched owned resources. Legacy actors can only read legacy tasks. System actors require explicit privileged capability for cross-scope access.
|
||||
|
||||
## Failure behavior
|
||||
|
||||
Missing tenant scope, forged scope, unknown scope types, JSON/column divergence, and unauthorized list/get operations fail closed with canonical TaskPlane errors. Public get does not reveal whether an out-of-scope task exists.
|
||||
|
||||
## Verification
|
||||
|
||||
Tests exercise real model round trips, SQLite migration/restart, Service/Policy/Store wiring, tenant A versus tenant B list/get denial, ownership filtering, forged create payload rejection, legacy isolation, privileged access, and SQL parameterization. Existing TaskPlane API, RBAC, recovery/security, and security-contract suites remain mandatory.
|
||||
|
||||
The candidate is complete only after real `TASK_CLAIMED`, `TASK_HEARTBEAT`, exact WriterLease, governed commit, OID convergence, result submission, and `TASK_VERIFIED`. Protected main and production remain unchanged.
|
||||
@@ -0,0 +1,90 @@
|
||||
# R4.2 P0-C Trace / Tool-MCP / Credential Governance
|
||||
|
||||
## Status boundary
|
||||
|
||||
This change is a candidate implementation on the governed P0-C branch. It does
|
||||
not promote protected `main`, deploy Production, or assert that all R4.2 target
|
||||
architecture is complete.
|
||||
|
||||
## Scope model
|
||||
|
||||
Governance resources use an explicit `scope_type`:
|
||||
|
||||
- `tenant` requires `tenant_id` and may narrow to user, project,
|
||||
conversation, and run;
|
||||
- `system` and `global` prohibit tenant ownership fields;
|
||||
- `legacy` preserves unscoped historical records without inventing ownership.
|
||||
|
||||
System and global tools therefore never receive a synthetic tenant merely to
|
||||
satisfy a tenant-oriented schema.
|
||||
|
||||
## Trace lineage
|
||||
|
||||
`TaskPlan` and `MessageEnvelope` scope is bound to a `TraceContext`. The trace
|
||||
records `trace_id`, `span_id`, parent span, correlation, causation, task,
|
||||
message, and the canonical P0-A scope. Child spans must keep the parent's trace,
|
||||
task, and scope. Privileged visibility does not permit cross-scope trace
|
||||
splicing.
|
||||
|
||||
`MessageEnvelope.correlation_id` is persisted and identity-bound. Existing
|
||||
handoff databases are upgraded in place with an idempotent column migration;
|
||||
legacy rows retain their prior event IDs because a null correlation field is
|
||||
not added to the historical identity material.
|
||||
|
||||
Observability keeps its legacy presentation fields, but new governed records
|
||||
also carry immutable trace and scope fields. Scoped records require a trusted
|
||||
server actor for queries. A repeated execution ID is an idempotent replay only
|
||||
when the complete record matches; conflicting overwrites are denied.
|
||||
|
||||
## Tool and MCP governance
|
||||
|
||||
`ToolDescriptor` binds a tool or MCP server to:
|
||||
|
||||
- an explicit resource scope;
|
||||
- required actor capabilities;
|
||||
- an optional credential reference.
|
||||
|
||||
Invocation authorization requires exact propagation of the trace scope and a
|
||||
capability superset. Unknown tools, cross-tenant calls, scope substitution, and
|
||||
non-privileged access to system/global resources fail closed. Every successful
|
||||
registration and invocation appends hash-chained audit evidence.
|
||||
|
||||
## Credential governance
|
||||
|
||||
Credentials are represented only by references whose schemes are
|
||||
`systemd-credential`, `vault`, or `secret-manager`. Raw bearer tokens, passwords,
|
||||
and arbitrary values are rejected. Grants are bound to scope, operation, task,
|
||||
tool, and expiry. Public/audit records contain a reference hash where disclosure
|
||||
is unnecessary; secret material is never persisted by this module.
|
||||
|
||||
The existing runtime credential broker remains backward compatible for system
|
||||
leases, adds explicit tenant/system/global invariants, and rejects providers
|
||||
that attempt to return raw token/secret/password environment values instead of
|
||||
file/reference indirection.
|
||||
|
||||
## Durable evidence
|
||||
|
||||
The SQLite governance store is restart-safe and idempotently initializes four
|
||||
tables: governed tools, trace spans, credential grants, and audit evidence.
|
||||
Audit entries form a SHA-256 chain. Acceptance requires the chain to verify and
|
||||
contain the required actions; missing evidence is not treated as success.
|
||||
|
||||
## Acceptance tests
|
||||
|
||||
The candidate must pass:
|
||||
|
||||
- `TRACE_LINEAGE_CONTINUITY`;
|
||||
- `TRACE_SCOPE_MISMATCH_DENY`;
|
||||
- `TOOL_MCP_AUTHZ_DENY`;
|
||||
- `TOOL_SCOPE_PROPAGATION`;
|
||||
- `CREDENTIAL_SCOPE_DENY`;
|
||||
- `CREDENTIAL_SECRET_EXPOSURE_DENY`;
|
||||
- `AUDIT_EVIDENCE_COMPLETENESS`;
|
||||
- P0-A scope and P0-B handoff regression suites;
|
||||
- Python compilation, `git diff --check`, and a diff-scoped secret scan.
|
||||
|
||||
The repository-wide baseline currently has seven unrelated failures in the
|
||||
isolated host environment (five stale continuation-contract fixtures and two
|
||||
host-permission fixtures). The same seven failures reproduce at the untouched
|
||||
`a642b045775b9154a00ae78543037fbef4fb447b` base; they are not suppressed or
|
||||
modified by P0-C.
|
||||
@@ -0,0 +1,9 @@
|
||||
# R4.2 P0-A TaskPlane Scope Isolation
|
||||
|
||||
Status: candidate implementation. This is not proof of protected-main promotion or production deployment.
|
||||
|
||||
Task scope is now represented by `scope_type`, `tenant_id`, `user_id`, `project_id`, `conversation_id`, and `run_id` in the model and SQLite persistence. Historical rows become `legacy`; their tenant is never guessed. Tenant tasks require `tenant_id`. System resources use `scope_type=system` without a fabricated tenant.
|
||||
|
||||
The HTTP adapter uses the authenticated identity. `PolicyEngine` resolves that identity through server-owned `identity_actor_contexts`; request payload fields do not grant scope. Public create binds the trusted scope, and public list/get apply parameterized scope predicates in `TaskPlaneStore`.
|
||||
|
||||
SQLite is the current runtime database. PostgreSQL remains a separate migration and runtime-acceptance gate.
|
||||
@@ -0,0 +1,38 @@
|
||||
# R4.2 P0-B — Durable Message / Handoff Fabric
|
||||
|
||||
Status: candidate implementation; not evidence of protected-main merge or Production deployment.
|
||||
|
||||
`MessageEnvelope` is the single durable handoff identity and carries source/target/type, tenant/user/
|
||||
project/conversation/task/run scope, causation, sequence, capability scope, confidentiality scope,
|
||||
idempotency key, expiry and delivery state.
|
||||
|
||||
Identity includes **target + scope + sequence + idempotency key + payload hash**, fixing the existing
|
||||
AgentEventBus target collision. Server ActorContext is authoritative; caller scope may narrow but may
|
||||
not widen/forge trusted tenant/user/project/conversation/run fields.
|
||||
|
||||
`DurableHandoffStore` provides SQLite durability in the current runtime. Each logical stream has
|
||||
unique `(stream_key, idempotency_key)` and `(stream_key, sequence)`. Exact retry returns `REPLAY`;
|
||||
changed payload under the same key is rejected; sequence gaps are rejected; expired messages are not
|
||||
delivered.
|
||||
|
||||
Confidentiality scopes:
|
||||
|
||||
- public: no tenant requirement
|
||||
- tenant: same tenant
|
||||
- project: same tenant + project
|
||||
- private: same tenant + project + user
|
||||
- run: same tenant + project + user + run
|
||||
|
||||
Required `capability_scope` must be present on the authenticated delivery actor unless the actor is
|
||||
explicitly administrative.
|
||||
|
||||
Compatibility adapters convert AgentEventBus events, TaskPlane CloudEvents and legacy Agent OS
|
||||
MessageBus messages into the durable envelope. Existing message_outbox remains intact as a migration
|
||||
source; P0-B does not delete or silently rewrite it.
|
||||
|
||||
Required gates:
|
||||
`MESSAGE_SCOPE_MISMATCH_DENY`, `MESSAGE_TARGET_COLLISION_DENY`, `MESSAGE_IDEMPOTENCY_REPLAY`,
|
||||
`MESSAGE_SEQUENCE_GAP_DETECT`, `MESSAGE_CONFIDENTIALITY_SCOPE_ENFORCE`,
|
||||
`HANDOFF_CROSS_TENANT_DENY`, plus existing P0-A TaskPlane scope/security regressions.
|
||||
|
||||
SQLite remains current runtime persistence. PostgreSQL remains a separate migration gate.
|
||||
@@ -48,3 +48,8 @@ class MessageBus:
|
||||
messages = list(self.queue)
|
||||
self.queue.clear()
|
||||
return messages
|
||||
|
||||
# R4.2 P0-B compatibility adapter.
|
||||
def to_handoff_envelope(message, *, target=None, sequence=1, idempotency_key=None, **scope):
|
||||
from message_handoff_adapters import legacy_message_to_envelope
|
||||
return legacy_message_to_envelope(message, target=target, sequence=sequence, idempotency_key=idempotency_key, **scope)
|
||||
|
||||
@@ -45,6 +45,9 @@ class ShadowClientRBACPublicInterfaceTests(unittest.TestCase):
|
||||
identity_verifier=self.IdentityVerifier({"shadow-client"}),
|
||||
execution_enabled=False,
|
||||
identity_capabilities={"shadow-client": set(capabilities)},
|
||||
identity_actor_contexts={
|
||||
"shadow-client": {"scope_type": "system"}
|
||||
},
|
||||
)
|
||||
service = self.TaskPlaneService(store, policy=policy)
|
||||
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import threading
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from http.server import ThreadingHTTPServer
|
||||
|
||||
import pytest
|
||||
|
||||
ROOT = os.getcwd()
|
||||
if os.path.join(ROOT, "active-runtime") not in sys.path:
|
||||
sys.path.append(os.path.join(ROOT, "active-runtime"))
|
||||
|
||||
from app.task_plane.api import TaskPlaneRequestHandler, TaskPlaneService
|
||||
from app.task_plane.errors import ForbiddenError, NotFoundError, ValidationError
|
||||
from app.task_plane.models import TaskPlan
|
||||
from app.task_plane.policy import PolicyEngine
|
||||
from app.task_plane.store import TaskPlaneStore
|
||||
|
||||
|
||||
def _policy() -> PolicyEngine:
|
||||
return PolicyEngine(
|
||||
identity_capabilities={
|
||||
"actor-a": {"task.creator", "task.read"},
|
||||
"actor-b": {"task.creator", "task.read"},
|
||||
"reader-a": {"task.read"},
|
||||
"reader-b": {"task.read"},
|
||||
"owner": {"task.creator", "task.read", "task.read.all"},
|
||||
"unbound": {"task.creator", "task.read"},
|
||||
},
|
||||
identity_actor_contexts={
|
||||
"actor-a": {
|
||||
"scope_type": "tenant",
|
||||
"tenant_id": "tenant-a",
|
||||
"user_id": "user-a",
|
||||
"project_id": "project-a",
|
||||
"conversation_id": "conversation-a",
|
||||
"run_id": "run-a",
|
||||
},
|
||||
"reader-a": {
|
||||
"scope_type": "tenant",
|
||||
"tenant_id": "tenant-a",
|
||||
"user_id": "user-a",
|
||||
"project_id": "project-a",
|
||||
"conversation_id": "conversation-a",
|
||||
"run_id": "run-a",
|
||||
},
|
||||
"actor-b": {
|
||||
"scope_type": "tenant",
|
||||
"tenant_id": "tenant-b",
|
||||
"user_id": "user-b",
|
||||
"project_id": "project-b",
|
||||
},
|
||||
"reader-b": {
|
||||
"scope_type": "tenant",
|
||||
"tenant_id": "tenant-b",
|
||||
"user_id": "user-b",
|
||||
"project_id": "project-b",
|
||||
},
|
||||
"owner": {"scope_type": "system"},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _service() -> TaskPlaneService:
|
||||
return TaskPlaneService(TaskPlaneStore(":memory:"), policy=_policy())
|
||||
|
||||
|
||||
def test_taskplan_scope_fields_round_trip_as_first_class_values() -> None:
|
||||
plan = TaskPlan.from_dict(
|
||||
{
|
||||
"task_id": "task-scope-roundtrip",
|
||||
"scope_type": "tenant",
|
||||
"tenant_id": "tenant-a",
|
||||
"user_id": "user-a",
|
||||
"project_id": "project-a",
|
||||
"conversation_id": "conversation-a",
|
||||
"run_id": "run-a",
|
||||
}
|
||||
)
|
||||
payload = plan.to_dict()
|
||||
assert TaskPlan.from_dict(payload).to_dict() == payload
|
||||
|
||||
|
||||
def test_tenant_scope_requires_tenant_id_and_system_scope_does_not() -> None:
|
||||
with pytest.raises(ValidationError) as exc:
|
||||
TaskPlan.from_dict({"task_id": "task-missing-tenant", "scope_type": "tenant"})
|
||||
assert exc.value.error_code == "TASK_SCOPE_TENANT_ID_REQUIRED"
|
||||
system = TaskPlan.from_dict(
|
||||
{"task_id": "task-system", "scope_type": "system", "run_id": "system-run"}
|
||||
)
|
||||
assert system.tenant_id is None
|
||||
|
||||
|
||||
def test_ACTOR_BINDING_AUTHZ_MERGE_uses_server_scope() -> None:
|
||||
service = _service()
|
||||
created = service.create_task(
|
||||
{"task_id": "task-a", "task_type": "code.change"},
|
||||
actor_identity="actor-a",
|
||||
)
|
||||
assert created["plan"]["tenant_id"] == "tenant-a"
|
||||
assert created["plan"]["user_id"] == "user-a"
|
||||
assert created["plan"]["project_id"] == "project-a"
|
||||
declared = service.create_task(
|
||||
{
|
||||
"task_id": "task-a-declared",
|
||||
"tenant_id": "tenant-a",
|
||||
"user_id": "user-a",
|
||||
"project_id": "project-a",
|
||||
"conversation_id": "conversation-a",
|
||||
"run_id": "run-a",
|
||||
},
|
||||
actor_identity="actor-a",
|
||||
)
|
||||
assert declared["plan"]["scope_type"] == "tenant"
|
||||
|
||||
|
||||
def test_TASK_SCOPE_MISMATCH_DENY_rejects_forged_client_scope() -> None:
|
||||
service = _service()
|
||||
with pytest.raises(ForbiddenError) as exc:
|
||||
service.create_task(
|
||||
{
|
||||
"task_id": "task-forged",
|
||||
"scope_type": "tenant",
|
||||
"tenant_id": "tenant-b",
|
||||
"user_id": "user-b",
|
||||
"project_id": "project-b",
|
||||
},
|
||||
actor_identity="actor-a",
|
||||
)
|
||||
assert exc.value.error_code == "TASK_SCOPE_MISMATCH_DENY"
|
||||
|
||||
|
||||
def test_public_create_fails_closed_without_server_actor_scope() -> None:
|
||||
service = _service()
|
||||
with pytest.raises(ForbiddenError) as exc:
|
||||
service.create_task(
|
||||
{"task_id": "task-unbound"}, actor_identity="unbound"
|
||||
)
|
||||
assert exc.value.error_code == "TASK_ACTOR_SCOPE_UNAVAILABLE"
|
||||
|
||||
|
||||
def test_TASKPLANE_LIST_AUTHZ_DENY_and_TASK_OWNERSHIP_FILTER() -> None:
|
||||
service = _service()
|
||||
service.create_task({"task_id": "task-a"}, actor_identity="actor-a")
|
||||
service.create_task({"task_id": "task-b"}, actor_identity="actor-b")
|
||||
assert [item["task_id"] for item in service.list_tasks("reader-a")] == ["task-a"]
|
||||
assert [item["task_id"] for item in service.list_tasks("reader-b")] == ["task-b"]
|
||||
|
||||
|
||||
def test_TASKPLANE_GET_AUTHZ_DENY_does_not_disclose_cross_tenant_task() -> None:
|
||||
service = _service()
|
||||
service.create_task({"task_id": "task-a"}, actor_identity="actor-a")
|
||||
with pytest.raises(NotFoundError):
|
||||
service.get_task("task-a", "reader-b")
|
||||
|
||||
|
||||
def test_http_authentication_drives_server_scope_and_cross_tenant_404() -> None:
|
||||
service = _service()
|
||||
|
||||
class Handler(TaskPlaneRequestHandler):
|
||||
pass
|
||||
|
||||
Handler.service = service
|
||||
Handler.authenticator = lambda token: {
|
||||
"token-a": "actor-a",
|
||||
"token-b": "reader-b",
|
||||
}.get(token)
|
||||
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||||
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
||||
thread.start()
|
||||
base = f"http://127.0.0.1:{server.server_port}"
|
||||
try:
|
||||
request = urllib.request.Request(
|
||||
base + "/tasks",
|
||||
data=json.dumps({"task_id": "task-http-a"}).encode(),
|
||||
headers={
|
||||
"Authorization": "Bearer token-a",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=3) as response:
|
||||
created = json.loads(response.read())
|
||||
assert created["plan"]["tenant_id"] == "tenant-a"
|
||||
|
||||
denied = urllib.request.Request(
|
||||
base + "/tasks/task-http-a",
|
||||
headers={"Authorization": "Bearer token-b"},
|
||||
)
|
||||
with pytest.raises(urllib.error.HTTPError) as exc:
|
||||
urllib.request.urlopen(denied, timeout=3)
|
||||
assert exc.value.code == 404
|
||||
finally:
|
||||
server.shutdown()
|
||||
server.server_close()
|
||||
thread.join(timeout=3)
|
||||
|
||||
|
||||
def test_mutating_endpoint_is_scoped_before_cancel() -> None:
|
||||
policy = PolicyEngine(
|
||||
execution_enabled=True,
|
||||
identity_capabilities={
|
||||
"actor-a": {"task.creator", "task.read"},
|
||||
"actor-b": {"task.cancel"},
|
||||
},
|
||||
identity_actor_contexts={
|
||||
"actor-a": {"scope_type": "tenant", "tenant_id": "tenant-a"},
|
||||
"actor-b": {"scope_type": "tenant", "tenant_id": "tenant-b"},
|
||||
},
|
||||
)
|
||||
service = TaskPlaneService(TaskPlaneStore(":memory:"), policy=policy)
|
||||
service.create_task({"task_id": "task-a"}, actor_identity="actor-a")
|
||||
with pytest.raises(NotFoundError):
|
||||
service.cancel("task-a", {"actor_identity": "actor-b"})
|
||||
|
||||
|
||||
def test_privileged_server_actor_can_cross_scope() -> None:
|
||||
service = _service()
|
||||
service.create_task({"task_id": "task-a"}, actor_identity="actor-a")
|
||||
service.create_task({"task_id": "task-b"}, actor_identity="actor-b")
|
||||
assert {item["task_id"] for item in service.list_tasks("owner")} == {
|
||||
"task-a",
|
||||
"task-b",
|
||||
}
|
||||
|
||||
|
||||
def test_scope_sql_parameters_do_not_interpolate_tenant_values() -> None:
|
||||
service = _service()
|
||||
service.create_task({"task_id": "task-a"}, actor_identity="actor-a")
|
||||
policy = PolicyEngine(
|
||||
identity_capabilities={"attacker": {"task.read"}},
|
||||
identity_actor_contexts={
|
||||
"attacker": {
|
||||
"scope_type": "tenant",
|
||||
"tenant_id": "tenant-a' OR 1=1 --",
|
||||
}
|
||||
},
|
||||
)
|
||||
attacker_service = TaskPlaneService(service.store, policy=policy)
|
||||
assert attacker_service.list_tasks("attacker") == []
|
||||
|
||||
|
||||
def test_existing_sqlite_rows_migrate_to_legacy_without_tenant_guess(tmp_path) -> None:
|
||||
path = tmp_path / "legacy.sqlite3"
|
||||
connection = sqlite3.connect(path)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE tasks (
|
||||
task_id TEXT PRIMARY KEY, revision INTEGER NOT NULL, status TEXT NOT NULL,
|
||||
task_json TEXT NOT NULL, task_type TEXT NOT NULL, root_task_id TEXT NOT NULL,
|
||||
parent_task_id TEXT, correlation_id TEXT, idempotency_key TEXT UNIQUE,
|
||||
created_at REAL NOT NULL, updated_at REAL NOT NULL, failure_reason TEXT
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO tasks VALUES (?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
(
|
||||
"task-legacy",
|
||||
1,
|
||||
"CREATED",
|
||||
json.dumps({"task_id": "task-legacy"}),
|
||||
"code.change",
|
||||
"task-legacy",
|
||||
None,
|
||||
None,
|
||||
"legacy-key",
|
||||
1.0,
|
||||
1.0,
|
||||
None,
|
||||
),
|
||||
)
|
||||
connection.commit()
|
||||
connection.close()
|
||||
|
||||
store = TaskPlaneStore(str(path))
|
||||
task = store.get_task("task-legacy")
|
||||
assert task.task_plan.scope_type == "legacy"
|
||||
assert task.task_plan.tenant_id is None
|
||||
replay = store.create_task(
|
||||
TaskPlan.from_dict({"task_id": "task-legacy"}),
|
||||
idempotency_key="legacy-key",
|
||||
)
|
||||
assert replay.task_id == "task-legacy"
|
||||
assert tuple(
|
||||
store.conn.execute(
|
||||
"SELECT scope_type,tenant_id FROM tasks WHERE task_id='task-legacy'"
|
||||
).fetchone()
|
||||
) == ("legacy", None)
|
||||
tenant_context = _policy().actor_context("reader-a")
|
||||
with pytest.raises(NotFoundError):
|
||||
store.get_task("task-legacy", tenant_context)
|
||||
store.close()
|
||||
@@ -20,3 +20,12 @@ def test_unified_event_flow():
|
||||
|
||||
dashboard = AgentEventDashboard(bus).overview()
|
||||
assert dashboard["audit"]["enabled"] is True
|
||||
|
||||
|
||||
def test_target_changes_event_identity(tmp_path):
|
||||
bus = AgentEventBus(tmp_path)
|
||||
first = bus.publish('test', 'same', 'user-a', 'same summary', {'value': 1})
|
||||
second = bus.publish('test', 'same', 'user-b', 'same summary', {'value': 1})
|
||||
first_id = first.get('id') if isinstance(first, dict) else getattr(first, 'id', first)
|
||||
second_id = second.get('id') if isinstance(second, dict) else getattr(second, 'id', second)
|
||||
assert first_id != second_id
|
||||
|
||||
@@ -2,6 +2,7 @@ import base64
|
||||
from pathlib import Path
|
||||
|
||||
from aios_authority_git_executor import AuthorityGitExecutor
|
||||
from aios_chatgpt_bridge_v2 import BridgeV2Adapter, OPERATION_REQUIRED_FIELDS
|
||||
from aios_chatgpt_bridge_v2_server import BridgeHandler, RuntimeConfig
|
||||
from aios_continuation_executor import GiteaContinuationRemote, ServerSideCredentialProjection
|
||||
from aios_continuation_lease import WriterLeaseRequestV2
|
||||
@@ -26,6 +27,7 @@ class Probe:
|
||||
|
||||
def test_create_new_lease_is_exact_base_and_absent():
|
||||
request = WriterLeaseRequestV2(
|
||||
job_id="job-bridge-runtime-lineage-test",
|
||||
task_id="TASK-P0-BRIDGE-V21-DURABLE-RECONCILIATION-20260809",
|
||||
repository="aios/aios",
|
||||
branch_mode="CREATE_NEW",
|
||||
@@ -39,6 +41,7 @@ def test_create_new_lease_is_exact_base_and_absent():
|
||||
|
||||
def test_create_new_lease_rejects_existing_target():
|
||||
request = WriterLeaseRequestV2(
|
||||
job_id="job-bridge-runtime-lineage-test",
|
||||
task_id="task",
|
||||
repository="aios/aios",
|
||||
branch_mode="CREATE_NEW",
|
||||
@@ -75,7 +78,7 @@ def test_slash_branch_uses_paginated_collection():
|
||||
def test_http_route_contract_is_complete():
|
||||
expected = {
|
||||
"/v2/runtime", "/v2/capabilities", "/v2/governed-task",
|
||||
"/v2/writer-lease", "/v2/commit-file", "/v2/commit-files",
|
||||
"/v2/writer-lease", "/v2/writer-lease/bootstrap", "/v2/writer-lease/renew", "/v2/commit-file", "/v2/commit-files",
|
||||
"/v2/job", "/v2/job/resume", "/v2/job/reconcile",
|
||||
"/v2/tag/approval-request",
|
||||
"/v2/tag/publish",
|
||||
@@ -88,3 +91,121 @@ def test_http_route_contract_is_complete():
|
||||
from aios_chatgpt_bridge_v2_server import ROUTES
|
||||
|
||||
assert set(ROUTES) == expected
|
||||
|
||||
|
||||
def test_renew_writer_lease_is_exposed_in_action_and_capability_contracts():
|
||||
adapter = BridgeV2Adapter(runtime_status={})
|
||||
capabilities = adapter.getGovernedWriteCapabilities()
|
||||
contract = capabilities["operation_contracts"]["renewWriterLease"]
|
||||
|
||||
assert contract["method"] == "POST"
|
||||
assert contract["path"] == "/v2/writer-lease/renew"
|
||||
assert tuple(contract["required_fields"]) == OPERATION_REQUIRED_FIELDS["renewWriterLease"]
|
||||
assert "runtime_rehydrated" in contract["response_fields"]
|
||||
|
||||
action_schema = Path("docs/gitops/openapi-chatgpt-actions.yaml").read_text(encoding="utf-8")
|
||||
assert "/v2/writer-lease/renew:" in action_schema
|
||||
assert "operationId: renewWriterLease" in action_schema
|
||||
assert "BridgeWriterLeaseRecoveryRequest" in action_schema
|
||||
assert "BridgeWriterLeaseRecoveryResponse" in action_schema
|
||||
assert {
|
||||
capabilities["operation_contracts"][operation]["path"]
|
||||
for operation in (
|
||||
"acquireWriterLease",
|
||||
"renewWriterLease",
|
||||
"commitFileGoverned",
|
||||
"commitFilesGoverned",
|
||||
)
|
||||
} == {
|
||||
"/v2/writer-lease",
|
||||
"/v2/writer-lease/renew",
|
||||
"/v2/commit-file",
|
||||
"/v2/commit-files",
|
||||
}
|
||||
|
||||
|
||||
def test_bootstrap_writer_lease_is_exposed_in_action_and_capability_contracts():
|
||||
adapter = BridgeV2Adapter(runtime_status={})
|
||||
capabilities = adapter.getGovernedWriteCapabilities()
|
||||
contract = capabilities["operation_contracts"]["bootstrapWriterLease"]
|
||||
|
||||
assert contract["method"] == "POST"
|
||||
assert contract["path"] == "/v2/writer-lease/bootstrap"
|
||||
assert tuple(contract["required_fields"]) == OPERATION_REQUIRED_FIELDS["bootstrapWriterLease"]
|
||||
assert "bootstrapped" in contract["response_fields"]
|
||||
assert "durable_branch_lease_present" in contract["response_fields"]
|
||||
|
||||
action_schema = Path("docs/gitops/openapi-chatgpt-actions.yaml").read_text(encoding="utf-8")
|
||||
assert "/v2/writer-lease/bootstrap:" in action_schema
|
||||
assert "operationId: bootstrapWriterLease" in action_schema
|
||||
assert "bootstrapped: {type: boolean}" in action_schema
|
||||
assert "durable_branch_lease_present: {type: boolean}" in action_schema
|
||||
|
||||
|
||||
|
||||
def test_http_allows_explicit_job_id_on_lease_and_commit_requests():
|
||||
handler = BridgeHandler.__new__(BridgeHandler)
|
||||
lease_body = {
|
||||
"schema": WriterLeaseRequestV2.schema,
|
||||
"task_id": "task",
|
||||
"job_id": "job-explicit-runtime-lineage",
|
||||
"repository": "aios/aios",
|
||||
"branch_mode": "CONTINUE_EXISTING",
|
||||
"base_oid": "a" * 40,
|
||||
"expected_branch_oid": "a" * 40,
|
||||
"target_branch": "agent/explicit-runtime-lineage",
|
||||
"generation": 2,
|
||||
"lease_seconds": 120,
|
||||
}
|
||||
handler._allowed("acquireWriterLease", lease_body)
|
||||
handler._allowed(
|
||||
"commitFilesGoverned",
|
||||
{
|
||||
"task_id": "task",
|
||||
"job_id": "job-explicit-runtime-lineage",
|
||||
"repository": "aios/aios",
|
||||
"expected_branch_oid": "a" * 40,
|
||||
"target_branch": "agent/explicit-runtime-lineage",
|
||||
"generation": 2,
|
||||
"idempotency_key": "commit-explicit-runtime-lineage",
|
||||
"files": {"docs/x.txt": "eA=="},
|
||||
"message": "test",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_http_allows_omitted_job_id_for_durable_server_resolution():
|
||||
handler = BridgeHandler.__new__(BridgeHandler)
|
||||
lease_body = {
|
||||
"schema": WriterLeaseRequestV2.schema,
|
||||
"task_id": "task",
|
||||
"repository": "aios/aios",
|
||||
"branch_mode": "CONTINUE_EXISTING",
|
||||
"base_oid": "a" * 40,
|
||||
"expected_branch_oid": "a" * 40,
|
||||
"target_branch": "agent/implicit-runtime-lineage",
|
||||
"generation": 2,
|
||||
"lease_seconds": 120,
|
||||
}
|
||||
handler._allowed("acquireWriterLease", lease_body)
|
||||
handler._allowed(
|
||||
"commitFilesGoverned",
|
||||
{
|
||||
"task_id": "task",
|
||||
"repository": "aios/aios",
|
||||
"expected_branch_oid": "a" * 40,
|
||||
"target_branch": "agent/implicit-runtime-lineage",
|
||||
"generation": 2,
|
||||
"idempotency_key": "commit-implicit-runtime-lineage",
|
||||
"files": {"docs/x.txt": "eA=="},
|
||||
"message": "test",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def test_capability_contracts_return_exact_job_id_lineage():
|
||||
contracts = BridgeV2Adapter(runtime_status={}).getGovernedWriteCapabilities()["operation_contracts"]
|
||||
for operation in ("acquireWriterLease", "bootstrapWriterLease", "renewWriterLease"):
|
||||
assert "job_id" in contracts[operation]["response_fields"]
|
||||
for operation in ("commitFileGoverned", "commitFilesGoverned"):
|
||||
assert "job_id" in contracts[operation]["response_fields"]
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import re
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from aios_chatgpt_bridge_v2_server import GovernedRuntime
|
||||
from aios_continuation_executor import ContinuationExecutor
|
||||
from aios_continuation_lease import WriterLeaseRequestV2
|
||||
from aios_gitops_job_controller import JobState
|
||||
|
||||
|
||||
TASK = "continuation-normalization-test"
|
||||
JOB = "job-continuation-normalization-test"
|
||||
REPO = "aios/aios"
|
||||
BASE = "a" * 40
|
||||
BRANCH = "agent/continuation-normalization-test"
|
||||
|
||||
|
||||
class Authority:
|
||||
def __init__(self, head: str | None) -> None:
|
||||
self.head = head
|
||||
|
||||
def read_ref(self, ref: str) -> str | None:
|
||||
assert ref == "refs/heads/" + BRANCH
|
||||
return self.head
|
||||
|
||||
def commit_exists(self, oid: str) -> bool:
|
||||
return oid == BASE
|
||||
|
||||
def branch_exists(self, branch: str) -> bool:
|
||||
assert branch == BRANCH
|
||||
return self.head is not None
|
||||
|
||||
def branch_head(self, branch: str) -> str:
|
||||
assert branch == BRANCH and self.head is not None
|
||||
return self.head
|
||||
|
||||
def is_ancestor(self, older: str, newer: str) -> bool:
|
||||
return older == BASE and newer == BASE
|
||||
|
||||
|
||||
def lease(**overrides: object) -> WriterLeaseRequestV2:
|
||||
values = {
|
||||
"task_id": TASK,
|
||||
"job_id": JOB,
|
||||
"repository": REPO,
|
||||
"branch_mode": "CREATE_NEW",
|
||||
"base_oid": BASE,
|
||||
"expected_branch_oid": BASE,
|
||||
"target_branch": BRANCH,
|
||||
"generation": 1,
|
||||
"lease_seconds": 120,
|
||||
}
|
||||
values.update(overrides)
|
||||
return WriterLeaseRequestV2(**values)
|
||||
|
||||
|
||||
def runtime(*, head: str | None = BASE, **binding_overrides: object) -> GovernedRuntime:
|
||||
value = GovernedRuntime.__new__(GovernedRuntime)
|
||||
value.authority = Authority(head)
|
||||
binding = {
|
||||
"task_id": TASK,
|
||||
"job_id": JOB,
|
||||
"repository": REPO,
|
||||
"base_oid": BASE,
|
||||
"expected_branch_oid": BASE,
|
||||
"target_branch": BRANCH,
|
||||
"generation": 1,
|
||||
"branch_mode": "CREATE_NEW",
|
||||
}
|
||||
binding.update(binding_overrides)
|
||||
value.bindings = {TASK: binding}
|
||||
return value
|
||||
|
||||
|
||||
def test_same_lineage_create_new_normalizes_to_continue_existing() -> None:
|
||||
normalized = runtime()._normalize_runtime_lease(lease())
|
||||
assert normalized.branch_mode == "CONTINUE_EXISTING"
|
||||
assert normalized.task_id == TASK
|
||||
assert normalized.job_id == JOB
|
||||
assert normalized.repository == REPO
|
||||
assert normalized.target_branch == BRANCH
|
||||
assert normalized.expected_branch_oid == BASE
|
||||
assert normalized.generation == 1
|
||||
|
||||
|
||||
def test_unprovisioned_create_new_stays_create_new() -> None:
|
||||
assert runtime(head=None)._normalize_runtime_lease(lease()).branch_mode == "CREATE_NEW"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value"),
|
||||
(
|
||||
("repository", "other/repo"),
|
||||
("base_oid", "b" * 40),
|
||||
("expected_branch_oid", "b" * 40),
|
||||
("target_branch", "agent/foreign"),
|
||||
("generation", 2),
|
||||
),
|
||||
)
|
||||
def test_binding_mismatch_fails_closed(field: str, value: object) -> None:
|
||||
with pytest.raises(RuntimeError, match="LEASE_BINDING_CONFLICT"):
|
||||
runtime(**{field: value})._normalize_runtime_lease(lease())
|
||||
|
||||
|
||||
def test_drifted_existing_head_fails_closed() -> None:
|
||||
with pytest.raises(RuntimeError, match="EXPECTED_BRANCH_OID_CONFLICT"):
|
||||
runtime(head="b" * 40)._normalize_runtime_lease(lease())
|
||||
|
||||
|
||||
def test_openapi_contract_parses_and_requires_exact_commit_lineage() -> None:
|
||||
document = yaml.safe_load(
|
||||
Path("docs/gitops/openapi-chatgpt-actions.yaml").read_text(encoding="utf-8")
|
||||
)
|
||||
schema = document["components"]["schemas"]["BridgeCommitResponse"]
|
||||
assert {
|
||||
"job_id",
|
||||
"commit_oid",
|
||||
"commit_visible",
|
||||
"branch_mode",
|
||||
"target_branch",
|
||||
"generation",
|
||||
} <= set(schema["required"])
|
||||
|
||||
|
||||
def test_changed_production_files_pass_secret_scan() -> None:
|
||||
paths = [
|
||||
Path("active-runtime/app/aios_authority_git_executor.py"),
|
||||
Path("active-runtime/app/aios_chatgpt_bridge_v2_server.py"),
|
||||
Path("active-runtime/app/aios_continuation_executor.py"),
|
||||
Path("active-runtime/app/aios_continuation_lease.py"),
|
||||
Path("active-runtime/app/authorized_task_writer_provisioning.py"),
|
||||
Path("active-runtime/app/aios_chatgpt_bridge_v2.py"),
|
||||
Path("docs/gitops/openapi-chatgpt-actions.yaml"),
|
||||
]
|
||||
expression = re.compile(
|
||||
r"(?i)(BEGIN (RSA|OPENSSH|EC) PRIVATE KEY|"
|
||||
r"authorization:\\s*bearer\\s+[A-Za-z0-9._-]{12,}|"
|
||||
r"api[_-]?key\\s*[:=]\\s*['\\\"][^'\\\"]{12,})"
|
||||
)
|
||||
hits = []
|
||||
for path in paths:
|
||||
for number, line in enumerate(
|
||||
path.read_text(encoding="utf-8", errors="replace").splitlines(), 1
|
||||
):
|
||||
if expression.search(line):
|
||||
hits.append(f"{path}:{number}")
|
||||
assert hits == []
|
||||
|
||||
|
||||
def test_remote_verified_job_is_not_completed_when_head_cas_callback_fails() -> None:
|
||||
class Store:
|
||||
def read(self, _job_id):
|
||||
return {
|
||||
"branch": BRANCH,
|
||||
"request_json": json.dumps(
|
||||
{"binding": {"expected_branch_oid": BASE}}
|
||||
),
|
||||
}
|
||||
|
||||
class Controller:
|
||||
def __init__(self):
|
||||
self.store = Store()
|
||||
self.states = []
|
||||
|
||||
def advance(self, _job_id, state, **_kwargs):
|
||||
self.states.append(state)
|
||||
return SimpleNamespace(as_dict=lambda: {"state": state.value})
|
||||
|
||||
executor = ContinuationExecutor.__new__(ContinuationExecutor)
|
||||
executor.controller = Controller()
|
||||
executor._observe_remote = lambda _branch, oid: (oid, oid, True)
|
||||
|
||||
def fail_cas(_oid):
|
||||
raise RuntimeError("LEASE_CAS_CONFLICT")
|
||||
|
||||
with pytest.raises(RuntimeError, match="LEASE_CAS_CONFLICT"):
|
||||
executor._finish_remote_verification(
|
||||
JOB,
|
||||
"b" * 40,
|
||||
"b" * 40,
|
||||
before_complete=fail_cas,
|
||||
)
|
||||
|
||||
assert executor.controller.states == [JobState.REMOTE_VERIFIED]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("branch_mode", ("CREATE_NEW", "CONTINUE_EXISTING"))
|
||||
def test_executor_preflight_accepts_the_exact_mode_dependent_normalized_shape(
|
||||
branch_mode: str,
|
||||
) -> None:
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from aios_continuation_executor import (
|
||||
ContinuationPermissions,
|
||||
ContinuationRuntimeBinding,
|
||||
)
|
||||
|
||||
request = lease(branch_mode=branch_mode)
|
||||
now = datetime.now(timezone.utc)
|
||||
executor = ContinuationExecutor.__new__(ContinuationExecutor)
|
||||
executor.authority = Authority(None if branch_mode == "CREATE_NEW" else BASE)
|
||||
executor.now = lambda: now
|
||||
executor._max_generation = lambda _branch, *, exclude_job_id=None: 0
|
||||
executor.workspace = SimpleNamespace(
|
||||
is_detached=lambda: True,
|
||||
head_oid=lambda: BASE,
|
||||
)
|
||||
executor.remote = SimpleNamespace(repository_identity=lambda: REPO)
|
||||
binding = ContinuationRuntimeBinding(
|
||||
request=request,
|
||||
job_id=JOB,
|
||||
workspace_path=Path("/tmp/continuation-normalization-test"),
|
||||
lease_not_before=now - timedelta(seconds=1),
|
||||
lease_expires_at=now + timedelta(seconds=60),
|
||||
original_receipt_valid=True,
|
||||
original_grant_consumed=True,
|
||||
permissions=ContinuationPermissions(),
|
||||
)
|
||||
|
||||
executor._validate_preflight(binding, job_id=JOB)
|
||||
|
||||
|
||||
def test_create_new_executor_resumes_executing_and_uses_absence_bound_writers() -> None:
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from aios_continuation_executor import (
|
||||
ContinuationCommit,
|
||||
ContinuationPermissions,
|
||||
ContinuationRuntimeBinding,
|
||||
)
|
||||
|
||||
new_oid = "b" * 40
|
||||
|
||||
class Store:
|
||||
def read(self, _job_id: str) -> dict[str, object]:
|
||||
return {"state": "EXECUTING", "revision": 4}
|
||||
|
||||
class Controller:
|
||||
def __init__(self) -> None:
|
||||
self.store = Store()
|
||||
self.advanced: list[str] = []
|
||||
|
||||
def advance(self, _job_id: str, state: JobState, **_kwargs: object) -> object:
|
||||
self.advanced.append(state.value)
|
||||
return SimpleNamespace(as_dict=lambda: {"state": state.value})
|
||||
|
||||
class Workspace:
|
||||
def commit_files(self, _files: object, _message: str) -> str:
|
||||
return new_oid
|
||||
|
||||
class CreateAuthority:
|
||||
def __init__(self) -> None:
|
||||
self.created: list[tuple[str, str, str]] = []
|
||||
|
||||
def is_ancestor(self, older: str, newer: str) -> bool:
|
||||
return older == BASE and newer == new_oid
|
||||
|
||||
def create_task_branch_cas(self, branch: str, base: str, new: str) -> str:
|
||||
self.created.append((branch, base, new))
|
||||
return new
|
||||
|
||||
def compare_and_swap_branch(self, *_args: object) -> str:
|
||||
raise AssertionError("existing-branch CAS must not serve CREATE_NEW")
|
||||
|
||||
class CreateRemote:
|
||||
def __init__(self) -> None:
|
||||
self.created: list[tuple[str, str]] = []
|
||||
|
||||
def push_new_exact(self, branch: str, new: str) -> None:
|
||||
self.created.append((branch, new))
|
||||
|
||||
def push_exact(self, *_args: object) -> None:
|
||||
raise AssertionError("existing-branch push must not serve CREATE_NEW")
|
||||
|
||||
class Credential:
|
||||
def project(self) -> None: pass
|
||||
def revoke(self) -> None: pass
|
||||
|
||||
controller=Controller(); authority=CreateAuthority(); remote=CreateRemote()
|
||||
executor=ContinuationExecutor(controller,authority,Workspace(),remote,Credential())
|
||||
executor._validate_preflight=lambda *_args,**_kwargs: None
|
||||
executor._finish_remote_verification=lambda *_args,**_kwargs: {
|
||||
"job_id":JOB,"state":"COMPLETED","receipt":{"authority_oid":new_oid,"commit_visible":True}
|
||||
}
|
||||
now=datetime.now(timezone.utc)
|
||||
binding=ContinuationRuntimeBinding(
|
||||
request=lease(branch_mode="CREATE_NEW"),job_id=JOB,
|
||||
workspace_path=Path("/tmp/create-new-executor-resume"),
|
||||
lease_not_before=now-timedelta(seconds=1),lease_expires_at=now+timedelta(seconds=60),
|
||||
original_receipt_valid=True,original_grant_consumed=True,
|
||||
permissions=ContinuationPermissions(),
|
||||
)
|
||||
result=executor.execute(
|
||||
binding,ContinuationCommit("create-new-test",{"docs/test.txt":b"ok"},"test")
|
||||
)
|
||||
|
||||
assert result["state"]=="COMPLETED"
|
||||
assert authority.created==[(BRANCH,BASE,new_oid)]
|
||||
assert remote.created==[(BRANCH,new_oid)]
|
||||
assert controller.advanced==["LOCAL_VERIFIED","REMOTE_SYNCING"]
|
||||
@@ -0,0 +1,127 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
APP = pathlib.Path(__file__).resolve().parents[1] / "active-runtime" / "app"
|
||||
if str(APP) not in sys.path:
|
||||
sys.path.insert(0, str(APP))
|
||||
|
||||
from message_handoff import (
|
||||
ConfidentialityScopeDenied,
|
||||
DeliveryContext,
|
||||
DurableHandoffStore,
|
||||
HandoffFabric,
|
||||
IdempotencyConflict,
|
||||
MessageEnvelope,
|
||||
MessageExpired,
|
||||
MessageScopeMismatch,
|
||||
SequenceGap,
|
||||
authorize_delivery,
|
||||
bind_envelope_scope,
|
||||
stable_event_id,
|
||||
)
|
||||
|
||||
|
||||
def actor(**overrides):
|
||||
values = dict(
|
||||
tenant_id="tenant-a", user_id="user-a", project_id="project-a",
|
||||
conversation_id="conversation-a", run_id="run-a",
|
||||
capabilities=("task.read", "message.read"),
|
||||
)
|
||||
values.update(overrides)
|
||||
return DeliveryContext(**values)
|
||||
|
||||
|
||||
def envelope(**overrides):
|
||||
values = dict(
|
||||
source="agent-a", target="agent-b", message_type="handoff.request", payload={"value": 1},
|
||||
tenant_id="tenant-a", user_id="user-a", project_id="project-a",
|
||||
conversation_id="conversation-a", task_id="task-a", run_id="run-a",
|
||||
sequence=1, idempotency_key="idem-1", capability_scope=("message.read",),
|
||||
confidentiality_scope="private",
|
||||
)
|
||||
values.update(overrides)
|
||||
return MessageEnvelope(**values)
|
||||
|
||||
|
||||
def test_MESSAGE_TARGET_COLLISION_DENY():
|
||||
a = stable_event_id(source="a", target="user-a", message_type="event", payload={"x": 1},
|
||||
tenant_id="tenant-a", sequence=1, idempotency_key="same")
|
||||
b = stable_event_id(source="a", target="user-b", message_type="event", payload={"x": 1},
|
||||
tenant_id="tenant-a", sequence=1, idempotency_key="same")
|
||||
assert a != b
|
||||
|
||||
|
||||
def test_MESSAGE_SCOPE_MISMATCH_DENY():
|
||||
with pytest.raises(MessageScopeMismatch, match="MESSAGE_SCOPE_MISMATCH_DENY"):
|
||||
bind_envelope_scope(envelope(tenant_id="tenant-b"), actor())
|
||||
|
||||
|
||||
def test_HANDOFF_CROSS_TENANT_DENY(tmp_path):
|
||||
store = DurableHandoffStore(tmp_path / "handoff.sqlite3")
|
||||
env = envelope()
|
||||
store.put(env)
|
||||
with pytest.raises(MessageScopeMismatch, match="MESSAGE_SCOPE_MISMATCH_DENY"):
|
||||
store.get(env.envelope_id, actor=actor(tenant_id="tenant-b"))
|
||||
|
||||
|
||||
def test_MESSAGE_CONFIDENTIALITY_SCOPE_ENFORCE():
|
||||
with pytest.raises(ConfidentialityScopeDenied, match="MESSAGE_CONFIDENTIALITY_SCOPE_ENFORCE"):
|
||||
authorize_delivery(actor(project_id="project-b"), envelope(confidentiality_scope="project"))
|
||||
with pytest.raises(ConfidentialityScopeDenied, match="MESSAGE_CONFIDENTIALITY_SCOPE_ENFORCE"):
|
||||
authorize_delivery(actor(), envelope(capability_scope=("message.read", "secret.read")))
|
||||
|
||||
|
||||
def test_MESSAGE_IDEMPOTENCY_REPLAY(tmp_path):
|
||||
store = DurableHandoffStore(tmp_path / "handoff.sqlite3")
|
||||
env = envelope()
|
||||
assert store.put(env)[0] == "CREATED"
|
||||
status, replay = store.put(env)
|
||||
assert status == "REPLAY"
|
||||
assert replay.envelope_id == env.envelope_id
|
||||
with pytest.raises(IdempotencyConflict, match="MESSAGE_IDEMPOTENCY_CONFLICT"):
|
||||
store.put(envelope(payload={"value": 2}))
|
||||
|
||||
|
||||
def test_MESSAGE_SEQUENCE_GAP_DETECT(tmp_path):
|
||||
store = DurableHandoffStore(tmp_path / "handoff.sqlite3")
|
||||
store.put(envelope(sequence=1, idempotency_key="one"))
|
||||
with pytest.raises(SequenceGap, match="MESSAGE_SEQUENCE_GAP_DETECT"):
|
||||
store.put(envelope(sequence=3, idempotency_key="three"))
|
||||
|
||||
|
||||
def test_sequence_advances_and_scope_filters_pending(tmp_path):
|
||||
store = DurableHandoffStore(tmp_path / "handoff.sqlite3")
|
||||
store.put(envelope(sequence=1, idempotency_key="one"))
|
||||
store.put(envelope(sequence=2, idempotency_key="two", payload={"value": 2}))
|
||||
assert [item.sequence for item in store.pending(target="agent-b", actor=actor())] == [1, 2]
|
||||
assert store.pending(target="agent-b", actor=actor(tenant_id="tenant-b")) == []
|
||||
|
||||
|
||||
def test_expired_message_rejected(tmp_path):
|
||||
expired = (dt.datetime.now(dt.timezone.utc) - dt.timedelta(seconds=1)).isoformat()
|
||||
with pytest.raises(MessageExpired, match="MESSAGE_EXPIRED"):
|
||||
DurableHandoffStore(tmp_path / "handoff.sqlite3").put(envelope(expires_at=expired))
|
||||
|
||||
|
||||
def test_server_actor_binding_fills_scope_and_recomputes_identity():
|
||||
public = MessageEnvelope(
|
||||
source="agent-a", target="agent-b", message_type="handoff.request", payload={"value": 1},
|
||||
sequence=1, idempotency_key="bind-1", confidentiality_scope="public",
|
||||
)
|
||||
bound = bind_envelope_scope(public, actor())
|
||||
assert (bound.tenant_id, bound.user_id, bound.project_id, bound.run_id) == (
|
||||
"tenant-a", "user-a", "project-a", "run-a"
|
||||
)
|
||||
assert bound.envelope_id != public.envelope_id
|
||||
|
||||
|
||||
def test_handoff_fabric_publish_is_durable(tmp_path):
|
||||
store = DurableHandoffStore(tmp_path / "handoff.sqlite3")
|
||||
status, env = HandoffFabric(store).publish(envelope(), actor=actor())
|
||||
assert status == "CREATED"
|
||||
assert store.get(env.envelope_id, actor=actor()).payload == {"value": 1}
|
||||
@@ -0,0 +1,487 @@
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from active_runtime.app.aios_credential_broker import CredentialBroker
|
||||
from active_runtime.app.agent_observability_hooks import AgentObservabilityHooks
|
||||
from active_runtime.app.message_handoff import DurableHandoffStore, MessageEnvelope
|
||||
from active_runtime.app.search_gateway import (
|
||||
SearchEvidence,
|
||||
SearchGateway,
|
||||
SearchProviderAttempt,
|
||||
SearchRequest,
|
||||
)
|
||||
from active_runtime.app.search_gateway_orchestrator import SearchGatewayOrchestrator
|
||||
from active_runtime.app.trace_tool_credential_governance import (
|
||||
CredentialGrant,
|
||||
GovernanceActor,
|
||||
GovernanceError,
|
||||
GovernanceScope,
|
||||
GovernanceStore,
|
||||
ToolDescriptor,
|
||||
TraceContext,
|
||||
bind_trace_lineage,
|
||||
)
|
||||
|
||||
|
||||
TENANT_SCOPE = GovernanceScope(
|
||||
"tenant",
|
||||
tenant_id="tenant-a",
|
||||
user_id="user-a",
|
||||
project_id="project-a",
|
||||
conversation_id="conversation-a",
|
||||
run_id="run-a",
|
||||
)
|
||||
|
||||
|
||||
def actor(*capabilities: str, tenant_id: str = "tenant-a") -> GovernanceActor:
|
||||
return GovernanceActor(
|
||||
"actor-a",
|
||||
GovernanceScope(
|
||||
"tenant",
|
||||
tenant_id=tenant_id,
|
||||
user_id="user-a",
|
||||
project_id="project-a",
|
||||
conversation_id="conversation-a",
|
||||
run_id="run-a",
|
||||
),
|
||||
tuple(capabilities),
|
||||
)
|
||||
|
||||
|
||||
def owner() -> GovernanceActor:
|
||||
return GovernanceActor("owner-control", GovernanceScope("system"), ("system.admin",))
|
||||
|
||||
|
||||
def message(**changes):
|
||||
values = dict(
|
||||
source="task-plane",
|
||||
target="worker-a",
|
||||
message_type="tool.invoke",
|
||||
payload={"query": "safe"},
|
||||
sequence=1,
|
||||
idempotency_key="message-a-1",
|
||||
tenant_id="tenant-a",
|
||||
user_id="user-a",
|
||||
project_id="project-a",
|
||||
conversation_id="conversation-a",
|
||||
task_id="task-a",
|
||||
run_id="run-a",
|
||||
confidentiality_scope="run",
|
||||
)
|
||||
values.update(changes)
|
||||
return MessageEnvelope(**values)
|
||||
|
||||
|
||||
def task(**changes):
|
||||
value = {
|
||||
"task_id": "task-a",
|
||||
"claim_id": "claim-a",
|
||||
"correlation_id": "correlation-a",
|
||||
"scope_type": "tenant",
|
||||
"tenant_id": "tenant-a",
|
||||
"user_id": "user-a",
|
||||
"project_id": "project-a",
|
||||
"conversation_id": "conversation-a",
|
||||
"run_id": "run-a",
|
||||
}
|
||||
value.update(changes)
|
||||
return value
|
||||
|
||||
|
||||
def root_trace():
|
||||
return bind_trace_lineage(
|
||||
actor=actor("tool.search"),
|
||||
task=task(),
|
||||
message=message(),
|
||||
trace_id="trace-a",
|
||||
span_id="span-root",
|
||||
)
|
||||
|
||||
|
||||
def test_trace_lineage_continuity_and_durable_restart(tmp_path: Path) -> None:
|
||||
store = GovernanceStore(tmp_path / "governance.sqlite3")
|
||||
root = root_trace()
|
||||
child = root.child(span_id="span-tool", actor=actor("tool.search"))
|
||||
store.record_trace(root, actor("tool.search"))
|
||||
store.record_trace(child, actor("tool.search"))
|
||||
|
||||
reopened = GovernanceStore(tmp_path / "governance.sqlite3")
|
||||
assert reopened.verify_audit_chain(("TRACE_RECORDED",))
|
||||
assert child.trace_id == root.trace_id
|
||||
assert child.parent_span_id == root.span_id
|
||||
assert child.task_id == "task-a"
|
||||
assert child.claim_id == "claim-a"
|
||||
|
||||
|
||||
def test_trace_lineage_continuity_rejects_task_change() -> None:
|
||||
with pytest.raises(GovernanceError, match="TRACE_LINEAGE_CONTINUITY"):
|
||||
bind_trace_lineage(
|
||||
actor=actor("tool.search"),
|
||||
task=task(task_id="task-b"),
|
||||
message=message(),
|
||||
trace_id="trace-a",
|
||||
span_id="span-root",
|
||||
)
|
||||
|
||||
|
||||
def test_trace_scope_mismatch_deny() -> None:
|
||||
with pytest.raises(GovernanceError, match="TRACE_SCOPE_MISMATCH_DENY"):
|
||||
bind_trace_lineage(
|
||||
actor=actor("tool.search"),
|
||||
task=task(),
|
||||
message=message(tenant_id="tenant-b"),
|
||||
trace_id="trace-a",
|
||||
span_id="span-root",
|
||||
)
|
||||
|
||||
|
||||
def test_message_correlation_is_identity_bound() -> None:
|
||||
first = message(correlation_id="correlation-a")
|
||||
second = message(correlation_id="correlation-b")
|
||||
assert first.envelope_id != second.envelope_id
|
||||
assert first.as_record()["correlation_id"] == "correlation-a"
|
||||
|
||||
|
||||
def test_message_handoff_legacy_schema_migrates_without_changing_id(tmp_path: Path) -> None:
|
||||
import sqlite3
|
||||
|
||||
path = tmp_path / "legacy.sqlite3"
|
||||
legacy = message()
|
||||
record = legacy.as_record()
|
||||
record.pop("correlation_id")
|
||||
with sqlite3.connect(path) as connection:
|
||||
connection.executescript(
|
||||
"""
|
||||
CREATE TABLE message_handoff (
|
||||
envelope_id TEXT PRIMARY KEY, stream_key TEXT NOT NULL, source TEXT NOT NULL,
|
||||
target TEXT NOT NULL, message_type TEXT NOT NULL, payload_json TEXT NOT NULL,
|
||||
payload_sha256 TEXT NOT NULL, tenant_id TEXT, user_id TEXT, project_id TEXT,
|
||||
conversation_id TEXT, task_id TEXT, run_id TEXT, causation_id TEXT,
|
||||
sequence INTEGER NOT NULL, capability_scope_json TEXT NOT NULL,
|
||||
confidentiality_scope TEXT NOT NULL, idempotency_key TEXT NOT NULL,
|
||||
expires_at TEXT, created_at TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'PENDING',
|
||||
delivered_at TEXT, acked_at TEXT,
|
||||
UNIQUE(stream_key,idempotency_key), UNIQUE(stream_key,sequence)
|
||||
);
|
||||
"""
|
||||
)
|
||||
columns = tuple(record)
|
||||
connection.execute(
|
||||
f"INSERT INTO message_handoff ({','.join(columns)}) VALUES ({','.join('?' for _ in columns)})",
|
||||
tuple(record[name] for name in columns),
|
||||
)
|
||||
store = DurableHandoffStore(path)
|
||||
loaded = store.get(
|
||||
legacy.envelope_id,
|
||||
actor={
|
||||
"tenant_id": "tenant-a", "user_id": "user-a", "project_id": "project-a",
|
||||
"conversation_id": "conversation-a", "run_id": "run-a",
|
||||
"capabilities": [],
|
||||
},
|
||||
)
|
||||
assert loaded.envelope_id == legacy.envelope_id
|
||||
assert loaded.correlation_id is None
|
||||
|
||||
|
||||
def test_message_handoff_rejects_raw_credential_material(tmp_path: Path) -> None:
|
||||
with pytest.raises(ValueError, match="CREDENTIAL_SECRET_EXPOSURE_DENY"):
|
||||
message(payload={"value": "Bearer " + "x" * 24})
|
||||
DurableHandoffStore(tmp_path / "handoff.sqlite3")
|
||||
import sqlite3
|
||||
with sqlite3.connect(tmp_path / "handoff.sqlite3") as connection:
|
||||
assert connection.execute("SELECT count(*) FROM message_handoff").fetchone()[0] == 0
|
||||
|
||||
|
||||
def test_tool_mcp_authz_deny_and_scope_propagation(tmp_path: Path) -> None:
|
||||
store = GovernanceStore(tmp_path / "governance.sqlite3")
|
||||
store.register_tool(
|
||||
ToolDescriptor("mcp.search", "mcp", TENANT_SCOPE, ("tool.search",)),
|
||||
owner(),
|
||||
)
|
||||
trace = root_trace()
|
||||
store.record_trace(trace, actor("tool.search"))
|
||||
|
||||
with pytest.raises(GovernanceError, match="TOOL_MCP_AUTHZ_DENY"):
|
||||
store.authorize_tool(
|
||||
actor=actor(), tool_name="mcp.search", trace=trace,
|
||||
requested_scope=TENANT_SCOPE, input_digest="a" * 64,
|
||||
invocation_key="invoke-deny-capability",
|
||||
)
|
||||
with pytest.raises(GovernanceError, match="TOOL_SCOPE_PROPAGATION"):
|
||||
store.authorize_tool(
|
||||
actor=actor("tool.search"), tool_name="mcp.search", trace=trace,
|
||||
requested_scope=GovernanceScope("tenant", tenant_id="tenant-b"),
|
||||
input_digest="a" * 64, invocation_key="invoke-deny-scope",
|
||||
)
|
||||
|
||||
invocation = store.authorize_tool(
|
||||
actor=actor("tool.search"), tool_name="mcp.search", trace=trace,
|
||||
requested_scope=TENANT_SCOPE, input_digest="a" * 64,
|
||||
invocation_key="invoke-search-a",
|
||||
)
|
||||
assert invocation["trace_id"] == "trace-a"
|
||||
assert invocation["evidence"]["action"] == "TOOL_AUTHORIZED"
|
||||
|
||||
|
||||
def test_tool_requires_recorded_trace_and_receipt_is_idempotent(tmp_path: Path) -> None:
|
||||
store = GovernanceStore(tmp_path / "governance.sqlite3")
|
||||
store.register_tool(
|
||||
ToolDescriptor(
|
||||
"mcp.search", "mcp", TENANT_SCOPE, ("tool.search",),
|
||||
credential_ref="systemd-credential:search-provider-a",
|
||||
),
|
||||
owner(),
|
||||
)
|
||||
trace = root_trace()
|
||||
with pytest.raises(GovernanceError, match="TOOL_MCP_AUTHZ_DENY:trace"):
|
||||
store.authorize_tool(
|
||||
actor=actor("tool.search"), tool_name="mcp.search", trace=trace,
|
||||
requested_scope=TENANT_SCOPE, input_digest="a" * 64,
|
||||
invocation_key="invoke-unrecorded",
|
||||
)
|
||||
store.record_trace(trace, actor("tool.search"))
|
||||
authorized = store.authorize_tool(
|
||||
actor=actor("tool.search"), tool_name="mcp.search", trace=trace,
|
||||
requested_scope=TENANT_SCOPE, input_digest="a" * 64,
|
||||
invocation_key="invoke-replay-a",
|
||||
)
|
||||
assert authorized["credential_required"] is True
|
||||
assert "credential_ref" not in authorized
|
||||
receipt = store.record_tool_receipt(
|
||||
invocation_key="invoke-replay-a", result_digest="b" * 64,
|
||||
actor=actor("tool.search"), trace=trace,
|
||||
)
|
||||
assert receipt["status"] == "VERIFIED"
|
||||
replay = store.authorize_tool(
|
||||
actor=actor("tool.search"), tool_name="mcp.search", trace=trace,
|
||||
requested_scope=TENANT_SCOPE, input_digest="a" * 64,
|
||||
invocation_key="invoke-replay-a",
|
||||
)
|
||||
assert replay["idempotent"] is True
|
||||
assert replay["receipt_sha256"] == "b" * 64
|
||||
assert store.verify_audit_chain(
|
||||
("TRACE_RECORDED", "TOOL_AUTHORIZED", "TOOL_RESULT_VERIFIED")
|
||||
)
|
||||
|
||||
|
||||
def test_system_and_global_resources_do_not_use_fake_tenants(tmp_path: Path) -> None:
|
||||
with pytest.raises(GovernanceError, match="GOVERNANCE_NON_TENANT_SCOPE_INVALID"):
|
||||
GovernanceScope("system", tenant_id="fake-tenant")
|
||||
|
||||
store = GovernanceStore(tmp_path / "governance.sqlite3")
|
||||
descriptor = ToolDescriptor("system.health", "tool", GovernanceScope("global"), ("system.admin",))
|
||||
registered = store.register_tool(descriptor, owner())
|
||||
assert registered["descriptor"]["scope_type"] == "global"
|
||||
assert registered["descriptor"]["tenant_id"] is None
|
||||
|
||||
|
||||
def test_credential_scope_and_secret_exposure_deny(tmp_path: Path) -> None:
|
||||
store = GovernanceStore(tmp_path / "governance.sqlite3")
|
||||
grant = CredentialGrant(
|
||||
"grant-search-a",
|
||||
"systemd-credential:search-provider-a",
|
||||
TENANT_SCOPE,
|
||||
("search.invoke",),
|
||||
task_id="task-a",
|
||||
tool_name="mcp.search",
|
||||
expires_at=(datetime.now(timezone.utc) + timedelta(minutes=5)).isoformat(),
|
||||
)
|
||||
stored = store.store_grant(grant, owner())
|
||||
assert "secret" not in stored["grant"]
|
||||
trace = root_trace()
|
||||
store.record_trace(trace, actor("tool.search"))
|
||||
|
||||
with pytest.raises(GovernanceError, match="CREDENTIAL_SCOPE_DENY"):
|
||||
store.authorize_credential(
|
||||
grant_id=grant.grant_id,
|
||||
actor=actor("tool.search", tenant_id="tenant-b"),
|
||||
trace=trace,
|
||||
operation="search.invoke",
|
||||
task_id="task-a",
|
||||
tool_name="mcp.search",
|
||||
)
|
||||
with pytest.raises(GovernanceError, match="CREDENTIAL_SECRET_EXPOSURE_DENY"):
|
||||
CredentialGrant("grant-raw", "Bearer raw-secret", TENANT_SCOPE, ("search.invoke",))
|
||||
|
||||
authorized = store.authorize_credential(
|
||||
grant_id=grant.grant_id,
|
||||
actor=actor("tool.search"),
|
||||
trace=trace,
|
||||
operation="search.invoke",
|
||||
task_id="task-a",
|
||||
tool_name="mcp.search",
|
||||
)
|
||||
assert authorized["credential_ref"] == "systemd-credential:search-provider-a"
|
||||
assert "credential_ref" not in authorized["evidence"]["details"]
|
||||
|
||||
|
||||
def test_audit_evidence_completeness_and_tamper_detection(tmp_path: Path) -> None:
|
||||
path = tmp_path / "governance.sqlite3"
|
||||
store = GovernanceStore(path)
|
||||
trace = root_trace()
|
||||
store.record_trace(trace, actor("tool.search"))
|
||||
store.register_tool(
|
||||
ToolDescriptor("mcp.search", "mcp", TENANT_SCOPE, ("tool.search",)), owner()
|
||||
)
|
||||
store.authorize_tool(
|
||||
actor=actor("tool.search"), tool_name="mcp.search", trace=trace,
|
||||
requested_scope=TENANT_SCOPE, input_digest="a" * 64,
|
||||
invocation_key="invoke-audit-a",
|
||||
)
|
||||
assert store.verify_audit_chain(("TRACE_RECORDED", "TOOL_REGISTERED", "TOOL_AUTHORIZED"))
|
||||
assert not store.verify_audit_chain(("CREDENTIAL_AUTHORIZED",))
|
||||
|
||||
import sqlite3
|
||||
|
||||
with sqlite3.connect(path) as connection:
|
||||
connection.execute(
|
||||
"UPDATE governance_audit_evidence SET outcome='DENY' WHERE sequence=1"
|
||||
)
|
||||
assert not GovernanceStore(path).verify_audit_chain()
|
||||
|
||||
|
||||
def test_observability_record_carries_governed_trace(tmp_path: Path) -> None:
|
||||
from active_runtime.app.agent_observability_store import AgentObservabilityStore
|
||||
|
||||
hooks = AgentObservabilityHooks(AgentObservabilityStore(tmp_path / "observability"))
|
||||
trace = root_trace()
|
||||
started = hooks.start(
|
||||
"user-a", "project-a", "openclaw",
|
||||
actor=actor("tool.search"), trace=trace,
|
||||
)
|
||||
record = hooks.finish(started["execution_id"])["record"]
|
||||
assert record["trace_id"] == "trace-a"
|
||||
assert record["tenant_id"] == "tenant-a"
|
||||
assert record["task_id"] == "task-a"
|
||||
assert record["message_id"] == message().envelope_id
|
||||
|
||||
|
||||
def test_active_runtime_credential_broker_scope_and_raw_secret_deny() -> None:
|
||||
class Provider:
|
||||
def environment(self, lease):
|
||||
return {"ACCESS_TOKEN": "raw-secret"}
|
||||
|
||||
broker = CredentialBroker(Provider())
|
||||
lease = broker.issue(
|
||||
task_id="task-a", repository="aios/aios", target_branch="agent/task-a",
|
||||
generation=1, scopes=("tool.invoke",), scope_type="tenant",
|
||||
tenant_id="tenant-a", user_id="user-a", project_id="project-a", run_id="run-a",
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="CREDENTIAL_SCOPE_DENY"):
|
||||
broker.authorize(
|
||||
lease["lease_id"], task_id="task-a", operation="tool.invoke",
|
||||
scope_type="tenant", tenant_id="tenant-b", user_id="user-a",
|
||||
project_id="project-a", run_id="run-a",
|
||||
)
|
||||
with pytest.raises(RuntimeError, match="CREDENTIAL_SECRET_EXPOSURE_DENY"):
|
||||
broker.authorize(
|
||||
lease["lease_id"], task_id="task-a", operation="tool.invoke",
|
||||
scope_type="tenant", tenant_id="tenant-a", user_id="user-a",
|
||||
project_id="project-a", run_id="run-a",
|
||||
)
|
||||
|
||||
|
||||
def test_search_gateway_uses_governed_tool_boundary(tmp_path: Path) -> None:
|
||||
class Provider:
|
||||
name = "official_site"
|
||||
called = False
|
||||
|
||||
def search(self, request, queries):
|
||||
self.called = True
|
||||
return (
|
||||
[SearchEvidence("Official", "https://example.test", self.name, "safe")],
|
||||
SearchProviderAttempt(self.name, "ok", 1),
|
||||
)
|
||||
|
||||
store = GovernanceStore(tmp_path / "governance.sqlite3")
|
||||
store.register_tool(
|
||||
ToolDescriptor("search.official_site", "tool", TENANT_SCOPE, ("tool.search",)),
|
||||
owner(),
|
||||
)
|
||||
provider = Provider()
|
||||
gateway = SearchGateway([provider], governance_store=store)
|
||||
trace = root_trace()
|
||||
store.record_trace(trace, actor("tool.search"))
|
||||
response = gateway.search(
|
||||
SearchRequest(
|
||||
query="AIOS", requester="actor-a", correlation_id="trace-a",
|
||||
authorized_sources=["official_site"],
|
||||
),
|
||||
actor=actor("tool.search"),
|
||||
trace=trace,
|
||||
)
|
||||
assert response.status == "found"
|
||||
assert provider.called is True
|
||||
assert store.verify_audit_chain(
|
||||
("TOOL_REGISTERED", "TRACE_RECORDED", "TOOL_AUTHORIZED", "TOOL_RESULT_VERIFIED")
|
||||
)
|
||||
|
||||
with pytest.raises(GovernanceError, match="TOOL_MCP_AUTHZ_DENY"):
|
||||
gateway.search(
|
||||
SearchRequest(query="AIOS", requester="forged", correlation_id="trace-a"),
|
||||
actor=actor("tool.search"),
|
||||
trace=trace,
|
||||
)
|
||||
|
||||
|
||||
def test_search_gateway_redacts_provider_exception() -> None:
|
||||
class Provider:
|
||||
name = "general_web"
|
||||
|
||||
def search(self, request, queries):
|
||||
raise RuntimeError("Bearer should-not-leak")
|
||||
|
||||
response = SearchGateway([Provider()]).search(SearchRequest(query="AIOS", requester="legacy"))
|
||||
assert response.attempts[0].reason == "PROVIDER_FAILURE"
|
||||
assert "should-not-leak" not in str(response.to_dict())
|
||||
|
||||
|
||||
def test_search_runtime_default_path_requires_governance(tmp_path: Path) -> None:
|
||||
orchestrator = SearchGatewayOrchestrator(
|
||||
governance_store=GovernanceStore(tmp_path / "search-governance.sqlite3")
|
||||
)
|
||||
assert orchestrator.status()["governance"] == "required"
|
||||
with pytest.raises(GovernanceError, match="TOOL_MCP_AUTHZ_DENY"):
|
||||
orchestrator.execute({"query": "AIOS", "requester": "actor-a"})
|
||||
|
||||
|
||||
def test_governance_newer_schema_fails_closed(tmp_path: Path) -> None:
|
||||
import sqlite3
|
||||
path = tmp_path / "newer.sqlite3"
|
||||
with sqlite3.connect(path) as connection:
|
||||
connection.execute(
|
||||
"CREATE TABLE governance_schema_metadata(singleton INTEGER PRIMARY KEY, schema_version INTEGER NOT NULL)"
|
||||
)
|
||||
connection.execute(
|
||||
"INSERT INTO governance_schema_metadata(singleton,schema_version) VALUES(1,999)"
|
||||
)
|
||||
with pytest.raises(GovernanceError, match="GOVERNANCE_SCHEMA_VERSION_UNSUPPORTED"):
|
||||
GovernanceStore(path)
|
||||
|
||||
|
||||
def test_changed_production_files_have_no_embedded_secret_material() -> None:
|
||||
import re
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
paths = (
|
||||
"active-runtime/app/agent_event_bus.py",
|
||||
"active-runtime/app/agent_event_observability_bridge.py",
|
||||
"active-runtime/app/agent_observability.py",
|
||||
"active-runtime/app/agent_observability_api.py",
|
||||
"active-runtime/app/agent_observability_hooks.py",
|
||||
"active-runtime/app/agent_observability_store.py",
|
||||
"active-runtime/app/aios_credential_broker.py",
|
||||
"active-runtime/app/message_handoff.py",
|
||||
"active-runtime/app/search_agent_adapter.py",
|
||||
"active-runtime/app/search_gateway.py",
|
||||
"active-runtime/app/search_gateway_api.py",
|
||||
"active-runtime/app/search_gateway_orchestrator.py",
|
||||
"active-runtime/app/trace_tool_credential_governance.py",
|
||||
)
|
||||
patterns = (
|
||||
re.compile(r"BEGIN (?:RSA|OPENSSH|EC) PRIVATE KEY"),
|
||||
re.compile(r"Bearer\s+[A-Za-z0-9._~+/-]{24,}"),
|
||||
)
|
||||
hits = [path for path in paths for pattern in patterns if pattern.search((root / path).read_text(encoding="utf-8"))]
|
||||
assert hits == []
|
||||
@@ -0,0 +1,763 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import hashlib
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
import sqlite3
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from aios_chatgpt_bridge_v2_server import GovernedRuntime
|
||||
from aios_continuation_executor import ContinuationPermissions, ContinuationRuntimeBinding
|
||||
from aios_continuation_lease import WriterLeaseRequestV2
|
||||
from aios_gitops_job_controller import GitOpsJobController, JobError, JobStore
|
||||
from aios_governed_task_bootstrap import GovernedTaskBootstrap
|
||||
|
||||
|
||||
TASK_ID = "gov-writer-lease-recovery-test"
|
||||
BRANCH = "agent/writer-lease-recovery-test"
|
||||
BASE = "a" * 40
|
||||
JOB_ID_PREFIX = "writer-lease-runtime-lineage"
|
||||
|
||||
|
||||
class LocalAuthority:
|
||||
def __init__(self, head: str | None = None) -> None:
|
||||
self.head = head
|
||||
|
||||
def read_ref(self, _ref: str) -> str | None:
|
||||
return self.head
|
||||
|
||||
def commit_exists(self, _oid: str) -> bool:
|
||||
return True
|
||||
|
||||
def ancestry_check(self, _older: str, _newer: str) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def writer_request(task_id: str = TASK_ID, **overrides: object) -> dict[str, object]:
|
||||
request: dict[str, object] = {
|
||||
"schema": WriterLeaseRequestV2.schema,
|
||||
"task_id": task_id,
|
||||
"repository": "aios/aios",
|
||||
"branch_mode": "CREATE_NEW",
|
||||
"base_oid": BASE,
|
||||
"expected_branch_oid": BASE,
|
||||
"target_branch": BRANCH,
|
||||
"generation": 1,
|
||||
"lease_seconds": 120,
|
||||
}
|
||||
request.update(overrides)
|
||||
return request
|
||||
|
||||
|
||||
def make_runtime(tmp_path: Path, task_id: str = TASK_ID, *, authority_head: str | None = None) -> GovernedRuntime:
|
||||
runtime = GovernedRuntime.__new__(GovernedRuntime)
|
||||
runtime.config = SimpleNamespace(repository="aios/aios", state_root=tmp_path)
|
||||
runtime.authority = LocalAuthority(authority_head)
|
||||
runtime.bindings = {
|
||||
task_id: {
|
||||
"task_id": task_id,
|
||||
"repository": "aios/aios",
|
||||
"base_oid": BASE,
|
||||
"expected_branch_oid": BASE,
|
||||
"target_branch": BRANCH,
|
||||
"branch_mode": "CREATE_NEW",
|
||||
"generation": 1,
|
||||
}
|
||||
}
|
||||
runtime.runtimes = {}
|
||||
runtime.controllers = {}
|
||||
runtime.bootstrap = GovernedTaskBootstrap(
|
||||
runtime.authority,
|
||||
tmp_path,
|
||||
remote_name="test",
|
||||
token_path=tmp_path / "token",
|
||||
ssh_key_path=tmp_path / "key",
|
||||
)
|
||||
runtime.bindings = {}
|
||||
runtime.create_task(
|
||||
{
|
||||
"task_id": task_id,
|
||||
"repository": "aios/aios",
|
||||
"base_oid": BASE,
|
||||
"expected_branch_oid": BASE,
|
||||
"target_branch": BRANCH,
|
||||
"risk": "engineering",
|
||||
"description": "governed runtime lineage test",
|
||||
"idempotency_key": JOB_ID_PREFIX + "-" + task_id,
|
||||
}
|
||||
)
|
||||
job_root = runtime._job_root(task_id)
|
||||
runtime.controllers[task_id] = GitOpsJobController(
|
||||
JobStore(job_root, payload_root=job_root / "payloads")
|
||||
)
|
||||
|
||||
def build(
|
||||
lease: WriterLeaseRequestV2,
|
||||
*,
|
||||
job_id: str,
|
||||
lease_not_before: datetime | None = None,
|
||||
lease_expires_at: datetime | None = None,
|
||||
) -> tuple[ContinuationRuntimeBinding, GitOpsJobController, object]:
|
||||
controller = runtime.controllers.get(lease.task_id)
|
||||
if controller is None:
|
||||
root = runtime._job_root(lease.task_id)
|
||||
controller = GitOpsJobController(JobStore(root, payload_root=root / "payloads"))
|
||||
now = datetime.now(timezone.utc)
|
||||
return (
|
||||
ContinuationRuntimeBinding(
|
||||
request=lease,
|
||||
job_id=job_id,
|
||||
workspace_path=tmp_path / "worktrees" / lease.task_id,
|
||||
lease_not_before=lease_not_before or now - timedelta(seconds=1),
|
||||
lease_expires_at=lease_expires_at or now + timedelta(seconds=lease.lease_seconds),
|
||||
original_receipt_valid=True,
|
||||
original_grant_consumed=True,
|
||||
permissions=ContinuationPermissions(),
|
||||
),
|
||||
controller,
|
||||
object(),
|
||||
)
|
||||
|
||||
runtime._build_runtime = build
|
||||
return runtime
|
||||
|
||||
|
||||
def create_durable_lease(
|
||||
runtime: GovernedRuntime,
|
||||
*,
|
||||
expires_at: str = "2020-01-01T00:00:00+00:00",
|
||||
) -> GitOpsJobController:
|
||||
root = runtime._job_root(TASK_ID)
|
||||
controller = GitOpsJobController(JobStore(root, payload_root=root / "payloads"))
|
||||
receipt = SimpleNamespace(job_id=runtime.bindings[TASK_ID]["job_id"])
|
||||
controller.lease_job(
|
||||
receipt.job_id,
|
||||
lease_id="writer-lease-recovery",
|
||||
lineage_anchor_oid=BASE,
|
||||
current_head_oid=BASE,
|
||||
generation=1,
|
||||
lease_expires_at=expires_at,
|
||||
expected_revision=0,
|
||||
)
|
||||
runtime.controllers[TASK_ID] = controller
|
||||
return controller
|
||||
|
||||
|
||||
def durable_row(runtime: GovernedRuntime) -> tuple[str, int]:
|
||||
database = runtime._job_root(TASK_ID) / "state.sqlite3"
|
||||
with sqlite3.connect(f"file:{database}?mode=ro", uri=True) as connection:
|
||||
row = connection.execute(
|
||||
"SELECT lease_expires_at, revision FROM branch_leases WHERE lease_id=?",
|
||||
("writer-lease-recovery",),
|
||||
).fetchone()
|
||||
assert row is not None
|
||||
return row
|
||||
|
||||
|
||||
def test_first_bootstrap_refreshes_only_runtime_without_durable_lease(tmp_path: Path) -> None:
|
||||
runtime = make_runtime(tmp_path)
|
||||
|
||||
result = runtime.bootstrap_writer_lease(writer_request())
|
||||
|
||||
assert result["bootstrapped"] is True
|
||||
assert result["runtime_rehydrated"] is False
|
||||
assert result["durable_branch_lease_present"] is False
|
||||
assert result["idempotent"] is False
|
||||
database = runtime._job_root(TASK_ID) / "state.sqlite3"
|
||||
with sqlite3.connect(f"file:{database}?mode=ro", uri=True) as connection:
|
||||
assert connection.execute("SELECT COUNT(*) FROM branch_leases").fetchone()[0] == 0
|
||||
|
||||
|
||||
def test_duplicate_bootstrap_is_idempotent_without_consuming_generation(tmp_path: Path) -> None:
|
||||
runtime = make_runtime(tmp_path)
|
||||
|
||||
first = runtime.bootstrap_writer_lease(writer_request())
|
||||
second = runtime.bootstrap_writer_lease(writer_request())
|
||||
|
||||
assert first["bootstrapped"] is True
|
||||
assert first["generation"] == 1
|
||||
assert second["bootstrapped"] is False
|
||||
assert second["idempotent"] is True
|
||||
assert second["generation"] == 1
|
||||
database = runtime._job_root(TASK_ID) / "state.sqlite3"
|
||||
with sqlite3.connect(f"file:{database}?mode=ro", uri=True) as connection:
|
||||
assert connection.execute("SELECT COUNT(*) FROM branch_leases").fetchone()[0] == 0
|
||||
|
||||
|
||||
def test_commit_after_bootstrap_reuses_the_exact_runtime_binding(tmp_path: Path) -> None:
|
||||
runtime = make_runtime(tmp_path)
|
||||
|
||||
class RecordingWorkspace:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.files: dict[str, bytes] = {}
|
||||
|
||||
def materialize_files(self, files: dict[str, bytes]) -> None:
|
||||
self.files = dict(files)
|
||||
|
||||
class RecordingExecutor:
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.workspace = RecordingWorkspace()
|
||||
self.binding = None
|
||||
self.commit = None
|
||||
|
||||
def execute(self, binding: object, commit: object) -> dict[str, object]:
|
||||
self.binding = binding
|
||||
self.commit = commit
|
||||
return {'task_id': binding.request.task_id, 'generation': binding.request.generation, 'files': sorted(self.workspace.files)}
|
||||
executor = RecordingExecutor()
|
||||
original_build = runtime._build_runtime
|
||||
|
||||
def build(lease: WriterLeaseRequestV2, *, job_id: str, lease_not_before: datetime | None=None, lease_expires_at: datetime | None=None) -> tuple[ContinuationRuntimeBinding, GitOpsJobController, object]:
|
||||
binding, controller, _ = original_build(lease, job_id=job_id, lease_not_before=lease_not_before, lease_expires_at=lease_expires_at)
|
||||
return (binding, controller, executor)
|
||||
runtime._build_runtime = build
|
||||
runtime.bootstrap_writer_lease(writer_request())
|
||||
lease_response = runtime.acquire_lease(writer_request())
|
||||
binding, controller, executor = runtime.runtimes[TASK_ID, 1]
|
||||
assert lease_response['job_id'] == binding.job_id
|
||||
assert lease_response['durable_branch_lease_present'] is True
|
||||
_original_execute = executor.execute
|
||||
|
||||
def _execute_with_governed_commit_contract(*args, **kwargs):
|
||||
_before_complete = kwargs.pop('before_complete', None)
|
||||
_legacy = dict(_original_execute(*args, **kwargs))
|
||||
_result = {**_legacy, 'job_id': binding.job_id, 'state': 'COMPLETED', 'side_effect_state': 'REMOTE_VERIFIED', 'receipt': {'authority_oid': 'b' * 40, 'git_remote_oid': 'b' * 40, 'api_branch_oid': 'b' * 40, 'commit_visible': True, 'remote_verified': True, 'force_push': False}}
|
||||
if _before_complete is not None:
|
||||
_before_complete('b' * 40)
|
||||
return _result
|
||||
executor.execute = _execute_with_governed_commit_contract
|
||||
_bound_job_id = binding.job_id
|
||||
result = runtime.commit_files({'task_id': TASK_ID, 'repository': 'aios/aios', 'generation': 1, 'target_branch': BRANCH, 'expected_branch_oid': BASE, 'idempotency_key': 'commit-after-bootstrap-v1', 'path': 'docs/recovery.txt', 'content_base64': 'YQ==', 'message': 'test commit after bootstrap'})
|
||||
assert result['task_id'] == TASK_ID
|
||||
assert result['generation'] == 1
|
||||
assert result['files'] == ['docs/recovery.txt']
|
||||
assert executor.binding.request.task_id == TASK_ID
|
||||
assert executor.binding.request.generation == 1
|
||||
database = runtime._job_root(TASK_ID) / 'state.sqlite3'
|
||||
with sqlite3.connect(f'file:{database}?mode=ro', uri=True) as connection:
|
||||
assert connection.execute('SELECT COUNT(*) FROM branch_leases').fetchone()[0] == 1
|
||||
assert connection.execute('PRAGMA foreign_key_check(branch_leases)').fetchall() == []
|
||||
assert result['commit_oid'] == 'b' * 40
|
||||
assert result['commit_visible'] is True
|
||||
assert result['branch_mode'] == 'CONTINUE_EXISTING'
|
||||
assert result['target_branch'] == BRANCH
|
||||
assert int(result['generation']) == 1
|
||||
with controller.store._connect() as connection:
|
||||
_persisted = connection.execute('SELECT current_head_oid,revision FROM branch_leases WHERE lease_id=?', ('lease-' + str(_bound_job_id),)).fetchone()
|
||||
assert _persisted is not None
|
||||
assert _persisted[0] == 'b' * 40
|
||||
assert int(_persisted[1]) == 1
|
||||
fresh_runtime = make_runtime(tmp_path, authority_head='b' * 40)
|
||||
fresh_response = fresh_runtime.bootstrap_writer_lease(writer_request(branch_mode='CONTINUE_EXISTING', expected_branch_oid='b' * 40))
|
||||
fresh_binding, _, _ = fresh_runtime.runtimes[TASK_ID, 1]
|
||||
assert fresh_response['job_id'] == str(_bound_job_id)
|
||||
assert fresh_response['runtime_rehydrated'] is True
|
||||
assert fresh_binding.job_id == str(_bound_job_id)
|
||||
assert fresh_binding.request.expected_branch_oid == 'b' * 40
|
||||
with fresh_runtime.controllers[TASK_ID].store._connect() as connection:
|
||||
_fresh_lease = connection.execute('SELECT job_id,current_head_oid,revision FROM branch_leases WHERE lease_id=?', ('lease-' + str(_bound_job_id),)).fetchone()
|
||||
assert tuple(_fresh_lease) == (str(_bound_job_id), 'b' * 40, 1)
|
||||
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("field", "value", "error"),
|
||||
(
|
||||
("repository", "other/repository", "LEASE_BINDING_CONFLICT"),
|
||||
("base_oid", "b" * 40, "LEASE_BINDING_CONFLICT"),
|
||||
("expected_branch_oid", "b" * 40, "LEASE_BINDING_CONFLICT"),
|
||||
("target_branch", "agent/wrong-target", "LEASE_BINDING_CONFLICT"),
|
||||
("generation", 2, "GENERATION_BINDING_CONFLICT"),
|
||||
),
|
||||
)
|
||||
def test_bootstrap_rejects_any_nonmatching_binding_field(
|
||||
tmp_path: Path, field: str, value: object, error: str
|
||||
) -> None:
|
||||
runtime = make_runtime(tmp_path)
|
||||
|
||||
with pytest.raises(RuntimeError, match=error):
|
||||
runtime.bootstrap_writer_lease(writer_request(**{field: value}))
|
||||
|
||||
|
||||
def test_bootstrap_rejects_unknown_task_ownership(tmp_path: Path) -> None:
|
||||
runtime = make_runtime(tmp_path)
|
||||
|
||||
with pytest.raises(RuntimeError, match="TASK_OWNERSHIP_INVALID"):
|
||||
runtime.bootstrap_writer_lease(writer_request(task_id="unknown-task"))
|
||||
|
||||
|
||||
def test_renew_missing_durable_lease_is_not_bootstrap(tmp_path: Path) -> None:
|
||||
runtime = make_runtime(tmp_path)
|
||||
|
||||
with pytest.raises(RuntimeError, match="LEASE_NOT_FOUND"):
|
||||
runtime.renew_writer_lease(writer_request())
|
||||
|
||||
|
||||
def test_renew_is_generation_scoped_before_durable_lookup(tmp_path: Path) -> None:
|
||||
runtime = make_runtime(tmp_path)
|
||||
create_durable_lease(runtime)
|
||||
|
||||
with pytest.raises(RuntimeError, match="GENERATION_BINDING_CONFLICT"):
|
||||
runtime.renew_writer_lease(writer_request(generation=2))
|
||||
assert durable_row(runtime) == ("2020-01-01T00:00:00+00:00", 0)
|
||||
|
||||
|
||||
def test_restart_rehydrates_only_from_matching_durable_lease(tmp_path: Path) -> None:
|
||||
runtime = make_runtime(tmp_path)
|
||||
create_durable_lease(runtime)
|
||||
runtime.runtimes.clear()
|
||||
|
||||
result = runtime.renew_writer_lease(writer_request())
|
||||
|
||||
expires_at, revision = durable_row(runtime)
|
||||
assert result["recovered"] is True
|
||||
assert result["runtime_rehydrated"] is True
|
||||
assert result["durable_branch_lease_present"] is True
|
||||
assert revision == 1
|
||||
assert datetime.fromisoformat(expires_at) > datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def test_duplicate_renewal_is_idempotent_after_first_durable_recovery(tmp_path: Path) -> None:
|
||||
runtime = make_runtime(tmp_path)
|
||||
create_durable_lease(runtime)
|
||||
|
||||
first = runtime.renew_writer_lease(writer_request())
|
||||
second = runtime.renew_writer_lease(writer_request())
|
||||
|
||||
assert first["recovered"] is True
|
||||
assert first["idempotent"] is False
|
||||
assert second["recovered"] is False
|
||||
assert second["idempotent"] is True
|
||||
assert durable_row(runtime)[1] == 1
|
||||
|
||||
|
||||
def test_update_lease_keeps_expiry_without_optional_argument_and_keeps_cas(tmp_path: Path) -> None:
|
||||
runtime = make_runtime(tmp_path)
|
||||
controller = create_durable_lease(runtime)
|
||||
|
||||
controller.update_lease(
|
||||
"writer-lease-recovery",
|
||||
expected_revision=0,
|
||||
current_head_oid=BASE,
|
||||
generation=1,
|
||||
)
|
||||
assert durable_row(runtime) == ("2020-01-01T00:00:00+00:00", 1)
|
||||
with pytest.raises(JobError, match="LEASE_REVISION_CAS_CONFLICT"):
|
||||
controller.update_lease(
|
||||
"writer-lease-recovery",
|
||||
expected_revision=0,
|
||||
current_head_oid=BASE,
|
||||
generation=1,
|
||||
)
|
||||
|
||||
|
||||
def test_two_runtime_connections_resolve_renewal_cas_race_idempotently(tmp_path: Path) -> None:
|
||||
first = make_runtime(tmp_path)
|
||||
first_controller = create_durable_lease(first)
|
||||
second = make_runtime(tmp_path)
|
||||
second_controller = GitOpsJobController(
|
||||
JobStore(second._job_root(TASK_ID), payload_root=second._job_root(TASK_ID) / "payloads")
|
||||
)
|
||||
second.controllers[TASK_ID] = second_controller
|
||||
gate = threading.Barrier(2)
|
||||
|
||||
def gate_update(controller: GitOpsJobController) -> None:
|
||||
original = controller.update_lease
|
||||
|
||||
def update(*args: object, **kwargs: object) -> None:
|
||||
gate.wait(timeout=5)
|
||||
original(*args, **kwargs)
|
||||
|
||||
controller.update_lease = update
|
||||
|
||||
gate_update(first_controller)
|
||||
gate_update(second_controller)
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
results = list(pool.map(lambda runtime: runtime.renew_writer_lease(writer_request()), (first, second)))
|
||||
|
||||
assert sorted((item["recovered"], item["idempotent"]) for item in results) == [
|
||||
(False, True),
|
||||
(True, False),
|
||||
]
|
||||
assert durable_row(first)[1] == 1
|
||||
|
||||
|
||||
def test_bootstrap_preserves_exact_governed_job_lineage(tmp_path: Path) -> None:
|
||||
runtime = make_runtime(tmp_path)
|
||||
expected_job_id = runtime.bindings[TASK_ID]["job_id"]
|
||||
|
||||
response = runtime.bootstrap_writer_lease(writer_request())
|
||||
binding, _, _ = runtime.runtimes[(TASK_ID, 1)]
|
||||
|
||||
assert response["job_id"] == expected_job_id
|
||||
assert binding.job_id == expected_job_id
|
||||
assert binding.request.job_id == expected_job_id
|
||||
|
||||
|
||||
def test_restart_rehydrate_preserves_exact_governed_job_lineage(tmp_path: Path) -> None:
|
||||
first = make_runtime(tmp_path)
|
||||
expected_job_id = first.bindings[TASK_ID]["job_id"]
|
||||
first.acquire_lease(writer_request())
|
||||
|
||||
runtime = make_runtime(tmp_path)
|
||||
assert runtime is not first
|
||||
assert runtime.runtimes == {}
|
||||
response = runtime.bootstrap_writer_lease(writer_request())
|
||||
binding, _, _ = runtime.runtimes[(TASK_ID, 1)]
|
||||
|
||||
assert response["job_id"] == expected_job_id
|
||||
assert binding.job_id == expected_job_id
|
||||
assert binding.request.job_id == expected_job_id
|
||||
|
||||
|
||||
def test_wrong_governed_job_id_is_denied_before_runtime_or_lease_side_effect(tmp_path: Path) -> None:
|
||||
runtime = make_runtime(tmp_path)
|
||||
|
||||
with pytest.raises(RuntimeError, match="LEASE_BINDING_CONFLICT"):
|
||||
runtime.bootstrap_writer_lease(writer_request(job_id="job-wrong-lineage"))
|
||||
|
||||
assert runtime.runtimes == {}
|
||||
database = runtime._job_root(TASK_ID) / "state.sqlite3"
|
||||
with sqlite3.connect(f"file:{database}?mode=ro", uri=True) as connection:
|
||||
assert connection.execute("SELECT COUNT(*) FROM branch_leases").fetchone()[0] == 0
|
||||
assert connection.execute("PRAGMA foreign_key_check(branch_leases)").fetchall() == []
|
||||
|
||||
|
||||
def test_acquire_creates_exact_governed_writer_lease_with_fk_and_cas(tmp_path: Path) -> None:
|
||||
runtime = make_runtime(tmp_path)
|
||||
expected_job_id = runtime.bindings[TASK_ID]["job_id"]
|
||||
|
||||
response = runtime.acquire_lease(writer_request())
|
||||
|
||||
assert response["job_id"] == expected_job_id
|
||||
assert response["durable_branch_lease_present"] is True
|
||||
assert response["idempotent"] is False
|
||||
database = runtime._job_root(TASK_ID) / "state.sqlite3"
|
||||
with sqlite3.connect(f"file:{database}?mode=ro", uri=True) as connection:
|
||||
foreign_keys = connection.execute(
|
||||
"PRAGMA foreign_key_list(branch_leases)"
|
||||
).fetchall()
|
||||
row = connection.execute(
|
||||
"SELECT job_id,branch,lineage_anchor_oid,current_head_oid,generation,state,revision "
|
||||
"FROM branch_leases"
|
||||
).fetchone()
|
||||
job = connection.execute(
|
||||
"SELECT state,side_effect_state,revision FROM jobs WHERE job_id=?",
|
||||
(expected_job_id,),
|
||||
).fetchone()
|
||||
assert connection.execute("PRAGMA foreign_key_check(branch_leases)").fetchall() == []
|
||||
assert any(
|
||||
fk[2] == "jobs" and fk[3] == "job_id" and fk[4] == "job_id"
|
||||
for fk in foreign_keys
|
||||
)
|
||||
assert row == (expected_job_id, BRANCH, BASE, BASE, 1, "ACTIVE", 0)
|
||||
assert job == ("LEASED", "LEASED", 2)
|
||||
|
||||
|
||||
def test_duplicate_acquire_observes_same_durable_writer_lease(tmp_path: Path) -> None:
|
||||
runtime = make_runtime(tmp_path)
|
||||
expected_job_id = runtime.bindings[TASK_ID]["job_id"]
|
||||
|
||||
first = runtime.acquire_lease(writer_request())
|
||||
second = runtime.acquire_lease(writer_request())
|
||||
|
||||
assert first["job_id"] == expected_job_id
|
||||
assert second["job_id"] == expected_job_id
|
||||
assert second["idempotent"] is True
|
||||
database = runtime._job_root(TASK_ID) / "state.sqlite3"
|
||||
with sqlite3.connect(f"file:{database}?mode=ro", uri=True) as connection:
|
||||
leases = connection.execute(
|
||||
"SELECT lease_id,job_id,revision FROM branch_leases"
|
||||
).fetchall()
|
||||
job = connection.execute(
|
||||
"SELECT state,revision FROM jobs WHERE job_id=?",
|
||||
(expected_job_id,),
|
||||
).fetchone()
|
||||
assert leases == [("lease-" + expected_job_id, expected_job_id, 0)]
|
||||
assert job == ("LEASED", 2)
|
||||
|
||||
|
||||
def test_wrong_job_acquire_is_denied_before_authorize_or_lease(tmp_path: Path) -> None:
|
||||
runtime = make_runtime(tmp_path)
|
||||
expected_job_id = runtime.bindings[TASK_ID]["job_id"]
|
||||
|
||||
with pytest.raises(RuntimeError, match="LEASE_BINDING_CONFLICT"):
|
||||
runtime.acquire_lease(writer_request(job_id="job-wrong-lineage"))
|
||||
|
||||
database = runtime._job_root(TASK_ID) / "state.sqlite3"
|
||||
with sqlite3.connect(f"file:{database}?mode=ro", uri=True) as connection:
|
||||
job = connection.execute(
|
||||
"SELECT state,side_effect_state,revision FROM jobs WHERE job_id=?",
|
||||
(expected_job_id,),
|
||||
).fetchone()
|
||||
lease_count = connection.execute("SELECT COUNT(*) FROM branch_leases").fetchone()[0]
|
||||
assert job == ("REQUESTED", "NONE", 0)
|
||||
assert lease_count == 0
|
||||
|
||||
|
||||
def test_omitted_request_job_id_resolves_the_exact_durable_parent(tmp_path: Path) -> None:
|
||||
runtime = make_runtime(tmp_path)
|
||||
expected_job_id = runtime.bindings[TASK_ID]["job_id"]
|
||||
raw = dict(writer_request())
|
||||
|
||||
response = runtime.acquire_lease(raw)
|
||||
binding, _, _ = runtime.runtimes[(TASK_ID, 1)]
|
||||
|
||||
assert response["job_id"] == expected_job_id
|
||||
assert binding.job_id == expected_job_id
|
||||
assert binding.request.job_id == expected_job_id
|
||||
|
||||
|
||||
def test_durable_job_expected_head_mismatch_is_denied(tmp_path: Path) -> None:
|
||||
runtime = make_runtime(tmp_path)
|
||||
expected_job_id = runtime.bindings[TASK_ID]["job_id"]
|
||||
controller = runtime.controllers[TASK_ID]
|
||||
with controller.store._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT request_json FROM jobs WHERE job_id=?", (expected_job_id,)
|
||||
).fetchone()
|
||||
request = json.loads(row[0])
|
||||
request["expected_branch_oid"] = "c" * 40
|
||||
request["binding"]["expected_branch_oid"] = "c" * 40
|
||||
connection.execute(
|
||||
"UPDATE jobs SET request_json=? WHERE job_id=?",
|
||||
(json.dumps(request, sort_keys=True, separators=(",", ":")), expected_job_id),
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
with pytest.raises(RuntimeError, match="RUNTIME_GOVERNED_JOB_UNRESOLVED"):
|
||||
runtime.bootstrap_writer_lease(writer_request())
|
||||
|
||||
assert runtime.runtimes == {}
|
||||
|
||||
|
||||
def _commit_ready_runtime(tmp_path: Path):
|
||||
runtime = make_runtime(tmp_path)
|
||||
runtime.bootstrap_writer_lease(writer_request())
|
||||
binding, controller, _ = runtime.runtimes[(TASK_ID, 1)]
|
||||
|
||||
class Workspace:
|
||||
def materialize_files(self, _files):
|
||||
return None
|
||||
|
||||
class Executor:
|
||||
def __init__(self):
|
||||
self.workspace = Workspace()
|
||||
self.calls = 0
|
||||
|
||||
def execute(self, _binding, _commit, *, before_complete=None):
|
||||
self.calls += 1
|
||||
result = {
|
||||
"job_id": binding.job_id,
|
||||
"state": "COMPLETED",
|
||||
"side_effect_state": "REMOTE_VERIFIED",
|
||||
"receipt": {
|
||||
"authority_oid": "b" * 40,
|
||||
"git_remote_oid": "b" * 40,
|
||||
"api_branch_oid": "b" * 40,
|
||||
"commit_visible": True,
|
||||
"remote_verified": True,
|
||||
"force_push": False,
|
||||
},
|
||||
}
|
||||
if before_complete is not None:
|
||||
before_complete("b" * 40)
|
||||
return result
|
||||
|
||||
executor = Executor()
|
||||
runtime.runtimes[(TASK_ID, 1)] = (binding, controller, executor)
|
||||
runtime.acquire_lease(writer_request())
|
||||
return runtime, binding, controller, executor
|
||||
|
||||
|
||||
def _commit_request():
|
||||
return {
|
||||
"task_id": TASK_ID,
|
||||
"repository": "aios/aios",
|
||||
"generation": 1,
|
||||
"target_branch": BRANCH,
|
||||
"expected_branch_oid": BASE,
|
||||
"idempotency_key": "commit-cas-test-v1",
|
||||
"path": "docs/cas.txt",
|
||||
"content_base64": "YQ==",
|
||||
"message": "test commit cas",
|
||||
}
|
||||
|
||||
|
||||
def test_commit_lease_revision_cas_conflict_is_preserved(tmp_path: Path) -> None:
|
||||
runtime, _binding, controller, _executor = _commit_ready_runtime(tmp_path)
|
||||
|
||||
def conflict(*_args, **_kwargs):
|
||||
raise JobError("LEASE_REVISION_CAS_CONFLICT")
|
||||
|
||||
controller.update_lease = conflict
|
||||
with pytest.raises(RuntimeError, match="LEASE_CAS_CONFLICT"):
|
||||
runtime.commit_files(_commit_request())
|
||||
|
||||
|
||||
def test_commit_divergent_durable_head_fails_before_executor(tmp_path: Path) -> None:
|
||||
runtime, binding, controller, executor = _commit_ready_runtime(tmp_path)
|
||||
controller.update_lease(
|
||||
"lease-" + binding.job_id,
|
||||
expected_revision=0,
|
||||
current_head_oid="c" * 40,
|
||||
generation=1,
|
||||
)
|
||||
|
||||
with pytest.raises(RuntimeError, match="LEASE_HEAD_RECONCILIATION_REQUIRED"):
|
||||
runtime.commit_files(_commit_request())
|
||||
assert executor.calls == 0
|
||||
|
||||
|
||||
def test_remote_pending_never_advances_durable_head(tmp_path: Path) -> None:
|
||||
runtime, binding, controller, executor = _commit_ready_runtime(tmp_path)
|
||||
|
||||
def pending(_binding, _commit, **_kwargs):
|
||||
executor.calls += 1
|
||||
return {
|
||||
"job_id": binding.job_id,
|
||||
"state": "RECONCILIATION_REQUIRED",
|
||||
"side_effect_state": "REMOTE_PUSH_PENDING",
|
||||
"receipt": {
|
||||
"authority_oid": "b" * 40,
|
||||
"desired_remote_oid": "b" * 40,
|
||||
"remote_verified": False,
|
||||
},
|
||||
}
|
||||
|
||||
executor.execute = pending
|
||||
with pytest.raises(RuntimeError, match="RECONCILIATION_REQUIRED"):
|
||||
runtime.commit_files(_commit_request())
|
||||
with controller.store._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT current_head_oid,revision FROM branch_leases WHERE lease_id=?",
|
||||
("lease-" + binding.job_id,),
|
||||
).fetchone()
|
||||
assert tuple(row) == (BASE, 0)
|
||||
|
||||
|
||||
def test_retry_accepts_exact_head_already_advanced_at_remote_verified_boundary(tmp_path: Path) -> None:
|
||||
runtime, binding, controller, executor = _commit_ready_runtime(tmp_path)
|
||||
commit_oid = "b" * 40
|
||||
controller.update_lease(
|
||||
"lease-" + binding.job_id,
|
||||
expected_revision=0,
|
||||
current_head_oid=commit_oid,
|
||||
generation=1,
|
||||
)
|
||||
receipt = {
|
||||
"authority_oid": commit_oid,
|
||||
"git_remote_oid": commit_oid,
|
||||
"api_branch_oid": commit_oid,
|
||||
"commit_visible": True,
|
||||
"remote_verified": True,
|
||||
"force_push": False,
|
||||
}
|
||||
with controller.store._connect() as connection:
|
||||
connection.execute(
|
||||
"UPDATE jobs SET state='REMOTE_VERIFIED',side_effect_state='REMOTE_VERIFIED',"
|
||||
"receipt_json=? WHERE job_id=?",
|
||||
(json.dumps(receipt, sort_keys=True, separators=(",", ":")), binding.job_id),
|
||||
)
|
||||
connection.commit()
|
||||
|
||||
result = runtime.commit_files(_commit_request())
|
||||
|
||||
assert result["commit_oid"] == commit_oid
|
||||
assert executor.calls == 1
|
||||
with controller.store._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT current_head_oid,revision FROM branch_leases WHERE lease_id=?",
|
||||
("lease-" + binding.job_id,),
|
||||
).fetchone()
|
||||
assert tuple(row) == (commit_oid, 1)
|
||||
|
||||
|
||||
def test_completed_response_with_invalid_oid_uses_canonical_oid_error(tmp_path: Path) -> None:
|
||||
runtime, binding, controller, executor = _commit_ready_runtime(tmp_path)
|
||||
|
||||
def invalid_oid(_binding, _commit, **_kwargs):
|
||||
executor.calls += 1
|
||||
return {
|
||||
"job_id": binding.job_id,
|
||||
"state": "COMPLETED",
|
||||
"side_effect_state": "REMOTE_VERIFIED",
|
||||
"receipt": {
|
||||
"authority_oid": "not-an-oid",
|
||||
"commit_visible": True,
|
||||
"remote_verified": True,
|
||||
},
|
||||
}
|
||||
|
||||
executor.execute = invalid_oid
|
||||
with pytest.raises(RuntimeError, match="GOVERNED_COMMIT_OID_INVALID"):
|
||||
runtime.commit_files(_commit_request())
|
||||
with controller.store._connect() as connection:
|
||||
row = connection.execute(
|
||||
"SELECT current_head_oid,revision FROM branch_leases WHERE lease_id=?",
|
||||
("lease-" + binding.job_id,),
|
||||
).fetchone()
|
||||
assert tuple(row) == (BASE, 0)
|
||||
|
||||
|
||||
def test_acquire_renews_an_expired_matching_durable_lease_with_revision_cas(tmp_path: Path) -> None:
|
||||
runtime = make_runtime(tmp_path)
|
||||
create_durable_lease(runtime)
|
||||
runtime.runtimes.clear()
|
||||
|
||||
result = runtime.acquire_lease(writer_request())
|
||||
|
||||
expires_at, revision = durable_row(runtime)
|
||||
assert result["recovered"] is True
|
||||
assert result["runtime_rehydrated"] is False
|
||||
assert result["durable_branch_lease_present"] is True
|
||||
assert revision == 1
|
||||
assert datetime.fromisoformat(expires_at) > datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def test_authority_recovers_only_clean_detached_single_attempt_worktree(tmp_path: Path) -> None:
|
||||
import subprocess
|
||||
from aios_authority_git_executor import AuthorityGitExecutor
|
||||
|
||||
bare = tmp_path / "authority.git"
|
||||
seed = tmp_path / "seed"
|
||||
subprocess.run(["git", "init", "--bare", str(bare)], check=True, capture_output=True)
|
||||
subprocess.run(["git", "init", str(seed)], check=True, capture_output=True)
|
||||
(seed / "base.txt").write_text("base\n", encoding="utf-8")
|
||||
subprocess.run(["git", "-C", str(seed), "add", "base.txt"], check=True)
|
||||
subprocess.run(
|
||||
["git", "-C", str(seed), "-c", "user.name=test", "-c", "user.email=test@example.invalid",
|
||||
"commit", "-m", "base"], check=True, capture_output=True,
|
||||
)
|
||||
base = subprocess.check_output(["git", "-C", str(seed), "rev-parse", "HEAD"], text=True).strip()
|
||||
subprocess.run(["git", "-C", str(seed), "push", str(bare), "HEAD:refs/heads/main"], check=True, capture_output=True)
|
||||
root = tmp_path / "worktrees"
|
||||
authority = AuthorityGitExecutor(bare, root)
|
||||
worktree = authority.create_detached_worktree(base, "task")
|
||||
(worktree / "attempt.txt").write_text("attempt\n", encoding="utf-8")
|
||||
subprocess.run(["git", "-C", str(worktree), "add", "attempt.txt"], check=True)
|
||||
subprocess.run(
|
||||
["git", "-C", str(worktree), "-c", "user.name=test", "-c", "user.email=test@example.invalid",
|
||||
"commit", "-m", "failed attempt"], check=True, capture_output=True,
|
||||
)
|
||||
stale = subprocess.check_output(["git", "-C", str(worktree), "rev-parse", "HEAD"], text=True).strip()
|
||||
|
||||
recovered = authority.recover_failed_detached_worktree_exact(
|
||||
path=worktree,
|
||||
expected_oid=base,
|
||||
stale_oid=stale,
|
||||
target_ref="refs/heads/agent/recovery-test",
|
||||
)
|
||||
|
||||
assert recovered == stale
|
||||
assert subprocess.check_output(["git", "-C", str(worktree), "rev-parse", "HEAD"], text=True).strip() == base
|
||||
assert subprocess.run(["git", "-C", str(worktree), "symbolic-ref", "-q", "HEAD"]).returncode == 1
|
||||
assert subprocess.check_output(["git", "-C", str(worktree), "status", "--porcelain"], text=True) == ""
|
||||
Reference in New Issue
Block a user