vystak-workspace-rpc 0.2.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.
- vystak_workspace_rpc-0.2.0/.gitignore +45 -0
- vystak_workspace_rpc-0.2.0/PKG-INFO +15 -0
- vystak_workspace_rpc-0.2.0/README.md +8 -0
- vystak_workspace_rpc-0.2.0/pyproject.toml +21 -0
- vystak_workspace_rpc-0.2.0/src/vystak_workspace_rpc/__init__.py +3 -0
- vystak_workspace_rpc-0.2.0/src/vystak_workspace_rpc/__main__.py +50 -0
- vystak_workspace_rpc-0.2.0/src/vystak_workspace_rpc/progress.py +10 -0
- vystak_workspace_rpc-0.2.0/src/vystak_workspace_rpc/server.py +95 -0
- vystak_workspace_rpc-0.2.0/src/vystak_workspace_rpc/services/__init__.py +0 -0
- vystak_workspace_rpc-0.2.0/src/vystak_workspace_rpc/services/exec.py +94 -0
- vystak_workspace_rpc-0.2.0/src/vystak_workspace_rpc/services/fs.py +119 -0
- vystak_workspace_rpc-0.2.0/src/vystak_workspace_rpc/services/git.py +124 -0
- vystak_workspace_rpc-0.2.0/src/vystak_workspace_rpc/services/tool.py +51 -0
- vystak_workspace_rpc-0.2.0/tests/__init__.py +0 -0
- vystak_workspace_rpc-0.2.0/tests/test_main.py +21 -0
- vystak_workspace_rpc-0.2.0/tests/test_server.py +71 -0
- vystak_workspace_rpc-0.2.0/tests/test_service_exec.py +88 -0
- vystak_workspace_rpc-0.2.0/tests/test_service_fs.py +101 -0
- vystak_workspace_rpc-0.2.0/tests/test_service_git.py +90 -0
- vystak_workspace_rpc-0.2.0/tests/test_service_tool.py +74 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.egg-info/
|
|
6
|
+
dist/
|
|
7
|
+
build/
|
|
8
|
+
.eggs/
|
|
9
|
+
*.egg
|
|
10
|
+
.venv/
|
|
11
|
+
.pytest_cache/
|
|
12
|
+
.ruff_cache/
|
|
13
|
+
.pyright/
|
|
14
|
+
|
|
15
|
+
# TypeScript
|
|
16
|
+
node_modules/
|
|
17
|
+
*.tsbuildinfo
|
|
18
|
+
coverage/
|
|
19
|
+
|
|
20
|
+
# IDE
|
|
21
|
+
.idea/
|
|
22
|
+
.vscode/
|
|
23
|
+
*.swp
|
|
24
|
+
*.swo
|
|
25
|
+
*~
|
|
26
|
+
|
|
27
|
+
# OS
|
|
28
|
+
.DS_Store
|
|
29
|
+
Thumbs.db
|
|
30
|
+
|
|
31
|
+
# Environment
|
|
32
|
+
.env
|
|
33
|
+
.env.local
|
|
34
|
+
|
|
35
|
+
# Vystak generated output (was .agentstack/ before rename)
|
|
36
|
+
.vystak/
|
|
37
|
+
.agentstack/
|
|
38
|
+
|
|
39
|
+
# Bundled templates copied into vystak-cli wheel at build time (build hook output)
|
|
40
|
+
packages/python/vystak-cli/src/vystak_cli/templates/
|
|
41
|
+
|
|
42
|
+
# Tooling workspaces (brainstorm sessions, local agent state)
|
|
43
|
+
.superpowers/
|
|
44
|
+
.worktrees/
|
|
45
|
+
.claude/
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: vystak-workspace-rpc
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: JSON-RPC 2.0 subsystem server running inside vystak workspace containers
|
|
5
|
+
Requires-Python: >=3.11
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
|
|
8
|
+
# vystak-workspace-rpc
|
|
9
|
+
|
|
10
|
+
Runs inside the workspace container as an OpenSSH subsystem. Exposes
|
|
11
|
+
`fs.*`, `exec.*`, `git.*`, `tool.*` services over JSON-RPC 2.0 on
|
|
12
|
+
stdin/stdout.
|
|
13
|
+
|
|
14
|
+
Not intended to be run directly. Installed into the workspace image by
|
|
15
|
+
the vystak Docker provider; launched by sshd per-channel.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# vystak-workspace-rpc
|
|
2
|
+
|
|
3
|
+
Runs inside the workspace container as an OpenSSH subsystem. Exposes
|
|
4
|
+
`fs.*`, `exec.*`, `git.*`, `tool.*` services over JSON-RPC 2.0 on
|
|
5
|
+
stdin/stdout.
|
|
6
|
+
|
|
7
|
+
Not intended to be run directly. Installed into the workspace image by
|
|
8
|
+
the vystak Docker provider; launched by sshd per-channel.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "vystak-workspace-rpc"
|
|
3
|
+
dynamic = ["version"]
|
|
4
|
+
description = "JSON-RPC 2.0 subsystem server running inside vystak workspace containers"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
dependencies = [] # stdlib-only; runs in user-chosen base images
|
|
8
|
+
|
|
9
|
+
[build-system]
|
|
10
|
+
requires = ["hatchling", "hatch-vcs"]
|
|
11
|
+
build-backend = "hatchling.build"
|
|
12
|
+
|
|
13
|
+
[tool.hatch.build.targets.wheel]
|
|
14
|
+
packages = ["src/vystak_workspace_rpc"]
|
|
15
|
+
|
|
16
|
+
[tool.pytest.ini_options]
|
|
17
|
+
asyncio_mode = "auto"
|
|
18
|
+
|
|
19
|
+
[tool.hatch.version]
|
|
20
|
+
source = "vcs"
|
|
21
|
+
raw-options = {root = "../../.."}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Entry point. sshd runs this as the `vystak-rpc` subsystem.
|
|
2
|
+
|
|
3
|
+
Reads WORKSPACE_ROOT and TOOLS_DIR from env; defaults to /workspace and
|
|
4
|
+
/workspace/tools. Builds a JsonRpcServer with all services registered,
|
|
5
|
+
runs it against stdin/stdout.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from vystak_workspace_rpc.server import JsonRpcServer, run_stdio
|
|
15
|
+
from vystak_workspace_rpc.services.exec import register_exec
|
|
16
|
+
from vystak_workspace_rpc.services.fs import register_fs
|
|
17
|
+
from vystak_workspace_rpc.services.git import register_git
|
|
18
|
+
from vystak_workspace_rpc.services.tool import register_tool
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def build_server(workspace_root: Path, tools_dir: Path) -> JsonRpcServer:
|
|
22
|
+
"""Build a JsonRpcServer with all services registered."""
|
|
23
|
+
srv = JsonRpcServer()
|
|
24
|
+
|
|
25
|
+
async def progress_emitter(channel: str, data: dict) -> None:
|
|
26
|
+
"""Forward to stdout as a JSON-RPC $/progress notification."""
|
|
27
|
+
note = {
|
|
28
|
+
"jsonrpc": "2.0",
|
|
29
|
+
"method": "$/progress",
|
|
30
|
+
"params": {"channel": channel, **data},
|
|
31
|
+
}
|
|
32
|
+
sys.stdout.write(json.dumps(note) + "\n")
|
|
33
|
+
sys.stdout.flush()
|
|
34
|
+
|
|
35
|
+
register_fs(srv, workspace_root)
|
|
36
|
+
register_exec(srv, workspace_root, progress_emitter=progress_emitter)
|
|
37
|
+
register_git(srv, workspace_root)
|
|
38
|
+
register_tool(srv, tools_dir)
|
|
39
|
+
return srv
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def main() -> None:
|
|
43
|
+
workspace_root = Path(os.environ.get("WORKSPACE_ROOT", "/workspace"))
|
|
44
|
+
tools_dir = Path(os.environ.get("TOOLS_DIR", str(workspace_root / "tools")))
|
|
45
|
+
srv = build_server(workspace_root, tools_dir)
|
|
46
|
+
asyncio.run(run_stdio(srv))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
if __name__ == "__main__":
|
|
50
|
+
main()
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Progress notification helper for streaming responses.
|
|
2
|
+
|
|
3
|
+
Handlers receive a progress_emitter callable they invoke with a channel
|
|
4
|
+
name (e.g. 'stdout') and a data dict. The framework forwards these as
|
|
5
|
+
JSON-RPC $/progress notifications back to the client.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections.abc import Awaitable, Callable
|
|
9
|
+
|
|
10
|
+
ProgressEmitter = Callable[[str, dict], Awaitable[None]]
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""JSON-RPC 2.0 server over stdio, with per-channel dispatch.
|
|
2
|
+
|
|
3
|
+
Reads newline-delimited JSON from stdin, writes responses to stdout.
|
|
4
|
+
One instance per SSH channel (one process spawned by sshd for each
|
|
5
|
+
`subsystem vystak-rpc` request).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
import sys
|
|
11
|
+
from collections.abc import Callable
|
|
12
|
+
|
|
13
|
+
# JSON-RPC 2.0 error codes
|
|
14
|
+
ERROR_PARSE = -32700
|
|
15
|
+
ERROR_INVALID_REQUEST = -32600
|
|
16
|
+
ERROR_METHOD_NOT_FOUND = -32601
|
|
17
|
+
ERROR_INVALID_PARAMS = -32602
|
|
18
|
+
ERROR_INTERNAL = -32603
|
|
19
|
+
ERROR_SERVER = -32000 # implementation-defined server error
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class JsonRpcServer:
|
|
23
|
+
"""Minimal JSON-RPC 2.0 handler.
|
|
24
|
+
|
|
25
|
+
Register methods via register(); call handle_line() to process one
|
|
26
|
+
request line and get the response line (or None for notifications).
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self) -> None:
|
|
30
|
+
self._handlers: dict[str, Callable] = {}
|
|
31
|
+
|
|
32
|
+
def register(self, method: str, handler: Callable) -> None:
|
|
33
|
+
"""Register an async handler(params: dict) -> Any."""
|
|
34
|
+
self._handlers[method] = handler
|
|
35
|
+
|
|
36
|
+
async def handle_line(self, line: str) -> str | None:
|
|
37
|
+
"""Process one JSON-RPC request line. Returns response line or None."""
|
|
38
|
+
try:
|
|
39
|
+
req = json.loads(line)
|
|
40
|
+
except json.JSONDecodeError as e:
|
|
41
|
+
return self._error_response(None, ERROR_PARSE, f"Parse error: {e}")
|
|
42
|
+
|
|
43
|
+
req_id = req.get("id")
|
|
44
|
+
method = req.get("method")
|
|
45
|
+
params = req.get("params", {})
|
|
46
|
+
|
|
47
|
+
if method is None:
|
|
48
|
+
return self._error_response(req_id, ERROR_INVALID_REQUEST,
|
|
49
|
+
"Invalid Request: method missing")
|
|
50
|
+
|
|
51
|
+
handler = self._handlers.get(method)
|
|
52
|
+
if handler is None:
|
|
53
|
+
if req_id is None:
|
|
54
|
+
return None # notification to unknown method, silent
|
|
55
|
+
return self._error_response(req_id, ERROR_METHOD_NOT_FOUND,
|
|
56
|
+
f"Method not found: {method}")
|
|
57
|
+
|
|
58
|
+
try:
|
|
59
|
+
result = await handler(params)
|
|
60
|
+
except Exception as e: # noqa: BLE001
|
|
61
|
+
if req_id is None:
|
|
62
|
+
return None # notification errors are silent
|
|
63
|
+
return self._error_response(req_id, ERROR_SERVER, str(e))
|
|
64
|
+
|
|
65
|
+
if req_id is None:
|
|
66
|
+
return None # notification: no response
|
|
67
|
+
|
|
68
|
+
return json.dumps({"jsonrpc": "2.0", "id": req_id, "result": result})
|
|
69
|
+
|
|
70
|
+
def _error_response(self, req_id, code: int, message: str,
|
|
71
|
+
data: dict | None = None) -> str:
|
|
72
|
+
err = {"code": code, "message": message}
|
|
73
|
+
if data is not None:
|
|
74
|
+
err["data"] = data
|
|
75
|
+
return json.dumps({"jsonrpc": "2.0", "id": req_id, "error": err})
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def run_stdio(server: JsonRpcServer) -> None:
|
|
79
|
+
"""Read JSONL from stdin, write JSONL responses to stdout."""
|
|
80
|
+
loop = asyncio.get_event_loop()
|
|
81
|
+
reader = asyncio.StreamReader()
|
|
82
|
+
protocol = asyncio.StreamReaderProtocol(reader)
|
|
83
|
+
await loop.connect_read_pipe(lambda: protocol, sys.stdin)
|
|
84
|
+
|
|
85
|
+
while True:
|
|
86
|
+
line_bytes = await reader.readline()
|
|
87
|
+
if not line_bytes:
|
|
88
|
+
return # EOF
|
|
89
|
+
line = line_bytes.decode("utf-8").rstrip("\n")
|
|
90
|
+
if not line:
|
|
91
|
+
continue
|
|
92
|
+
resp = await server.handle_line(line)
|
|
93
|
+
if resp is not None:
|
|
94
|
+
sys.stdout.write(resp + "\n")
|
|
95
|
+
sys.stdout.flush()
|
|
File without changes
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""exec.* service — process execution with streaming stdout/stderr."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import shutil
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from vystak_workspace_rpc.progress import ProgressEmitter
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def register_exec(server, workspace_root: Path,
|
|
11
|
+
progress_emitter: ProgressEmitter) -> None:
|
|
12
|
+
"""Register exec.* handlers. progress_emitter forwards chunks to the
|
|
13
|
+
JSON-RPC client as $/progress notifications."""
|
|
14
|
+
root = Path(workspace_root).resolve()
|
|
15
|
+
|
|
16
|
+
async def _stream_subprocess(argv: list[str], cwd: Path,
|
|
17
|
+
env: dict | None, timeout_s: float | None) -> dict:
|
|
18
|
+
import time
|
|
19
|
+
|
|
20
|
+
start = time.time()
|
|
21
|
+
proc = await asyncio.create_subprocess_exec(
|
|
22
|
+
*argv,
|
|
23
|
+
cwd=str(cwd),
|
|
24
|
+
env=env,
|
|
25
|
+
stdout=asyncio.subprocess.PIPE,
|
|
26
|
+
stderr=asyncio.subprocess.PIPE,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
async def drain(reader, channel: str):
|
|
30
|
+
while True:
|
|
31
|
+
chunk = await reader.read(4096)
|
|
32
|
+
if not chunk:
|
|
33
|
+
return
|
|
34
|
+
await progress_emitter(channel, {"chunk": chunk.decode("utf-8",
|
|
35
|
+
errors="replace")})
|
|
36
|
+
|
|
37
|
+
drain_stdout = asyncio.create_task(drain(proc.stdout, "stdout"))
|
|
38
|
+
drain_stderr = asyncio.create_task(drain(proc.stderr, "stderr"))
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
if timeout_s is not None:
|
|
42
|
+
await asyncio.wait_for(proc.wait(), timeout=timeout_s)
|
|
43
|
+
else:
|
|
44
|
+
await proc.wait()
|
|
45
|
+
except TimeoutError:
|
|
46
|
+
proc.terminate()
|
|
47
|
+
try:
|
|
48
|
+
await asyncio.wait_for(proc.wait(), timeout=2.0)
|
|
49
|
+
except TimeoutError:
|
|
50
|
+
proc.kill()
|
|
51
|
+
await proc.wait()
|
|
52
|
+
drain_stdout.cancel()
|
|
53
|
+
drain_stderr.cancel()
|
|
54
|
+
raise TimeoutError(f"Process exceeded timeout {timeout_s}s") from None
|
|
55
|
+
|
|
56
|
+
await drain_stdout
|
|
57
|
+
await drain_stderr
|
|
58
|
+
duration_ms = int((time.time() - start) * 1000)
|
|
59
|
+
return {"exit_code": proc.returncode, "duration_ms": duration_ms}
|
|
60
|
+
|
|
61
|
+
async def run(params: dict) -> dict:
|
|
62
|
+
cmd = params["cmd"]
|
|
63
|
+
args = params.get("args", [])
|
|
64
|
+
argv = [cmd] + list(args) if isinstance(cmd, str) else list(cmd)
|
|
65
|
+
|
|
66
|
+
cwd_str = params.get("cwd", ".")
|
|
67
|
+
cwd = (root / cwd_str).resolve()
|
|
68
|
+
try:
|
|
69
|
+
cwd.relative_to(root)
|
|
70
|
+
except ValueError:
|
|
71
|
+
raise ValueError(f"cwd '{cwd_str}' escapes workspace root") from None
|
|
72
|
+
|
|
73
|
+
env = params.get("env") # None = inherit
|
|
74
|
+
timeout_s = params.get("timeout_s")
|
|
75
|
+
return await _stream_subprocess(argv, cwd, env, timeout_s)
|
|
76
|
+
|
|
77
|
+
async def shell(params: dict) -> dict:
|
|
78
|
+
script = params["script"]
|
|
79
|
+
cwd_str = params.get("cwd", ".")
|
|
80
|
+
cwd = (root / cwd_str).resolve()
|
|
81
|
+
try:
|
|
82
|
+
cwd.relative_to(root)
|
|
83
|
+
except ValueError:
|
|
84
|
+
raise ValueError(f"cwd '{cwd_str}' escapes workspace root") from None
|
|
85
|
+
timeout_s = params.get("timeout_s")
|
|
86
|
+
return await _stream_subprocess(["sh", "-c", script], cwd, None, timeout_s)
|
|
87
|
+
|
|
88
|
+
async def which(params: dict) -> str | None:
|
|
89
|
+
found = shutil.which(params["name"])
|
|
90
|
+
return found
|
|
91
|
+
|
|
92
|
+
server.register("exec.run", run)
|
|
93
|
+
server.register("exec.shell", shell)
|
|
94
|
+
server.register("exec.which", which)
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""fs.* service — file operations rooted at the workspace directory.
|
|
2
|
+
|
|
3
|
+
All paths are resolved relative to the workspace root. Attempts to
|
|
4
|
+
escape via `..` or absolute paths outside the root raise ValueError.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import difflib
|
|
8
|
+
import shutil
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def register_fs(server, workspace_root: Path) -> None:
|
|
13
|
+
"""Register fs.* handlers on the given JsonRpcServer."""
|
|
14
|
+
root = Path(workspace_root).resolve()
|
|
15
|
+
|
|
16
|
+
def _resolve(path: str) -> Path:
|
|
17
|
+
p = (root / path).resolve()
|
|
18
|
+
try:
|
|
19
|
+
p.relative_to(root)
|
|
20
|
+
except ValueError:
|
|
21
|
+
raise ValueError(
|
|
22
|
+
f"Path '{path}' resolves outside workspace root {root}"
|
|
23
|
+
) from None
|
|
24
|
+
return p
|
|
25
|
+
|
|
26
|
+
async def read_file(params: dict) -> str:
|
|
27
|
+
encoding = params.get("encoding", "utf-8")
|
|
28
|
+
return _resolve(params["path"]).read_text(encoding=encoding)
|
|
29
|
+
|
|
30
|
+
async def write_file(params: dict) -> None:
|
|
31
|
+
p = _resolve(params["path"])
|
|
32
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
encoding = params.get("encoding", "utf-8")
|
|
34
|
+
p.write_text(params["content"], encoding=encoding)
|
|
35
|
+
if "mode" in params:
|
|
36
|
+
p.chmod(int(params["mode"], 8) if isinstance(params["mode"], str)
|
|
37
|
+
else params["mode"])
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
async def append_file(params: dict) -> None:
|
|
41
|
+
p = _resolve(params["path"])
|
|
42
|
+
encoding = params.get("encoding", "utf-8")
|
|
43
|
+
with p.open("a", encoding=encoding) as fh:
|
|
44
|
+
fh.write(params["content"])
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
async def delete_file(params: dict) -> None:
|
|
48
|
+
p = _resolve(params["path"])
|
|
49
|
+
if p.is_dir():
|
|
50
|
+
shutil.rmtree(p)
|
|
51
|
+
else:
|
|
52
|
+
p.unlink()
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
async def list_dir(params: dict) -> list[dict]:
|
|
56
|
+
p = _resolve(params["path"])
|
|
57
|
+
entries = []
|
|
58
|
+
for entry in sorted(p.iterdir()):
|
|
59
|
+
stat = entry.stat()
|
|
60
|
+
entries.append({
|
|
61
|
+
"name": entry.name,
|
|
62
|
+
"type": "directory" if entry.is_dir() else "file",
|
|
63
|
+
"size": stat.st_size,
|
|
64
|
+
"mtime": stat.st_mtime,
|
|
65
|
+
})
|
|
66
|
+
return entries
|
|
67
|
+
|
|
68
|
+
async def stat(params: dict) -> dict:
|
|
69
|
+
p = _resolve(params["path"])
|
|
70
|
+
s = p.stat()
|
|
71
|
+
return {
|
|
72
|
+
"type": "directory" if p.is_dir() else "file",
|
|
73
|
+
"size": s.st_size,
|
|
74
|
+
"mtime": s.st_mtime,
|
|
75
|
+
"permissions": oct(s.st_mode & 0o777),
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async def exists(params: dict) -> bool:
|
|
79
|
+
try:
|
|
80
|
+
return _resolve(params["path"]).exists()
|
|
81
|
+
except ValueError:
|
|
82
|
+
return False
|
|
83
|
+
|
|
84
|
+
async def mkdir(params: dict) -> None:
|
|
85
|
+
p = _resolve(params["path"])
|
|
86
|
+
p.mkdir(parents=bool(params.get("parents", False)), exist_ok=True)
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
async def move(params: dict) -> None:
|
|
90
|
+
src = _resolve(params["src"])
|
|
91
|
+
dst = _resolve(params["dst"])
|
|
92
|
+
shutil.move(str(src), str(dst))
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
async def edit(params: dict) -> dict:
|
|
96
|
+
p = _resolve(params["path"])
|
|
97
|
+
old = params["old_str"]
|
|
98
|
+
new = params["new_str"]
|
|
99
|
+
content = p.read_text()
|
|
100
|
+
if old not in content:
|
|
101
|
+
raise ValueError(f"old_str not found in {params['path']}")
|
|
102
|
+
updated = content.replace(old, new, 1) # one replacement by default
|
|
103
|
+
p.write_text(updated)
|
|
104
|
+
diff = "\n".join(difflib.unified_diff(
|
|
105
|
+
content.splitlines(), updated.splitlines(),
|
|
106
|
+
fromfile=params["path"], tofile=params["path"], lineterm="",
|
|
107
|
+
))
|
|
108
|
+
return {"diff": diff}
|
|
109
|
+
|
|
110
|
+
server.register("fs.readFile", read_file)
|
|
111
|
+
server.register("fs.writeFile", write_file)
|
|
112
|
+
server.register("fs.appendFile", append_file)
|
|
113
|
+
server.register("fs.deleteFile", delete_file)
|
|
114
|
+
server.register("fs.listDir", list_dir)
|
|
115
|
+
server.register("fs.stat", stat)
|
|
116
|
+
server.register("fs.exists", exists)
|
|
117
|
+
server.register("fs.mkdir", mkdir)
|
|
118
|
+
server.register("fs.move", move)
|
|
119
|
+
server.register("fs.edit", edit)
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""git.* service — thin wrapper over the git CLI."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def register_git(server, workspace_root: Path) -> None:
|
|
8
|
+
root = Path(workspace_root).resolve()
|
|
9
|
+
|
|
10
|
+
async def _git(args: list[str]) -> tuple[int, str, str]:
|
|
11
|
+
proc = await asyncio.create_subprocess_exec(
|
|
12
|
+
"git", *args, cwd=str(root),
|
|
13
|
+
stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
|
14
|
+
)
|
|
15
|
+
stdout, stderr = await proc.communicate()
|
|
16
|
+
return proc.returncode, stdout.decode("utf-8"), stderr.decode("utf-8")
|
|
17
|
+
|
|
18
|
+
async def _ensure_repo() -> None:
|
|
19
|
+
code, _, _ = await _git(["rev-parse", "--is-inside-work-tree"])
|
|
20
|
+
if code != 0:
|
|
21
|
+
raise RuntimeError(f"{root} is not a git repo")
|
|
22
|
+
|
|
23
|
+
async def status(params: dict) -> dict:
|
|
24
|
+
await _ensure_repo()
|
|
25
|
+
code, branch_out, _ = await _git(["rev-parse", "--abbrev-ref", "HEAD"])
|
|
26
|
+
branch = branch_out.strip() if code == 0 else "HEAD"
|
|
27
|
+
|
|
28
|
+
code, out, _ = await _git(["status", "--porcelain"])
|
|
29
|
+
staged: list[str] = []
|
|
30
|
+
unstaged: list[str] = []
|
|
31
|
+
untracked: list[str] = []
|
|
32
|
+
for line in out.splitlines():
|
|
33
|
+
if not line:
|
|
34
|
+
continue
|
|
35
|
+
xy, _, path = line.partition(" ")
|
|
36
|
+
# porcelain format: XY path, where X = staged state, Y = unstaged
|
|
37
|
+
x = line[0]
|
|
38
|
+
y = line[1]
|
|
39
|
+
path = line[3:].strip()
|
|
40
|
+
if x == "?" and y == "?":
|
|
41
|
+
untracked.append(path)
|
|
42
|
+
else:
|
|
43
|
+
if x != " ":
|
|
44
|
+
staged.append(path)
|
|
45
|
+
if y != " ":
|
|
46
|
+
unstaged.append(path)
|
|
47
|
+
return {
|
|
48
|
+
"branch": branch,
|
|
49
|
+
"dirty": bool(staged or unstaged or untracked),
|
|
50
|
+
"staged": staged,
|
|
51
|
+
"unstaged": unstaged,
|
|
52
|
+
"untracked": untracked,
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async def log(params: dict) -> list[dict]:
|
|
56
|
+
await _ensure_repo()
|
|
57
|
+
limit = params.get("limit", 10)
|
|
58
|
+
path = params.get("path")
|
|
59
|
+
args = ["log", f"-{limit}", "--pretty=format:%H%x00%an%x00%ad%x00%s",
|
|
60
|
+
"--date=iso"]
|
|
61
|
+
if path:
|
|
62
|
+
args += ["--", path]
|
|
63
|
+
code, out, err = await _git(args)
|
|
64
|
+
if code != 0:
|
|
65
|
+
raise RuntimeError(f"git log failed: {err}")
|
|
66
|
+
result = []
|
|
67
|
+
for line in out.splitlines():
|
|
68
|
+
if not line:
|
|
69
|
+
continue
|
|
70
|
+
parts = line.split("\x00")
|
|
71
|
+
if len(parts) < 4:
|
|
72
|
+
continue
|
|
73
|
+
result.append({
|
|
74
|
+
"sha": parts[0],
|
|
75
|
+
"author": parts[1],
|
|
76
|
+
"date": parts[2],
|
|
77
|
+
"message": parts[3],
|
|
78
|
+
})
|
|
79
|
+
return result
|
|
80
|
+
|
|
81
|
+
async def diff(params: dict) -> str:
|
|
82
|
+
await _ensure_repo()
|
|
83
|
+
args = ["diff"]
|
|
84
|
+
if params.get("staged"):
|
|
85
|
+
args.append("--cached")
|
|
86
|
+
if params.get("path"):
|
|
87
|
+
args += ["--", params["path"]]
|
|
88
|
+
code, out, err = await _git(args)
|
|
89
|
+
if code != 0:
|
|
90
|
+
raise RuntimeError(f"git diff failed: {err}")
|
|
91
|
+
return out
|
|
92
|
+
|
|
93
|
+
async def add(params: dict) -> None:
|
|
94
|
+
await _ensure_repo()
|
|
95
|
+
paths = params.get("paths", [])
|
|
96
|
+
code, _, err = await _git(["add", *paths])
|
|
97
|
+
if code != 0:
|
|
98
|
+
raise RuntimeError(f"git add failed: {err}")
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
async def commit(params: dict) -> dict:
|
|
102
|
+
await _ensure_repo()
|
|
103
|
+
args = ["commit", "-m", params["message"]]
|
|
104
|
+
if params.get("author"):
|
|
105
|
+
args += ["--author", params["author"]]
|
|
106
|
+
code, _, err = await _git(args)
|
|
107
|
+
if code != 0:
|
|
108
|
+
raise RuntimeError(f"git commit failed: {err}")
|
|
109
|
+
code, sha, _ = await _git(["rev-parse", "HEAD"])
|
|
110
|
+
return {"sha": sha.strip()}
|
|
111
|
+
|
|
112
|
+
async def branch(params: dict) -> str:
|
|
113
|
+
await _ensure_repo()
|
|
114
|
+
code, out, err = await _git(["rev-parse", "--abbrev-ref", "HEAD"])
|
|
115
|
+
if code != 0:
|
|
116
|
+
raise RuntimeError(f"git branch failed: {err}")
|
|
117
|
+
return out.strip()
|
|
118
|
+
|
|
119
|
+
server.register("git.status", status)
|
|
120
|
+
server.register("git.log", log)
|
|
121
|
+
server.register("git.diff", diff)
|
|
122
|
+
server.register("git.add", add)
|
|
123
|
+
server.register("git.commit", commit)
|
|
124
|
+
server.register("git.branch", branch)
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""tool.* service — discovers and invokes user-defined tools from tools/."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import importlib.util
|
|
5
|
+
import inspect
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def register_tool(server, tools_dir: Path) -> None:
|
|
11
|
+
"""Tools are Python files in tools_dir/<name>.py containing a function
|
|
12
|
+
named <name>. Invoked synchronously in-process."""
|
|
13
|
+
tools_root = Path(tools_dir)
|
|
14
|
+
|
|
15
|
+
async def invoke(params: dict) -> object:
|
|
16
|
+
name = params["name"]
|
|
17
|
+
args = params.get("args", {})
|
|
18
|
+
tool_path = tools_root / f"{name}.py"
|
|
19
|
+
if not tool_path.exists():
|
|
20
|
+
raise FileNotFoundError(f"Tool {name}.py not found in {tools_root}")
|
|
21
|
+
|
|
22
|
+
# Load module
|
|
23
|
+
spec = importlib.util.spec_from_file_location(f"vystak_tool_{name}", tool_path)
|
|
24
|
+
module = importlib.util.module_from_spec(spec)
|
|
25
|
+
sys.modules[f"vystak_tool_{name}"] = module
|
|
26
|
+
spec.loader.exec_module(module)
|
|
27
|
+
|
|
28
|
+
fn = getattr(module, name, None)
|
|
29
|
+
if fn is None:
|
|
30
|
+
raise AttributeError(f"Tool {name}.py must define function '{name}'")
|
|
31
|
+
|
|
32
|
+
# Call sync or async, returning Python value
|
|
33
|
+
if inspect.iscoroutinefunction(fn):
|
|
34
|
+
return await fn(**args)
|
|
35
|
+
loop = asyncio.get_event_loop()
|
|
36
|
+
return await loop.run_in_executor(None, lambda: fn(**args))
|
|
37
|
+
|
|
38
|
+
async def list_tools(params: dict) -> list[dict]:
|
|
39
|
+
if not tools_root.exists():
|
|
40
|
+
return []
|
|
41
|
+
result = []
|
|
42
|
+
for entry in sorted(tools_root.iterdir()):
|
|
43
|
+
if entry.suffix != ".py":
|
|
44
|
+
continue
|
|
45
|
+
if entry.stem.startswith("_"):
|
|
46
|
+
continue
|
|
47
|
+
result.append({"name": entry.stem, "path": str(entry.relative_to(tools_root))})
|
|
48
|
+
return result
|
|
49
|
+
|
|
50
|
+
server.register("tool.invoke", invoke)
|
|
51
|
+
server.register("tool.list", list_tools)
|
|
File without changes
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Smoke test for __main__ wiring all services."""
|
|
2
|
+
|
|
3
|
+
from vystak_workspace_rpc.__main__ import build_server
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def test_build_server_registers_all_services(tmp_path):
|
|
7
|
+
tools_dir = tmp_path / "tools"
|
|
8
|
+
tools_dir.mkdir()
|
|
9
|
+
srv = build_server(workspace_root=tmp_path, tools_dir=tools_dir)
|
|
10
|
+
registered = set(srv._handlers.keys())
|
|
11
|
+
# fs.*
|
|
12
|
+
assert "fs.readFile" in registered
|
|
13
|
+
assert "fs.writeFile" in registered
|
|
14
|
+
# exec.*
|
|
15
|
+
assert "exec.run" in registered
|
|
16
|
+
assert "exec.shell" in registered
|
|
17
|
+
# git.*
|
|
18
|
+
assert "git.status" in registered
|
|
19
|
+
# tool.*
|
|
20
|
+
assert "tool.invoke" in registered
|
|
21
|
+
assert "tool.list" in registered
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Tests for the JSON-RPC 2.0 server core."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from vystak_workspace_rpc.server import JsonRpcServer
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@pytest.mark.asyncio
|
|
10
|
+
async def test_server_handles_single_request():
|
|
11
|
+
async def echo(params):
|
|
12
|
+
return {"echoed": params.get("message", "")}
|
|
13
|
+
|
|
14
|
+
srv = JsonRpcServer()
|
|
15
|
+
srv.register("test.echo", echo)
|
|
16
|
+
|
|
17
|
+
req = json.dumps({"jsonrpc": "2.0", "id": "1", "method": "test.echo",
|
|
18
|
+
"params": {"message": "hi"}})
|
|
19
|
+
response_line = await srv.handle_line(req)
|
|
20
|
+
assert response_line is not None
|
|
21
|
+
resp = json.loads(response_line)
|
|
22
|
+
assert resp["jsonrpc"] == "2.0"
|
|
23
|
+
assert resp["id"] == "1"
|
|
24
|
+
assert resp["result"] == {"echoed": "hi"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.mark.asyncio
|
|
28
|
+
async def test_server_handles_unknown_method():
|
|
29
|
+
srv = JsonRpcServer()
|
|
30
|
+
req = json.dumps({"jsonrpc": "2.0", "id": "2", "method": "nope", "params": {}})
|
|
31
|
+
line = await srv.handle_line(req)
|
|
32
|
+
resp = json.loads(line)
|
|
33
|
+
assert resp["error"]["code"] == -32601
|
|
34
|
+
assert "Method not found" in resp["error"]["message"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@pytest.mark.asyncio
|
|
38
|
+
async def test_server_handles_handler_exception():
|
|
39
|
+
async def boom(params):
|
|
40
|
+
raise ValueError("kaboom")
|
|
41
|
+
|
|
42
|
+
srv = JsonRpcServer()
|
|
43
|
+
srv.register("test.boom", boom)
|
|
44
|
+
|
|
45
|
+
req = json.dumps({"jsonrpc": "2.0", "id": "3", "method": "test.boom", "params": {}})
|
|
46
|
+
line = await srv.handle_line(req)
|
|
47
|
+
resp = json.loads(line)
|
|
48
|
+
assert resp["error"]["code"] == -32000
|
|
49
|
+
assert "kaboom" in resp["error"]["message"]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.mark.asyncio
|
|
53
|
+
async def test_server_handles_malformed_json():
|
|
54
|
+
srv = JsonRpcServer()
|
|
55
|
+
line = await srv.handle_line("not json {")
|
|
56
|
+
resp = json.loads(line)
|
|
57
|
+
assert resp["error"]["code"] == -32700
|
|
58
|
+
assert "Parse error" in resp["error"]["message"]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@pytest.mark.asyncio
|
|
62
|
+
async def test_server_notification_has_no_response():
|
|
63
|
+
"""Requests without an id are notifications — no response expected."""
|
|
64
|
+
async def noop(params):
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
srv = JsonRpcServer()
|
|
68
|
+
srv.register("test.noop", noop)
|
|
69
|
+
req = json.dumps({"jsonrpc": "2.0", "method": "test.noop", "params": {}})
|
|
70
|
+
line = await srv.handle_line(req)
|
|
71
|
+
assert line is None
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Tests for exec.* service."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from vystak_workspace_rpc.services.exec import register_exec
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _build_server(workspace_root, chunks_out):
|
|
8
|
+
from vystak_workspace_rpc.server import JsonRpcServer
|
|
9
|
+
|
|
10
|
+
srv = JsonRpcServer()
|
|
11
|
+
|
|
12
|
+
async def progress_sink(channel: str, data: dict):
|
|
13
|
+
chunks_out.append((channel, data))
|
|
14
|
+
|
|
15
|
+
register_exec(srv, workspace_root, progress_emitter=progress_sink)
|
|
16
|
+
return srv
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@pytest.mark.asyncio
|
|
20
|
+
async def test_exec_run_success(tmp_path):
|
|
21
|
+
chunks = []
|
|
22
|
+
srv = _build_server(tmp_path, chunks)
|
|
23
|
+
result = await srv._handlers["exec.run"]({
|
|
24
|
+
"cmd": ["echo", "hello"], "cwd": "."
|
|
25
|
+
})
|
|
26
|
+
assert result["exit_code"] == 0
|
|
27
|
+
assert any("hello" in c[1].get("chunk", "") for c in chunks)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.mark.asyncio
|
|
31
|
+
async def test_exec_run_nonzero_exit(tmp_path):
|
|
32
|
+
chunks = []
|
|
33
|
+
srv = _build_server(tmp_path, chunks)
|
|
34
|
+
result = await srv._handlers["exec.run"]({
|
|
35
|
+
"cmd": ["sh", "-c", "exit 3"]
|
|
36
|
+
})
|
|
37
|
+
assert result["exit_code"] == 3
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@pytest.mark.asyncio
|
|
41
|
+
async def test_exec_run_streams_stdout(tmp_path):
|
|
42
|
+
chunks = []
|
|
43
|
+
srv = _build_server(tmp_path, chunks)
|
|
44
|
+
await srv._handlers["exec.run"]({
|
|
45
|
+
"cmd": ["sh", "-c", "echo line1; echo line2"]
|
|
46
|
+
})
|
|
47
|
+
stdout_chunks = [c[1]["chunk"] for c in chunks if c[0] == "stdout"]
|
|
48
|
+
combined = "".join(stdout_chunks)
|
|
49
|
+
assert "line1" in combined and "line2" in combined
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.mark.asyncio
|
|
53
|
+
async def test_exec_shell_runs_script(tmp_path):
|
|
54
|
+
chunks = []
|
|
55
|
+
srv = _build_server(tmp_path, chunks)
|
|
56
|
+
result = await srv._handlers["exec.shell"]({
|
|
57
|
+
"script": "echo hi && false || echo recovered"
|
|
58
|
+
})
|
|
59
|
+
assert result["exit_code"] == 0
|
|
60
|
+
combined = "".join(c[1].get("chunk", "") for c in chunks)
|
|
61
|
+
assert "hi" in combined and "recovered" in combined
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@pytest.mark.asyncio
|
|
65
|
+
async def test_exec_run_timeout(tmp_path):
|
|
66
|
+
chunks = []
|
|
67
|
+
srv = _build_server(tmp_path, chunks)
|
|
68
|
+
with pytest.raises(TimeoutError):
|
|
69
|
+
await srv._handlers["exec.run"]({
|
|
70
|
+
"cmd": ["sleep", "10"], "timeout_s": 0.2
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@pytest.mark.asyncio
|
|
75
|
+
async def test_exec_which_found(tmp_path):
|
|
76
|
+
chunks = []
|
|
77
|
+
srv = _build_server(tmp_path, chunks)
|
|
78
|
+
result = await srv._handlers["exec.which"]({"name": "sh"})
|
|
79
|
+
assert result is not None
|
|
80
|
+
assert "sh" in result
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@pytest.mark.asyncio
|
|
84
|
+
async def test_exec_which_not_found(tmp_path):
|
|
85
|
+
chunks = []
|
|
86
|
+
srv = _build_server(tmp_path, chunks)
|
|
87
|
+
result = await srv._handlers["exec.which"]({"name": "definitely_not_a_real_command_12345"})
|
|
88
|
+
assert result is None
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""Tests for the fs.* service."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from vystak_workspace_rpc.services.fs import register_fs
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _build_server(workspace_root: Path):
|
|
10
|
+
from vystak_workspace_rpc.server import JsonRpcServer
|
|
11
|
+
|
|
12
|
+
srv = JsonRpcServer()
|
|
13
|
+
register_fs(srv, workspace_root)
|
|
14
|
+
return srv
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.mark.asyncio
|
|
18
|
+
async def test_fs_write_and_read(tmp_path):
|
|
19
|
+
srv = _build_server(tmp_path)
|
|
20
|
+
await srv._handlers["fs.writeFile"]({"path": "a.txt", "content": "hello"})
|
|
21
|
+
result = await srv._handlers["fs.readFile"]({"path": "a.txt"})
|
|
22
|
+
assert result == "hello"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.mark.asyncio
|
|
26
|
+
async def test_fs_exists(tmp_path):
|
|
27
|
+
srv = _build_server(tmp_path)
|
|
28
|
+
(tmp_path / "foo").write_text("x")
|
|
29
|
+
r = await srv._handlers["fs.exists"]({"path": "foo"})
|
|
30
|
+
assert r is True
|
|
31
|
+
r = await srv._handlers["fs.exists"]({"path": "nope"})
|
|
32
|
+
assert r is False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@pytest.mark.asyncio
|
|
36
|
+
async def test_fs_list_dir(tmp_path):
|
|
37
|
+
(tmp_path / "a.py").write_text("1")
|
|
38
|
+
(tmp_path / "b.md").write_text("2")
|
|
39
|
+
(tmp_path / "sub").mkdir()
|
|
40
|
+
srv = _build_server(tmp_path)
|
|
41
|
+
entries = await srv._handlers["fs.listDir"]({"path": "."})
|
|
42
|
+
names = {e["name"] for e in entries}
|
|
43
|
+
assert names == {"a.py", "b.md", "sub"}
|
|
44
|
+
sub = next(e for e in entries if e["name"] == "sub")
|
|
45
|
+
assert sub["type"] == "directory"
|
|
46
|
+
a = next(e for e in entries if e["name"] == "a.py")
|
|
47
|
+
assert a["type"] == "file"
|
|
48
|
+
assert a["size"] == 1
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@pytest.mark.asyncio
|
|
52
|
+
async def test_fs_delete_file(tmp_path):
|
|
53
|
+
(tmp_path / "gone.txt").write_text("bye")
|
|
54
|
+
srv = _build_server(tmp_path)
|
|
55
|
+
await srv._handlers["fs.deleteFile"]({"path": "gone.txt"})
|
|
56
|
+
assert not (tmp_path / "gone.txt").exists()
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@pytest.mark.asyncio
|
|
60
|
+
async def test_fs_mkdir_with_parents(tmp_path):
|
|
61
|
+
srv = _build_server(tmp_path)
|
|
62
|
+
await srv._handlers["fs.mkdir"]({"path": "a/b/c", "parents": True})
|
|
63
|
+
assert (tmp_path / "a" / "b" / "c").is_dir()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@pytest.mark.asyncio
|
|
67
|
+
async def test_fs_move(tmp_path):
|
|
68
|
+
(tmp_path / "src.txt").write_text("x")
|
|
69
|
+
srv = _build_server(tmp_path)
|
|
70
|
+
await srv._handlers["fs.move"]({"src": "src.txt", "dst": "dst.txt"})
|
|
71
|
+
assert not (tmp_path / "src.txt").exists()
|
|
72
|
+
assert (tmp_path / "dst.txt").read_text() == "x"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@pytest.mark.asyncio
|
|
76
|
+
async def test_fs_edit_replaces(tmp_path):
|
|
77
|
+
(tmp_path / "f.py").write_text("hello world")
|
|
78
|
+
srv = _build_server(tmp_path)
|
|
79
|
+
result = await srv._handlers["fs.edit"]({
|
|
80
|
+
"path": "f.py", "old_str": "world", "new_str": "vystak"
|
|
81
|
+
})
|
|
82
|
+
assert (tmp_path / "f.py").read_text() == "hello vystak"
|
|
83
|
+
assert "diff" in result
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@pytest.mark.asyncio
|
|
87
|
+
async def test_fs_edit_old_str_not_found_raises(tmp_path):
|
|
88
|
+
(tmp_path / "f.py").write_text("hello world")
|
|
89
|
+
srv = _build_server(tmp_path)
|
|
90
|
+
with pytest.raises(ValueError, match="old_str not found"):
|
|
91
|
+
await srv._handlers["fs.edit"]({
|
|
92
|
+
"path": "f.py", "old_str": "missing", "new_str": "x"
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@pytest.mark.asyncio
|
|
97
|
+
async def test_fs_readFile_escape_attempt_raises(tmp_path):
|
|
98
|
+
"""Paths outside workspace root are rejected."""
|
|
99
|
+
srv = _build_server(tmp_path)
|
|
100
|
+
with pytest.raises(ValueError, match="outside workspace root"):
|
|
101
|
+
await srv._handlers["fs.readFile"]({"path": "../../../etc/passwd"})
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Tests for git.* service. Uses a real temp git repo."""
|
|
2
|
+
|
|
3
|
+
import subprocess
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from vystak_workspace_rpc.services.git import register_git
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _init_repo(path):
|
|
10
|
+
subprocess.run(["git", "init", "-q"], cwd=path, check=True)
|
|
11
|
+
subprocess.run(["git", "config", "user.email", "test@example.com"], cwd=path, check=True)
|
|
12
|
+
subprocess.run(["git", "config", "user.name", "test"], cwd=path, check=True)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _build_server(workspace_root):
|
|
16
|
+
from vystak_workspace_rpc.server import JsonRpcServer
|
|
17
|
+
|
|
18
|
+
srv = JsonRpcServer()
|
|
19
|
+
register_git(srv, workspace_root)
|
|
20
|
+
return srv
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.mark.asyncio
|
|
24
|
+
async def test_git_status_clean(tmp_path):
|
|
25
|
+
_init_repo(tmp_path)
|
|
26
|
+
srv = _build_server(tmp_path)
|
|
27
|
+
result = await srv._handlers["git.status"]({})
|
|
28
|
+
assert "branch" in result
|
|
29
|
+
assert result["dirty"] is False
|
|
30
|
+
assert result["staged"] == []
|
|
31
|
+
assert result["unstaged"] == []
|
|
32
|
+
assert result["untracked"] == []
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@pytest.mark.asyncio
|
|
36
|
+
async def test_git_status_with_untracked_file(tmp_path):
|
|
37
|
+
_init_repo(tmp_path)
|
|
38
|
+
(tmp_path / "new.txt").write_text("hi")
|
|
39
|
+
srv = _build_server(tmp_path)
|
|
40
|
+
result = await srv._handlers["git.status"]({})
|
|
41
|
+
assert result["dirty"] is True
|
|
42
|
+
assert "new.txt" in result["untracked"]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@pytest.mark.asyncio
|
|
46
|
+
async def test_git_add_and_commit(tmp_path):
|
|
47
|
+
_init_repo(tmp_path)
|
|
48
|
+
(tmp_path / "f.txt").write_text("hello")
|
|
49
|
+
srv = _build_server(tmp_path)
|
|
50
|
+
await srv._handlers["git.add"]({"paths": ["f.txt"]})
|
|
51
|
+
status = await srv._handlers["git.status"]({})
|
|
52
|
+
assert "f.txt" in status["staged"]
|
|
53
|
+
commit = await srv._handlers["git.commit"]({"message": "add f"})
|
|
54
|
+
assert "sha" in commit
|
|
55
|
+
assert len(commit["sha"]) >= 7
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
@pytest.mark.asyncio
|
|
59
|
+
async def test_git_log(tmp_path):
|
|
60
|
+
_init_repo(tmp_path)
|
|
61
|
+
(tmp_path / "a.txt").write_text("x")
|
|
62
|
+
subprocess.run(["git", "add", "."], cwd=tmp_path, check=True)
|
|
63
|
+
subprocess.run(["git", "commit", "-qm", "first"], cwd=tmp_path, check=True)
|
|
64
|
+
srv = _build_server(tmp_path)
|
|
65
|
+
result = await srv._handlers["git.log"]({"limit": 10})
|
|
66
|
+
assert isinstance(result, list)
|
|
67
|
+
assert len(result) == 1
|
|
68
|
+
assert result[0]["message"] == "first"
|
|
69
|
+
assert "sha" in result[0]
|
|
70
|
+
assert "author" in result[0]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@pytest.mark.asyncio
|
|
74
|
+
async def test_git_branch(tmp_path):
|
|
75
|
+
_init_repo(tmp_path)
|
|
76
|
+
(tmp_path / "a").write_text("x")
|
|
77
|
+
subprocess.run(["git", "add", "."], cwd=tmp_path, check=True)
|
|
78
|
+
subprocess.run(["git", "commit", "-qm", "init"], cwd=tmp_path, check=True)
|
|
79
|
+
srv = _build_server(tmp_path)
|
|
80
|
+
branch = await srv._handlers["git.branch"]({})
|
|
81
|
+
# Default branch is either "main" or "master" depending on git version config
|
|
82
|
+
assert branch in ("main", "master")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@pytest.mark.asyncio
|
|
86
|
+
async def test_git_not_a_repo_returns_error(tmp_path):
|
|
87
|
+
# No git init
|
|
88
|
+
srv = _build_server(tmp_path)
|
|
89
|
+
with pytest.raises(RuntimeError, match="not a git repo"):
|
|
90
|
+
await srv._handlers["git.status"]({})
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Tests for tool.* service — user-defined tools discovery + invocation."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from vystak_workspace_rpc.services.tool import register_tool
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _build_server(tools_dir):
|
|
8
|
+
from vystak_workspace_rpc.server import JsonRpcServer
|
|
9
|
+
|
|
10
|
+
srv = JsonRpcServer()
|
|
11
|
+
register_tool(srv, tools_dir)
|
|
12
|
+
return srv
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@pytest.mark.asyncio
|
|
16
|
+
async def test_tool_invoke_simple(tmp_path):
|
|
17
|
+
tools = tmp_path / "tools"
|
|
18
|
+
tools.mkdir()
|
|
19
|
+
(tools / "greet.py").write_text(
|
|
20
|
+
"def greet(name: str) -> str:\n return f'hello {name}'\n"
|
|
21
|
+
)
|
|
22
|
+
srv = _build_server(tools)
|
|
23
|
+
result = await srv._handlers["tool.invoke"]({
|
|
24
|
+
"name": "greet", "args": {"name": "world"}
|
|
25
|
+
})
|
|
26
|
+
assert result == "hello world"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.mark.asyncio
|
|
30
|
+
async def test_tool_invoke_returns_dict(tmp_path):
|
|
31
|
+
tools = tmp_path / "tools"
|
|
32
|
+
tools.mkdir()
|
|
33
|
+
(tools / "calc.py").write_text(
|
|
34
|
+
"def calc(x: int, y: int) -> dict:\n return {'sum': x+y, 'prod': x*y}\n"
|
|
35
|
+
)
|
|
36
|
+
srv = _build_server(tools)
|
|
37
|
+
result = await srv._handlers["tool.invoke"]({
|
|
38
|
+
"name": "calc", "args": {"x": 3, "y": 4}
|
|
39
|
+
})
|
|
40
|
+
assert result == {"sum": 7, "prod": 12}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.mark.asyncio
|
|
44
|
+
async def test_tool_invoke_unknown_raises(tmp_path):
|
|
45
|
+
tools = tmp_path / "tools"
|
|
46
|
+
tools.mkdir()
|
|
47
|
+
srv = _build_server(tools)
|
|
48
|
+
with pytest.raises(FileNotFoundError, match="nope.py"):
|
|
49
|
+
await srv._handlers["tool.invoke"]({"name": "nope", "args": {}})
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.mark.asyncio
|
|
53
|
+
async def test_tool_list(tmp_path):
|
|
54
|
+
tools = tmp_path / "tools"
|
|
55
|
+
tools.mkdir()
|
|
56
|
+
(tools / "a.py").write_text("def a(): return 1\n")
|
|
57
|
+
(tools / "b.py").write_text("def b(): return 2\n")
|
|
58
|
+
(tools / "__init__.py").write_text("") # should not appear in list
|
|
59
|
+
srv = _build_server(tools)
|
|
60
|
+
result = await srv._handlers["tool.list"]({})
|
|
61
|
+
names = {t["name"] for t in result}
|
|
62
|
+
assert names == {"a", "b"}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@pytest.mark.asyncio
|
|
66
|
+
async def test_tool_invoke_tool_raising_propagates(tmp_path):
|
|
67
|
+
tools = tmp_path / "tools"
|
|
68
|
+
tools.mkdir()
|
|
69
|
+
(tools / "oops.py").write_text(
|
|
70
|
+
"def oops():\n raise ValueError('user-error')\n"
|
|
71
|
+
)
|
|
72
|
+
srv = _build_server(tools)
|
|
73
|
+
with pytest.raises(ValueError, match="user-error"):
|
|
74
|
+
await srv._handlers["tool.invoke"]({"name": "oops", "args": {}})
|