Migrate test plugins to Rust and async-ify syscall modules

- Replace Python test plugins (echo, ai_test, claudecode) with Rust
  implementations driven by the plugin SDK
- Convert fs/network/process/timer syscall modules to async/await
- Change Network.OpenConn to return stream ConnEvent (conn_id handed
  to client in first "open" frame for Send/Close routing)
- Bind timer callbacks to plugin identity; stream_call subscriptions
  are identity-checked
- Update docs and regenerate Python SDK protobuf stubs

Verified: cargo build/test/clippy clean; echo + ai_test full syscall
suites pass; hermes & claudecode register and heartbeat; cross-plugin
bridge.call routing and auth denial verified end-to-end.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 14:27:46 +08:00
parent d533d0a30e
commit 3299468df6
26 changed files with 843 additions and 814 deletions

File diff suppressed because one or more lines are too long

View File

@@ -1160,7 +1160,7 @@ class NetworkStub:
self.OpenConn = channel.unary_stream(
'/agentsd.Network/OpenConn',
request_serializer=agentsd__pb2.ConnectRequest.SerializeToString,
response_deserializer=agentsd__pb2.DataChunk.FromString,
response_deserializer=agentsd__pb2.ConnEvent.FromString,
_registered_method=True)
self.Listen = channel.unary_stream(
'/agentsd.Network/Listen',
@@ -1235,7 +1235,7 @@ def add_NetworkServicer_to_server(servicer, server):
'OpenConn': grpc.unary_stream_rpc_method_handler(
servicer.OpenConn,
request_deserializer=agentsd__pb2.ConnectRequest.FromString,
response_serializer=agentsd__pb2.DataChunk.SerializeToString,
response_serializer=agentsd__pb2.ConnEvent.SerializeToString,
),
'Listen': grpc.unary_stream_rpc_method_handler(
servicer.Listen,
@@ -1312,7 +1312,7 @@ class Network:
target,
'/agentsd.Network/OpenConn',
agentsd__pb2.ConnectRequest.SerializeToString,
agentsd__pb2.DataChunk.FromString,
agentsd__pb2.ConnEvent.FromString,
options,
channel_credentials,
insecure,

View File

@@ -1,222 +0,0 @@
"""
AI Test Plugin for Agentsd
==========================
Manual verification plugin for AI agents and humans. It is intentionally
not autoloaded by default; run it only when testing Agentsd runtime behavior.
Usage:
# Start agentsd first:
# RUST_LOG=agentsd=info ./target/release/agentsd.exe
python plugins/ai_test/ai_test_plugin.py
"""
import json
import sys
import time
from concurrent.futures import ThreadPoolExecutor, TimeoutError
from pathlib import Path
import grpc
ROOT = Path(__file__).resolve().parents[2]
SDK_DIR = ROOT / "plugins" / "_sdk"
sys.path.insert(0, str(SDK_DIR))
import agentsd_pb2 as pb # noqa: E402
import agentsd_pb2_grpc as rpc # noqa: E402
PLUGIN_ID = "ai-test"
METADATA = (("x-plugin-id", PLUGIN_ID),)
ADDR = "localhost:50051"
class CheckRunner:
def __init__(self):
self.channel = grpc.insecure_channel(ADDR)
self.bridge = rpc.PluginBridgeStub(self.channel)
self.display = rpc.DisplayStub(self.channel)
self.memory = rpc.MemoryStub(self.channel)
self.fs = rpc.FSStub(self.channel)
self.process = rpc.ProcessStub(self.channel)
self.timer = rpc.TimerStub(self.channel)
self.network = rpc.NetworkStub(self.channel)
self.results = []
self.run_id = str(int(time.time() * 1000))
self.scratch_dir = ROOT / "target" / "release" / "data" / "ai_test" / self.run_id
def close(self):
self.channel.close()
def record(self, name, ok, details=""):
icon = "PASS" if ok else "FAIL"
print(f"[{icon}] {name}: {details}")
self.results.append({"name": name, "ok": bool(ok), "details": details})
def check(self, name, fn):
try:
details = fn()
self.record(name, True, details or "ok")
except Exception as exc:
self.record(name, False, str(exc))
def register(self):
resp = self.bridge.Register(pb.PluginInfo(
id=PLUGIN_ID,
name="AI Test Plugin",
version="0.1.0",
plugin_type="basic",
syscalls=[
"display.text",
"memory.write", "memory.read", "memory.list", "memory.delete",
"fs.write", "fs.read", "fs.stat", "fs.list", "fs.rename", "fs.delete",
"process.spawn", "process.stdout", "process.wait",
"timer.once",
],
depends=[],
exports=[pb.ExportDef(name="run_checks", description="Run Agentsd verification checks")],
))
if not resp.ok:
raise RuntimeError(resp.error)
return f"session={resp.session_id}"
def display_text(self):
resp = self.display.Text(
pb.DisplayTextRequest(content=f"[ai-test] display check run={self.run_id}"),
metadata=METADATA,
)
if not resp.ok:
raise RuntimeError(resp.error)
return "display.text ok"
def memory_roundtrip(self):
key = f"ai_test.{self.run_id}.greeting"
value = f"hello-{self.run_id}".encode("utf-8")
resp = self.memory.Write(pb.MemoryWriteRequest(key=key, value=value), metadata=METADATA)
if not resp.ok:
raise RuntimeError(f"write: {resp.error}")
resp = self.memory.Read(pb.MemoryReadRequest(key=key), metadata=METADATA)
if not resp.found or resp.value != value:
raise RuntimeError(f"read mismatch found={resp.found} value={resp.value!r}")
resp = self.memory.List(pb.MemoryListRequest(prefix=f"ai_test.{self.run_id}", limit=10), metadata=METADATA)
if key not in resp.keys:
raise RuntimeError(f"list missing key; keys={list(resp.keys)} error={resp.error!r}")
resp = self.memory.Delete(pb.MemoryKeyRequest(key=key), metadata=METADATA)
if not resp.ok:
raise RuntimeError(f"delete: {resp.error}")
return f"key={key}"
def fs_roundtrip(self):
base = self.scratch_dir
file_a = base / "sample.txt"
file_b = base / "renamed.txt"
data = f"AI_TEST_FS_OK {self.run_id}".encode("utf-8")
resp = self.fs.Write(pb.FsWriteRequest(path=str(file_a), data=data, append=False), metadata=METADATA)
if not resp.ok:
raise RuntimeError(f"write: {resp.error}")
resp = self.fs.Read(pb.FsReadRequest(path=str(file_a), offset=0, length=0), metadata=METADATA)
if not resp.ok or resp.data != data:
raise RuntimeError(f"read mismatch ok={resp.ok} data={resp.data!r} error={resp.error!r}")
resp = self.fs.Stat(pb.FsPathRequest(path=str(file_a)), metadata=METADATA)
if not resp.ok or not resp.is_file or resp.size != len(data):
raise RuntimeError(f"stat mismatch ok={resp.ok} size={resp.size} error={resp.error!r}")
resp = self.fs.List(pb.FsPathRequest(path=str(base)), metadata=METADATA)
names = [e.name for e in resp.entries]
if not resp.ok or "sample.txt" not in names:
raise RuntimeError(f"list mismatch ok={resp.ok} names={names} error={resp.error!r}")
resp = self.fs.Rename(pb.FsRenameRequest(**{"from": str(file_a), "to": str(file_b)}), metadata=METADATA)
if not resp.ok:
raise RuntimeError(f"rename: {resp.error}")
resp = self.fs.Delete(pb.FsPathRequest(path=str(base)), metadata=METADATA)
if not resp.ok:
raise RuntimeError(f"delete: {resp.error}")
return str(base)
def process_roundtrip(self):
code = "print('AI_TEST_PROCESS_OK')"
resp = self.process.Spawn(pb.SpawnRequest(
cmd=sys.executable,
args=["-c", code],
env={},
cwd=str(ROOT),
), metadata=METADATA)
if not resp.ok:
raise RuntimeError(resp.error)
pid = resp.pid
chunks = []
for chunk in self.process.Stdout(pb.PidRequest(pid=pid), metadata=METADATA):
chunks.append(chunk.data)
output = b"".join(chunks).decode("utf-8", errors="replace").strip()
wait = self.process.Wait(pb.PidRequest(pid=pid), metadata=METADATA)
if not wait.ok or wait.exit_code != 0:
raise RuntimeError(f"wait failed ok={wait.ok} code={wait.exit_code} error={wait.error!r}")
if output != "AI_TEST_PROCESS_OK":
raise RuntimeError(f"stdout mismatch: {output!r}")
return f"pid={pid} stdout={output}"
def timer_event(self):
stream = self.bridge.StreamCall(
pb.CallRequest(target=PLUGIN_ID, fn="events", args=b""),
metadata=METADATA,
)
resp = self.timer.Once(pb.TimerOnceRequest(delay_ms=100, callback_id="ai-test-callback"), metadata=METADATA)
if not resp.ok:
raise RuntimeError(resp.error)
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(next, stream)
try:
event = future.result(timeout=5)
except TimeoutError:
stream.cancel()
raise RuntimeError("timed out waiting for timer event")
payload = json.loads(event.result.decode("utf-8"))
if payload.get("event") != "timer" or payload.get("callback_id") != "ai-test-callback":
raise RuntimeError(f"unexpected payload: {payload}")
return json.dumps(payload, ensure_ascii=False)
def permission_denial(self):
try:
self.network.Available(pb.Empty(), metadata=METADATA)
except grpc.RpcError as exc:
if exc.code() == grpc.StatusCode.PERMISSION_DENIED:
return exc.details()
raise RuntimeError(f"wrong error: {exc.code().name} {exc.details()}")
raise RuntimeError("network.available unexpectedly succeeded")
def run(self):
print(f"AI Test Plugin connecting to {ADDR}")
print(f"Scratch dir: {self.scratch_dir}")
self.check("register", self.register)
self.check("display.text", self.display_text)
self.check("memory roundtrip", self.memory_roundtrip)
self.check("fs roundtrip", self.fs_roundtrip)
self.check("process roundtrip", self.process_roundtrip)
self.check("timer event", self.timer_event)
self.check("permission denial", self.permission_denial)
ok = all(r["ok"] for r in self.results)
summary = {"plugin": PLUGIN_ID, "ok": ok, "results": self.results}
print("AI_TEST_SUMMARY " + json.dumps(summary, ensure_ascii=False))
return 0 if ok else 1
def main():
runner = CheckRunner()
try:
return runner.run()
finally:
runner.close()
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -28,4 +28,4 @@ exports:
- name: run_checks
description: Run end-to-end Agentsd syscall checks for AI verification
entry: python ai_test_plugin.py
entry: cargo run -p agentsd-plugin-ai-test

View File

@@ -1,312 +0,0 @@
"""
Claude Code Plugin for Agentsd
==============================
Bridges locally installed Claude Code CLI into agentsd as a standard plugin.
Enables agentsd to use Claude Code for code generation, editing, and reasoning.
Architecture:
agentsd (Rust) ←gRPC→ claudecode_plugin.py → claude CLI (subprocess)
Capabilities:
- chat: Send a prompt to Claude Code, get response
- code: Generate/edit code with full Claude Code capabilities
- session: Resume previous Claude Code sessions
- tools: Let Claude Code use its built-in tools (Bash, Edit, Read, etc.)
Usage:
# Start agentsd first, then:
python claudecode_plugin.py
"""
import json
import logging
import os
import subprocess
import sys
import time
from pathlib import Path
from concurrent import futures
import grpc
# Add SDK path
SDK_DIR = Path(__file__).resolve().parent.parent / "_sdk"
sys.path.insert(0, str(SDK_DIR))
import agentsd_pb2 as pb
import agentsd_pb2_grpc as rpc
logging.basicConfig(level=logging.INFO, format="[claudecode] %(levelname)s %(message)s")
logger = logging.getLogger(__name__)
# Find claude CLI
CLAUDE_BIN = os.environ.get("CLAUDE_BIN", None)
def find_claude():
"""Verify claude CLI is available and return the path."""
global CLAUDE_BIN
if CLAUDE_BIN is None:
# Auto-detect
candidates = [
"claude",
"claude.cmd",
os.path.expandvars(r"%APPDATA%\npm\claude.cmd"),
os.path.expanduser("~/.local/bin/claude"),
"/usr/local/bin/claude",
]
for c in candidates:
try:
result = subprocess.run(
[c, "--version"],
capture_output=True, text=True, timeout=10
)
if result.returncode == 0:
CLAUDE_BIN = c
break
except (FileNotFoundError, subprocess.TimeoutExpired, OSError):
continue
if CLAUDE_BIN is None:
logger.error("Claude Code CLI not found")
logger.error("Install: npm install -g @anthropic-ai/claude-code")
return None
try:
result = subprocess.run(
[CLAUDE_BIN, "--version"],
capture_output=True, text=True, timeout=10
)
version = result.stdout.strip()
logger.info("Found Claude Code: %s at %s", version, CLAUDE_BIN)
return version
except Exception as e:
logger.error("Claude Code CLI error: %s", e)
return None
class ClaudeCodePlugin:
"""Bridges Claude Code CLI into agentsd."""
def __init__(self, agentsd_addr: str = "localhost:50051"):
self.addr = agentsd_addr
self.channel = None
self.bridge = None
self.version = None
self.default_cwd = os.getcwd()
def connect(self):
"""Connect to agentsd."""
self.channel = grpc.insecure_channel(self.addr)
self.bridge = rpc.PluginBridgeStub(self.channel)
logger.info("Connected to agentsd at %s", self.addr)
def register(self):
"""Register with agentsd."""
resp = self.bridge.Register(pb.PluginInfo(
id="claudecode",
name="Claude Code",
version=self.version or "unknown",
plugin_type="standard",
syscalls=["process.spawn", "display.text", "fs.read", "fs.write", "network.request"],
depends=["hermes"],
exports=[
pb.ExportDef(name="chat", description="Send prompt to Claude Code, get text response"),
pb.ExportDef(name="code", description="Generate or edit code with Claude Code"),
pb.ExportDef(name="review", description="Review code with Claude Code"),
pb.ExportDef(name="ask", description="Ask Claude Code a question about the codebase"),
pb.ExportDef(name="run", description="Run Claude Code with full tool access"),
],
))
if resp.ok:
logger.info("Registered with agentsd: session=%s", resp.session_id)
else:
logger.error("Registration failed: %s", resp.error)
return resp.ok
def call_claude(self, prompt: str, cwd: str = None, max_turns: int = None,
allowed_tools: list = None, system_prompt: str = None,
output_format: str = "text") -> dict:
"""
Call Claude Code CLI in print mode.
Args:
prompt: The prompt to send
cwd: Working directory for Claude Code
max_turns: Max agentic turns (default: unlimited)
allowed_tools: List of tools to allow (e.g. ["Bash", "Read", "Edit"])
system_prompt: Override system prompt
output_format: "text" or "json" or "stream-json"
Returns:
dict with "result" or "error"
"""
cmd = [CLAUDE_BIN, "-p", "--output-format", output_format]
if max_turns is not None:
cmd.extend(["--max-turns", str(max_turns)])
if allowed_tools:
cmd.extend(["--allowedTools"] + allowed_tools)
if system_prompt:
cmd.extend(["--system-prompt", system_prompt])
work_dir = cwd or self.default_cwd
logger.info("Calling claude: cwd=%s prompt=%s...", work_dir, prompt[:60])
try:
result = subprocess.run(
cmd,
input=prompt,
capture_output=True,
text=True,
cwd=work_dir,
timeout=300, # 5 min max
env={**os.environ, "CLAUDE_CODE_SIMPLE": "1"},
)
if result.returncode == 0:
output = result.stdout.strip()
if output_format == "json":
try:
return json.loads(output)
except json.JSONDecodeError:
return {"result": output}
return {"result": output}
else:
error = result.stderr.strip() or f"exit code {result.returncode}"
return {"error": error}
except subprocess.TimeoutExpired:
return {"error": "timeout (300s)"}
except Exception as e:
return {"error": str(e)}
def handle_call(self, fn_name: str, args: dict) -> dict:
"""Route a call to the appropriate handler."""
prompt = args.get("prompt", args.get("text", ""))
cwd = args.get("cwd", self.default_cwd)
if fn_name == "chat":
return self.call_claude(
prompt=prompt,
cwd=cwd,
max_turns=1, # Single turn, no tools
allowed_tools=[],
output_format="text",
)
elif fn_name == "code":
return self.call_claude(
prompt=prompt,
cwd=cwd,
allowed_tools=["Bash", "Read", "Edit", "Write", "Glob", "Grep"],
output_format="text",
)
elif fn_name == "review":
review_prompt = f"Review the following code or changes. Be concise and actionable:\n\n{prompt}"
return self.call_claude(
prompt=review_prompt,
cwd=cwd,
max_turns=1,
allowed_tools=["Read", "Glob", "Grep"],
output_format="text",
)
elif fn_name == "ask":
return self.call_claude(
prompt=prompt,
cwd=cwd,
max_turns=3,
allowed_tools=["Read", "Glob", "Grep"],
output_format="text",
)
elif fn_name == "run":
# Full access mode
return self.call_claude(
prompt=prompt,
cwd=cwd,
output_format="text",
)
else:
return {"error": f"unknown function: {fn_name}"}
def run_server(self, port: int = 50053):
"""Run callback gRPC server for agentsd."""
server = grpc.server(futures.ThreadPoolExecutor(max_workers=4))
rpc.add_PluginBridgeServicer_to_server(ClaudeCodeServicer(self), server)
server.add_insecure_port(f"[::]:{port}")
server.start()
logger.info("Claude Code plugin server listening on port %d", port)
return server
class ClaudeCodeServicer(rpc.PluginBridgeServicer):
"""gRPC servicer for Claude Code plugin."""
def __init__(self, plugin: ClaudeCodePlugin):
self.plugin = plugin
def Call(self, request, context):
fn_name = request.fn
try:
args = json.loads(request.args) if request.args else {}
except json.JSONDecodeError:
args = {}
logger.info("Call: %s(%s)", fn_name, list(args.keys()))
result = self.plugin.handle_call(fn_name, args)
result_bytes = json.dumps(result, ensure_ascii=False, default=str).encode("utf-8")
if "error" in result and not result.get("result"):
return pb.CallResponse(result=result_bytes, ok=False, error=result["error"])
return pb.CallResponse(result=result_bytes, ok=True, error="")
def Register(self, request, context):
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
return pb.RegisterResponse(ok=False, error="not a bridge")
def Heartbeat(self, request, context):
return pb.HeartbeatResponse(ok=True)
def StreamCall(self, request, context):
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
return pb.CallResponse(ok=False, error="not implemented")
def main():
# Verify claude CLI
version = find_claude()
if not version:
sys.exit(1)
plugin = ClaudeCodePlugin()
plugin.version = version
# Connect and register
plugin.connect()
if not plugin.register():
sys.exit(1)
# Start callback server
server = plugin.run_server(port=50053)
logger.info("Claude Code plugin running (v%s). Exports: chat, code, review, ask, run", version)
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
logger.info("Shutting down...")
server.stop(grace=5)
plugin.channel.close()
if __name__ == "__main__":
main()

View File

@@ -25,5 +25,5 @@ exports:
- name: run
description: Full Claude Code access (all tools, unlimited turns)
entry: python claudecode_plugin.py
entry: cargo run -p agentsd-plugin-claudecode
port: 50053

View File

@@ -1,29 +0,0 @@
"""Quick test: call Claude Code plugin's chat export."""
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent / "_sdk"))
import grpc
import agentsd_pb2 as pb
import agentsd_pb2_grpc as rpc
def main():
# Connect to claudecode plugin's callback server
channel = grpc.insecure_channel("localhost:50053")
bridge = rpc.PluginBridgeStub(channel)
# Call "chat"
args = json.dumps({"prompt": "What is 2+2? Reply with just the number."}).encode()
resp = bridge.Call(pb.CallRequest(target="claudecode", fn="chat", args=args))
print(f"ok={resp.ok}")
if resp.result:
result = json.loads(resp.result)
print(f"result: {result.get('result', result.get('error', ''))}")
channel.close()
if __name__ == "__main__":
main()

