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.
@@ -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,6 @@
1
+ Metadata-Version: 2.4
2
+ Name: anip-stdio
3
+ Version: 0.11.0
4
+ Summary: ANIP stdio transport binding — JSON-RPC 2.0 over stdin/stdout
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: anip-service==0.11.0
@@ -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,4 @@
1
+ from .server import serve_stdio, AnipStdioServer
2
+ from .client import AnipStdioClient
3
+
4
+ __all__ = ["serve_stdio", "AnipStdioServer", "AnipStdioClient"]
@@ -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