anip-stdio 0.11.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.
- anip_stdio-0.11.0/.gitignore +32 -0
- anip_stdio-0.11.0/PKG-INFO +6 -0
- anip_stdio-0.11.0/pyproject.toml +18 -0
- anip_stdio-0.11.0/src/anip_stdio/__init__.py +4 -0
- anip_stdio-0.11.0/src/anip_stdio/client.py +252 -0
- anip_stdio-0.11.0/src/anip_stdio/framing.py +19 -0
- anip_stdio-0.11.0/src/anip_stdio/protocol.py +109 -0
- anip_stdio-0.11.0/src/anip_stdio/server.py +282 -0
- anip_stdio-0.11.0/tests/__init__.py +0 -0
- anip_stdio-0.11.0/tests/_serve_fixture.py +40 -0
- anip_stdio-0.11.0/tests/test_client.py +67 -0
- anip_stdio-0.11.0/tests/test_protocol.py +158 -0
- anip_stdio-0.11.0/tests/test_server.py +268 -0
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
.DS_Store
|
|
2
|
+
*.swp
|
|
3
|
+
*~
|
|
4
|
+
.env
|
|
5
|
+
node_modules/
|
|
6
|
+
__pycache__/
|
|
7
|
+
*.db
|
|
8
|
+
*.db-wal
|
|
9
|
+
*.db-shm
|
|
10
|
+
.venv/
|
|
11
|
+
anip-keys.json
|
|
12
|
+
anip-keys
|
|
13
|
+
dist/
|
|
14
|
+
*.tsbuildinfo
|
|
15
|
+
build/
|
|
16
|
+
*.egg-info/
|
|
17
|
+
.worktrees/
|
|
18
|
+
.serena/
|
|
19
|
+
|
|
20
|
+
# Java build outputs
|
|
21
|
+
target/
|
|
22
|
+
|
|
23
|
+
# Go binaries
|
|
24
|
+
packages/go/flight-service
|
|
25
|
+
packages/go/flights-gin
|
|
26
|
+
*.exe
|
|
27
|
+
|
|
28
|
+
# .NET build outputs
|
|
29
|
+
bin/
|
|
30
|
+
obj/
|
|
31
|
+
*.user
|
|
32
|
+
.vs/
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "anip-stdio"
|
|
7
|
+
version = "0.11.0"
|
|
8
|
+
description = "ANIP stdio transport binding — JSON-RPC 2.0 over stdin/stdout"
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"anip-service==0.11.0",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[tool.hatch.build.targets.wheel]
|
|
15
|
+
packages = ["src/anip_stdio"]
|
|
16
|
+
|
|
17
|
+
[tool.pytest.ini_options]
|
|
18
|
+
asyncio_mode = "auto"
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
"""ANIP stdio client — spawns a subprocess and communicates via JSON-RPC 2.0."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import itertools
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from .framing import read_message, write_message
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class InvokeStream:
|
|
12
|
+
"""Async iterator for streaming invoke progress. Terminal result in .result."""
|
|
13
|
+
|
|
14
|
+
def __init__(self, reader: asyncio.StreamReader, req_id: int) -> None:
|
|
15
|
+
self._reader = reader
|
|
16
|
+
self._req_id = req_id
|
|
17
|
+
self.result: dict[str, Any] | None = None
|
|
18
|
+
|
|
19
|
+
def __aiter__(self) -> InvokeStream:
|
|
20
|
+
return self
|
|
21
|
+
|
|
22
|
+
async def __anext__(self) -> dict[str, Any]:
|
|
23
|
+
while True:
|
|
24
|
+
msg = await read_message(self._reader)
|
|
25
|
+
if msg is None:
|
|
26
|
+
raise StopAsyncIteration
|
|
27
|
+
|
|
28
|
+
# Notification (progress)
|
|
29
|
+
if "method" in msg and msg["method"] == "anip.invoke.progress":
|
|
30
|
+
return msg["params"]
|
|
31
|
+
|
|
32
|
+
# Final response for our request
|
|
33
|
+
if "id" in msg and msg["id"] == self._req_id:
|
|
34
|
+
if "error" in msg:
|
|
35
|
+
raise Exception(f"Invoke failed: {msg['error']}")
|
|
36
|
+
self.result = msg["result"]
|
|
37
|
+
raise StopAsyncIteration
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AnipStdioClient:
|
|
41
|
+
"""Async context manager that spawns an ANIP service subprocess and talks JSON-RPC 2.0.
|
|
42
|
+
|
|
43
|
+
IMPORTANT: This client is single-request-at-a-time. Do not issue concurrent
|
|
44
|
+
calls or overlap a streaming invoke with other requests — there is no response
|
|
45
|
+
demultiplexer. Concurrent request support is a future enhancement.
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def __init__(self, *cmd: str) -> None:
|
|
49
|
+
self._cmd = cmd
|
|
50
|
+
self._proc: asyncio.subprocess.Process | None = None
|
|
51
|
+
self._id_counter = itertools.count(1)
|
|
52
|
+
self._stderr_task: asyncio.Task | None = None
|
|
53
|
+
|
|
54
|
+
async def __aenter__(self) -> AnipStdioClient:
|
|
55
|
+
self._proc = await asyncio.create_subprocess_exec(
|
|
56
|
+
*self._cmd,
|
|
57
|
+
stdin=asyncio.subprocess.PIPE,
|
|
58
|
+
stdout=asyncio.subprocess.PIPE,
|
|
59
|
+
stderr=asyncio.subprocess.PIPE,
|
|
60
|
+
)
|
|
61
|
+
# Drain stderr in background to prevent pipe buffer deadlock.
|
|
62
|
+
# The stdio spec reserves stderr for logs/diagnostics — a chatty service
|
|
63
|
+
# can fill the pipe buffer and block if nobody reads it.
|
|
64
|
+
self._stderr_task = asyncio.create_task(self._drain_stderr())
|
|
65
|
+
return self
|
|
66
|
+
|
|
67
|
+
async def _drain_stderr(self) -> None:
|
|
68
|
+
"""Read and discard stderr to prevent pipe buffer deadlock."""
|
|
69
|
+
assert self._proc is not None and self._proc.stderr is not None
|
|
70
|
+
try:
|
|
71
|
+
while True:
|
|
72
|
+
line = await self._proc.stderr.readline()
|
|
73
|
+
if not line:
|
|
74
|
+
break
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
77
|
+
|
|
78
|
+
async def __aexit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
79
|
+
if self._proc is not None:
|
|
80
|
+
if self._proc.stdin is not None:
|
|
81
|
+
self._proc.stdin.close()
|
|
82
|
+
await self._proc.wait()
|
|
83
|
+
if self._stderr_task is not None:
|
|
84
|
+
self._stderr_task.cancel()
|
|
85
|
+
try:
|
|
86
|
+
await self._stderr_task
|
|
87
|
+
except asyncio.CancelledError:
|
|
88
|
+
pass
|
|
89
|
+
|
|
90
|
+
# --- Core RPC plumbing ---
|
|
91
|
+
|
|
92
|
+
async def _call(self, method: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
93
|
+
"""Send a JSON-RPC request and return the result, skipping interleaved notifications."""
|
|
94
|
+
assert self._proc is not None and self._proc.stdin is not None and self._proc.stdout is not None
|
|
95
|
+
|
|
96
|
+
req_id = next(self._id_counter)
|
|
97
|
+
request = {
|
|
98
|
+
"jsonrpc": "2.0",
|
|
99
|
+
"id": req_id,
|
|
100
|
+
"method": method,
|
|
101
|
+
"params": params or {},
|
|
102
|
+
}
|
|
103
|
+
await write_message(self._proc.stdin, request)
|
|
104
|
+
|
|
105
|
+
# Read messages until we get the response matching our request id
|
|
106
|
+
while True:
|
|
107
|
+
msg = await read_message(self._proc.stdout)
|
|
108
|
+
if msg is None:
|
|
109
|
+
raise ConnectionError("Subprocess closed stdout before responding")
|
|
110
|
+
|
|
111
|
+
# Skip notifications (no "id" field)
|
|
112
|
+
if "id" not in msg:
|
|
113
|
+
continue
|
|
114
|
+
|
|
115
|
+
if msg["id"] == req_id:
|
|
116
|
+
if "error" in msg:
|
|
117
|
+
err = msg["error"]
|
|
118
|
+
raise Exception(f"JSON-RPC error {err.get('code')}: {err.get('message')}")
|
|
119
|
+
return msg["result"]
|
|
120
|
+
|
|
121
|
+
# --- Typed public methods ---
|
|
122
|
+
|
|
123
|
+
async def discovery(self) -> dict[str, Any]:
|
|
124
|
+
"""Call anip.discovery."""
|
|
125
|
+
return await self._call("anip.discovery")
|
|
126
|
+
|
|
127
|
+
async def manifest(self) -> dict[str, Any]:
|
|
128
|
+
"""Call anip.manifest — returns {manifest, signature}."""
|
|
129
|
+
return await self._call("anip.manifest")
|
|
130
|
+
|
|
131
|
+
async def jwks(self) -> dict[str, Any]:
|
|
132
|
+
"""Call anip.jwks — returns {keys: [...]}."""
|
|
133
|
+
return await self._call("anip.jwks")
|
|
134
|
+
|
|
135
|
+
async def issue_token(
|
|
136
|
+
self,
|
|
137
|
+
bearer: str,
|
|
138
|
+
*,
|
|
139
|
+
subject: str | None = None,
|
|
140
|
+
scope: list[str] | None = None,
|
|
141
|
+
capability: str | None = None,
|
|
142
|
+
**kwargs: Any,
|
|
143
|
+
) -> dict[str, Any]:
|
|
144
|
+
"""Call anip.tokens.issue."""
|
|
145
|
+
params: dict[str, Any] = {"auth": {"bearer": bearer}}
|
|
146
|
+
if subject is not None:
|
|
147
|
+
params["subject"] = subject
|
|
148
|
+
if scope is not None:
|
|
149
|
+
params["scope"] = scope
|
|
150
|
+
if capability is not None:
|
|
151
|
+
params["capability"] = capability
|
|
152
|
+
params.update(kwargs)
|
|
153
|
+
return await self._call("anip.tokens.issue", params)
|
|
154
|
+
|
|
155
|
+
async def permissions(self, bearer: str) -> dict[str, Any]:
|
|
156
|
+
"""Call anip.permissions."""
|
|
157
|
+
return await self._call("anip.permissions", {"auth": {"bearer": bearer}})
|
|
158
|
+
|
|
159
|
+
async def invoke(
|
|
160
|
+
self,
|
|
161
|
+
bearer: str,
|
|
162
|
+
capability: str,
|
|
163
|
+
parameters: dict[str, Any] | None = None,
|
|
164
|
+
*,
|
|
165
|
+
client_reference_id: str | None = None,
|
|
166
|
+
stream: bool = False,
|
|
167
|
+
) -> dict[str, Any] | InvokeStream:
|
|
168
|
+
"""Call anip.invoke. Returns result dict, or InvokeStream if stream=True."""
|
|
169
|
+
assert self._proc is not None and self._proc.stdin is not None and self._proc.stdout is not None
|
|
170
|
+
|
|
171
|
+
params: dict[str, Any] = {
|
|
172
|
+
"auth": {"bearer": bearer},
|
|
173
|
+
"capability": capability,
|
|
174
|
+
}
|
|
175
|
+
if parameters is not None:
|
|
176
|
+
params["parameters"] = parameters
|
|
177
|
+
if client_reference_id is not None:
|
|
178
|
+
params["client_reference_id"] = client_reference_id
|
|
179
|
+
if stream:
|
|
180
|
+
params["stream"] = True
|
|
181
|
+
|
|
182
|
+
req_id = next(self._id_counter)
|
|
183
|
+
request = {
|
|
184
|
+
"jsonrpc": "2.0",
|
|
185
|
+
"id": req_id,
|
|
186
|
+
"method": "anip.invoke",
|
|
187
|
+
"params": params,
|
|
188
|
+
}
|
|
189
|
+
await write_message(self._proc.stdin, request)
|
|
190
|
+
|
|
191
|
+
if stream:
|
|
192
|
+
return InvokeStream(self._proc.stdout, req_id)
|
|
193
|
+
|
|
194
|
+
# Non-streaming: read until we get the matching response
|
|
195
|
+
while True:
|
|
196
|
+
msg = await read_message(self._proc.stdout)
|
|
197
|
+
if msg is None:
|
|
198
|
+
raise ConnectionError("Subprocess closed stdout before responding")
|
|
199
|
+
if "id" not in msg:
|
|
200
|
+
continue
|
|
201
|
+
if msg["id"] == req_id:
|
|
202
|
+
if "error" in msg:
|
|
203
|
+
err = msg["error"]
|
|
204
|
+
raise Exception(f"JSON-RPC error {err.get('code')}: {err.get('message')}")
|
|
205
|
+
return msg["result"]
|
|
206
|
+
|
|
207
|
+
async def audit_query(
|
|
208
|
+
self,
|
|
209
|
+
bearer: str,
|
|
210
|
+
*,
|
|
211
|
+
capability: str | None = None,
|
|
212
|
+
since: str | None = None,
|
|
213
|
+
invocation_id: str | None = None,
|
|
214
|
+
client_reference_id: str | None = None,
|
|
215
|
+
event_class: str | None = None,
|
|
216
|
+
limit: int | None = None,
|
|
217
|
+
) -> dict[str, Any]:
|
|
218
|
+
"""Call anip.audit.query."""
|
|
219
|
+
params: dict[str, Any] = {"auth": {"bearer": bearer}}
|
|
220
|
+
if capability is not None:
|
|
221
|
+
params["capability"] = capability
|
|
222
|
+
if since is not None:
|
|
223
|
+
params["since"] = since
|
|
224
|
+
if invocation_id is not None:
|
|
225
|
+
params["invocation_id"] = invocation_id
|
|
226
|
+
if client_reference_id is not None:
|
|
227
|
+
params["client_reference_id"] = client_reference_id
|
|
228
|
+
if event_class is not None:
|
|
229
|
+
params["event_class"] = event_class
|
|
230
|
+
if limit is not None:
|
|
231
|
+
params["limit"] = limit
|
|
232
|
+
return await self._call("anip.audit.query", params)
|
|
233
|
+
|
|
234
|
+
async def checkpoints_list(self, limit: int = 10) -> dict[str, Any]:
|
|
235
|
+
"""Call anip.checkpoints.list."""
|
|
236
|
+
return await self._call("anip.checkpoints.list", {"limit": limit})
|
|
237
|
+
|
|
238
|
+
async def checkpoints_get(
|
|
239
|
+
self,
|
|
240
|
+
id: str,
|
|
241
|
+
*,
|
|
242
|
+
include_proof: bool = False,
|
|
243
|
+
leaf_index: int | None = None,
|
|
244
|
+
consistency_from: str | None = None,
|
|
245
|
+
) -> dict[str, Any]:
|
|
246
|
+
"""Call anip.checkpoints.get."""
|
|
247
|
+
params: dict[str, Any] = {"id": id, "include_proof": include_proof}
|
|
248
|
+
if leaf_index is not None:
|
|
249
|
+
params["leaf_index"] = leaf_index
|
|
250
|
+
if consistency_from is not None:
|
|
251
|
+
params["consistency_from"] = consistency_from
|
|
252
|
+
return await self._call("anip.checkpoints.get", params)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Newline-delimited JSON framing for ANIP stdio transport."""
|
|
2
|
+
import json
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
async def read_message(reader: asyncio.StreamReader) -> dict[str, Any] | None:
|
|
8
|
+
"""Read one newline-delimited JSON message. Returns None on EOF."""
|
|
9
|
+
line = await reader.readline()
|
|
10
|
+
if not line:
|
|
11
|
+
return None
|
|
12
|
+
return json.loads(line.strip())
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def write_message(writer: asyncio.StreamWriter, message: dict[str, Any]) -> None:
|
|
16
|
+
"""Write one newline-delimited JSON message."""
|
|
17
|
+
data = json.dumps(message, separators=(",", ":")) + "\n"
|
|
18
|
+
writer.write(data.encode())
|
|
19
|
+
await writer.drain()
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""JSON-RPC 2.0 protocol helpers for ANIP stdio transport."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
# --- Valid ANIP methods ---
|
|
7
|
+
|
|
8
|
+
VALID_METHODS: frozenset[str] = frozenset({
|
|
9
|
+
"anip.discovery",
|
|
10
|
+
"anip.manifest",
|
|
11
|
+
"anip.jwks",
|
|
12
|
+
"anip.tokens.issue",
|
|
13
|
+
"anip.permissions",
|
|
14
|
+
"anip.invoke",
|
|
15
|
+
"anip.audit.query",
|
|
16
|
+
"anip.checkpoints.list",
|
|
17
|
+
"anip.checkpoints.get",
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
# --- JSON-RPC 2.0 error codes ---
|
|
21
|
+
|
|
22
|
+
PARSE_ERROR = -32700
|
|
23
|
+
INVALID_REQUEST = -32600
|
|
24
|
+
METHOD_NOT_FOUND = -32601
|
|
25
|
+
AUTH_ERROR = -32001
|
|
26
|
+
SCOPE_ERROR = -32002
|
|
27
|
+
NOT_FOUND = -32004
|
|
28
|
+
INTERNAL_ERROR = -32603
|
|
29
|
+
|
|
30
|
+
# --- ANIP failure type to JSON-RPC error code mapping ---
|
|
31
|
+
|
|
32
|
+
FAILURE_TYPE_TO_CODE: dict[str, int] = {
|
|
33
|
+
"authentication_required": AUTH_ERROR,
|
|
34
|
+
"invalid_token": AUTH_ERROR,
|
|
35
|
+
"token_expired": AUTH_ERROR,
|
|
36
|
+
"scope_insufficient": SCOPE_ERROR,
|
|
37
|
+
"budget_exceeded": SCOPE_ERROR,
|
|
38
|
+
"purpose_mismatch": SCOPE_ERROR,
|
|
39
|
+
"unknown_capability": NOT_FOUND,
|
|
40
|
+
"not_found": NOT_FOUND,
|
|
41
|
+
"internal_error": INTERNAL_ERROR,
|
|
42
|
+
"unavailable": INTERNAL_ERROR,
|
|
43
|
+
"concurrent_lock": INTERNAL_ERROR,
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# --- Message constructors ---
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def make_response(request_id: int | str | None, result: Any) -> dict[str, Any]:
|
|
51
|
+
"""Build a JSON-RPC 2.0 success response."""
|
|
52
|
+
return {"jsonrpc": "2.0", "id": request_id, "result": result}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def make_error(
|
|
56
|
+
request_id: int | str | None,
|
|
57
|
+
code: int,
|
|
58
|
+
message: str,
|
|
59
|
+
data: dict[str, Any] | None = None,
|
|
60
|
+
) -> dict[str, Any]:
|
|
61
|
+
"""Build a JSON-RPC 2.0 error response."""
|
|
62
|
+
error: dict[str, Any] = {"code": code, "message": message}
|
|
63
|
+
if data is not None:
|
|
64
|
+
error["data"] = data
|
|
65
|
+
return {"jsonrpc": "2.0", "id": request_id, "error": error}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def make_notification(method: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
69
|
+
"""Build a JSON-RPC 2.0 notification (no id)."""
|
|
70
|
+
return {"jsonrpc": "2.0", "method": method, "params": params}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# --- Request validation ---
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def validate_request(msg: dict[str, Any]) -> str | None:
|
|
77
|
+
"""Validate a JSON-RPC 2.0 request.
|
|
78
|
+
|
|
79
|
+
Returns None if valid, or an error description string if invalid.
|
|
80
|
+
"""
|
|
81
|
+
if not isinstance(msg, dict):
|
|
82
|
+
return "Request must be a JSON object"
|
|
83
|
+
if msg.get("jsonrpc") != "2.0":
|
|
84
|
+
return "Missing or invalid 'jsonrpc' field (must be '2.0')"
|
|
85
|
+
if "method" not in msg:
|
|
86
|
+
return "Missing 'method' field"
|
|
87
|
+
if not isinstance(msg["method"], str):
|
|
88
|
+
return "'method' must be a string"
|
|
89
|
+
if "id" not in msg:
|
|
90
|
+
return "Missing 'id' field (notifications not supported as requests)"
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# --- Auth extraction ---
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def extract_auth(params: dict[str, Any] | None) -> str | None:
|
|
98
|
+
"""Extract bearer token from params.auth.bearer.
|
|
99
|
+
|
|
100
|
+
Returns the bearer string, or None if not present.
|
|
101
|
+
"""
|
|
102
|
+
if params is None:
|
|
103
|
+
return None
|
|
104
|
+
auth = params.get("auth")
|
|
105
|
+
if auth is None:
|
|
106
|
+
return None
|
|
107
|
+
if not isinstance(auth, dict):
|
|
108
|
+
return None
|
|
109
|
+
return auth.get("bearer")
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""ANIP stdio transport server — JSON-RPC 2.0 over stdin/stdout."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import asyncio
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from anip_service import ANIPService
|
|
10
|
+
from anip_service.types import ANIPError
|
|
11
|
+
|
|
12
|
+
from .framing import read_message, write_message
|
|
13
|
+
from .protocol import (
|
|
14
|
+
AUTH_ERROR,
|
|
15
|
+
FAILURE_TYPE_TO_CODE,
|
|
16
|
+
INTERNAL_ERROR,
|
|
17
|
+
INVALID_REQUEST,
|
|
18
|
+
METHOD_NOT_FOUND,
|
|
19
|
+
NOT_FOUND,
|
|
20
|
+
PARSE_ERROR,
|
|
21
|
+
VALID_METHODS,
|
|
22
|
+
extract_auth,
|
|
23
|
+
make_error,
|
|
24
|
+
make_notification,
|
|
25
|
+
make_response,
|
|
26
|
+
validate_request,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AnipStdioServer:
|
|
31
|
+
"""JSON-RPC 2.0 server wrapping an ANIPService for stdio transport."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, service: ANIPService) -> None:
|
|
34
|
+
self._service = service
|
|
35
|
+
|
|
36
|
+
# --- Public dispatch ---
|
|
37
|
+
|
|
38
|
+
async def handle_request(self, msg: dict[str, Any]) -> dict[str, Any] | list[dict[str, Any]]:
|
|
39
|
+
"""Validate and dispatch a JSON-RPC request to the appropriate handler.
|
|
40
|
+
|
|
41
|
+
Returns a single JSON-RPC response dict, or for streaming invocations
|
|
42
|
+
a list of [notification..., response].
|
|
43
|
+
"""
|
|
44
|
+
error_desc = validate_request(msg)
|
|
45
|
+
if error_desc is not None:
|
|
46
|
+
return make_error(msg.get("id"), INVALID_REQUEST, error_desc)
|
|
47
|
+
|
|
48
|
+
request_id = msg["id"]
|
|
49
|
+
method = msg["method"]
|
|
50
|
+
params = msg.get("params") or {}
|
|
51
|
+
|
|
52
|
+
if method not in VALID_METHODS:
|
|
53
|
+
return make_error(request_id, METHOD_NOT_FOUND, f"Unknown method: {method}")
|
|
54
|
+
|
|
55
|
+
handler = self._DISPATCH.get(method)
|
|
56
|
+
if handler is None:
|
|
57
|
+
return make_error(request_id, INTERNAL_ERROR, f"No handler for {method}")
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
result = await handler(self, params)
|
|
61
|
+
except ANIPError as exc:
|
|
62
|
+
code = FAILURE_TYPE_TO_CODE.get(exc.error_type, INTERNAL_ERROR)
|
|
63
|
+
return make_error(request_id, code, exc.detail, {
|
|
64
|
+
"type": exc.error_type,
|
|
65
|
+
"detail": exc.detail,
|
|
66
|
+
"retry": exc.retry,
|
|
67
|
+
})
|
|
68
|
+
except Exception as exc:
|
|
69
|
+
return make_error(request_id, INTERNAL_ERROR, str(exc))
|
|
70
|
+
|
|
71
|
+
# Streaming invoke returns (notifications, result)
|
|
72
|
+
if isinstance(result, tuple) and len(result) == 2:
|
|
73
|
+
notifications, final_result = result
|
|
74
|
+
messages: list[dict[str, Any]] = list(notifications)
|
|
75
|
+
messages.append(make_response(request_id, final_result))
|
|
76
|
+
return messages
|
|
77
|
+
|
|
78
|
+
return make_response(request_id, result)
|
|
79
|
+
|
|
80
|
+
# --- Method handlers ---
|
|
81
|
+
|
|
82
|
+
async def _handle_anip_discovery(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
83
|
+
return self._service.get_discovery()
|
|
84
|
+
|
|
85
|
+
async def _handle_anip_manifest(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
86
|
+
body_bytes, signature = self._service.get_signed_manifest()
|
|
87
|
+
return {"manifest": json.loads(body_bytes), "signature": signature}
|
|
88
|
+
|
|
89
|
+
async def _handle_anip_jwks(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
90
|
+
return self._service.get_jwks()
|
|
91
|
+
|
|
92
|
+
async def _handle_anip_tokens_issue(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
93
|
+
bearer = extract_auth(params)
|
|
94
|
+
if bearer is None:
|
|
95
|
+
raise ANIPError("authentication_required", "This method requires auth.bearer")
|
|
96
|
+
|
|
97
|
+
# Try bootstrap auth (API key) first, then ANIP JWT
|
|
98
|
+
principal = await self._service.authenticate_bearer(bearer)
|
|
99
|
+
if principal is None:
|
|
100
|
+
raise ANIPError("invalid_token", "Bearer token not recognized")
|
|
101
|
+
|
|
102
|
+
# Build the token request body from params
|
|
103
|
+
body: dict[str, Any] = {}
|
|
104
|
+
for key in ("subject", "scope", "capability", "purpose_parameters",
|
|
105
|
+
"parent_token", "ttl_hours", "caller_class"):
|
|
106
|
+
if key in params:
|
|
107
|
+
body[key] = params[key]
|
|
108
|
+
|
|
109
|
+
return await self._service.issue_token(principal, body)
|
|
110
|
+
|
|
111
|
+
async def _handle_anip_permissions(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
112
|
+
token = await self._resolve_jwt(params)
|
|
113
|
+
perm = self._service.discover_permissions(token)
|
|
114
|
+
return perm.model_dump() if hasattr(perm, "model_dump") else perm
|
|
115
|
+
|
|
116
|
+
async def _handle_anip_invoke(
|
|
117
|
+
self, params: dict[str, Any],
|
|
118
|
+
) -> dict[str, Any] | tuple[list[dict[str, Any]], dict[str, Any]]:
|
|
119
|
+
token = await self._resolve_jwt(params)
|
|
120
|
+
|
|
121
|
+
capability = params.get("capability")
|
|
122
|
+
if not capability:
|
|
123
|
+
raise ANIPError("unknown_capability", "Missing 'capability' in params")
|
|
124
|
+
|
|
125
|
+
parameters = params.get("parameters", {})
|
|
126
|
+
client_reference_id = params.get("client_reference_id")
|
|
127
|
+
stream = params.get("stream", False)
|
|
128
|
+
|
|
129
|
+
if stream:
|
|
130
|
+
notifications: list[dict[str, Any]] = []
|
|
131
|
+
|
|
132
|
+
async def _progress_sink(payload: dict[str, Any]) -> None:
|
|
133
|
+
notifications.append(
|
|
134
|
+
make_notification("anip.invoke.progress", payload)
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
result = await self._service.invoke(
|
|
138
|
+
capability, token, parameters,
|
|
139
|
+
client_reference_id=client_reference_id,
|
|
140
|
+
stream=True,
|
|
141
|
+
_progress_sink=_progress_sink,
|
|
142
|
+
)
|
|
143
|
+
return (notifications, result)
|
|
144
|
+
|
|
145
|
+
result = await self._service.invoke(
|
|
146
|
+
capability, token, parameters,
|
|
147
|
+
client_reference_id=client_reference_id,
|
|
148
|
+
)
|
|
149
|
+
return result
|
|
150
|
+
|
|
151
|
+
async def _handle_anip_audit_query(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
152
|
+
token = await self._resolve_jwt(params)
|
|
153
|
+
|
|
154
|
+
filters: dict[str, Any] = {}
|
|
155
|
+
for key in ("capability", "since", "invocation_id",
|
|
156
|
+
"client_reference_id", "event_class", "limit"):
|
|
157
|
+
if key in params:
|
|
158
|
+
filters[key] = params[key]
|
|
159
|
+
|
|
160
|
+
return await self._service.query_audit(token, filters)
|
|
161
|
+
|
|
162
|
+
async def _handle_anip_checkpoints_list(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
163
|
+
limit = params.get("limit", 10)
|
|
164
|
+
return await self._service.get_checkpoints(limit)
|
|
165
|
+
|
|
166
|
+
async def _handle_anip_checkpoints_get(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
167
|
+
checkpoint_id = params.get("id")
|
|
168
|
+
if not checkpoint_id:
|
|
169
|
+
raise ANIPError("not_found", "Missing 'id' in params")
|
|
170
|
+
|
|
171
|
+
options: dict[str, Any] = {}
|
|
172
|
+
for key in ("include_proof", "leaf_index", "consistency_from"):
|
|
173
|
+
if key in params:
|
|
174
|
+
options[key] = params[key]
|
|
175
|
+
|
|
176
|
+
result = await self._service.get_checkpoint(checkpoint_id, options)
|
|
177
|
+
if result is None:
|
|
178
|
+
raise ANIPError("not_found", f"Checkpoint not found: {checkpoint_id}")
|
|
179
|
+
return result
|
|
180
|
+
|
|
181
|
+
# --- Internal helpers ---
|
|
182
|
+
|
|
183
|
+
async def _resolve_jwt(self, params: dict[str, Any]) -> Any:
|
|
184
|
+
"""Extract and verify a JWT bearer token from params.
|
|
185
|
+
|
|
186
|
+
Returns the resolved DelegationToken.
|
|
187
|
+
Raises ANIPError if auth is missing or invalid.
|
|
188
|
+
"""
|
|
189
|
+
bearer = extract_auth(params)
|
|
190
|
+
if bearer is None:
|
|
191
|
+
raise ANIPError("authentication_required", "This method requires auth.bearer")
|
|
192
|
+
return await self._service.resolve_bearer_token(bearer)
|
|
193
|
+
|
|
194
|
+
# --- Dispatch table ---
|
|
195
|
+
|
|
196
|
+
_DISPATCH: dict[str, Any] = {
|
|
197
|
+
"anip.discovery": _handle_anip_discovery,
|
|
198
|
+
"anip.manifest": _handle_anip_manifest,
|
|
199
|
+
"anip.jwks": _handle_anip_jwks,
|
|
200
|
+
"anip.tokens.issue": _handle_anip_tokens_issue,
|
|
201
|
+
"anip.permissions": _handle_anip_permissions,
|
|
202
|
+
"anip.invoke": _handle_anip_invoke,
|
|
203
|
+
"anip.audit.query": _handle_anip_audit_query,
|
|
204
|
+
"anip.checkpoints.list": _handle_anip_checkpoints_list,
|
|
205
|
+
"anip.checkpoints.get": _handle_anip_checkpoints_get,
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class _StdioWriter:
|
|
210
|
+
"""Minimal async writer wrapping sys.stdout.buffer."""
|
|
211
|
+
|
|
212
|
+
def __init__(self):
|
|
213
|
+
self._out = sys.stdout.buffer
|
|
214
|
+
|
|
215
|
+
def write(self, data: bytes) -> None:
|
|
216
|
+
self._out.write(data)
|
|
217
|
+
|
|
218
|
+
async def drain(self) -> None:
|
|
219
|
+
self._out.flush()
|
|
220
|
+
|
|
221
|
+
def close(self) -> None:
|
|
222
|
+
pass
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _make_stdio_streams():
|
|
226
|
+
"""Create async reader/writer for stdin/stdout without connect_*_pipe."""
|
|
227
|
+
reader = asyncio.StreamReader()
|
|
228
|
+
|
|
229
|
+
async def _feed_stdin():
|
|
230
|
+
loop = asyncio.get_event_loop()
|
|
231
|
+
while True:
|
|
232
|
+
line = await loop.run_in_executor(None, sys.stdin.buffer.readline)
|
|
233
|
+
if not line:
|
|
234
|
+
reader.feed_eof()
|
|
235
|
+
break
|
|
236
|
+
reader.feed_data(line)
|
|
237
|
+
|
|
238
|
+
asyncio.get_event_loop().create_task(_feed_stdin())
|
|
239
|
+
writer = _StdioWriter()
|
|
240
|
+
return reader, writer
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
async def serve_stdio(
|
|
244
|
+
service: ANIPService,
|
|
245
|
+
reader: asyncio.StreamReader | None = None,
|
|
246
|
+
writer: asyncio.StreamWriter | None = None,
|
|
247
|
+
) -> None:
|
|
248
|
+
"""Run the ANIP stdio server, reading JSON-RPC from reader and writing to writer.
|
|
249
|
+
|
|
250
|
+
If reader/writer are not provided, connects stdin/stdout as asyncio streams.
|
|
251
|
+
"""
|
|
252
|
+
if reader is None or writer is None:
|
|
253
|
+
# Use a simple sync wrapper for stdin/stdout.
|
|
254
|
+
# This avoids connect_read_pipe/connect_write_pipe which fail
|
|
255
|
+
# when stdin/stdout are not real pipes (e.g., terminals, redirected files).
|
|
256
|
+
reader, writer = _make_stdio_streams()
|
|
257
|
+
|
|
258
|
+
server = AnipStdioServer(service)
|
|
259
|
+
await service.start()
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
while True:
|
|
263
|
+
try:
|
|
264
|
+
msg = await read_message(reader)
|
|
265
|
+
except json.JSONDecodeError as exc:
|
|
266
|
+
error_resp = make_error(None, PARSE_ERROR, f"Parse error: {exc}")
|
|
267
|
+
await write_message(writer, error_resp)
|
|
268
|
+
continue
|
|
269
|
+
|
|
270
|
+
if msg is None:
|
|
271
|
+
break # EOF
|
|
272
|
+
|
|
273
|
+
response = await server.handle_request(msg)
|
|
274
|
+
|
|
275
|
+
if isinstance(response, list):
|
|
276
|
+
for item in response:
|
|
277
|
+
await write_message(writer, item)
|
|
278
|
+
else:
|
|
279
|
+
await write_message(writer, response)
|
|
280
|
+
finally:
|
|
281
|
+
await service.shutdown()
|
|
282
|
+
service.stop()
|
|
File without changes
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Minimal ANIP stdio server for testing the client."""
|
|
3
|
+
import asyncio
|
|
4
|
+
|
|
5
|
+
from anip_service import ANIPService, Capability, InvocationContext
|
|
6
|
+
from anip_core import (
|
|
7
|
+
CapabilityDeclaration,
|
|
8
|
+
CapabilityInput,
|
|
9
|
+
CapabilityOutput,
|
|
10
|
+
SideEffect,
|
|
11
|
+
SideEffectType,
|
|
12
|
+
)
|
|
13
|
+
from anip_stdio import serve_stdio
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
async def _echo_handler(ctx: InvocationContext, params: dict) -> dict:
|
|
17
|
+
return {"message": params.get("message", "")}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
service = ANIPService(
|
|
21
|
+
service_id="test-stdio-client",
|
|
22
|
+
capabilities=[
|
|
23
|
+
Capability(
|
|
24
|
+
declaration=CapabilityDeclaration(
|
|
25
|
+
name="echo",
|
|
26
|
+
description="Echo",
|
|
27
|
+
contract_version="1.0",
|
|
28
|
+
inputs=[CapabilityInput(name="message", type="string", description="msg")],
|
|
29
|
+
output=CapabilityOutput(type="object", fields=["message"]),
|
|
30
|
+
side_effect=SideEffect(type=SideEffectType.READ),
|
|
31
|
+
minimum_scope=["test"],
|
|
32
|
+
),
|
|
33
|
+
handler=_echo_handler,
|
|
34
|
+
),
|
|
35
|
+
],
|
|
36
|
+
storage=":memory:",
|
|
37
|
+
authenticate=lambda bearer: "user@test.com" if bearer == "test-key" else None,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
asyncio.run(serve_stdio(service))
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Tests for ANIP stdio client."""
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from anip_stdio.client import AnipStdioClient
|
|
8
|
+
|
|
9
|
+
SERVE_SCRIPT = os.path.join(os.path.dirname(__file__), "_serve_fixture.py")
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.mark.asyncio
|
|
13
|
+
async def test_discovery():
|
|
14
|
+
async with AnipStdioClient(sys.executable, SERVE_SCRIPT) as client:
|
|
15
|
+
result = await client.discovery()
|
|
16
|
+
assert "anip_discovery" in result
|
|
17
|
+
assert result["anip_discovery"]["protocol"] == "anip/0.11"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@pytest.mark.asyncio
|
|
21
|
+
async def test_manifest():
|
|
22
|
+
async with AnipStdioClient(sys.executable, SERVE_SCRIPT) as client:
|
|
23
|
+
result = await client.manifest()
|
|
24
|
+
assert "manifest" in result
|
|
25
|
+
assert "signature" in result
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.mark.asyncio
|
|
29
|
+
async def test_jwks():
|
|
30
|
+
async with AnipStdioClient(sys.executable, SERVE_SCRIPT) as client:
|
|
31
|
+
result = await client.jwks()
|
|
32
|
+
assert "keys" in result
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@pytest.mark.asyncio
|
|
36
|
+
async def test_full_flow():
|
|
37
|
+
async with AnipStdioClient(sys.executable, SERVE_SCRIPT) as client:
|
|
38
|
+
# Issue token
|
|
39
|
+
tok = await client.issue_token(
|
|
40
|
+
"test-key", subject="agent:bot", scope=["test"], capability="echo",
|
|
41
|
+
)
|
|
42
|
+
assert tok["issued"] is True
|
|
43
|
+
jwt = tok["token"]
|
|
44
|
+
|
|
45
|
+
# Invoke
|
|
46
|
+
result = await client.invoke(jwt, "echo", {"message": "hello stdio"})
|
|
47
|
+
assert result["success"] is True
|
|
48
|
+
assert result["result"]["message"] == "hello stdio"
|
|
49
|
+
|
|
50
|
+
# Permissions
|
|
51
|
+
perms = await client.permissions(jwt)
|
|
52
|
+
assert "available" in perms
|
|
53
|
+
|
|
54
|
+
# Audit
|
|
55
|
+
audit = await client.audit_query(jwt, capability="echo")
|
|
56
|
+
assert len(audit["entries"]) > 0
|
|
57
|
+
|
|
58
|
+
# Checkpoints
|
|
59
|
+
cps = await client.checkpoints_list()
|
|
60
|
+
assert "checkpoints" in cps
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@pytest.mark.asyncio
|
|
64
|
+
async def test_auth_error():
|
|
65
|
+
async with AnipStdioClient(sys.executable, SERVE_SCRIPT) as client:
|
|
66
|
+
with pytest.raises(Exception, match="32001|auth|Auth"):
|
|
67
|
+
await client.invoke("bad-token", "echo", {"message": "fail"})
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Unit tests for JSON-RPC 2.0 protocol helpers."""
|
|
2
|
+
from anip_stdio.protocol import (
|
|
3
|
+
AUTH_ERROR,
|
|
4
|
+
FAILURE_TYPE_TO_CODE,
|
|
5
|
+
INTERNAL_ERROR,
|
|
6
|
+
INVALID_REQUEST,
|
|
7
|
+
METHOD_NOT_FOUND,
|
|
8
|
+
NOT_FOUND,
|
|
9
|
+
PARSE_ERROR,
|
|
10
|
+
SCOPE_ERROR,
|
|
11
|
+
VALID_METHODS,
|
|
12
|
+
extract_auth,
|
|
13
|
+
make_error,
|
|
14
|
+
make_notification,
|
|
15
|
+
make_response,
|
|
16
|
+
validate_request,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# --- VALID_METHODS ---
|
|
21
|
+
|
|
22
|
+
class TestValidMethods:
|
|
23
|
+
def test_method_count(self):
|
|
24
|
+
assert len(VALID_METHODS) == 9
|
|
25
|
+
|
|
26
|
+
def test_all_methods_present(self):
|
|
27
|
+
expected = {
|
|
28
|
+
"anip.discovery", "anip.manifest", "anip.jwks",
|
|
29
|
+
"anip.tokens.issue", "anip.permissions", "anip.invoke",
|
|
30
|
+
"anip.audit.query", "anip.checkpoints.list", "anip.checkpoints.get",
|
|
31
|
+
}
|
|
32
|
+
assert VALID_METHODS == expected
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# --- Error code mapping ---
|
|
36
|
+
|
|
37
|
+
class TestErrorCodeMapping:
|
|
38
|
+
def test_auth_errors(self):
|
|
39
|
+
for t in ("authentication_required", "invalid_token", "token_expired"):
|
|
40
|
+
assert FAILURE_TYPE_TO_CODE[t] == AUTH_ERROR
|
|
41
|
+
|
|
42
|
+
def test_scope_errors(self):
|
|
43
|
+
for t in ("scope_insufficient", "budget_exceeded", "purpose_mismatch"):
|
|
44
|
+
assert FAILURE_TYPE_TO_CODE[t] == SCOPE_ERROR
|
|
45
|
+
|
|
46
|
+
def test_not_found_errors(self):
|
|
47
|
+
for t in ("unknown_capability", "not_found"):
|
|
48
|
+
assert FAILURE_TYPE_TO_CODE[t] == NOT_FOUND
|
|
49
|
+
|
|
50
|
+
def test_internal_errors(self):
|
|
51
|
+
for t in ("internal_error", "unavailable", "concurrent_lock"):
|
|
52
|
+
assert FAILURE_TYPE_TO_CODE[t] == INTERNAL_ERROR
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# --- make_response ---
|
|
56
|
+
|
|
57
|
+
class TestMakeResponse:
|
|
58
|
+
def test_basic(self):
|
|
59
|
+
resp = make_response(1, {"key": "value"})
|
|
60
|
+
assert resp == {
|
|
61
|
+
"jsonrpc": "2.0",
|
|
62
|
+
"id": 1,
|
|
63
|
+
"result": {"key": "value"},
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
def test_null_id(self):
|
|
67
|
+
resp = make_response(None, "ok")
|
|
68
|
+
assert resp["id"] is None
|
|
69
|
+
|
|
70
|
+
def test_string_id(self):
|
|
71
|
+
resp = make_response("abc-123", [1, 2, 3])
|
|
72
|
+
assert resp["id"] == "abc-123"
|
|
73
|
+
assert resp["result"] == [1, 2, 3]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# --- make_error ---
|
|
77
|
+
|
|
78
|
+
class TestMakeError:
|
|
79
|
+
def test_basic(self):
|
|
80
|
+
resp = make_error(1, -32600, "Invalid request")
|
|
81
|
+
assert resp["jsonrpc"] == "2.0"
|
|
82
|
+
assert resp["id"] == 1
|
|
83
|
+
assert resp["error"]["code"] == -32600
|
|
84
|
+
assert resp["error"]["message"] == "Invalid request"
|
|
85
|
+
assert "data" not in resp["error"]
|
|
86
|
+
|
|
87
|
+
def test_with_data(self):
|
|
88
|
+
resp = make_error(2, -32001, "Auth required", {"type": "authentication_required"})
|
|
89
|
+
assert resp["error"]["data"]["type"] == "authentication_required"
|
|
90
|
+
|
|
91
|
+
def test_null_id(self):
|
|
92
|
+
resp = make_error(None, PARSE_ERROR, "Parse error")
|
|
93
|
+
assert resp["id"] is None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# --- make_notification ---
|
|
97
|
+
|
|
98
|
+
class TestMakeNotification:
|
|
99
|
+
def test_basic(self):
|
|
100
|
+
notif = make_notification("anip.invoke.progress", {"invocation_id": "inv-1"})
|
|
101
|
+
assert notif["jsonrpc"] == "2.0"
|
|
102
|
+
assert notif["method"] == "anip.invoke.progress"
|
|
103
|
+
assert notif["params"]["invocation_id"] == "inv-1"
|
|
104
|
+
assert "id" not in notif
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
# --- validate_request ---
|
|
108
|
+
|
|
109
|
+
class TestValidateRequest:
|
|
110
|
+
def test_valid_request(self):
|
|
111
|
+
msg = {"jsonrpc": "2.0", "id": 1, "method": "anip.discovery"}
|
|
112
|
+
assert validate_request(msg) is None
|
|
113
|
+
|
|
114
|
+
def test_missing_jsonrpc(self):
|
|
115
|
+
msg = {"id": 1, "method": "anip.discovery"}
|
|
116
|
+
assert validate_request(msg) is not None
|
|
117
|
+
|
|
118
|
+
def test_wrong_jsonrpc(self):
|
|
119
|
+
msg = {"jsonrpc": "1.0", "id": 1, "method": "anip.discovery"}
|
|
120
|
+
assert validate_request(msg) is not None
|
|
121
|
+
|
|
122
|
+
def test_missing_method(self):
|
|
123
|
+
msg = {"jsonrpc": "2.0", "id": 1}
|
|
124
|
+
assert validate_request(msg) is not None
|
|
125
|
+
|
|
126
|
+
def test_non_string_method(self):
|
|
127
|
+
msg = {"jsonrpc": "2.0", "id": 1, "method": 42}
|
|
128
|
+
assert validate_request(msg) is not None
|
|
129
|
+
|
|
130
|
+
def test_missing_id(self):
|
|
131
|
+
msg = {"jsonrpc": "2.0", "method": "anip.discovery"}
|
|
132
|
+
assert validate_request(msg) is not None
|
|
133
|
+
|
|
134
|
+
def test_not_a_dict(self):
|
|
135
|
+
assert validate_request("not a dict") is not None # type: ignore[arg-type]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# --- extract_auth ---
|
|
139
|
+
|
|
140
|
+
class TestExtractAuth:
|
|
141
|
+
def test_extracts_bearer(self):
|
|
142
|
+
params = {"auth": {"bearer": "my-token"}}
|
|
143
|
+
assert extract_auth(params) == "my-token"
|
|
144
|
+
|
|
145
|
+
def test_no_params(self):
|
|
146
|
+
assert extract_auth(None) is None
|
|
147
|
+
|
|
148
|
+
def test_no_auth(self):
|
|
149
|
+
assert extract_auth({"capability": "echo"}) is None
|
|
150
|
+
|
|
151
|
+
def test_auth_not_dict(self):
|
|
152
|
+
assert extract_auth({"auth": "string"}) is None
|
|
153
|
+
|
|
154
|
+
def test_no_bearer(self):
|
|
155
|
+
assert extract_auth({"auth": {"type": "basic"}}) is None
|
|
156
|
+
|
|
157
|
+
def test_empty_params(self):
|
|
158
|
+
assert extract_auth({}) is None
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Integration tests for the ANIP stdio server — all 9 methods through handle_request()."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
from anip_service import ANIPService, Capability, InvocationContext
|
|
6
|
+
from anip_core import (
|
|
7
|
+
CapabilityDeclaration,
|
|
8
|
+
CapabilityInput,
|
|
9
|
+
CapabilityOutput,
|
|
10
|
+
ResponseMode,
|
|
11
|
+
SideEffect,
|
|
12
|
+
SideEffectType,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
from anip_stdio.server import AnipStdioServer
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# --- Test capability: echo ---
|
|
19
|
+
|
|
20
|
+
def _echo_capability() -> Capability:
|
|
21
|
+
async def handler(ctx: InvocationContext, params: dict) -> dict:
|
|
22
|
+
return {"echo": params.get("message", ""), "invocation_id": ctx.invocation_id}
|
|
23
|
+
|
|
24
|
+
return Capability(
|
|
25
|
+
declaration=CapabilityDeclaration(
|
|
26
|
+
name="echo",
|
|
27
|
+
description="Echo the input back",
|
|
28
|
+
inputs=[CapabilityInput(name="message", type="string", required=True, description="message")],
|
|
29
|
+
output=CapabilityOutput(type="object", fields=["echo"]),
|
|
30
|
+
side_effect=SideEffect(type=SideEffectType.READ),
|
|
31
|
+
minimum_scope=["echo"],
|
|
32
|
+
response_modes=[ResponseMode.UNARY, ResponseMode.STREAMING],
|
|
33
|
+
),
|
|
34
|
+
handler=handler,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# --- Fixtures ---
|
|
39
|
+
|
|
40
|
+
@pytest.fixture
|
|
41
|
+
def service():
|
|
42
|
+
return ANIPService(
|
|
43
|
+
service_id="test-stdio-service",
|
|
44
|
+
capabilities=[_echo_capability()],
|
|
45
|
+
storage=":memory:",
|
|
46
|
+
authenticate=lambda bearer: "human:test@example.com" if bearer == "test-api-key" else None,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@pytest.fixture
|
|
51
|
+
def server(service):
|
|
52
|
+
return AnipStdioServer(service)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _req(method: str, params: dict | None = None, request_id: int = 1) -> dict:
|
|
56
|
+
"""Build a JSON-RPC request."""
|
|
57
|
+
return {"jsonrpc": "2.0", "id": request_id, "method": method, "params": params or {}}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# --- Helper to issue a token via the server ---
|
|
61
|
+
|
|
62
|
+
async def _issue_token(server: AnipStdioServer) -> str:
|
|
63
|
+
"""Issue a token through the server and return the JWT string."""
|
|
64
|
+
resp = await server.handle_request(_req("anip.tokens.issue", {
|
|
65
|
+
"auth": {"bearer": "test-api-key"},
|
|
66
|
+
"subject": "agent:test-agent",
|
|
67
|
+
"scope": ["echo"],
|
|
68
|
+
"capability": "echo",
|
|
69
|
+
"caller_class": "internal",
|
|
70
|
+
}))
|
|
71
|
+
assert "result" in resp, f"Expected result, got: {resp}"
|
|
72
|
+
assert resp["result"]["issued"] is True
|
|
73
|
+
return resp["result"]["token"]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# --- Tests ---
|
|
77
|
+
|
|
78
|
+
class TestDiscovery:
|
|
79
|
+
async def test_returns_protocol_version(self, server):
|
|
80
|
+
resp = await server.handle_request(_req("anip.discovery"))
|
|
81
|
+
assert "result" in resp
|
|
82
|
+
assert "anip_discovery" in resp["result"]
|
|
83
|
+
assert "protocol" in resp["result"]["anip_discovery"]
|
|
84
|
+
|
|
85
|
+
async def test_contains_capabilities(self, server):
|
|
86
|
+
resp = await server.handle_request(_req("anip.discovery"))
|
|
87
|
+
caps = resp["result"]["anip_discovery"]["capabilities"]
|
|
88
|
+
assert "echo" in caps
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class TestManifest:
|
|
92
|
+
async def test_returns_manifest_and_signature(self, server):
|
|
93
|
+
resp = await server.handle_request(_req("anip.manifest"))
|
|
94
|
+
assert "result" in resp
|
|
95
|
+
result = resp["result"]
|
|
96
|
+
assert "manifest" in result
|
|
97
|
+
assert "signature" in result
|
|
98
|
+
assert isinstance(result["manifest"], dict)
|
|
99
|
+
assert isinstance(result["signature"], str)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class TestJWKS:
|
|
103
|
+
async def test_returns_keys(self, server):
|
|
104
|
+
resp = await server.handle_request(_req("anip.jwks"))
|
|
105
|
+
assert "result" in resp
|
|
106
|
+
assert "keys" in resp["result"]
|
|
107
|
+
assert isinstance(resp["result"]["keys"], list)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class TestTokensIssue:
|
|
111
|
+
async def test_issue_with_api_key(self, server):
|
|
112
|
+
resp = await server.handle_request(_req("anip.tokens.issue", {
|
|
113
|
+
"auth": {"bearer": "test-api-key"},
|
|
114
|
+
"subject": "agent:test-agent",
|
|
115
|
+
"scope": ["echo"],
|
|
116
|
+
"capability": "echo",
|
|
117
|
+
}))
|
|
118
|
+
assert "result" in resp
|
|
119
|
+
result = resp["result"]
|
|
120
|
+
assert result["issued"] is True
|
|
121
|
+
assert "token" in result
|
|
122
|
+
assert "token_id" in result
|
|
123
|
+
assert "expires" in result
|
|
124
|
+
|
|
125
|
+
async def test_issue_without_auth_returns_error(self, server):
|
|
126
|
+
resp = await server.handle_request(_req("anip.tokens.issue", {
|
|
127
|
+
"subject": "agent:test-agent",
|
|
128
|
+
"scope": ["echo"],
|
|
129
|
+
}))
|
|
130
|
+
assert "error" in resp
|
|
131
|
+
assert resp["error"]["code"] == -32001
|
|
132
|
+
|
|
133
|
+
async def test_issue_with_bad_key_returns_error(self, server):
|
|
134
|
+
resp = await server.handle_request(_req("anip.tokens.issue", {
|
|
135
|
+
"auth": {"bearer": "wrong-key"},
|
|
136
|
+
"subject": "agent:test-agent",
|
|
137
|
+
"scope": ["echo"],
|
|
138
|
+
}))
|
|
139
|
+
assert "error" in resp
|
|
140
|
+
assert resp["error"]["code"] == -32001
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class TestInvoke:
|
|
144
|
+
async def test_invoke_with_jwt(self, server):
|
|
145
|
+
token_jwt = await _issue_token(server)
|
|
146
|
+
resp = await server.handle_request(_req("anip.invoke", {
|
|
147
|
+
"auth": {"bearer": token_jwt},
|
|
148
|
+
"capability": "echo",
|
|
149
|
+
"parameters": {"message": "hello"},
|
|
150
|
+
"client_reference_id": "ref-001",
|
|
151
|
+
}))
|
|
152
|
+
assert "result" in resp
|
|
153
|
+
result = resp["result"]
|
|
154
|
+
assert result["success"] is True
|
|
155
|
+
assert result["result"]["echo"] == "hello"
|
|
156
|
+
assert "invocation_id" in result
|
|
157
|
+
|
|
158
|
+
async def test_invoke_without_auth_returns_error(self, server):
|
|
159
|
+
resp = await server.handle_request(_req("anip.invoke", {
|
|
160
|
+
"capability": "echo",
|
|
161
|
+
"parameters": {"message": "hello"},
|
|
162
|
+
}))
|
|
163
|
+
assert "error" in resp
|
|
164
|
+
assert resp["error"]["code"] == -32001
|
|
165
|
+
|
|
166
|
+
async def test_invoke_streaming(self, server):
|
|
167
|
+
token_jwt = await _issue_token(server)
|
|
168
|
+
resp = await server.handle_request(_req("anip.invoke", {
|
|
169
|
+
"auth": {"bearer": token_jwt},
|
|
170
|
+
"capability": "echo",
|
|
171
|
+
"parameters": {"message": "streamed"},
|
|
172
|
+
"stream": True,
|
|
173
|
+
}))
|
|
174
|
+
# Streaming returns a list: [notifications..., final_response]
|
|
175
|
+
assert isinstance(resp, list)
|
|
176
|
+
assert len(resp) >= 1
|
|
177
|
+
final = resp[-1]
|
|
178
|
+
assert "result" in final
|
|
179
|
+
assert final["result"]["success"] is True
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class TestPermissions:
|
|
183
|
+
async def test_discover_permissions(self, server):
|
|
184
|
+
token_jwt = await _issue_token(server)
|
|
185
|
+
resp = await server.handle_request(_req("anip.permissions", {
|
|
186
|
+
"auth": {"bearer": token_jwt},
|
|
187
|
+
}))
|
|
188
|
+
assert "result" in resp
|
|
189
|
+
# PermissionResponse has available/restricted/denied
|
|
190
|
+
result = resp["result"]
|
|
191
|
+
assert "available" in result or "restricted" in result or "denied" in result
|
|
192
|
+
|
|
193
|
+
async def test_permissions_without_auth(self, server):
|
|
194
|
+
resp = await server.handle_request(_req("anip.permissions", {}))
|
|
195
|
+
assert "error" in resp
|
|
196
|
+
assert resp["error"]["code"] == -32001
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
class TestAuditQuery:
|
|
200
|
+
async def test_audit_after_invocation(self, server):
|
|
201
|
+
token_jwt = await _issue_token(server)
|
|
202
|
+
|
|
203
|
+
# Invoke something to create audit entries
|
|
204
|
+
await server.handle_request(_req("anip.invoke", {
|
|
205
|
+
"auth": {"bearer": token_jwt},
|
|
206
|
+
"capability": "echo",
|
|
207
|
+
"parameters": {"message": "audit-test"},
|
|
208
|
+
}))
|
|
209
|
+
|
|
210
|
+
# Query audit
|
|
211
|
+
resp = await server.handle_request(_req("anip.audit.query", {
|
|
212
|
+
"auth": {"bearer": token_jwt},
|
|
213
|
+
"capability": "echo",
|
|
214
|
+
}))
|
|
215
|
+
assert "result" in resp
|
|
216
|
+
result = resp["result"]
|
|
217
|
+
assert "entries" in result
|
|
218
|
+
assert "count" in result
|
|
219
|
+
|
|
220
|
+
async def test_audit_without_auth(self, server):
|
|
221
|
+
resp = await server.handle_request(_req("anip.audit.query", {}))
|
|
222
|
+
assert "error" in resp
|
|
223
|
+
assert resp["error"]["code"] == -32001
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class TestCheckpointsList:
|
|
227
|
+
async def test_list_checkpoints(self, server):
|
|
228
|
+
resp = await server.handle_request(_req("anip.checkpoints.list", {"limit": 5}))
|
|
229
|
+
assert "result" in resp
|
|
230
|
+
result = resp["result"]
|
|
231
|
+
assert "checkpoints" in result
|
|
232
|
+
assert isinstance(result["checkpoints"], list)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class TestCheckpointsGet:
|
|
236
|
+
async def test_get_missing_checkpoint(self, server):
|
|
237
|
+
resp = await server.handle_request(_req("anip.checkpoints.get", {
|
|
238
|
+
"id": "cp-nonexistent",
|
|
239
|
+
}))
|
|
240
|
+
assert "error" in resp
|
|
241
|
+
assert resp["error"]["code"] == -32004
|
|
242
|
+
|
|
243
|
+
async def test_get_missing_id(self, server):
|
|
244
|
+
resp = await server.handle_request(_req("anip.checkpoints.get", {}))
|
|
245
|
+
assert "error" in resp
|
|
246
|
+
assert resp["error"]["code"] == -32004
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class TestErrorHandling:
|
|
250
|
+
async def test_unknown_method(self, server):
|
|
251
|
+
resp = await server.handle_request(_req("anip.nonexistent"))
|
|
252
|
+
assert "error" in resp
|
|
253
|
+
assert resp["error"]["code"] == -32601
|
|
254
|
+
|
|
255
|
+
async def test_invalid_request_missing_jsonrpc(self, server):
|
|
256
|
+
resp = await server.handle_request({"id": 1, "method": "anip.discovery"})
|
|
257
|
+
assert "error" in resp
|
|
258
|
+
assert resp["error"]["code"] == -32600
|
|
259
|
+
|
|
260
|
+
async def test_invalid_request_missing_id(self, server):
|
|
261
|
+
resp = await server.handle_request({"jsonrpc": "2.0", "method": "anip.discovery"})
|
|
262
|
+
assert "error" in resp
|
|
263
|
+
assert resp["error"]["code"] == -32600
|
|
264
|
+
|
|
265
|
+
async def test_invalid_request_missing_method(self, server):
|
|
266
|
+
resp = await server.handle_request({"jsonrpc": "2.0", "id": 1})
|
|
267
|
+
assert "error" in resp
|
|
268
|
+
assert resp["error"]["code"] == -32600
|