agentic-runner 2.7.0__tar.gz → 2.8.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/PKG-INFO +4 -1
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/pyproject.toml +11 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/__init__.py +1 -1
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/registration.py +4 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/service.py +35 -4
- agentic_runner-2.8.0/src/agentic_runner/testing/__init__.py +18 -0
- agentic_runner-2.8.0/src/agentic_runner/testing/__main__.py +32 -0
- agentic_runner-2.8.0/src/agentic_runner/testing/control_plane.py +441 -0
- agentic_runner-2.8.0/src/agentic_runner/testing/plugin.py +178 -0
- agentic_runner-2.8.0/src/agentic_runner/testing/test_conformance.py +163 -0
- agentic_runner-2.8.0/src/agentic_runner/testing/workflow.py +37 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/.gitignore +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/LICENSE +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/README.md +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/activities.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/attempts.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/callback.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/child_watcher.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/cli.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/config.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/credentials.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/device_login_activities.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/egress.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/heartbeat_link.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/hooks.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/host_store.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/integrations/__init__.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/integrations/git/__init__.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/integrations/git/contracts.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/integrations/git/evidence.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/integrations/git/fake_workspace.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/integrations/git/workspace.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/integrations/github/__init__.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/integrations/github/auth.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/integrations/github/fake_client.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/integrations/github/gh_client.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/lifecycle.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/llm_proxy.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/mcp.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/message_store.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/py.typed +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/recipient_key_secret.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/runtime/__init__.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/runtime/verifier_command.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/sealed_box.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/tiny_http.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/triage_activities.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/user_sources.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/workers/__init__.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/workers/_runtime_support.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/workers/agent_runtime.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/workers/claude_runtime.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/workers/codex_runtime.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/workers/command_policy.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/workers/contract_device_login.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/workers/contract_isolation.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/workers/fastapi_client.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/workers/harness_usage.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/workers/mcp_config.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/workers/settings.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/workers/skills.py +0 -0
- {agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/workstation.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: agentic-runner
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.8.0
|
|
4
4
|
Summary: The Agentic OS Runner: an activity-only Temporal executor for workspaces, Agent Runtimes and verb seams
|
|
5
5
|
License-Expression: AGPL-3.0-only
|
|
6
6
|
License-File: LICENSE
|
|
@@ -12,6 +12,9 @@ Requires-Dist: pydantic-settings<3.0,>=2.0
|
|
|
12
12
|
Requires-Dist: pydantic<3.0,>=2.0
|
|
13
13
|
Requires-Dist: pyjwt[crypto]>=2.13.0
|
|
14
14
|
Requires-Dist: temporalio<2.0,>=1.10
|
|
15
|
+
Provides-Extra: testing
|
|
16
|
+
Requires-Dist: pytest-asyncio<1.0,>=0.24; extra == 'testing'
|
|
17
|
+
Requires-Dist: pytest<9.0,>=8.0; extra == 'testing'
|
|
15
18
|
Description-Content-Type: text/markdown
|
|
16
19
|
|
|
17
20
|
# agentic-runner
|
|
@@ -19,9 +19,20 @@ dependencies = [
|
|
|
19
19
|
"temporalio>=1.10,<2.0",
|
|
20
20
|
]
|
|
21
21
|
|
|
22
|
+
[project.optional-dependencies]
|
|
23
|
+
# The conformance kit (`agentic_runner.testing`, docs/conformance.md): the fake control
|
|
24
|
+
# plane needs nothing beyond the dependencies above; the scenario suite needs these.
|
|
25
|
+
testing = [
|
|
26
|
+
"pytest>=8.0,<9.0",
|
|
27
|
+
"pytest-asyncio>=0.24,<1.0",
|
|
28
|
+
]
|
|
29
|
+
|
|
22
30
|
[project.scripts]
|
|
23
31
|
agentic-runner = "agentic_runner.cli:main"
|
|
24
32
|
|
|
33
|
+
[project.entry-points.pytest11]
|
|
34
|
+
agentic_runner = "agentic_runner.testing.plugin"
|
|
35
|
+
|
|
25
36
|
[build-system]
|
|
26
37
|
requires = ["hatchling>=1.27,<2.0"]
|
|
27
38
|
build-backend = "hatchling.build"
|
|
@@ -64,6 +64,10 @@ DIRECTIVE_TOKEN_PATH = "/api/runner/v1/runners/directive-token"
|
|
|
64
64
|
INTAKE_WORK_RECORDS_PATH = "/api/runner/v1/runners/intake/work-records"
|
|
65
65
|
INTAKE_IGNORED_PATH = "/api/runner/v1/runners/intake/ignored"
|
|
66
66
|
|
|
67
|
+
# The refusal a revoked Runner's heartbeat earns (06 §2: revocation *is* the refused
|
|
68
|
+
# heartbeat). The one reason this process stops on rather than retries.
|
|
69
|
+
RUNNER_REVOKED_REASON = "runner_revoked"
|
|
70
|
+
|
|
67
71
|
# What the state directory holds after a successful bootstrap. One file, mode 0600: the
|
|
68
72
|
# private key is in it, and a Runner that loses it re-bootstraps rather than recovering.
|
|
69
73
|
STATE_FILENAME = "runner-identity.json"
|
|
@@ -64,6 +64,7 @@ from agentic_runner.llm_proxy import CeilingStore, LlmProxy, SlotStore, UsageOut
|
|
|
64
64
|
from agentic_runner.mcp import ToolServerHealthLog
|
|
65
65
|
from agentic_runner.message_store import MESSAGES_DIR, MessageStore, TemporalWorkflowSignaller
|
|
66
66
|
from agentic_runner.registration import (
|
|
67
|
+
RUNNER_REVOKED_REASON,
|
|
67
68
|
RunnerRegistrationClient,
|
|
68
69
|
RunnerRegistrationError,
|
|
69
70
|
RunnerState,
|
|
@@ -890,6 +891,8 @@ async def _serve_registered(
|
|
|
890
891
|
await link.exchange()
|
|
891
892
|
break
|
|
892
893
|
except Exception as error: # noqa: BLE001 - keep trying until the control plane answers
|
|
894
|
+
if _revoked(error):
|
|
895
|
+
return 1
|
|
893
896
|
_logger.warning("heartbeat failed before start: %s", error)
|
|
894
897
|
if stop is not None and stop.is_set():
|
|
895
898
|
return 1
|
|
@@ -931,7 +934,11 @@ async def _serve_registered(
|
|
|
931
934
|
graceful_shutdown_timeout=graceful_shutdown_timeout(settings),
|
|
932
935
|
max_concurrent_activities=state.max_concurrent_directives,
|
|
933
936
|
)
|
|
934
|
-
|
|
937
|
+
stop = stop or asyncio.Event()
|
|
938
|
+
revoked = asyncio.Event()
|
|
939
|
+
beat = asyncio.create_task(
|
|
940
|
+
_heartbeat_forever(link, stream, client, interval, stop=stop, revoked=revoked)
|
|
941
|
+
)
|
|
935
942
|
poll = asyncio.create_task(_poll_sources_forever(stream.sources, _source_poll_seconds()))
|
|
936
943
|
readiness.ready = True
|
|
937
944
|
await run_runner_lifecycle_hook(hooks, HookName.RUNNER_STARTUP)
|
|
@@ -945,7 +952,20 @@ async def _serve_registered(
|
|
|
945
952
|
await task
|
|
946
953
|
await run_runner_lifecycle_hook(hooks, HookName.RUNNER_SHUTDOWN)
|
|
947
954
|
await _report_stop(link, lifecycle)
|
|
948
|
-
return 0
|
|
955
|
+
return 1 if revoked.is_set() else 0
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
def _revoked(error: Exception) -> bool:
|
|
959
|
+
"""Whether a failed heartbeat was the control plane revoking this Runner.
|
|
960
|
+
|
|
961
|
+
Every other failure is a lost beat and the next tick retries it; a revocation is
|
|
962
|
+
final, so retrying would only dial a control plane that has already said no.
|
|
963
|
+
"""
|
|
964
|
+
|
|
965
|
+
if isinstance(error, RunnerRegistrationError) and error.reason == RUNNER_REVOKED_REASON:
|
|
966
|
+
print(f"revoked by the control plane: {error}", flush=True)
|
|
967
|
+
return True
|
|
968
|
+
return False
|
|
949
969
|
|
|
950
970
|
|
|
951
971
|
async def _report_stop(link: HeartbeatLink, lifecycle: LifecycleOutbox) -> None:
|
|
@@ -959,13 +979,20 @@ async def _report_stop(link: HeartbeatLink, lifecycle: LifecycleOutbox) -> None:
|
|
|
959
979
|
|
|
960
980
|
|
|
961
981
|
async def _heartbeat_forever(
|
|
962
|
-
link: HeartbeatLink,
|
|
982
|
+
link: HeartbeatLink,
|
|
983
|
+
stream: ControlPlaneStream,
|
|
984
|
+
client: Client,
|
|
985
|
+
interval: float,
|
|
986
|
+
*,
|
|
987
|
+
stop: asyncio.Event,
|
|
988
|
+
revoked: asyncio.Event,
|
|
963
989
|
) -> None:
|
|
964
990
|
"""Every 30 s, for as long as the process lives; a missed beat is logged, not fatal.
|
|
965
991
|
|
|
966
992
|
Staleness is the link's own property (issue 44): after three minutes of failures the
|
|
967
993
|
verb seams refuse on their own, so there is nothing for this loop to do about a
|
|
968
|
-
failure except try again on the next tick.
|
|
994
|
+
failure except try again on the next tick. A revocation is the exception: it drains
|
|
995
|
+
the Worker exactly as SIGTERM would, and the process exits non-zero.
|
|
969
996
|
"""
|
|
970
997
|
|
|
971
998
|
while True:
|
|
@@ -974,6 +1001,10 @@ async def _heartbeat_forever(
|
|
|
974
1001
|
try:
|
|
975
1002
|
await link.exchange()
|
|
976
1003
|
except Exception as error: # noqa: BLE001 - any failed exchange is a lost beat
|
|
1004
|
+
if _revoked(error):
|
|
1005
|
+
revoked.set()
|
|
1006
|
+
stop.set()
|
|
1007
|
+
return
|
|
977
1008
|
_logger.warning("heartbeat failed: %s", error)
|
|
978
1009
|
continue
|
|
979
1010
|
if stream.runner_token is not None and stream.runner_token != token_before:
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""The Runner conformance kit (runner-repo issue 07, ``docs/conformance.md``).
|
|
2
|
+
|
|
3
|
+
A fake control plane (:class:`FakeControlPlane`) and a scenario suite that proves a Runner
|
|
4
|
+
build works without the platform: ``pip install 'agentic-runner[testing]'`` and then
|
|
5
|
+
``pytest --pyargs agentic_runner.testing``. The fake itself needs only the Runner's own
|
|
6
|
+
dependencies, so ``python -m agentic_runner.testing`` serves it from any Runner install,
|
|
7
|
+
the image included; pytest and the scenario fixtures come with the extra.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from agentic_runner.testing.control_plane import (
|
|
11
|
+
DEFAULT_NAMESPACE,
|
|
12
|
+
RUNNER_PREFIX,
|
|
13
|
+
Evidence,
|
|
14
|
+
FakeControlPlane,
|
|
15
|
+
Refusal,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = ["DEFAULT_NAMESPACE", "RUNNER_PREFIX", "Evidence", "FakeControlPlane", "Refusal"]
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""``python -m agentic_runner.testing [port]``: serve the fake control plane until killed.
|
|
2
|
+
|
|
3
|
+
What the chart, Docker and workstation tests register a Runner against; ``GET /stats``
|
|
4
|
+
reports what it saw.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
|
|
11
|
+
from agentic_runner.testing.control_plane import DEFAULT_NAMESPACE, FakeControlPlane
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def main() -> None:
|
|
15
|
+
parser = argparse.ArgumentParser(prog="python -m agentic_runner.testing")
|
|
16
|
+
parser.add_argument("port", type=int, nargs="?", default=8000)
|
|
17
|
+
parser.add_argument("--host", default="0.0.0.0")
|
|
18
|
+
parser.add_argument("--namespace", default=DEFAULT_NAMESPACE)
|
|
19
|
+
parser.add_argument("--host-party", default="organisation")
|
|
20
|
+
arguments = parser.parse_args()
|
|
21
|
+
|
|
22
|
+
plane = FakeControlPlane(namespace=arguments.namespace, host_party=arguments.host_party)
|
|
23
|
+
server = plane.server(arguments.host, arguments.port)
|
|
24
|
+
print(
|
|
25
|
+
f"fake control plane on :{server.server_address[1]}, namespace {arguments.namespace}",
|
|
26
|
+
flush=True,
|
|
27
|
+
)
|
|
28
|
+
server.serve_forever()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
if __name__ == "__main__":
|
|
32
|
+
main()
|
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
"""The fake control plane: the Runner's side of the platform, answered the way it answers.
|
|
2
|
+
|
|
3
|
+
One oracle for every test that needs a control plane (runner-repo issue 07): the unit
|
|
4
|
+
tests mount it as an ``httpx.MockTransport``, the chart, Docker and workstation tests
|
|
5
|
+
serve it over HTTP from the Runner's own install (``python -m agentic_runner.testing``),
|
|
6
|
+
and the conformance scenarios do both. Standard library plus the Runner's own
|
|
7
|
+
dependencies, so it runs anywhere a Runner does.
|
|
8
|
+
|
|
9
|
+
It serves the public Runner surface only -- the ``/api/runner/v1`` prefix of issue 85
|
|
10
|
+
(ADR-0016) -- and refuses anything else, so a Runner build that reaches for a platform
|
|
11
|
+
route outside that surface fails here before it fails in production. What it checks is
|
|
12
|
+
what the platform checks on that surface: the bootstrap body, the Ed25519 signature of
|
|
13
|
+
every signed request against the identity it handed out, revocation, and the hosted
|
|
14
|
+
queue. What it answers is the same shape the platform answers, refusals included
|
|
15
|
+
(``{"detail": {"reason", "detail"}}``). Everything it saw is kept for the test to read,
|
|
16
|
+
and ``GET /stats`` -- the one route outside the prefix, and the fake's own -- summarises
|
|
17
|
+
it for a shell script.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import asyncio
|
|
23
|
+
import base64
|
|
24
|
+
import binascii
|
|
25
|
+
import json
|
|
26
|
+
import threading
|
|
27
|
+
import time
|
|
28
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
29
|
+
from contextlib import contextmanager
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from datetime import UTC, datetime, timedelta
|
|
32
|
+
from email.utils import formatdate
|
|
33
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
34
|
+
from typing import Any
|
|
35
|
+
from uuid import UUID, uuid4
|
|
36
|
+
|
|
37
|
+
import httpx
|
|
38
|
+
from cryptography.exceptions import InvalidSignature
|
|
39
|
+
from cryptography.hazmat.primitives import serialization
|
|
40
|
+
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey, Ed25519PublicKey
|
|
41
|
+
from pydantic import BaseModel, ValidationError
|
|
42
|
+
|
|
43
|
+
from agentic_runner.registration import (
|
|
44
|
+
BOOTSTRAP_PATH,
|
|
45
|
+
DIRECTIVE_TOKEN_PATH,
|
|
46
|
+
HEARTBEAT_PATH,
|
|
47
|
+
RUNNER_REVOKED_REASON,
|
|
48
|
+
)
|
|
49
|
+
from agentic_runner_contracts import __version__ as contracts_version
|
|
50
|
+
from agentic_runner_contracts.public_metadata import runner_task_queue
|
|
51
|
+
from agentic_runner_contracts.runner_registration import (
|
|
52
|
+
BootstrapRequest,
|
|
53
|
+
BootstrapResponse,
|
|
54
|
+
DirectiveTokenRequest,
|
|
55
|
+
DirectiveTokenResponse,
|
|
56
|
+
FloorState,
|
|
57
|
+
HeartbeatAck,
|
|
58
|
+
HeartbeatEnvelope,
|
|
59
|
+
RunnerIdentityMaterial,
|
|
60
|
+
)
|
|
61
|
+
from agentic_runner_contracts.runner_signature import (
|
|
62
|
+
RUNNER_ID_HEADER,
|
|
63
|
+
SIGNATURE_HEADER,
|
|
64
|
+
SIGNED_AT_HEADER,
|
|
65
|
+
canonical_request,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
__all__ = [
|
|
69
|
+
"DEFAULT_NAMESPACE",
|
|
70
|
+
"RUNNER_PREFIX",
|
|
71
|
+
"Evidence",
|
|
72
|
+
"FakeControlPlane",
|
|
73
|
+
"Refusal",
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
RUNNER_PREFIX = "/api/runner/v1"
|
|
77
|
+
STATS_PATH = "/stats"
|
|
78
|
+
# The namespace the chart, Docker and workstation tests start their Temporal dev server
|
|
79
|
+
# with: one Organisation, whose id is the first non-nil UUID.
|
|
80
|
+
DEFAULT_NAMESPACE = "org-00000000-0000-0000-0000-000000000001"
|
|
81
|
+
_EVIDENCE_PATH_SUFFIX = "/evidence"
|
|
82
|
+
_WORK_RECORDS_PATH = f"{RUNNER_PREFIX}/work-records/"
|
|
83
|
+
_TOKEN_TTL = timedelta(hours=1)
|
|
84
|
+
_DIRECTIVE_TOKEN_TTL = timedelta(minutes=15)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True)
|
|
88
|
+
class Evidence:
|
|
89
|
+
"""One Evidence write as received; ``credential`` is ``directive`` or ``signed``."""
|
|
90
|
+
|
|
91
|
+
work_record_id: str
|
|
92
|
+
actor: str
|
|
93
|
+
source: str
|
|
94
|
+
payload: dict[str, Any]
|
|
95
|
+
credential: str
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True)
|
|
99
|
+
class Refusal:
|
|
100
|
+
method: str
|
|
101
|
+
path: str
|
|
102
|
+
status: int
|
|
103
|
+
reason: str
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True)
|
|
107
|
+
class _Answer:
|
|
108
|
+
status: int
|
|
109
|
+
body: dict[str, Any] | None = None
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class _RefusedError(Exception):
|
|
113
|
+
def __init__(self, status: int, reason: str, detail: str) -> None:
|
|
114
|
+
super().__init__(detail)
|
|
115
|
+
self.status = status
|
|
116
|
+
self.reason = reason
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass
|
|
120
|
+
class _Registered:
|
|
121
|
+
public_key: Ed25519PublicKey
|
|
122
|
+
revoked: bool = False
|
|
123
|
+
tokens: int = 0
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class FakeControlPlane:
|
|
128
|
+
"""Bootstrap, heartbeat, Directive token and Evidence, answered the way the platform does.
|
|
129
|
+
|
|
130
|
+
``heartbeat_interval_seconds`` is the cadence bootstrap hands out; a scenario that
|
|
131
|
+
waits on beats sets it low rather than waiting 30 s per beat.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
namespace: str = DEFAULT_NAMESPACE
|
|
135
|
+
host_party: str = "organisation"
|
|
136
|
+
heartbeat_interval_seconds: int = 30
|
|
137
|
+
contracts_floor: str = contracts_version
|
|
138
|
+
|
|
139
|
+
bootstraps: list[BootstrapRequest] = field(default_factory=list)
|
|
140
|
+
runner_ids: list[UUID] = field(default_factory=list)
|
|
141
|
+
heartbeats: list[HeartbeatEnvelope] = field(default_factory=list)
|
|
142
|
+
heartbeat_runner_ids: list[UUID] = field(default_factory=list)
|
|
143
|
+
directive_tokens: list[str] = field(default_factory=list)
|
|
144
|
+
evidence: list[Evidence] = field(default_factory=list)
|
|
145
|
+
refusals: list[Refusal] = field(default_factory=list)
|
|
146
|
+
|
|
147
|
+
def __post_init__(self) -> None:
|
|
148
|
+
self._lock = threading.Lock()
|
|
149
|
+
self._registered: dict[UUID, _Registered] = {}
|
|
150
|
+
self._minted: set[str] = set()
|
|
151
|
+
|
|
152
|
+
# ---------------------------------------------------------------- the test's levers
|
|
153
|
+
|
|
154
|
+
def revoke(self, runner_id: UUID) -> None:
|
|
155
|
+
"""Revoke a Runner: from now on its signed requests are refused ``runner_revoked``."""
|
|
156
|
+
|
|
157
|
+
with self._lock:
|
|
158
|
+
self._registered[runner_id].revoked = True
|
|
159
|
+
|
|
160
|
+
async def wait_for(self, condition: Callable[[], bool], *, timeout: float = 10.0) -> None:
|
|
161
|
+
"""Poll ``condition`` until it holds; works whether the fake is mounted or served."""
|
|
162
|
+
|
|
163
|
+
deadline = time.monotonic() + timeout
|
|
164
|
+
while not condition():
|
|
165
|
+
if time.monotonic() > deadline:
|
|
166
|
+
raise TimeoutError(f"fake control plane: condition not met in {timeout}s")
|
|
167
|
+
await asyncio.sleep(0.02)
|
|
168
|
+
|
|
169
|
+
def stats(self) -> dict[str, Any]:
|
|
170
|
+
with self._lock:
|
|
171
|
+
return {
|
|
172
|
+
"bootstraps": len(self.bootstraps),
|
|
173
|
+
"runner_ids": [str(runner_id) for runner_id in self.runner_ids],
|
|
174
|
+
"recipient_key_ids": sorted({b.recipient_key.key_id for b in self.bootstraps}),
|
|
175
|
+
"isolation_modes": sorted({b.isolation_mode.value for b in self.bootstraps}),
|
|
176
|
+
"heartbeats": len(self.heartbeats),
|
|
177
|
+
"heartbeat_runner_ids": sorted({str(r) for r in self.heartbeat_runner_ids}),
|
|
178
|
+
"directive_tokens": len(self.directive_tokens),
|
|
179
|
+
"evidence": len(self.evidence),
|
|
180
|
+
"refusals": [f"{r.method} {r.path} {r.status} {r.reason}" for r in self.refusals],
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
# ---------------------------------------------------------------- the two mounts
|
|
184
|
+
|
|
185
|
+
def transport(self) -> httpx.MockTransport:
|
|
186
|
+
"""In-process: hand this to ``httpx.AsyncClient(transport=...)``."""
|
|
187
|
+
|
|
188
|
+
def handle(request: httpx.Request) -> httpx.Response:
|
|
189
|
+
answer = self.handle(
|
|
190
|
+
request.method,
|
|
191
|
+
request.url.raw_path.decode("ascii"),
|
|
192
|
+
request.headers,
|
|
193
|
+
request.content,
|
|
194
|
+
)
|
|
195
|
+
return httpx.Response(
|
|
196
|
+
answer.status,
|
|
197
|
+
json=answer.body,
|
|
198
|
+
headers={"date": formatdate(usegmt=True)},
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
return httpx.MockTransport(handle)
|
|
202
|
+
|
|
203
|
+
@contextmanager
|
|
204
|
+
def serving(self, host: str = "127.0.0.1", port: int = 0) -> Iterator[str]:
|
|
205
|
+
"""Over HTTP, on a background thread; yields the base URL. Port 0 picks a free one."""
|
|
206
|
+
|
|
207
|
+
server = self.server(host, port)
|
|
208
|
+
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
209
|
+
thread.start()
|
|
210
|
+
try:
|
|
211
|
+
yield f"http://{host}:{server.server_address[1]}"
|
|
212
|
+
finally:
|
|
213
|
+
server.shutdown()
|
|
214
|
+
server.server_close()
|
|
215
|
+
thread.join()
|
|
216
|
+
|
|
217
|
+
def server(self, host: str, port: int) -> ThreadingHTTPServer:
|
|
218
|
+
plane = self
|
|
219
|
+
|
|
220
|
+
class Handler(BaseHTTPRequestHandler):
|
|
221
|
+
def _answer(self) -> None:
|
|
222
|
+
length = int(self.headers.get("content-length", "0") or 0)
|
|
223
|
+
answer = plane.handle(
|
|
224
|
+
self.command, self.path, dict(self.headers), self.rfile.read(length)
|
|
225
|
+
)
|
|
226
|
+
raw = b"" if answer.body is None else json.dumps(answer.body).encode()
|
|
227
|
+
# send_response adds the `Date` header the Runner's clock-skew reading uses.
|
|
228
|
+
self.send_response(answer.status)
|
|
229
|
+
self.send_header("content-type", "application/json")
|
|
230
|
+
self.send_header("content-length", str(len(raw)))
|
|
231
|
+
self.end_headers()
|
|
232
|
+
self.wfile.write(raw)
|
|
233
|
+
|
|
234
|
+
do_GET = _answer # noqa: N815 - http.server's contract
|
|
235
|
+
do_POST = _answer # noqa: N815 - http.server's contract
|
|
236
|
+
do_PUT = _answer # noqa: N815 - http.server's contract
|
|
237
|
+
do_DELETE = _answer # noqa: N815 - http.server's contract
|
|
238
|
+
|
|
239
|
+
def log_message(self, format: str, *args: Any) -> None: # noqa: A002
|
|
240
|
+
print(f"fake control plane: {format % args}", flush=True)
|
|
241
|
+
|
|
242
|
+
return ThreadingHTTPServer((host, port), Handler)
|
|
243
|
+
|
|
244
|
+
# ---------------------------------------------------------------- the routes
|
|
245
|
+
|
|
246
|
+
def handle(self, method: str, target: str, headers: Mapping[str, str], body: bytes) -> _Answer:
|
|
247
|
+
"""One request, as received: the raw target (query included) and the exact body."""
|
|
248
|
+
|
|
249
|
+
path = target.partition("?")[0]
|
|
250
|
+
if method == "GET" and path == STATS_PATH:
|
|
251
|
+
return _Answer(200, self.stats())
|
|
252
|
+
headers = {key.lower(): value for key, value in headers.items()}
|
|
253
|
+
try:
|
|
254
|
+
with self._lock:
|
|
255
|
+
return self._route(method, target, path, headers, body)
|
|
256
|
+
except _RefusedError as refused:
|
|
257
|
+
with self._lock:
|
|
258
|
+
self.refusals.append(Refusal(method, path, refused.status, refused.reason))
|
|
259
|
+
return _Answer(
|
|
260
|
+
refused.status, {"detail": {"reason": refused.reason, "detail": str(refused)}}
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
def _route(
|
|
264
|
+
self, method: str, target: str, path: str, headers: dict[str, str], body: bytes
|
|
265
|
+
) -> _Answer:
|
|
266
|
+
if not path.startswith(f"{RUNNER_PREFIX}/"):
|
|
267
|
+
raise _RefusedError(
|
|
268
|
+
404,
|
|
269
|
+
"outside_runner_prefix",
|
|
270
|
+
f"{path} is not on the Runner surface ({RUNNER_PREFIX}); a Runner calls "
|
|
271
|
+
"nothing else",
|
|
272
|
+
)
|
|
273
|
+
if method == "POST" and path == BOOTSTRAP_PATH:
|
|
274
|
+
return self._bootstrap(body)
|
|
275
|
+
if method == "POST" and path == HEARTBEAT_PATH:
|
|
276
|
+
return self._heartbeat(method, target, headers, body)
|
|
277
|
+
if method == "POST" and path == DIRECTIVE_TOKEN_PATH:
|
|
278
|
+
return self._directive_token(method, target, headers, body)
|
|
279
|
+
if (
|
|
280
|
+
method == "POST"
|
|
281
|
+
and path.startswith(_WORK_RECORDS_PATH)
|
|
282
|
+
and path.endswith(_EVIDENCE_PATH_SUFFIX)
|
|
283
|
+
):
|
|
284
|
+
work_record_id = path[len(_WORK_RECORDS_PATH) : -len(_EVIDENCE_PATH_SUFFIX)]
|
|
285
|
+
return self._evidence(work_record_id, method, target, headers, body)
|
|
286
|
+
raise _RefusedError(
|
|
287
|
+
404, "not_served", f"the fake control plane does not serve {method} {path}"
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
def _bootstrap(self, body: bytes) -> _Answer:
|
|
291
|
+
request = _parse(BootstrapRequest, body)
|
|
292
|
+
runner_id = uuid4()
|
|
293
|
+
key = Ed25519PrivateKey.generate()
|
|
294
|
+
self._registered[runner_id] = _Registered(public_key=key.public_key())
|
|
295
|
+
self.bootstraps.append(request)
|
|
296
|
+
self.runner_ids.append(runner_id)
|
|
297
|
+
print(f"bootstrap {runner_id} isolation={request.isolation_mode.value}", flush=True)
|
|
298
|
+
response = BootstrapResponse(
|
|
299
|
+
identity=RunnerIdentityMaterial(
|
|
300
|
+
runner_id=runner_id,
|
|
301
|
+
identity_id=f"identity-{runner_id}",
|
|
302
|
+
private_key_pem=key.private_bytes(
|
|
303
|
+
serialization.Encoding.PEM,
|
|
304
|
+
serialization.PrivateFormat.PKCS8,
|
|
305
|
+
serialization.NoEncryption(),
|
|
306
|
+
).decode("ascii"),
|
|
307
|
+
),
|
|
308
|
+
temporal_namespace=self.namespace,
|
|
309
|
+
task_queue=runner_task_queue(runner_id),
|
|
310
|
+
runner_token=f"runner-token-{runner_id}-0",
|
|
311
|
+
runner_token_expires_at=datetime.now(UTC) + _TOKEN_TTL,
|
|
312
|
+
tag_set_version=1,
|
|
313
|
+
floor_state=FloorState.OK,
|
|
314
|
+
contracts_floor=self.contracts_floor,
|
|
315
|
+
host_party=self.host_party,
|
|
316
|
+
heartbeat_interval_seconds=self.heartbeat_interval_seconds,
|
|
317
|
+
)
|
|
318
|
+
return _Answer(200, response.model_dump(mode="json"))
|
|
319
|
+
|
|
320
|
+
def _heartbeat(self, method: str, target: str, headers: dict[str, str], body: bytes) -> _Answer:
|
|
321
|
+
runner_id, runner = self._signed(method, target, headers, body)
|
|
322
|
+
envelope = _parse(HeartbeatEnvelope, body)
|
|
323
|
+
expected = runner_task_queue(runner_id)
|
|
324
|
+
if envelope.hosted_task_queue != expected:
|
|
325
|
+
raise _RefusedError(
|
|
326
|
+
409,
|
|
327
|
+
"hosted_queue_mismatch",
|
|
328
|
+
f"Runner {runner_id} reports polling {envelope.hosted_task_queue!r}; "
|
|
329
|
+
f"Directives are dispatched to {expected!r}",
|
|
330
|
+
)
|
|
331
|
+
self.heartbeats.append(envelope)
|
|
332
|
+
self.heartbeat_runner_ids.append(runner_id)
|
|
333
|
+
runner.tokens += 1
|
|
334
|
+
ack = HeartbeatAck(
|
|
335
|
+
runner_id=runner_id,
|
|
336
|
+
runner_token=f"runner-token-{runner_id}-{runner.tokens}",
|
|
337
|
+
runner_token_expires_at=datetime.now(UTC) + _TOKEN_TTL,
|
|
338
|
+
floor_state=FloorState.OK,
|
|
339
|
+
contracts_floor=self.contracts_floor,
|
|
340
|
+
tag_set_version=envelope.tag_set_version,
|
|
341
|
+
accepts_new_directives=True,
|
|
342
|
+
# Every Usage Record committed, so the Runner's outbox drops exactly these.
|
|
343
|
+
usage_accepted=[f"{u.directive_id}:{u.sequence}" for u in envelope.usage],
|
|
344
|
+
)
|
|
345
|
+
return _Answer(200, ack.model_dump(mode="json"))
|
|
346
|
+
|
|
347
|
+
def _directive_token(
|
|
348
|
+
self, method: str, target: str, headers: dict[str, str], body: bytes
|
|
349
|
+
) -> _Answer:
|
|
350
|
+
self._signed(method, target, headers, body)
|
|
351
|
+
request = _parse(DirectiveTokenRequest, body)
|
|
352
|
+
token = f"directive-token-{len(self.directive_tokens) + 1}"
|
|
353
|
+
self._minted.add(token)
|
|
354
|
+
self.directive_tokens.append(request.directive_id)
|
|
355
|
+
response = DirectiveTokenResponse(
|
|
356
|
+
directive_id=request.directive_id,
|
|
357
|
+
token=token,
|
|
358
|
+
expires_at=datetime.now(UTC) + _DIRECTIVE_TOKEN_TTL,
|
|
359
|
+
)
|
|
360
|
+
return _Answer(200, response.model_dump(mode="json"))
|
|
361
|
+
|
|
362
|
+
def _evidence(
|
|
363
|
+
self,
|
|
364
|
+
work_record_id: str,
|
|
365
|
+
method: str,
|
|
366
|
+
target: str,
|
|
367
|
+
headers: dict[str, str],
|
|
368
|
+
body: bytes,
|
|
369
|
+
) -> _Answer:
|
|
370
|
+
# Either credential the platform accepts on this route: the Directive token the
|
|
371
|
+
# activity pulled, or -- before one exists -- the signed Runner envelope.
|
|
372
|
+
scheme, _, token = headers.get("authorization", "").partition(" ")
|
|
373
|
+
if scheme.lower() == "bearer" and token in self._minted:
|
|
374
|
+
credential = "directive"
|
|
375
|
+
else:
|
|
376
|
+
self._signed(method, target, headers, body)
|
|
377
|
+
credential = "signed"
|
|
378
|
+
try:
|
|
379
|
+
payload = json.loads(body)
|
|
380
|
+
entry = Evidence(
|
|
381
|
+
work_record_id=work_record_id,
|
|
382
|
+
actor=str(payload["actor"]),
|
|
383
|
+
source=str(payload["source"]),
|
|
384
|
+
payload=dict(payload["payload"]),
|
|
385
|
+
credential=credential,
|
|
386
|
+
)
|
|
387
|
+
except (ValueError, KeyError, TypeError) as error:
|
|
388
|
+
raise _RefusedError(
|
|
389
|
+
422, "evidence_invalid", f"not an Evidence write: {error}"
|
|
390
|
+
) from None
|
|
391
|
+
self.evidence.append(entry)
|
|
392
|
+
return _Answer(
|
|
393
|
+
201,
|
|
394
|
+
{
|
|
395
|
+
"evidence_event_id": str(uuid4()),
|
|
396
|
+
"work_record_id": work_record_id,
|
|
397
|
+
"sequence": len(self.evidence),
|
|
398
|
+
"created": True,
|
|
399
|
+
"idempotency_key": payload.get("idempotency_key"),
|
|
400
|
+
},
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
def _signed(
|
|
404
|
+
self, method: str, target: str, headers: dict[str, str], body: bytes
|
|
405
|
+
) -> tuple[UUID, _Registered]:
|
|
406
|
+
"""The signed Runner envelope, checked the way the platform checks it (issue 71)."""
|
|
407
|
+
|
|
408
|
+
try:
|
|
409
|
+
runner_id = UUID(headers.get(RUNNER_ID_HEADER.lower(), ""))
|
|
410
|
+
except ValueError:
|
|
411
|
+
raise _RefusedError(
|
|
412
|
+
401, "signature_invalid", "no Runner id on a signed request"
|
|
413
|
+
) from None
|
|
414
|
+
runner = self._registered.get(runner_id)
|
|
415
|
+
if runner is None:
|
|
416
|
+
raise _RefusedError(404, "runner_not_found", f"Runner {runner_id} not found")
|
|
417
|
+
try:
|
|
418
|
+
signature = base64.b64decode(headers.get(SIGNATURE_HEADER.lower(), ""), validate=True)
|
|
419
|
+
runner.public_key.verify(
|
|
420
|
+
signature,
|
|
421
|
+
canonical_request(
|
|
422
|
+
method=method,
|
|
423
|
+
path=target,
|
|
424
|
+
signed_at=headers.get(SIGNED_AT_HEADER.lower(), ""),
|
|
425
|
+
body=body,
|
|
426
|
+
),
|
|
427
|
+
)
|
|
428
|
+
except (binascii.Error, InvalidSignature):
|
|
429
|
+
raise _RefusedError(
|
|
430
|
+
401, "signature_invalid", f"Runner {runner_id}: signature does not verify"
|
|
431
|
+
) from None
|
|
432
|
+
if runner.revoked:
|
|
433
|
+
raise _RefusedError(403, RUNNER_REVOKED_REASON, f"Runner {runner_id} is revoked")
|
|
434
|
+
return runner_id, runner
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _parse[Model: BaseModel](model: type[Model], body: bytes) -> Model:
|
|
438
|
+
try:
|
|
439
|
+
return model.model_validate_json(body)
|
|
440
|
+
except ValidationError as error:
|
|
441
|
+
raise _RefusedError(422, "request_invalid", str(error)) from None
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""The conformance kit's pytest plugin (``pytest11`` entry point ``agentic_runner``).
|
|
2
|
+
|
|
3
|
+
Fixtures a fork's own tests can use as well as the scenarios in ``test_conformance.py``:
|
|
4
|
+
``fake_control_plane`` serves :class:`FakeControlPlane` on a free port, and
|
|
5
|
+
``runner_launcher`` starts the *installed* Runner -- ``agentic-runner run``, a real
|
|
6
|
+
process -- against it, so a scenario sees exactly what an operator's process would do.
|
|
7
|
+
|
|
8
|
+
Imports nothing beyond pytest and the Runner's own dependencies: the entry point loads
|
|
9
|
+
this module into every pytest session of an environment that has the Runner installed,
|
|
10
|
+
with or without the ``testing`` extra.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import os
|
|
17
|
+
import shutil
|
|
18
|
+
import signal
|
|
19
|
+
import socket
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
import urllib.error
|
|
24
|
+
import urllib.request
|
|
25
|
+
from collections.abc import Iterator
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
import pytest
|
|
30
|
+
from temporalio.testing import WorkflowEnvironment
|
|
31
|
+
|
|
32
|
+
from agentic_runner.testing.control_plane import DEFAULT_NAMESPACE, FakeControlPlane
|
|
33
|
+
|
|
34
|
+
# A conformance Runner never runs a Directive that needs isolation or a credential, so it
|
|
35
|
+
# runs `none`, unprivileged, with every path under the test's own directory.
|
|
36
|
+
AGENT_TOKEN = "conformance-agent-token-0123456789"
|
|
37
|
+
_RUN = "import sys; from agentic_runner.cli import main; sys.exit(main(['run']))"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class ServedControlPlane:
|
|
42
|
+
plane: FakeControlPlane
|
|
43
|
+
url: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class RunnerProcess:
|
|
48
|
+
"""One ``agentic-runner run`` process; its stdout and stderr go to ``log``."""
|
|
49
|
+
|
|
50
|
+
process: subprocess.Popen[bytes]
|
|
51
|
+
log: Path
|
|
52
|
+
readiness_port: int
|
|
53
|
+
|
|
54
|
+
def ready(self) -> bool:
|
|
55
|
+
"""The readiness probe the chart points Kubernetes at: registered, heard, polling."""
|
|
56
|
+
|
|
57
|
+
url = f"http://127.0.0.1:{self.readiness_port}/healthz"
|
|
58
|
+
try:
|
|
59
|
+
with urllib.request.urlopen(url, timeout=1) as response:
|
|
60
|
+
return bool(response.status == 200)
|
|
61
|
+
except (urllib.error.URLError, OSError):
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
def output(self) -> str:
|
|
65
|
+
return self.log.read_text(encoding="utf-8", errors="replace")
|
|
66
|
+
|
|
67
|
+
def running(self) -> bool:
|
|
68
|
+
return self.process.poll() is None
|
|
69
|
+
|
|
70
|
+
def wait(self, timeout: float) -> int:
|
|
71
|
+
return self.process.wait(timeout=timeout)
|
|
72
|
+
|
|
73
|
+
def terminate(self, timeout: float = 60) -> int:
|
|
74
|
+
"""SIGTERM -- what Kubernetes, ``docker stop`` and launchd send -- then wait."""
|
|
75
|
+
|
|
76
|
+
self.process.send_signal(signal.SIGTERM)
|
|
77
|
+
return self.wait(timeout)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class RunnerLauncher:
|
|
82
|
+
"""Starts Runners that share one state directory, so a restart is the same Runner."""
|
|
83
|
+
|
|
84
|
+
root: Path
|
|
85
|
+
started: list[RunnerProcess] = field(default_factory=list)
|
|
86
|
+
|
|
87
|
+
def start(self, *, control_plane_url: str, temporal_address: str) -> RunnerProcess:
|
|
88
|
+
state = self.root / "state"
|
|
89
|
+
readiness_port = _free_port()
|
|
90
|
+
environment = {
|
|
91
|
+
**os.environ,
|
|
92
|
+
"AGENTIC_CONTROL_PLANE_URL": control_plane_url,
|
|
93
|
+
"AGENTIC_AGENT_TOKEN": AGENT_TOKEN,
|
|
94
|
+
"AGENTIC_RUNNER_ISOLATION": "none",
|
|
95
|
+
"AGENTIC_RUNNER_STATE_DIR": str(state),
|
|
96
|
+
"AGENTIC_RUNNER_WORKSPACE_ROOT": str(self.root / "workspaces"),
|
|
97
|
+
"AGENTIC_RUNNER_SOCKET_DIR": str(self.root / "sockets"),
|
|
98
|
+
"AGENTIC_RUNNER_CONFIG": str(self.root / "absent-config.toml"),
|
|
99
|
+
"AGENTIC_RUNNER_READINESS_PORT": str(readiness_port),
|
|
100
|
+
"TEMPORAL_ADDRESS": temporal_address,
|
|
101
|
+
"AGENTIC_TEMPORAL_TLS": "false",
|
|
102
|
+
"WORKSPACE_ROOT": str(self.root / "workspaces"),
|
|
103
|
+
"WORKER_STATE_DIR": str(state),
|
|
104
|
+
"CODEX_HOME": str(self.root / "codex"),
|
|
105
|
+
}
|
|
106
|
+
log = self.root / f"runner-{len(self.started)}.log"
|
|
107
|
+
with log.open("wb") as sink:
|
|
108
|
+
process = subprocess.Popen(
|
|
109
|
+
[sys.executable, "-c", _RUN],
|
|
110
|
+
env=environment,
|
|
111
|
+
stdout=sink,
|
|
112
|
+
stderr=subprocess.STDOUT,
|
|
113
|
+
)
|
|
114
|
+
runner = RunnerProcess(process=process, log=log, readiness_port=readiness_port)
|
|
115
|
+
self.started.append(runner)
|
|
116
|
+
return runner
|
|
117
|
+
|
|
118
|
+
def close(self) -> None:
|
|
119
|
+
for runner in self.started:
|
|
120
|
+
if runner.running():
|
|
121
|
+
runner.process.kill()
|
|
122
|
+
runner.process.wait()
|
|
123
|
+
print(f"--- {runner.log.name} (exit {runner.process.returncode})")
|
|
124
|
+
print(runner.output())
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
async def start_temporal(namespace: str = DEFAULT_NAMESPACE) -> WorkflowEnvironment:
|
|
128
|
+
"""A local Temporal dev server holding ``namespace``.
|
|
129
|
+
|
|
130
|
+
Uses a ``temporal`` CLI already on ``PATH`` when there is one; otherwise the SDK
|
|
131
|
+
downloads the dev server on first use.
|
|
132
|
+
"""
|
|
133
|
+
|
|
134
|
+
return await WorkflowEnvironment.start_local(
|
|
135
|
+
namespace=namespace, dev_server_existing_path=shutil.which("temporal")
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
async def until_ready(served: ServedControlPlane, runner: RunnerProcess, beats: int = 1) -> None:
|
|
140
|
+
"""Wait until the Runner is ready and the fake has heard ``beats`` heartbeats.
|
|
141
|
+
|
|
142
|
+
Ready, not merely registered: the drain handler is installed once the Worker polls,
|
|
143
|
+
and a scenario that signals before then is testing start-up, not the drain. Fails at
|
|
144
|
+
once -- with the Runner's output -- if the process dies first.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
plane = served.plane
|
|
148
|
+
deadline = time.monotonic() + 120
|
|
149
|
+
while len(plane.heartbeats) < beats or not runner.ready():
|
|
150
|
+
if not runner.running():
|
|
151
|
+
pytest.fail(f"the Runner exited {runner.process.returncode}:\n{runner.output()}")
|
|
152
|
+
if time.monotonic() > deadline:
|
|
153
|
+
pytest.fail(f"not ready in 120s; the fake saw {plane.stats()}:\n{runner.output()}")
|
|
154
|
+
await asyncio.sleep(0.1)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _free_port() -> int:
|
|
158
|
+
with socket.socket() as probe:
|
|
159
|
+
probe.bind(("127.0.0.1", 0))
|
|
160
|
+
return int(probe.getsockname()[1])
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@pytest.fixture
|
|
164
|
+
def fake_control_plane() -> Iterator[ServedControlPlane]:
|
|
165
|
+
"""The fake, served over HTTP, handing out a one-second heartbeat cadence."""
|
|
166
|
+
|
|
167
|
+
plane = FakeControlPlane(heartbeat_interval_seconds=1)
|
|
168
|
+
with plane.serving() as url:
|
|
169
|
+
yield ServedControlPlane(plane=plane, url=url)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@pytest.fixture
|
|
173
|
+
def runner_launcher(tmp_path: Path) -> Iterator[RunnerLauncher]:
|
|
174
|
+
launcher = RunnerLauncher(root=tmp_path)
|
|
175
|
+
try:
|
|
176
|
+
yield launcher
|
|
177
|
+
finally:
|
|
178
|
+
launcher.close()
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""The conformance scenarios: ``pytest --pyargs agentic_runner.testing``.
|
|
2
|
+
|
|
3
|
+
Each one starts the installed Runner as a process against the fake control plane and a
|
|
4
|
+
local Temporal dev server, and checks one thing an operator relies on: it registers once,
|
|
5
|
+
it heartbeats signed, it runs a Directive dispatched to its own queue, it stops when
|
|
6
|
+
revoked, and it drains on SIGTERM. A fork that passes these has a Runner the platform can
|
|
7
|
+
drive; ``docs/conformance.md`` says what they do not cover.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from uuid import uuid4
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
from temporalio.worker import Worker
|
|
16
|
+
from temporalio.worker.workflow_sandbox import SandboxedWorkflowRunner, SandboxRestrictions
|
|
17
|
+
|
|
18
|
+
from agentic_runner import __version__ as runner_version
|
|
19
|
+
from agentic_runner.registration import RUNNER_REVOKED_REASON
|
|
20
|
+
from agentic_runner.testing.plugin import (
|
|
21
|
+
RunnerLauncher,
|
|
22
|
+
ServedControlPlane,
|
|
23
|
+
start_temporal,
|
|
24
|
+
until_ready,
|
|
25
|
+
)
|
|
26
|
+
from agentic_runner.testing.workflow import RoundTripWorkflow
|
|
27
|
+
from agentic_runner_contracts import __version__ as contracts_version
|
|
28
|
+
from agentic_runner_contracts.activity_io import ContractResidueInput
|
|
29
|
+
from agentic_runner_contracts.public_metadata import runner_task_queue
|
|
30
|
+
from agentic_runner_contracts.routing import DirectiveRouting
|
|
31
|
+
from agentic_runner_contracts.runner_registration import IsolationMode, LifecycleKind
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@pytest.mark.asyncio
|
|
35
|
+
async def test_a_runner_registers_once_and_a_restart_is_the_same_runner(
|
|
36
|
+
fake_control_plane: ServedControlPlane, runner_launcher: RunnerLauncher
|
|
37
|
+
) -> None:
|
|
38
|
+
plane = fake_control_plane.plane
|
|
39
|
+
async with await start_temporal(plane.namespace) as temporal:
|
|
40
|
+
address = temporal.client.service_client.config.target_host
|
|
41
|
+
first = runner_launcher.start(
|
|
42
|
+
control_plane_url=fake_control_plane.url, temporal_address=address
|
|
43
|
+
)
|
|
44
|
+
await until_ready(fake_control_plane, first)
|
|
45
|
+
|
|
46
|
+
[bootstrap] = plane.bootstraps
|
|
47
|
+
assert bootstrap.isolation_mode is IsolationMode.NONE
|
|
48
|
+
assert bootstrap.runner_version == runner_version
|
|
49
|
+
assert bootstrap.contracts_version == contracts_version
|
|
50
|
+
assert first.terminate() == 0
|
|
51
|
+
|
|
52
|
+
beats = len(plane.heartbeats)
|
|
53
|
+
second = runner_launcher.start(
|
|
54
|
+
control_plane_url=fake_control_plane.url, temporal_address=address
|
|
55
|
+
)
|
|
56
|
+
await until_ready(fake_control_plane, second, beats=beats + 1)
|
|
57
|
+
assert second.terminate() == 0
|
|
58
|
+
|
|
59
|
+
# The identity is read back from the state directory: no second registration.
|
|
60
|
+
assert len(plane.bootstraps) == 1
|
|
61
|
+
assert set(plane.heartbeat_runner_ids) == set(plane.runner_ids)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@pytest.mark.asyncio
|
|
65
|
+
async def test_a_runner_heartbeats_signed_from_its_own_queue(
|
|
66
|
+
fake_control_plane: ServedControlPlane, runner_launcher: RunnerLauncher
|
|
67
|
+
) -> None:
|
|
68
|
+
plane = fake_control_plane.plane
|
|
69
|
+
async with await start_temporal(plane.namespace) as temporal:
|
|
70
|
+
runner = runner_launcher.start(
|
|
71
|
+
control_plane_url=fake_control_plane.url,
|
|
72
|
+
temporal_address=temporal.client.service_client.config.target_host,
|
|
73
|
+
)
|
|
74
|
+
await until_ready(fake_control_plane, runner, beats=3)
|
|
75
|
+
assert runner.terminate() == 0
|
|
76
|
+
|
|
77
|
+
[runner_id] = plane.runner_ids
|
|
78
|
+
assert set(plane.heartbeat_runner_ids) == {runner_id}
|
|
79
|
+
# Every beat verified against the identity the fake handed out; none was refused.
|
|
80
|
+
assert plane.refusals == []
|
|
81
|
+
assert {beat.hosted_task_queue for beat in plane.heartbeats} == {runner_task_queue(runner_id)}
|
|
82
|
+
assert {beat.contracts_version for beat in plane.heartbeats} == {contracts_version}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@pytest.mark.asyncio
|
|
86
|
+
async def test_a_directive_dispatched_to_the_runner_queue_comes_back_answered(
|
|
87
|
+
fake_control_plane: ServedControlPlane, runner_launcher: RunnerLauncher
|
|
88
|
+
) -> None:
|
|
89
|
+
plane = fake_control_plane.plane
|
|
90
|
+
async with await start_temporal(plane.namespace) as temporal:
|
|
91
|
+
runner = runner_launcher.start(
|
|
92
|
+
control_plane_url=fake_control_plane.url,
|
|
93
|
+
temporal_address=temporal.client.service_client.config.target_host,
|
|
94
|
+
)
|
|
95
|
+
await until_ready(fake_control_plane, runner)
|
|
96
|
+
[runner_id] = plane.runner_ids
|
|
97
|
+
contract_id = str(uuid4())
|
|
98
|
+
request = ContractResidueInput(
|
|
99
|
+
contract_id=contract_id,
|
|
100
|
+
routing=DirectiveRouting(runner_id=str(runner_id), host_party=plane.host_party),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# The workflow is this suite's, not the platform's; the kit's own modules pass
|
|
104
|
+
# through the sandbox rather than being re-imported inside it.
|
|
105
|
+
async with Worker(
|
|
106
|
+
temporal.client,
|
|
107
|
+
task_queue="conformance-workflows",
|
|
108
|
+
workflows=[RoundTripWorkflow],
|
|
109
|
+
workflow_runner=SandboxedWorkflowRunner(
|
|
110
|
+
restrictions=SandboxRestrictions.default.with_passthrough_modules(
|
|
111
|
+
"agentic_runner", "agentic_runner_contracts"
|
|
112
|
+
)
|
|
113
|
+
),
|
|
114
|
+
):
|
|
115
|
+
answer = await temporal.client.execute_workflow(
|
|
116
|
+
RoundTripWorkflow.run,
|
|
117
|
+
args=[runner_task_queue(runner_id), request],
|
|
118
|
+
id=f"conformance-{uuid4()}",
|
|
119
|
+
task_queue="conformance-workflows",
|
|
120
|
+
)
|
|
121
|
+
assert runner.terminate() == 0
|
|
122
|
+
|
|
123
|
+
assert answer.contract_id == contract_id
|
|
124
|
+
assert answer.workspaces_wiped == 0
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@pytest.mark.asyncio
|
|
128
|
+
async def test_a_revoked_runner_stops(
|
|
129
|
+
fake_control_plane: ServedControlPlane, runner_launcher: RunnerLauncher
|
|
130
|
+
) -> None:
|
|
131
|
+
plane = fake_control_plane.plane
|
|
132
|
+
async with await start_temporal(plane.namespace) as temporal:
|
|
133
|
+
runner = runner_launcher.start(
|
|
134
|
+
control_plane_url=fake_control_plane.url,
|
|
135
|
+
temporal_address=temporal.client.service_client.config.target_host,
|
|
136
|
+
)
|
|
137
|
+
await until_ready(fake_control_plane, runner)
|
|
138
|
+
[runner_id] = plane.runner_ids
|
|
139
|
+
|
|
140
|
+
plane.revoke(runner_id)
|
|
141
|
+
exit_code = runner.wait(timeout=60)
|
|
142
|
+
|
|
143
|
+
assert exit_code != 0
|
|
144
|
+
assert "revoked" in runner.output()
|
|
145
|
+
assert {refusal.reason for refusal in plane.refusals} == {RUNNER_REVOKED_REASON}
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
@pytest.mark.asyncio
|
|
149
|
+
async def test_sigterm_drains_to_a_clean_exit_and_reports_the_stop(
|
|
150
|
+
fake_control_plane: ServedControlPlane, runner_launcher: RunnerLauncher
|
|
151
|
+
) -> None:
|
|
152
|
+
plane = fake_control_plane.plane
|
|
153
|
+
async with await start_temporal(plane.namespace) as temporal:
|
|
154
|
+
runner = runner_launcher.start(
|
|
155
|
+
control_plane_url=fake_control_plane.url,
|
|
156
|
+
temporal_address=temporal.client.service_client.config.target_host,
|
|
157
|
+
)
|
|
158
|
+
await until_ready(fake_control_plane, runner)
|
|
159
|
+
|
|
160
|
+
assert runner.terminate() == 0
|
|
161
|
+
|
|
162
|
+
# The last beat is the one the drain sends on its way out.
|
|
163
|
+
assert LifecycleKind.STOP in {event.kind for event in plane.heartbeats[-1].lifecycle}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""The control plane's half of a Directive round trip, as small as one can be.
|
|
2
|
+
|
|
3
|
+
The platform's workflows live in the platform; this one stands in for them in the
|
|
4
|
+
conformance suite. It dispatches one Runner activity to ``runner.{runner_id}`` -- the
|
|
5
|
+
queue a real workflow routes to -- and returns what the Runner answered. The activity is
|
|
6
|
+
``wipe_contract_residue``: it touches no workspace, no Agent Runtime and no GitHub, so its
|
|
7
|
+
answer depends only on the Runner having picked the task up off its own queue.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from datetime import timedelta
|
|
13
|
+
|
|
14
|
+
from temporalio import workflow
|
|
15
|
+
from temporalio.common import RetryPolicy
|
|
16
|
+
|
|
17
|
+
with workflow.unsafe.imports_passed_through():
|
|
18
|
+
from agentic_runner_contracts.activity_io import ContractResidueInput, ContractResidueOutput
|
|
19
|
+
|
|
20
|
+
ROUND_TRIP_ACTIVITY = "wipe_contract_residue"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@workflow.defn(name="agentic_runner_conformance_round_trip")
|
|
24
|
+
class RoundTripWorkflow:
|
|
25
|
+
@workflow.run
|
|
26
|
+
async def run(self, task_queue: str, request: ContractResidueInput) -> ContractResidueOutput:
|
|
27
|
+
answer: ContractResidueOutput = await workflow.execute_activity(
|
|
28
|
+
ROUND_TRIP_ACTIVITY,
|
|
29
|
+
request,
|
|
30
|
+
task_queue=task_queue,
|
|
31
|
+
result_type=ContractResidueOutput,
|
|
32
|
+
start_to_close_timeout=timedelta(seconds=30),
|
|
33
|
+
# One attempt: a Runner that cannot answer should fail the scenario, not
|
|
34
|
+
# retry it into the test's timeout.
|
|
35
|
+
retry_policy=RetryPolicy(maximum_attempts=1),
|
|
36
|
+
)
|
|
37
|
+
return answer
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/integrations/git/__init__.py
RENAMED
|
File without changes
|
{agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/integrations/git/contracts.py
RENAMED
|
File without changes
|
{agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/integrations/git/evidence.py
RENAMED
|
File without changes
|
{agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/integrations/git/fake_workspace.py
RENAMED
|
File without changes
|
{agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/integrations/git/workspace.py
RENAMED
|
File without changes
|
{agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/integrations/github/__init__.py
RENAMED
|
File without changes
|
{agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/integrations/github/auth.py
RENAMED
|
File without changes
|
{agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/integrations/github/fake_client.py
RENAMED
|
File without changes
|
{agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/integrations/github/gh_client.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/runtime/verifier_command.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/workers/_runtime_support.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/workers/contract_device_login.py
RENAMED
|
File without changes
|
{agentic_runner-2.7.0 → agentic_runner-2.8.0}/src/agentic_runner/workers/contract_isolation.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|