View File

@@ -1,67 +0,0 @@
"""
Echo plugin for agentsd - verifies the gRPC syscall chain works.
Registers as a basic plugin, calls Display.Text and FS.Read.
"""
import grpc
import sys
import os
# Add SDK path
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "_sdk"))
from agentsd_pb2 import (
DisplayTextRequest, FsReadRequest, FsPathRequest,
MemoryWriteRequest, MemoryReadRequest,
PluginInfo, ExportDef, Empty
)
from agentsd_pb2_grpc import (
DisplayStub, FSStub, MemoryStub, PluginBridgeStub
)
PLUGIN_ID = "echo-test"
METADATA = (("x-plugin-id", PLUGIN_ID),)
def main():
channel = grpc.insecure_channel('localhost:50051')
# Register plugin
bridge = PluginBridgeStub(channel)
resp = bridge.Register(PluginInfo(
id=PLUGIN_ID,
name="Echo Test Plugin",
version="0.1.0",
plugin_type="basic",
syscalls=["display.text", "fs.list", "memory.read", "memory.write"],
depends=[],
exports=[ExportDef(name="echo", description="Echo test")]
))
print(f"Register: ok={resp.ok} session={resp.session_id}")
# Test Display
display = DisplayStub(channel)
resp = display.Text(DisplayTextRequest(content="Hello from echo plugin!"), metadata=METADATA)
print(f"Display.Text: ok={resp.ok}")
# Test Memory write + read
memory = MemoryStub(channel)
resp = memory.Write(MemoryWriteRequest(key="test.greeting", value=b"Hello World"), metadata=METADATA)
print(f"Memory.Write: ok={resp.ok}")
resp = memory.Read(MemoryReadRequest(key="test.greeting"), metadata=METADATA)
print(f"Memory.Read: found={resp.found} value={resp.value.decode()}")
# Test FS list
fs = FSStub(channel)
resp = fs.List(FsPathRequest(path="."), metadata=METADATA)
print(f"FS.List: ok={resp.ok} entries={len(resp.entries)}")
for entry in resp.entries[:5]:
print(f" {entry.name} {'[dir]' if entry.is_dir else ''}")
print("\nAll syscalls verified OK!")
channel.close()
if __name__ == "__main__":
main()

View File

@@ -13,4 +13,4 @@ exports:
- name: echo
description: Echo test
entry: python echo_plugin.py
entry: cargo run -p agentsd-plugin-echo

View File

@@ -45,8 +45,10 @@ logger = logging.getLogger(__name__)
class HermesPlugin:
"""Bridges Hermes tools into agentsd."""
def __init__(self, agentsd_addr: str = "localhost:50051"):
self.addr = agentsd_addr
def __init__(self, agentsd_addr: str = None):
# agentsd binds [::1]:50051 (IPv6 loopback); match it so "localhost"
# resolving to IPv4 first doesn't break the connection. AGENTSD_ADDR overrides.
self.addr = agentsd_addr or os.environ.get("AGENTSD_ADDR", "[::1]:50051")
self.channel = None
self.bridge = None
self.registry = None