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.
@@ -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,3 @@
1
+ """Vystak workspace RPC subsystem — JSON-RPC 2.0 over stdio."""
2
+
3
+ __version__ = "0.1.0"
@@ -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()
@@ -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": {}})