224 lines
7.7 KiB
Python
224 lines
7.7 KiB
Python
from __future__ import annotations
|
|
|
|
import ast
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(ROOT / "active-runtime" / "app"))
|
|
|
|
from aios_chatgpt_bridge_v2 import ( # noqa: E402
|
|
BridgeV2Adapter,
|
|
BridgeV2Error,
|
|
OPERATION_OPTIONAL_FIELDS,
|
|
OPERATION_REQUIRED_FIELDS,
|
|
READ_ONLY_OPERATIONS,
|
|
operation_contracts,
|
|
)
|
|
|
|
|
|
DISCOVERY_CONTRACTS = {
|
|
"listRepositoryTree": {
|
|
"path": "/v2/repository/tree",
|
|
"legacy_path": "/api/repository/tree",
|
|
},
|
|
"searchRepositorySource": {
|
|
"path": "/v2/repository/search",
|
|
"legacy_path": "/api/repository/search",
|
|
},
|
|
"getDiffIndex": {
|
|
"path": "/v2/repository/diff-index",
|
|
"legacy_path": "/api/repository/diff-index",
|
|
},
|
|
"readRepositoryFile": {
|
|
"path": "/v2/repository/file",
|
|
"legacy_path": "/api/repository/file",
|
|
},
|
|
}
|
|
|
|
|
|
def _openapi_path_block(document: str, path: str) -> str:
|
|
marker = f" {path}:\n"
|
|
start = document.index(marker)
|
|
end = document.find("\n /", start + len(marker))
|
|
return document[start:] if end < 0 else document[start:end]
|
|
|
|
|
|
def _assignment(source: str, name: str) -> ast.AST:
|
|
module = ast.parse(source)
|
|
for node in module.body:
|
|
if isinstance(node, (ast.Assign, ast.AnnAssign)):
|
|
targets = node.targets if isinstance(node, ast.Assign) else [node.target]
|
|
if any(isinstance(target, ast.Name) and target.id == name for target in targets):
|
|
return node.value
|
|
raise AssertionError(f"assignment {name!r} not found")
|
|
|
|
|
|
def _literal_assignment(source: str, name: str):
|
|
value = _assignment(source, name)
|
|
if isinstance(value, ast.Call) and isinstance(value.func, ast.Name):
|
|
assert value.func.id == "frozenset"
|
|
assert len(value.args) == 1
|
|
return set(ast.literal_eval(value.args[0]))
|
|
return ast.literal_eval(value)
|
|
|
|
|
|
def test_discovery_runtime_bridge_and_openapi_contracts_are_aligned():
|
|
document = (ROOT / "docs" / "gitops" / "openapi-chatgpt-actions.yaml").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
bridge_source = (ROOT / "active-runtime" / "app" / "aios_chatgpt_bridge_v2_server.py").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
actions_source = (ROOT / "active-runtime" / "app" / "aios_chatgpt_actions_api.py").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
routes = _literal_assignment(bridge_source, "ROUTES")
|
|
discovery_actions = _literal_assignment(actions_source, "_DISCOVERY_ACTIONS")
|
|
read_only_paths = _literal_assignment(actions_source, "READ_ONLY_GET_PATHS")
|
|
runtime = operation_contracts()
|
|
|
|
for operation, expected in DISCOVERY_CONTRACTS.items():
|
|
assert runtime[operation] == {
|
|
"method": "GET",
|
|
"path": expected["path"],
|
|
"required_fields": list(OPERATION_REQUIRED_FIELDS[operation]),
|
|
"optional_fields": list(OPERATION_OPTIONAL_FIELDS[operation]),
|
|
"read_only": True,
|
|
}
|
|
assert operation in READ_ONLY_OPERATIONS
|
|
assert routes[expected["path"]] == operation
|
|
assert expected["legacy_path"] in read_only_paths
|
|
assert expected["legacy_path"] in discovery_actions
|
|
|
|
block = _openapi_path_block(document, expected["path"])
|
|
assert block.count(f"operationId: {operation}") == 1
|
|
assert " get:\n" in block
|
|
assert " security: []" in block
|
|
assert " x-openai-isConsequential: false" in block
|
|
assert " x-aios-read-only: true" in block
|
|
assert " x-aios-bounded: true" in block
|
|
assert " x-aios-repository-scoped: true" in block
|
|
assert " x-aios-no-shell: true" in block
|
|
assert f" x-aios-legacy-path: {expected['legacy_path']}" in block
|
|
parameter_names = {
|
|
line.split("name:", 1)[1].split(",", 1)[0].strip()
|
|
for line in block.splitlines()
|
|
if "name:" in line and "in: query" in line
|
|
}
|
|
assert parameter_names == set(OPERATION_REQUIRED_FIELDS[operation]) | set(
|
|
OPERATION_OPTIONAL_FIELDS[operation]
|
|
)
|
|
|
|
for operation in DISCOVERY_CONTRACTS:
|
|
assert document.count(f"operationId: {operation}") == 1
|
|
|
|
class_nodes = [
|
|
node
|
|
for node in ast.parse(actions_source).body
|
|
if isinstance(node, ast.ClassDef) and node.name == "H"
|
|
]
|
|
assert len(class_nodes) == 1
|
|
post_methods = [
|
|
node for node in class_nodes[0].body
|
|
if isinstance(node, ast.FunctionDef) and node.name == "do_POST"
|
|
]
|
|
assert post_methods
|
|
effective_post = ast.get_source_segment(actions_source, post_methods[-1]) or ""
|
|
assert "method_not_allowed" in effective_post
|
|
assert "max_num_fields=64" in actions_source
|
|
assert "query_too_large" in actions_source
|
|
|
|
|
|
def test_discovery_adapter_rejects_task_principal_and_write_identity_fields():
|
|
called = False
|
|
|
|
def handler(_request):
|
|
nonlocal called
|
|
called = True
|
|
return {}
|
|
|
|
adapter = BridgeV2Adapter(
|
|
runtime_status={}, handlers={"listRepositoryTree": handler}
|
|
)
|
|
request = {
|
|
"repository": "aios/aios",
|
|
"ref": "main",
|
|
"limit": 1,
|
|
"task_id": "must-not-enter-read-route",
|
|
"principal": "must-not-enter-read-route",
|
|
"writer_lease": "must-not-enter-read-route",
|
|
}
|
|
with pytest.raises(BridgeV2Error, match="BRIDGE_REQUEST_FIELDS_UNKNOWN"):
|
|
adapter.listRepositoryTree(request)
|
|
assert called is False
|
|
|
|
|
|
def test_discovery_source_is_read_only_and_staging_freeze_remains_intact():
|
|
source = (ROOT / "active-runtime" / "app" / "aios_repository_discovery.py").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
assert '_READ_ONLY_COMMANDS = frozenset({"cat-file", "diff", "ls-tree", "rev-parse"})' in source
|
|
assert "shell=True" not in source
|
|
assert "os.system" not in source
|
|
assert "update-ref" not in source
|
|
assert "git push" not in source
|
|
|
|
unit = (
|
|
ROOT
|
|
/ "deploy"
|
|
/ "systemd"
|
|
/ "aios-gitops-model-api-v2r2c1-staging.service"
|
|
).read_text(encoding="utf-8")
|
|
assert "AIOS_GITOPS_WRITES_ENABLED=false" in unit
|
|
assert "AIOS_GITOPS_PORT=18893" in unit
|
|
assert "AIOS_GITOPS_DURABLE_JOB_EXECUTION_ENABLED=false" in unit
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("path", "query"),
|
|
[
|
|
("/api/repository/tree", "repository=aios%2Faios&ref=main&limit=1"),
|
|
(
|
|
"/api/repository/search",
|
|
"repository=aios%2Faios&ref=main&query=needle&max_matches=1&max_bytes=16",
|
|
),
|
|
(
|
|
"/api/repository/diff-index",
|
|
"repository=aios%2Faios&from=main&to=main&limit=1",
|
|
),
|
|
(
|
|
"/api/repository/file",
|
|
"repository=aios%2Faios&ref=main&path=README.md",
|
|
),
|
|
],
|
|
)
|
|
def test_actions_discovery_get_route_dispatches_and_post_is_rejected(
|
|
monkeypatch: pytest.MonkeyPatch, path: str, query: str
|
|
):
|
|
if sys.platform == "win32":
|
|
pytest.skip("Actions adapter imports POSIX-only fcntl; Windows integration is unverified")
|
|
import aios_chatgpt_actions_api as actions_api
|
|
observed: list[tuple[str, object]] = []
|
|
|
|
def fake_discovery_request(route: str, values: dict[str, list[str]]):
|
|
observed.append((route, values))
|
|
return {"route": route}
|
|
|
|
monkeypatch.setattr(actions_api, "repository_discovery_request", fake_discovery_request)
|
|
handler = object.__new__(actions_api.H)
|
|
responses: list[tuple[object, int]] = []
|
|
handler.sendj = lambda value, code=200: responses.append((value, code))
|
|
handler.path = f"{path}?{query}"
|
|
|
|
actions_api.H.do_GET(handler)
|
|
assert observed[-1][0] == path
|
|
assert responses[-1] == ({"route": path}, 200)
|
|
|
|
handler.path = path
|
|
actions_api.H.do_POST(handler)
|
|
assert responses[-1] == ({"error": "method_not_allowed"}, 405)
|