remote-exec-grpc 0.2.1__py3-none-any.whl
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.
- remote_exec/__init__.py +10 -0
- remote_exec/claude_skill.md +118 -0
- remote_exec/client.py +242 -0
- remote_exec/client_cli.py +226 -0
- remote_exec/cmdlog.py +159 -0
- remote_exec/config.py +202 -0
- remote_exec/executor.py +287 -0
- remote_exec/policy.py +420 -0
- remote_exec/security.py +178 -0
- remote_exec/server.py +292 -0
- remote_exec/server_cli.py +219 -0
- remote_exec/skill.py +39 -0
- remote_exec/v1/__init__.py +1 -0
- remote_exec/v1/remote_exec_pb2.py +57 -0
- remote_exec/v1/remote_exec_pb2.pyi +108 -0
- remote_exec/v1/remote_exec_pb2_grpc.py +142 -0
- remote_exec_grpc-0.2.1.dist-info/METADATA +539 -0
- remote_exec_grpc-0.2.1.dist-info/RECORD +20 -0
- remote_exec_grpc-0.2.1.dist-info/WHEEL +4 -0
- remote_exec_grpc-0.2.1.dist-info/entry_points.txt +3 -0
remote_exec/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Remote command execution over gRPC."""
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
4
|
+
|
|
5
|
+
# Single source of truth: the version in pyproject.toml (via installed metadata),
|
|
6
|
+
# shared by remote-exec-server, remote-exec-client and the GetServerInfo RPC.
|
|
7
|
+
try:
|
|
8
|
+
__version__ = version("remote-exec-grpc")
|
|
9
|
+
except PackageNotFoundError: # running from a source tree without installation
|
|
10
|
+
__version__ = "0.0.0+unknown"
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Run commands on remote Linux servers that have specialised tools or hardware (FPGA tools, GPUs, licensed software, datasets) using remote-exec-client. Use when a task needs a tool or resource that is not available locally but exists on a configured remote server.
|
|
3
|
+
argument-hint: "[task or SERVER COMMAND]"
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Remote execution with `remote-exec-client`
|
|
7
|
+
|
|
8
|
+
`remote-exec-client` runs a shell command on a remote server and streams the
|
|
9
|
+
remote **stdout and stderr live**, like `ssh SERVER COMMAND`. Its exit status is
|
|
10
|
+
the remote command's exit status, so judge success exactly as for a local
|
|
11
|
+
command. You only ever need `remote-exec-client`; the servers are already set
|
|
12
|
+
up and running.
|
|
13
|
+
|
|
14
|
+
If this was invoked with arguments, the request is: $ARGUMENTS
|
|
15
|
+
|
|
16
|
+
## 1. Find out which servers exist
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
remote-exec-client --list # NAME HOST:PORT description (tools/resources)
|
|
20
|
+
remote-exec-client SERVER --ping # reachable? remote host/user, allowed and denied programs
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Each server may restrict which programs commands can run (its `tools.yaml`).
|
|
24
|
+
`--ping` shows the allowed and denied program lists; by default everything
|
|
25
|
+
except `ssh` is allowed. Plan commands that only use allowed programs.
|
|
26
|
+
|
|
27
|
+
Pick the server whose description lists the tool or resource you need. If
|
|
28
|
+
none fits or the description is unclear, check with a harmless probe such as
|
|
29
|
+
`remote-exec-client SERVER "command -v vivado nvcc python3; nvidia-smi -L"`,
|
|
30
|
+
or ask the user. Do not guess server names that `--list` does not show.
|
|
31
|
+
|
|
32
|
+
## 2. When to run remotely
|
|
33
|
+
|
|
34
|
+
- The task needs a tool that is not installed locally (e.g. `vivado`, `vsim`,
|
|
35
|
+
`nvcc`, `nvidia-smi`) and a server provides it.
|
|
36
|
+
- The task needs remote hardware (GPU, FPGA board), licenses, or data that live
|
|
37
|
+
on a server.
|
|
38
|
+
- Do **not** use it for things that work locally (editing files, local `git`,
|
|
39
|
+
reading code). A server sees only its own filesystem: project files must
|
|
40
|
+
already exist there (git checkout, NFS, shared directory). If local changes
|
|
41
|
+
are needed remotely, sync them first (e.g. commit/push, then `git pull`
|
|
42
|
+
remotely) and tell the user you did so.
|
|
43
|
+
|
|
44
|
+
## 3. Usage
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
remote-exec-client SERVER [options] "COMMAND"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
- Pass the command as **one quoted argument**. It runs as `bash -c "COMMAND"`
|
|
51
|
+
on the server, so pipes, `&&`, globs and `$VARS` are evaluated remotely. Use
|
|
52
|
+
single quotes locally when `$VAR` must not expand on your machine.
|
|
53
|
+
- `--cwd DIR` sets the remote working directory (absolute, or `'~/path'`).
|
|
54
|
+
Prefer it over `cd DIR && ...`.
|
|
55
|
+
- `-e KEY=VALUE` sets a remote environment variable (repeatable); `-e KEY`
|
|
56
|
+
copies the local value.
|
|
57
|
+
- `--timeout SECONDS` kills the remote command **and all its child processes**
|
|
58
|
+
when exceeded. Always set one, sized to the job (e.g. `120` for a quick test,
|
|
59
|
+
`7200` for a full FPGA implementation run).
|
|
60
|
+
- `--verbose` prints a summary line (host, status, exit code, duration) to stderr.
|
|
61
|
+
- stdin is not forwarded: commands must be non-interactive.
|
|
62
|
+
- Programs that buffer output when not on a TTY (e.g. Python) stream better
|
|
63
|
+
with `-e PYTHONUNBUFFERED=1` or `python -u`.
|
|
64
|
+
|
|
65
|
+
Examples:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
remote-exec-client gpu01 --timeout 30 "nvidia-smi"
|
|
69
|
+
remote-exec-client fpga01 --cwd /home/user/project --timeout 7200 \
|
|
70
|
+
"vivado -mode batch -source build.tcl"
|
|
71
|
+
remote-exec-client fpga01 --cwd /home/user/project \
|
|
72
|
+
-e FPGA_PART=xcvu37p -e BUILD_MODE=release --timeout 3600 "./build.sh"
|
|
73
|
+
remote-exec-client gpu01 --cwd '~/ml' -e PYTHONUNBUFFERED=1 --timeout 600 \
|
|
74
|
+
"python train.py --epochs 1"
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## 4. Interpreting the result
|
|
78
|
+
|
|
79
|
+
| exit status | meaning |
|
|
80
|
+
|-------------|---------|
|
|
81
|
+
| `0` | remote command succeeded |
|
|
82
|
+
| other `1..123`, `125..127` | remote command failed with that status; `127` = command not found on that server (wrong server or PATH) |
|
|
83
|
+
| `124` | `--timeout` exceeded; the remote process tree was killed. Output so far was printed. Increase the timeout only if the job was making progress |
|
|
84
|
+
| `126` | the server's tools policy blocked a program in the command; nothing was run. The message names the program |
|
|
85
|
+
| `128+N` | remote command killed by signal N (`137` = SIGKILL, often out of memory) |
|
|
86
|
+
| `130` / `143` | the client itself was interrupted; the remote command was cancelled |
|
|
87
|
+
| `255` | remote-exec problem, not the command: unreachable server, authentication failure, bad config, invalid `--cwd`. Read the message on stderr |
|
|
88
|
+
|
|
89
|
+
Lines starting with `remote-exec-client:` on stderr come from the client;
|
|
90
|
+
all other output comes from the remote command.
|
|
91
|
+
|
|
92
|
+
## 5. Long-running jobs and large output
|
|
93
|
+
|
|
94
|
+
- Keep output small: filter **remotely** inside the quoted command, e.g.
|
|
95
|
+
`"make 2>&1 | tail -n 100"`, or write the full log to a remote file and
|
|
96
|
+
inspect it with follow-up commands:
|
|
97
|
+
`"vivado -mode batch -source build.tcl > build.log 2>&1; echo exit=$?; tail -n 100 build.log"`.
|
|
98
|
+
- If your own tool call has a time limit shorter than the job, set the remote
|
|
99
|
+
`--timeout` below it so the remote side is cleaned up deterministically.
|
|
100
|
+
For jobs longer than that, start them with output redirected to a log file
|
|
101
|
+
and a generous `--timeout`, run the call in the background, and check
|
|
102
|
+
progress with separate commands (`tail -n 50 build.log`).
|
|
103
|
+
- Interrupting or killing the client terminates the remote command.
|
|
104
|
+
|
|
105
|
+
## 6. Safety
|
|
106
|
+
|
|
107
|
+
- Commands run with the remote service user's permissions on shared machines.
|
|
108
|
+
Do not run destructive commands (`rm -rf`, `git reset --hard`, `git clean`,
|
|
109
|
+
killing other users' processes, editing system files, rebooting, flashing
|
|
110
|
+
hardware) unless the user explicitly asked for exactly that. When in doubt,
|
|
111
|
+
ask first.
|
|
112
|
+
- Inspect before changing anything (`ls`, `cat`, `git status`).
|
|
113
|
+
- If a command is blocked by the server's tools policy (exit `126`), do not
|
|
114
|
+
try to work around it (wrappers, scripts, `python -c`, variables). The
|
|
115
|
+
restriction is deliberate: use an allowed alternative or ask the user. Do not
|
|
116
|
+
`ssh` from a remote server to other machines.
|
|
117
|
+
- Never put secrets (tokens, license keys, passwords) in commands or `-e`
|
|
118
|
+
values: command lines are recorded in the server's audit log.
|
remote_exec/client.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Async gRPC client library (no CLI concerns).
|
|
2
|
+
|
|
3
|
+
Typical use::
|
|
4
|
+
|
|
5
|
+
async with RemoteExecClient(endpoint) as client:
|
|
6
|
+
result = await client.run("make test", on_stdout=..., on_stderr=...)
|
|
7
|
+
print(result.exit_code)
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import os
|
|
14
|
+
from collections.abc import AsyncIterator, Callable, Mapping
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from datetime import datetime
|
|
17
|
+
|
|
18
|
+
import grpc
|
|
19
|
+
|
|
20
|
+
from .config import TOKEN_ENV_VAR, ServerEndpoint
|
|
21
|
+
from .security import auth_metadata, channel_credentials, read_secret_file
|
|
22
|
+
from .v1 import remote_exec_pb2 as pb
|
|
23
|
+
from .v1 import remote_exec_pb2_grpc as pb_grpc
|
|
24
|
+
|
|
25
|
+
DEFAULT_CONNECT_TIMEOUT = 10.0
|
|
26
|
+
|
|
27
|
+
# Shell/ssh-like exit codes for outcomes that are not a remote exit status.
|
|
28
|
+
EXIT_TIMEOUT = 124 # same as coreutils `timeout`
|
|
29
|
+
EXIT_REJECTED = 126 # same as the shell's "cannot execute": blocked by the server's tools policy
|
|
30
|
+
EXIT_INTERRUPTED = 130 # 128 + SIGINT
|
|
31
|
+
EXIT_CLIENT_ERROR = 255 # same as ssh: connection/auth/protocol failure
|
|
32
|
+
|
|
33
|
+
CLIENT_OPTIONS = [
|
|
34
|
+
("grpc.keepalive_time_ms", 30_000),
|
|
35
|
+
("grpc.keepalive_timeout_ms", 20_000),
|
|
36
|
+
("grpc.keepalive_permit_without_calls", 1),
|
|
37
|
+
("grpc.http2.max_pings_without_data", 0),
|
|
38
|
+
("grpc.max_receive_message_length", 16 * 1024 * 1024),
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class RemoteExecError(Exception):
|
|
43
|
+
"""Failure of the remote execution machinery (not of the remote command)."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class ServerUnavailable(RemoteExecError):
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class CommandRejected(RemoteExecError):
|
|
51
|
+
"""The server refused to run the command (tools policy)."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class ExecutionResult:
|
|
56
|
+
status: str # "exited" | "timeout" | "error"
|
|
57
|
+
exit_code: int
|
|
58
|
+
signal: int
|
|
59
|
+
message: str
|
|
60
|
+
execution_id: str
|
|
61
|
+
hostname: str
|
|
62
|
+
start_time: datetime | None
|
|
63
|
+
end_time: datetime | None
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def ok(self) -> bool:
|
|
67
|
+
return self.status == "exited" and self.exit_code == 0
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def duration(self) -> float | None:
|
|
71
|
+
if self.start_time and self.end_time:
|
|
72
|
+
return (self.end_time - self.start_time).total_seconds()
|
|
73
|
+
return None
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def local_exit_code(self) -> int:
|
|
77
|
+
"""Exit code a CLI wrapper should return for this result."""
|
|
78
|
+
if self.status == "exited":
|
|
79
|
+
return self.exit_code
|
|
80
|
+
if self.status == "timeout":
|
|
81
|
+
return EXIT_TIMEOUT
|
|
82
|
+
return EXIT_CLIENT_ERROR
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
_STATUS_NAMES = {
|
|
86
|
+
pb.Finished.EXITED: "exited",
|
|
87
|
+
pb.Finished.TIMEOUT: "timeout",
|
|
88
|
+
pb.Finished.ERROR: "error",
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def resolve_token(endpoint: ServerEndpoint) -> str | None:
|
|
93
|
+
if endpoint.token:
|
|
94
|
+
return endpoint.token
|
|
95
|
+
if endpoint.token_file:
|
|
96
|
+
return read_secret_file(endpoint.token_file)
|
|
97
|
+
return os.environ.get(TOKEN_ENV_VAR) or None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _describe_rpc_error(endpoint: ServerEndpoint, err: grpc.aio.AioRpcError) -> str:
|
|
101
|
+
code = err.code()
|
|
102
|
+
details = err.details() or ""
|
|
103
|
+
where = f"{endpoint.name} ({endpoint.address})" if endpoint.name != endpoint.address else endpoint.address
|
|
104
|
+
if code == grpc.StatusCode.UNAVAILABLE:
|
|
105
|
+
return f"cannot reach server {where}: {details}"
|
|
106
|
+
if code == grpc.StatusCode.UNAUTHENTICATED:
|
|
107
|
+
return f"authentication failed for {where}: {details}"
|
|
108
|
+
if code == grpc.StatusCode.PERMISSION_DENIED:
|
|
109
|
+
return f"{where} refused to run the command: {details}"
|
|
110
|
+
if code == grpc.StatusCode.INVALID_ARGUMENT:
|
|
111
|
+
return f"invalid request: {details}"
|
|
112
|
+
if code == grpc.StatusCode.RESOURCE_EXHAUSTED:
|
|
113
|
+
return f"server {where} is busy: {details}"
|
|
114
|
+
if code == grpc.StatusCode.CANCELLED:
|
|
115
|
+
return f"call to {where} was cancelled: {details}"
|
|
116
|
+
return f"{where}: {code.name}: {details}"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class RemoteExecClient:
|
|
120
|
+
def __init__(self, endpoint: ServerEndpoint, connect_timeout: float | None = None):
|
|
121
|
+
self.endpoint = endpoint
|
|
122
|
+
self.connect_timeout = connect_timeout or endpoint.connect_timeout or DEFAULT_CONNECT_TIMEOUT
|
|
123
|
+
self._metadata = auth_metadata(resolve_token(endpoint))
|
|
124
|
+
self._channel: grpc.aio.Channel | None = None
|
|
125
|
+
self._stub: pb_grpc.RemoteExecutorStub | None = None
|
|
126
|
+
|
|
127
|
+
def _make_channel(self) -> grpc.aio.Channel:
|
|
128
|
+
ep = self.endpoint
|
|
129
|
+
options = list(CLIENT_OPTIONS)
|
|
130
|
+
if ep.uses_tls:
|
|
131
|
+
if ep.tls_server_name:
|
|
132
|
+
options.append(("grpc.ssl_target_name_override", ep.tls_server_name))
|
|
133
|
+
creds = channel_credentials(ep.ca_cert, ep.client_cert, ep.client_key)
|
|
134
|
+
return grpc.aio.secure_channel(ep.address, creds, options=options)
|
|
135
|
+
return grpc.aio.insecure_channel(ep.address, options=options)
|
|
136
|
+
|
|
137
|
+
async def __aenter__(self) -> RemoteExecClient:
|
|
138
|
+
self._channel = self._make_channel()
|
|
139
|
+
self._stub = pb_grpc.RemoteExecutorStub(self._channel)
|
|
140
|
+
return self
|
|
141
|
+
|
|
142
|
+
async def __aexit__(self, *exc) -> None:
|
|
143
|
+
await self.close()
|
|
144
|
+
|
|
145
|
+
async def close(self) -> None:
|
|
146
|
+
if self._channel is not None:
|
|
147
|
+
await self._channel.close()
|
|
148
|
+
self._channel = None
|
|
149
|
+
|
|
150
|
+
async def server_info(self) -> pb.ServerInfo:
|
|
151
|
+
assert self._stub, "use 'async with RemoteExecClient(...)'"
|
|
152
|
+
try:
|
|
153
|
+
return await self._stub.GetServerInfo(
|
|
154
|
+
pb.GetServerInfoRequest(), metadata=self._metadata, timeout=self.connect_timeout
|
|
155
|
+
)
|
|
156
|
+
except grpc.aio.AioRpcError as err:
|
|
157
|
+
raise self._error(err) from None
|
|
158
|
+
|
|
159
|
+
def _error(self, err: grpc.aio.AioRpcError) -> RemoteExecError:
|
|
160
|
+
msg = _describe_rpc_error(self.endpoint, err)
|
|
161
|
+
if err.code() in (grpc.StatusCode.UNAVAILABLE, grpc.StatusCode.DEADLINE_EXCEEDED):
|
|
162
|
+
return ServerUnavailable(msg)
|
|
163
|
+
if err.code() == grpc.StatusCode.PERMISSION_DENIED:
|
|
164
|
+
return CommandRejected(msg)
|
|
165
|
+
return RemoteExecError(msg)
|
|
166
|
+
|
|
167
|
+
async def execute(
|
|
168
|
+
self,
|
|
169
|
+
command: str,
|
|
170
|
+
cwd: str | None = None,
|
|
171
|
+
env: Mapping[str, str] | None = None,
|
|
172
|
+
timeout: float | None = None,
|
|
173
|
+
) -> AsyncIterator[pb.ExecuteEvent]:
|
|
174
|
+
"""Yield raw ExecuteEvent messages as they arrive.
|
|
175
|
+
|
|
176
|
+
Closing the iterator early (or cancelling the task) cancels the RPC,
|
|
177
|
+
which makes the server terminate the remote process group.
|
|
178
|
+
"""
|
|
179
|
+
assert self._stub, "use 'async with RemoteExecClient(...)'"
|
|
180
|
+
request = pb.ExecuteRequest(
|
|
181
|
+
command=command, working_directory=cwd or "", environment=dict(env or {}),
|
|
182
|
+
timeout_seconds=float(timeout or 0),
|
|
183
|
+
)
|
|
184
|
+
call = self._stub.Execute(request, metadata=self._metadata, wait_for_ready=False)
|
|
185
|
+
try:
|
|
186
|
+
# The server answers with Started right after spawning, so the first
|
|
187
|
+
# message doubles as a connection check with a bounded wait.
|
|
188
|
+
try:
|
|
189
|
+
first = await asyncio.wait_for(call.read(), self.connect_timeout)
|
|
190
|
+
except asyncio.TimeoutError:
|
|
191
|
+
raise ServerUnavailable(
|
|
192
|
+
f"no response from {self.endpoint.address} within {self.connect_timeout:g}s"
|
|
193
|
+
) from None
|
|
194
|
+
if first is grpc.aio.EOF:
|
|
195
|
+
raise RemoteExecError("server closed the stream without sending any event")
|
|
196
|
+
yield first
|
|
197
|
+
while (event := await call.read()) is not grpc.aio.EOF:
|
|
198
|
+
yield event
|
|
199
|
+
except grpc.aio.AioRpcError as err:
|
|
200
|
+
raise self._error(err) from None
|
|
201
|
+
finally:
|
|
202
|
+
if not call.done():
|
|
203
|
+
call.cancel()
|
|
204
|
+
|
|
205
|
+
async def run(
|
|
206
|
+
self,
|
|
207
|
+
command: str,
|
|
208
|
+
cwd: str | None = None,
|
|
209
|
+
env: Mapping[str, str] | None = None,
|
|
210
|
+
timeout: float | None = None,
|
|
211
|
+
on_stdout: Callable[[bytes], None] | None = None,
|
|
212
|
+
on_stderr: Callable[[bytes], None] | None = None,
|
|
213
|
+
) -> ExecutionResult:
|
|
214
|
+
"""Run a command, forwarding output chunks to the callbacks."""
|
|
215
|
+
hostname = ""
|
|
216
|
+
events = self.execute(command, cwd=cwd, env=env, timeout=timeout)
|
|
217
|
+
try:
|
|
218
|
+
async for ev in events:
|
|
219
|
+
kind = ev.WhichOneof("event")
|
|
220
|
+
if kind == "stdout":
|
|
221
|
+
if on_stdout:
|
|
222
|
+
on_stdout(ev.stdout.data)
|
|
223
|
+
elif kind == "stderr":
|
|
224
|
+
if on_stderr:
|
|
225
|
+
on_stderr(ev.stderr.data)
|
|
226
|
+
elif kind == "started":
|
|
227
|
+
hostname = ev.started.hostname
|
|
228
|
+
elif kind == "finished":
|
|
229
|
+
f = ev.finished
|
|
230
|
+
return ExecutionResult(
|
|
231
|
+
status=_STATUS_NAMES.get(f.status, "error"),
|
|
232
|
+
exit_code=f.exit_code,
|
|
233
|
+
signal=f.signal,
|
|
234
|
+
message=f.message,
|
|
235
|
+
execution_id=f.execution_id,
|
|
236
|
+
hostname=hostname,
|
|
237
|
+
start_time=f.start_time.ToDatetime() if f.HasField("start_time") else None,
|
|
238
|
+
end_time=f.end_time.ToDatetime() if f.HasField("end_time") else None,
|
|
239
|
+
)
|
|
240
|
+
finally:
|
|
241
|
+
await events.aclose()
|
|
242
|
+
raise RemoteExecError("stream ended without a final status (server crashed or connection lost)")
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""``remote-exec-client`` command line entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import asyncio
|
|
7
|
+
import os
|
|
8
|
+
import signal
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from . import __version__
|
|
12
|
+
from .client import (
|
|
13
|
+
EXIT_CLIENT_ERROR,
|
|
14
|
+
EXIT_INTERRUPTED,
|
|
15
|
+
EXIT_REJECTED,
|
|
16
|
+
CommandRejected,
|
|
17
|
+
RemoteExecClient,
|
|
18
|
+
RemoteExecError,
|
|
19
|
+
ServerUnavailable,
|
|
20
|
+
)
|
|
21
|
+
from .config import ClientConfig, ConfigError, default_client_config_path, load_client_config
|
|
22
|
+
from .security import SecurityError
|
|
23
|
+
from .skill import DEFAULT_SKILL_DIR, SKILL_NAME, SkillExistsError, install_skill
|
|
24
|
+
|
|
25
|
+
PROG = "remote-exec-client"
|
|
26
|
+
|
|
27
|
+
EPILOG = f"""\
|
|
28
|
+
exit status:
|
|
29
|
+
N the remote command's exit status (128+N if killed by signal N)
|
|
30
|
+
124 the command timed out (--timeout)
|
|
31
|
+
126 the server's tools policy does not allow a program in the command
|
|
32
|
+
(see the allow/deny lists shown by --ping)
|
|
33
|
+
130 interrupted locally (Ctrl-C); the remote command is terminated
|
|
34
|
+
255 remote-exec error: config, connection, authentication, or the command
|
|
35
|
+
could not be started (message on stderr)
|
|
36
|
+
|
|
37
|
+
default config: {default_client_config_path()} (override with --config or $REMOTE_EXEC_CONFIG)
|
|
38
|
+
|
|
39
|
+
examples:
|
|
40
|
+
{PROG} fpga01 hostname
|
|
41
|
+
{PROG} fpga01 --cwd /home/user/project "vivado -mode batch -source build.tcl"
|
|
42
|
+
{PROG} fpga01 -e FPGA_PART=xcvu37p -e BUILD_MODE=release ./build.sh
|
|
43
|
+
{PROG} gpu01 --timeout 60 -- nvidia-smi -L
|
|
44
|
+
{PROG} --list
|
|
45
|
+
{PROG} --install-skill # teach Claude Code to use this tool
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
50
|
+
p = argparse.ArgumentParser(
|
|
51
|
+
prog=PROG,
|
|
52
|
+
description="Run a shell command on a remote-exec-server and stream its stdout/stderr, "
|
|
53
|
+
"exiting with the remote exit status (like ssh).",
|
|
54
|
+
epilog=EPILOG,
|
|
55
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
56
|
+
)
|
|
57
|
+
p.add_argument("server", nargs="?", help="server name from the config, or HOST[:PORT] (default port 50051)")
|
|
58
|
+
p.add_argument("command", nargs="*", help="command line; multiple words are joined with spaces (like ssh)")
|
|
59
|
+
p.add_argument("-C", "--cwd", metavar="DIR", help="remote working directory (absolute, or ~/...)")
|
|
60
|
+
p.add_argument("-e", "--env", metavar="KEY=VALUE", action="append", default=[],
|
|
61
|
+
help="set a remote environment variable (repeatable); bare KEY copies the local value")
|
|
62
|
+
p.add_argument("-t", "--timeout", type=float, metavar="SECONDS",
|
|
63
|
+
help="kill the remote command (and its children) after SECONDS")
|
|
64
|
+
p.add_argument("-c", "--config", metavar="FILE", help="client config file")
|
|
65
|
+
p.add_argument("--connect-timeout", type=float, metavar="SECONDS",
|
|
66
|
+
help="max wait for the server to accept the command (default: 10)")
|
|
67
|
+
p.add_argument("--verbose", action="store_true", help="print execution summary to stderr")
|
|
68
|
+
p.add_argument("--list", action="store_true", help="list configured servers and exit")
|
|
69
|
+
p.add_argument("--ping", action="store_true", help="check that SERVER is reachable and show its info")
|
|
70
|
+
p.add_argument("--install-skill", nargs="?", const=str(DEFAULT_SKILL_DIR), metavar="DIR",
|
|
71
|
+
help=f"install the '{SKILL_NAME}' Claude Code skill as DIR/{SKILL_NAME}.md "
|
|
72
|
+
f"(default DIR: {DEFAULT_SKILL_DIR}) and exit")
|
|
73
|
+
p.add_argument("--force", action="store_true", help="with --install-skill: overwrite an existing, modified skill")
|
|
74
|
+
p.add_argument("-v", "--version", action="version", version=f"%(prog)s {__version__}")
|
|
75
|
+
return p
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def do_install_skill(directory: str, force: bool) -> int:
|
|
79
|
+
try:
|
|
80
|
+
path, status = install_skill(directory, force=force)
|
|
81
|
+
except SkillExistsError as exc:
|
|
82
|
+
err(f"error: {exc}")
|
|
83
|
+
return 1
|
|
84
|
+
except OSError as exc:
|
|
85
|
+
err(f"error: cannot install skill: {exc}")
|
|
86
|
+
return 1
|
|
87
|
+
if status == "unchanged":
|
|
88
|
+
print(f"skill already up to date: {path}")
|
|
89
|
+
else:
|
|
90
|
+
print(f"skill {status}: {path}")
|
|
91
|
+
print(f"Claude Code can now use it automatically, or invoke it with /{SKILL_NAME}")
|
|
92
|
+
return 0
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def parse_env(items: list[str]) -> dict[str, str]:
|
|
96
|
+
env = {}
|
|
97
|
+
for item in items:
|
|
98
|
+
key, sep, value = item.partition("=")
|
|
99
|
+
if not key:
|
|
100
|
+
raise ConfigError(f"invalid --env {item!r}, expected KEY=VALUE")
|
|
101
|
+
if not sep:
|
|
102
|
+
if key not in os.environ:
|
|
103
|
+
raise ConfigError(f"--env {key}: not set in the local environment")
|
|
104
|
+
value = os.environ[key]
|
|
105
|
+
env[key] = value
|
|
106
|
+
return env
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def err(msg: str) -> None:
|
|
110
|
+
print(f"{PROG}: {msg}", file=sys.stderr, flush=True)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _write(stream):
|
|
114
|
+
def write(data: bytes) -> None:
|
|
115
|
+
stream.write(data)
|
|
116
|
+
stream.flush()
|
|
117
|
+
return write
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def list_servers(config: ClientConfig) -> int:
|
|
121
|
+
if not config.servers:
|
|
122
|
+
where = config.path or default_client_config_path()
|
|
123
|
+
print(f"no servers configured (config: {where})")
|
|
124
|
+
return 0
|
|
125
|
+
width = max(len(n) for n in config.servers)
|
|
126
|
+
for name, ep in config.servers.items():
|
|
127
|
+
line = f"{name:<{width}} {ep.address}"
|
|
128
|
+
if ep.description:
|
|
129
|
+
line += f" {ep.description}"
|
|
130
|
+
print(line)
|
|
131
|
+
return 0
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def _ping(client: RemoteExecClient) -> int:
|
|
135
|
+
info = await client.server_info()
|
|
136
|
+
print(f"{client.endpoint.name}: ok host={info.hostname} user={info.user} version={info.version} shell={info.shell}")
|
|
137
|
+
print(f" allowed programs: {', '.join(info.allowed_tools) or '(none)'}")
|
|
138
|
+
print(f" denied programs: {', '.join(info.denied_tools) or '(none)'}")
|
|
139
|
+
return 0
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def _execute(client: RemoteExecClient, args, command: str, env: dict[str, str]) -> int:
|
|
143
|
+
result = await client.run(
|
|
144
|
+
command, cwd=args.cwd, env=env, timeout=args.timeout,
|
|
145
|
+
on_stdout=_write(sys.stdout.buffer), on_stderr=_write(sys.stderr.buffer),
|
|
146
|
+
)
|
|
147
|
+
name = client.endpoint.name
|
|
148
|
+
if result.status == "timeout":
|
|
149
|
+
err(f"{name}: {result.message or 'command timed out'}; remote process group terminated")
|
|
150
|
+
elif result.status == "error":
|
|
151
|
+
err(f"{name}: {result.message or 'remote execution failed'}")
|
|
152
|
+
elif result.signal:
|
|
153
|
+
err(f"{name}: remote command killed by signal {result.signal}")
|
|
154
|
+
if args.verbose:
|
|
155
|
+
dur = f"{result.duration:.3f}s" if result.duration is not None else "?"
|
|
156
|
+
err(f"{name}: host={result.hostname} status={result.status} exit_code={result.exit_code} "
|
|
157
|
+
f"duration={dur} id={result.execution_id}")
|
|
158
|
+
return result.local_exit_code
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
async def _main_async(args, endpoint, command: str | None, env: dict[str, str]) -> int:
|
|
162
|
+
task = asyncio.current_task()
|
|
163
|
+
loop = asyncio.get_running_loop()
|
|
164
|
+
# SIGTERM (e.g. from an agent's tool timeout) cancels the RPC cleanly so the
|
|
165
|
+
# server kills the remote command right away.
|
|
166
|
+
loop.add_signal_handler(signal.SIGTERM, task.cancel)
|
|
167
|
+
async with RemoteExecClient(endpoint, connect_timeout=args.connect_timeout) as client:
|
|
168
|
+
if args.ping:
|
|
169
|
+
return await _ping(client)
|
|
170
|
+
return await _execute(client, args, command, env)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def main(argv: list[str] | None = None) -> int:
|
|
174
|
+
parser = build_parser()
|
|
175
|
+
args = parser.parse_intermixed_args(argv)
|
|
176
|
+
if args.install_skill:
|
|
177
|
+
return do_install_skill(args.install_skill, args.force)
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
config = load_client_config(args.config)
|
|
181
|
+
if args.list:
|
|
182
|
+
return list_servers(config)
|
|
183
|
+
if not args.server:
|
|
184
|
+
parser.error("missing SERVER")
|
|
185
|
+
command = " ".join(args.command)
|
|
186
|
+
if not args.ping and not command.strip():
|
|
187
|
+
parser.error("missing COMMAND")
|
|
188
|
+
if args.timeout is not None and args.timeout <= 0:
|
|
189
|
+
parser.error("--timeout must be > 0")
|
|
190
|
+
env = parse_env(args.env)
|
|
191
|
+
endpoint = config.resolve(args.server)
|
|
192
|
+
except (ConfigError, SecurityError) as exc:
|
|
193
|
+
err(f"error: {exc}")
|
|
194
|
+
return EXIT_CLIENT_ERROR
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
return asyncio.run(_main_async(args, endpoint, command, env))
|
|
198
|
+
except KeyboardInterrupt:
|
|
199
|
+
err("interrupted; remote command cancelled")
|
|
200
|
+
return EXIT_INTERRUPTED
|
|
201
|
+
except asyncio.CancelledError:
|
|
202
|
+
err("terminated; remote command cancelled")
|
|
203
|
+
return 128 + signal.SIGTERM
|
|
204
|
+
except ServerUnavailable as exc:
|
|
205
|
+
hint = ""
|
|
206
|
+
if args.server not in config.servers and not any(c in args.server for c in ".:"):
|
|
207
|
+
hint = f" ('{args.server}' is not a configured server name; config: {config.path or default_client_config_path()})"
|
|
208
|
+
err(f"error: {exc}{hint}")
|
|
209
|
+
return EXIT_CLIENT_ERROR
|
|
210
|
+
except CommandRejected as exc:
|
|
211
|
+
err(f"error: {exc}")
|
|
212
|
+
return EXIT_REJECTED
|
|
213
|
+
except (RemoteExecError, SecurityError) as exc:
|
|
214
|
+
err(f"error: {exc}")
|
|
215
|
+
return EXIT_CLIENT_ERROR
|
|
216
|
+
except BrokenPipeError:
|
|
217
|
+
# Local stdout closed (e.g. piped into `head`); the RPC was cancelled.
|
|
218
|
+
try:
|
|
219
|
+
sys.stdout = open(os.devnull, "w")
|
|
220
|
+
except OSError:
|
|
221
|
+
pass
|
|
222
|
+
return 141
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
if __name__ == "__main__":
|
|
226
|
+
sys.exit(main())
|