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/server.py ADDED
@@ -0,0 +1,292 @@
1
+ """Async gRPC server: request validation, audit logging, and lifecycle.
2
+
3
+ The servicer is a thin adapter between protobuf messages and
4
+ :class:`remote_exec.executor.Executor`.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import contextlib
11
+ import getpass
12
+ import json
13
+ import logging
14
+ import math
15
+ import os
16
+ import re
17
+ import socket
18
+ import time
19
+ import uuid
20
+ from dataclasses import dataclass
21
+
22
+ import grpc
23
+ from google.protobuf.timestamp_pb2 import Timestamp
24
+
25
+ from . import __version__
26
+ from .config import DEFAULT_HOST, DEFAULT_PORT, format_address
27
+ from .cmdlog import CommandLog
28
+ from .executor import CommandSpec, Executor, Finished, Output, Started, Status, Stream
29
+ from .policy import PolicyProvider
30
+ from .security import TokenAuthInterceptor, peer_identity, server_credentials
31
+ from .v1 import remote_exec_pb2 as pb
32
+ from .v1 import remote_exec_pb2_grpc as pb_grpc
33
+
34
+ log = logging.getLogger(__name__)
35
+ audit = logging.getLogger("remote_exec.audit")
36
+
37
+ MAX_COMMAND_BYTES = 256 * 1024
38
+ MAX_ENV_ENTRIES = 1024
39
+ MAX_ENV_VALUE_BYTES = 128 * 1024
40
+ _ENV_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
41
+
42
+ # Keepalive: lets long silent commands survive NAT/firewall idle timeouts and
43
+ # lets the server notice vanished clients (which cancels their commands).
44
+ SERVER_OPTIONS = [
45
+ ("grpc.keepalive_time_ms", 60_000),
46
+ ("grpc.keepalive_timeout_ms", 20_000),
47
+ ("grpc.keepalive_permit_without_calls", 1),
48
+ ("grpc.http2.min_recv_ping_interval_without_data_ms", 10_000),
49
+ ("grpc.http2.max_ping_strikes", 0),
50
+ ]
51
+
52
+
53
+ def default_shell() -> str:
54
+ return "/bin/bash" if os.path.exists("/bin/bash") else "/bin/sh"
55
+
56
+
57
+ @dataclass
58
+ class ServerSettings:
59
+ host: str = DEFAULT_HOST
60
+ port: int = DEFAULT_PORT
61
+ shell: str = ""
62
+ max_concurrent: int = 32
63
+ max_timeout: float | None = None
64
+ kill_grace: float = 5.0
65
+ token: str | None = None
66
+ tls_cert: str | None = None
67
+ tls_key: str | None = None
68
+ tls_client_ca: str | None = None
69
+ tools_file: str | None = None # None: default ~/.config/remote-exec/tools.yaml if present
70
+ tools_required: bool = False # error if tools_file is missing (explicit --tools)
71
+ command_log: str | None = None # file recording commands and their output; None = off
72
+ command_log_max_mb: float = 10.0 # rotate to a new file beyond this size
73
+ command_log_keep: int = 0 # rotated files to keep; 0 = all
74
+
75
+ @property
76
+ def address(self) -> str:
77
+ return format_address(self.host, self.port)
78
+
79
+
80
+ class RequestError(ValueError):
81
+ pass
82
+
83
+
84
+ def validate_request(req: pb.ExecuteRequest, max_timeout: float | None) -> CommandSpec:
85
+ command = req.command
86
+ if not command.strip():
87
+ raise RequestError("command must not be empty")
88
+ if len(command.encode()) > MAX_COMMAND_BYTES:
89
+ raise RequestError(f"command exceeds {MAX_COMMAND_BYTES} bytes")
90
+ if "\x00" in command:
91
+ raise RequestError("command must not contain NUL bytes")
92
+
93
+ cwd = None
94
+ if req.working_directory:
95
+ if "\x00" in req.working_directory:
96
+ raise RequestError("working_directory must not contain NUL bytes")
97
+ cwd = os.path.expanduser(req.working_directory)
98
+ if not os.path.isabs(cwd):
99
+ raise RequestError(f"working_directory must be an absolute path (or start with ~): {req.working_directory!r}")
100
+
101
+ env = dict(req.environment)
102
+ if len(env) > MAX_ENV_ENTRIES:
103
+ raise RequestError(f"too many environment variables (max {MAX_ENV_ENTRIES})")
104
+ for key, value in env.items():
105
+ if not _ENV_NAME_RE.match(key):
106
+ raise RequestError(f"invalid environment variable name: {key!r}")
107
+ if "\x00" in value or len(value.encode()) > MAX_ENV_VALUE_BYTES:
108
+ raise RequestError(f"invalid value for environment variable {key}")
109
+
110
+ timeout = req.timeout_seconds
111
+ if math.isnan(timeout) or math.isinf(timeout) or timeout < 0:
112
+ raise RequestError("timeout_seconds must be a finite number >= 0")
113
+ if max_timeout and (timeout == 0 or timeout > max_timeout):
114
+ timeout = max_timeout
115
+ return CommandSpec(command=command, cwd=cwd, env=env, timeout=timeout or None)
116
+
117
+
118
+ def _ts(dt) -> Timestamp:
119
+ t = Timestamp()
120
+ t.FromDatetime(dt)
121
+ return t
122
+
123
+
124
+ _STATUS_TO_PB = {
125
+ Status.EXITED: pb.Finished.EXITED,
126
+ Status.TIMEOUT: pb.Finished.TIMEOUT,
127
+ Status.ERROR: pb.Finished.ERROR,
128
+ }
129
+
130
+
131
+ def event_to_proto(ev, hostname: str) -> pb.ExecuteEvent:
132
+ if isinstance(ev, Output):
133
+ chunk = pb.OutputChunk(data=ev.data)
134
+ return pb.ExecuteEvent(stdout=chunk) if ev.stream is Stream.STDOUT else pb.ExecuteEvent(stderr=chunk)
135
+ if isinstance(ev, Started):
136
+ return pb.ExecuteEvent(started=pb.Started(
137
+ execution_id=ev.execution_id, pid=ev.pid, start_time=_ts(ev.start_time), hostname=hostname,
138
+ ))
139
+ if isinstance(ev, Finished):
140
+ return pb.ExecuteEvent(finished=pb.Finished(
141
+ status=_STATUS_TO_PB[ev.status], exit_code=ev.exit_code, signal=ev.signal,
142
+ start_time=_ts(ev.start_time), end_time=_ts(ev.end_time),
143
+ message=ev.message, execution_id=ev.execution_id,
144
+ ))
145
+ raise TypeError(f"unknown event {ev!r}")
146
+
147
+
148
+ def _q(value: str) -> str:
149
+ """Quote a value for single-line key=value audit records."""
150
+ return json.dumps(value, ensure_ascii=False)
151
+
152
+
153
+ class RemoteExecutorService(pb_grpc.RemoteExecutorServicer):
154
+ def __init__(self, settings: ServerSettings, executor: Executor, policy: PolicyProvider,
155
+ command_log: CommandLog | None = None):
156
+ self.settings = settings
157
+ self.executor = executor
158
+ self.policy = policy
159
+ self.command_log = command_log
160
+ self.hostname = socket.gethostname()
161
+ self._active = 0
162
+
163
+ async def Execute(self, request: pb.ExecuteRequest, context: grpc.aio.ServicerContext):
164
+ client = peer_identity(context)
165
+ try:
166
+ spec = validate_request(request, self.settings.max_timeout)
167
+ except RequestError as exc:
168
+ audit.warning("rejected client=%s reason=%s", client, _q(str(exc)))
169
+ if self.command_log:
170
+ self.command_log.rejected(client, request.command, str(exc))
171
+ await context.abort(grpc.StatusCode.INVALID_ARGUMENT, str(exc))
172
+
173
+ if reason := self.policy.current().check(spec.command):
174
+ audit.warning("rejected client=%s reason=%s command=%s", client, _q(f"tools policy: {reason}"),
175
+ _q(spec.command))
176
+ if self.command_log:
177
+ self.command_log.rejected(client, spec.command, f"tools policy: {reason}")
178
+ await context.abort(grpc.StatusCode.PERMISSION_DENIED, f"blocked by the server's tools policy: {reason}")
179
+
180
+ if self._active >= self.settings.max_concurrent:
181
+ audit.warning("rejected client=%s reason=%s", client, _q("too many concurrent commands"))
182
+ if self.command_log:
183
+ self.command_log.rejected(client, spec.command, "too many concurrent commands")
184
+ await context.abort(
185
+ grpc.StatusCode.RESOURCE_EXHAUSTED,
186
+ f"server is already running {self._active} commands (max {self.settings.max_concurrent})",
187
+ )
188
+
189
+ execution_id = uuid.uuid4().hex[:12]
190
+ audit.info(
191
+ "start id=%s client=%s cwd=%s timeout=%s env_keys=%s command=%s",
192
+ execution_id, client, _q(spec.cwd or os.getcwd()), spec.timeout or 0,
193
+ _q(",".join(sorted(spec.env))), _q(spec.command),
194
+ )
195
+ if self.command_log:
196
+ self.command_log.start(execution_id, client, spec.cwd or os.getcwd(), spec.command,
197
+ spec.timeout or 0, spec.env)
198
+ started = time.monotonic()
199
+ outcome = "cancelled"
200
+ exit_code: int | str = "-"
201
+ self._active += 1
202
+ try:
203
+ async with contextlib.aclosing(self.executor.run(spec, execution_id)) as events:
204
+ async for ev in events:
205
+ if isinstance(ev, Output) and self.command_log:
206
+ self.command_log.output(execution_id, ev.stream.value, ev.data)
207
+ elif isinstance(ev, Finished):
208
+ outcome, exit_code = ev.status.value, ev.exit_code
209
+ if ev.status is Status.ERROR:
210
+ exit_code = "-"
211
+ outcome = f"error message={_q(ev.message)}"
212
+ yield event_to_proto(ev, self.hostname)
213
+ finally:
214
+ self._active -= 1
215
+ duration = time.monotonic() - started
216
+ audit.info(
217
+ "end id=%s client=%s status=%s exit_code=%s duration=%.3fs",
218
+ execution_id, client, outcome, exit_code, duration,
219
+ )
220
+ if self.command_log:
221
+ self.command_log.end(execution_id, outcome, exit_code, duration)
222
+
223
+ async def GetServerInfo(self, request, context):
224
+ policy = self.policy.current()
225
+ return pb.ServerInfo(
226
+ hostname=self.hostname, version=__version__, user=getpass.getuser(), shell=self.executor.shell,
227
+ allowed_tools=policy.allow, denied_tools=policy.deny,
228
+ )
229
+
230
+
231
+ class RemoteExecServer:
232
+ """Owns the grpc.aio server and the executor."""
233
+
234
+ def __init__(self, settings: ServerSettings):
235
+ self.settings = settings
236
+ self.executor = Executor(shell=settings.shell or default_shell(), kill_grace=settings.kill_grace)
237
+ self.policy = PolicyProvider(settings.tools_file, required=settings.tools_required)
238
+ self.command_log = CommandLog(
239
+ settings.command_log, int(settings.command_log_max_mb * 1024 * 1024), settings.command_log_keep,
240
+ ) if settings.command_log else None
241
+ interceptors = [TokenAuthInterceptor(settings.token)] if settings.token else []
242
+ self._server = grpc.aio.server(interceptors=interceptors, options=SERVER_OPTIONS)
243
+ pb_grpc.add_RemoteExecutorServicer_to_server(
244
+ RemoteExecutorService(settings, self.executor, self.policy, self.command_log), self._server
245
+ )
246
+ self.bound_port: int | None = None
247
+
248
+ async def start(self) -> int:
249
+ s = self.settings
250
+ if s.tls_cert:
251
+ creds = server_credentials(s.tls_cert, s.tls_key, s.tls_client_ca)
252
+ port = self._server.add_secure_port(s.address, creds)
253
+ else:
254
+ port = self._server.add_insecure_port(s.address)
255
+ if not port:
256
+ raise OSError(f"failed to bind {s.address}")
257
+ await self._server.start()
258
+ self.bound_port = port
259
+ log.info(
260
+ "remote-exec-server %s listening on %s (tls=%s, mtls=%s, token_auth=%s, shell=%s)",
261
+ __version__, format_address(s.host, port), bool(s.tls_cert), bool(s.tls_client_ca),
262
+ bool(s.token), self.executor.shell,
263
+ )
264
+ log.info("tools policy: %s", self.policy.current().describe())
265
+ if self.command_log:
266
+ log.info("command log: %s (rotate at %g MB, keep %s)", self.command_log.path,
267
+ self.settings.command_log_max_mb, self.settings.command_log_keep or "all")
268
+ else:
269
+ log.info("command log: disabled")
270
+ return port
271
+
272
+ async def stop(self, grace: float = 5.0) -> None:
273
+ """Stop accepting RPCs, cancel running ones and clean up their processes."""
274
+ await self._server.stop(grace)
275
+ await self.executor.wait_cleanups(timeout=self.settings.kill_grace + 2)
276
+ self.executor.kill_all()
277
+ if self.command_log:
278
+ self.command_log.close()
279
+
280
+ async def wait_for_termination(self) -> None:
281
+ await self._server.wait_for_termination()
282
+
283
+
284
+ async def serve(settings: ServerSettings, stop_event: asyncio.Event | None = None) -> None:
285
+ server = RemoteExecServer(settings)
286
+ await server.start()
287
+ stop_event = stop_event or asyncio.Event()
288
+ try:
289
+ await stop_event.wait()
290
+ finally:
291
+ log.info("shutting down")
292
+ await server.stop()
@@ -0,0 +1,219 @@
1
+ """``remote-exec-server`` command line entry point."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import logging
8
+ import os
9
+ import signal
10
+ import sys
11
+
12
+ from . import __version__
13
+ from .cmdlog import DEFAULT_COMMAND_LOG, DEFAULT_MAX_MB, CommandLog
14
+ from .config import DEFAULT_HOST, DEFAULT_PORT, ConfigError, load_server_config, split_host_port, validate_port
15
+ from .policy import DEFAULT_TOOLS_FILE, PolicyError, PolicyProvider
16
+ from .security import DEFAULT_TOKEN_FILE, SecurityError, generate_token_file, is_loopback_host, read_secret_file
17
+ from .server import ServerSettings, serve
18
+
19
+ log = logging.getLogger("remote_exec.server")
20
+
21
+ DEFAULTS = {
22
+ "host": DEFAULT_HOST,
23
+ "port": DEFAULT_PORT,
24
+ "max_concurrent": 32,
25
+ "max_timeout": None,
26
+ "shell": "",
27
+ "kill_grace": 5.0,
28
+ "log_level": "INFO",
29
+ "log_file": None,
30
+ "token_file": None,
31
+ "tls_cert": None,
32
+ "tls_key": None,
33
+ "tls_client_ca": None,
34
+ "insecure_no_auth": False,
35
+ "allow_root": False,
36
+ "listen": None,
37
+ "tools": None,
38
+ "command_log": DEFAULT_COMMAND_LOG,
39
+ "command_log_max_mb": DEFAULT_MAX_MB,
40
+ "command_log_keep": 0,
41
+ "no_command_log": False,
42
+ }
43
+
44
+
45
+ def build_parser() -> argparse.ArgumentParser:
46
+ p = argparse.ArgumentParser(
47
+ prog="remote-exec-server",
48
+ description="Run the remote-exec gRPC server. Executes commands requested by "
49
+ "remote-exec-client as the current (non-root) user.",
50
+ epilog="Listening on a non-loopback address requires authentication "
51
+ "(--token-file and/or --tls-client-ca) unless --insecure-no-auth is given.",
52
+ )
53
+ addr = p.add_argument_group("listen address")
54
+ addr.add_argument("--host", help=f"address to bind (default: {DEFAULT_HOST}; use 0.0.0.0 for all interfaces)")
55
+ addr.add_argument("--port", type=int, help=f"TCP port (default: {DEFAULT_PORT})")
56
+ addr.add_argument("--listen", metavar="HOST:PORT", help="shorthand for --host and --port, e.g. 0.0.0.0:50051")
57
+ p.add_argument("--config", metavar="FILE", help="YAML file with server settings (CLI options override it)")
58
+
59
+ sec = p.add_argument_group("security")
60
+ sec.add_argument("--token-file", metavar="FILE", help="file containing the shared bearer token clients must send")
61
+ sec.add_argument("--tls-cert", metavar="FILE", help="server TLS certificate (PEM)")
62
+ sec.add_argument("--tls-key", metavar="FILE", help="server TLS private key (PEM)")
63
+ sec.add_argument("--tls-client-ca", metavar="FILE", help="CA for client certificates; enables mTLS")
64
+ sec.add_argument("--insecure-no-auth", action="store_true", default=None,
65
+ help="allow listening on a non-loopback address without any authentication (NOT recommended)")
66
+ sec.add_argument("--allow-root", action="store_true", default=None,
67
+ help="allow running as root (NOT recommended)")
68
+ sec.add_argument("--generate-token", nargs="?", const=str(DEFAULT_TOKEN_FILE), metavar="FILE",
69
+ help=f"create a new random token file (mode 600) and exit; default FILE: {DEFAULT_TOKEN_FILE}")
70
+ sec.add_argument("--force", action="store_true", default=None,
71
+ help="with --generate-token: replace an existing token file")
72
+
73
+ ex = p.add_argument_group("execution")
74
+ ex.add_argument("--tools", metavar="FILE",
75
+ help=f"tools.yaml with allowed/denied programs (default: {DEFAULT_TOOLS_FILE} if it exists, "
76
+ "else allow everything except ssh); reloaded automatically when changed")
77
+ ex.add_argument("--shell", help="shell used as '<shell> -c <command>' (default: /bin/bash, else /bin/sh)")
78
+ ex.add_argument("--max-concurrent", type=int, help="max concurrently running commands (default: 32)")
79
+ ex.add_argument("--max-timeout", type=float, metavar="SECONDS",
80
+ help="upper bound for command timeouts; also applied to commands without a timeout")
81
+ ex.add_argument("--kill-grace", type=float, metavar="SECONDS",
82
+ help="seconds between SIGTERM and SIGKILL when stopping a command (default: 5)")
83
+
84
+ lg = p.add_argument_group("logging")
85
+ lg.add_argument("--log-level", help="DEBUG, INFO, WARNING, ERROR (default: INFO)")
86
+ lg.add_argument("--log-file", metavar="FILE", help="also append logs to FILE (default: stderr only)")
87
+ lg.add_argument("--command-log", metavar="FILE",
88
+ help=f"file recording every command and its output (default: ./{DEFAULT_COMMAND_LOG} "
89
+ "in the directory the server is started from)")
90
+ lg.add_argument("--command-log-max-mb", type=float, metavar="MB",
91
+ help=f"start a new command log file when it would exceed MB (default: {DEFAULT_MAX_MB:g})")
92
+ lg.add_argument("--command-log-keep", type=int, metavar="N",
93
+ help="keep only the N most recent rotated command logs (default: 0 = keep all)")
94
+ lg.add_argument("--no-command-log", action="store_true", default=None, help="do not write a command log")
95
+ p.add_argument("-v", "--version", action="version", version=f"%(prog)s {__version__}")
96
+ return p
97
+
98
+
99
+ def resolve_settings(args: argparse.Namespace) -> tuple[ServerSettings, dict]:
100
+ if args.listen and (args.host is not None or args.port is not None):
101
+ raise ConfigError("--listen cannot be combined with --host/--port")
102
+ # Precedence: defaults < config file < command line; "listen" expands to host+port.
103
+ opts = dict(DEFAULTS)
104
+ for layer in (load_server_config(args.config) if args.config else {}, vars(args)):
105
+ layer = {k: v for k, v in layer.items() if v is not None and k != "config"}
106
+ if layer.get("listen"):
107
+ layer["host"], layer["port"] = split_host_port(str(layer.pop("listen")), DEFAULT_PORT)
108
+ opts.update(layer)
109
+ opts["port"] = validate_port(opts["port"])
110
+ if opts["max_concurrent"] < 1:
111
+ raise ConfigError("max_concurrent must be >= 1")
112
+ if opts["max_timeout"] is not None and opts["max_timeout"] <= 0:
113
+ raise ConfigError("max_timeout must be > 0")
114
+ if bool(opts["tls_cert"]) != bool(opts["tls_key"]):
115
+ raise ConfigError("--tls-cert and --tls-key must be given together")
116
+ if opts["tls_client_ca"] and not opts["tls_cert"]:
117
+ raise ConfigError("--tls-client-ca (mTLS) requires --tls-cert/--tls-key")
118
+ if float(opts["command_log_max_mb"]) < 0.001:
119
+ raise ConfigError("command_log_max_mb must be at least 0.001 (1 KiB)")
120
+ if int(opts["command_log_keep"]) < 0:
121
+ raise ConfigError("command_log_keep must be >= 0")
122
+ command_log = None
123
+ if not opts["no_command_log"] and opts["command_log"] not in (None, False, ""):
124
+ # Relative paths are relative to the directory the server is started from.
125
+ command_log = os.path.abspath(os.path.expanduser(str(opts["command_log"])))
126
+
127
+ token = read_secret_file(opts["token_file"]) if opts["token_file"] else None
128
+ settings = ServerSettings(
129
+ host=str(opts["host"]), port=opts["port"], shell=opts["shell"] or "",
130
+ max_concurrent=int(opts["max_concurrent"]), max_timeout=opts["max_timeout"],
131
+ kill_grace=float(opts["kill_grace"]), token=token,
132
+ tls_cert=opts["tls_cert"], tls_key=opts["tls_key"], tls_client_ca=opts["tls_client_ca"],
133
+ tools_file=opts["tools"], tools_required=bool(opts["tools"]),
134
+ command_log=command_log, command_log_max_mb=float(opts["command_log_max_mb"]),
135
+ command_log_keep=int(opts["command_log_keep"]),
136
+ )
137
+ return settings, opts
138
+
139
+
140
+ def check_safety(settings: ServerSettings, opts: dict) -> None:
141
+ if hasattr(os, "geteuid") and os.geteuid() == 0 and not opts["allow_root"]:
142
+ raise SecurityError(
143
+ "refusing to run as root: commands would execute with full privileges. "
144
+ "Run as an unprivileged user (or pass --allow-root if you really mean it)."
145
+ )
146
+ authenticated = bool(settings.token or settings.tls_client_ca)
147
+ if not is_loopback_host(settings.host):
148
+ if not authenticated and not opts["insecure_no_auth"]:
149
+ raise SecurityError(
150
+ f"refusing to listen on non-loopback address {settings.host!r} without authentication. "
151
+ "Configure --token-file and/or mTLS (--tls-cert/--tls-key/--tls-client-ca), "
152
+ "or pass --insecure-no-auth on a fully trusted network."
153
+ )
154
+ if not authenticated:
155
+ log.warning("listening on %s WITHOUT authentication: anyone who can reach this port can run commands", settings.host)
156
+ if not settings.tls_cert:
157
+ log.warning("TLS is disabled: traffic (including the token) is unencrypted; use only over a trusted/encrypted network such as WireGuard/Tailscale")
158
+
159
+
160
+ def setup_logging(level: str, log_file: str | None) -> None:
161
+ handlers: list[logging.Handler] = [logging.StreamHandler(sys.stderr)]
162
+ if log_file:
163
+ handlers.append(logging.FileHandler(os.path.expanduser(log_file)))
164
+ logging.basicConfig(
165
+ level=getattr(logging, str(level).upper(), logging.INFO),
166
+ format="%(asctime)s %(levelname)s %(name)s: %(message)s",
167
+ handlers=handlers,
168
+ force=True,
169
+ )
170
+
171
+
172
+ async def _run(settings: ServerSettings) -> None:
173
+ stop = asyncio.Event()
174
+ loop = asyncio.get_running_loop()
175
+ for sig in (signal.SIGINT, signal.SIGTERM):
176
+ loop.add_signal_handler(sig, stop.set)
177
+ await serve(settings, stop)
178
+
179
+
180
+ def do_generate_token(path: str, force: bool) -> int:
181
+ try:
182
+ target = generate_token_file(path, force=force)
183
+ except SecurityError as exc:
184
+ print(f"remote-exec-server: error: {exc}", file=sys.stderr)
185
+ return 1
186
+ print(f"token written to {target} (mode 600)")
187
+ print("next steps:")
188
+ print(f" server: remote-exec-server --host 0.0.0.0 --token-file {target}")
189
+ print(" client: copy this file to the client machine (e.g. ~/.config/remote-exec/token, chmod 600)")
190
+ print(" and set 'token_file:' in the client config")
191
+ return 0
192
+
193
+
194
+ def main(argv: list[str] | None = None) -> int:
195
+ args = build_parser().parse_args(argv)
196
+ if args.generate_token:
197
+ return do_generate_token(args.generate_token, bool(args.force))
198
+ try:
199
+ # Logging first so config warnings (e.g. token file permissions) are shown.
200
+ setup_logging(args.log_level or "INFO", args.log_file)
201
+ settings, opts = resolve_settings(args)
202
+ setup_logging(opts["log_level"], opts["log_file"])
203
+ check_safety(settings, opts)
204
+ PolicyProvider(settings.tools_file, required=settings.tools_required) # validate tools.yaml early
205
+ if settings.command_log: # fail now, not on the first command, if the log is not writable
206
+ CommandLog(settings.command_log, int(settings.command_log_max_mb * 1024 * 1024)).close()
207
+ except (ConfigError, SecurityError, PolicyError, OSError) as exc:
208
+ print(f"remote-exec-server: error: {exc}", file=sys.stderr)
209
+ return 2
210
+ try:
211
+ asyncio.run(_run(settings))
212
+ except OSError as exc:
213
+ print(f"remote-exec-server: error: {exc}", file=sys.stderr)
214
+ return 1
215
+ return 0
216
+
217
+
218
+ if __name__ == "__main__":
219
+ sys.exit(main())
remote_exec/skill.py ADDED
@@ -0,0 +1,39 @@
1
+ """Install the Claude Code skill (custom slash command) for remote-exec-client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib import resources
6
+ from pathlib import Path
7
+
8
+ SKILL_NAME = "remote-exec"
9
+ DEFAULT_SKILL_DIR = Path("~/.claude/commands")
10
+
11
+
12
+ class SkillExistsError(Exception):
13
+ pass
14
+
15
+
16
+ def skill_text() -> str:
17
+ return resources.files("remote_exec").joinpath("claude_skill.md").read_text(encoding="utf-8")
18
+
19
+
20
+ def install_skill(directory: str | Path | None = None, force: bool = False) -> tuple[Path, str]:
21
+ """Write ``<directory>/remote-exec.md``.
22
+
23
+ Returns the path and one of ``"installed"``, ``"updated"``, ``"unchanged"``.
24
+ An existing file with different content (e.g. edited by the user) is only
25
+ replaced when ``force`` is true.
26
+ """
27
+ target = Path(directory or DEFAULT_SKILL_DIR).expanduser() / f"{SKILL_NAME}.md"
28
+ text = skill_text()
29
+ if target.exists():
30
+ if target.read_text(encoding="utf-8") == text:
31
+ return target, "unchanged"
32
+ if not force:
33
+ raise SkillExistsError(f"{target} already exists with different content; use --force to overwrite it")
34
+ status = "updated"
35
+ else:
36
+ status = "installed"
37
+ target.parent.mkdir(parents=True, exist_ok=True)
38
+ target.write_text(text, encoding="utf-8")
39
+ return target, status
@@ -0,0 +1 @@
1
+ """Generated protobuf / gRPC code for API version v1 (see proto/)."""
@@ -0,0 +1,57 @@
1
+ # -*- coding: utf-8 -*-
2
+ # Generated by the protocol buffer compiler. DO NOT EDIT!
3
+ # NO CHECKED-IN PROTOBUF GENCODE
4
+ # source: remote_exec/v1/remote_exec.proto
5
+ # Protobuf Python Version: 7.35.1
6
+ """Generated protocol buffer code."""
7
+ from google.protobuf import descriptor as _descriptor
8
+ from google.protobuf import descriptor_pool as _descriptor_pool
9
+ from google.protobuf import runtime_version as _runtime_version
10
+ from google.protobuf import symbol_database as _symbol_database
11
+ from google.protobuf.internal import builder as _builder
12
+ _runtime_version.ValidateProtobufRuntimeVersion(
13
+ _runtime_version.Domain.PUBLIC,
14
+ 7,
15
+ 35,
16
+ 1,
17
+ '',
18
+ 'remote_exec/v1/remote_exec.proto'
19
+ )
20
+ # @@protoc_insertion_point(imports)
21
+
22
+ _sym_db = _symbol_database.Default()
23
+
24
+
25
+ from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__pb2
26
+
27
+
28
+ DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n remote_exec/v1/remote_exec.proto\x12\x0eremote_exec.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\xcf\x01\n\x0e\x45xecuteRequest\x12\x0f\n\x07\x63ommand\x18\x01 \x01(\t\x12\x19\n\x11working_directory\x18\x02 \x01(\t\x12\x44\n\x0b\x65nvironment\x18\x03 \x03(\x0b\x32/.remote_exec.v1.ExecuteRequest.EnvironmentEntry\x12\x17\n\x0ftimeout_seconds\x18\x04 \x01(\x01\x1a\x32\n\x10\x45nvironmentEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xcf\x01\n\x0c\x45xecuteEvent\x12*\n\x07started\x18\x01 \x01(\x0b\x32\x17.remote_exec.v1.StartedH\x00\x12-\n\x06stdout\x18\x02 \x01(\x0b\x32\x1b.remote_exec.v1.OutputChunkH\x00\x12-\n\x06stderr\x18\x03 \x01(\x0b\x32\x1b.remote_exec.v1.OutputChunkH\x00\x12,\n\x08\x66inished\x18\x04 \x01(\x0b\x32\x18.remote_exec.v1.FinishedH\x00\x42\x07\n\x05\x65vent\"n\n\x07Started\x12\x14\n\x0c\x65xecution_id\x18\x01 \x01(\t\x12\x0b\n\x03pid\x18\x02 \x01(\x05\x12.\n\nstart_time\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x10\n\x08hostname\x18\x04 \x01(\t\"\x1b\n\x0bOutputChunk\x12\x0c\n\x04\x64\x61ta\x18\x01 \x01(\x0c\"\xa9\x02\n\x08\x46inished\x12/\n\x06status\x18\x01 \x01(\x0e\x32\x1f.remote_exec.v1.Finished.Status\x12\x11\n\texit_code\x18\x02 \x01(\x05\x12\x0e\n\x06signal\x18\x03 \x01(\x05\x12.\n\nstart_time\x18\x04 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12,\n\x08\x65nd_time\x18\x05 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x0f\n\x07message\x18\x06 \x01(\t\x12\x14\n\x0c\x65xecution_id\x18\x07 \x01(\t\"D\n\x06Status\x12\x16\n\x12STATUS_UNSPECIFIED\x10\x00\x12\n\n\x06\x45XITED\x10\x01\x12\x0b\n\x07TIMEOUT\x10\x02\x12\t\n\x05\x45RROR\x10\x03\"\x16\n\x14GetServerInfoRequest\"y\n\nServerInfo\x12\x10\n\x08hostname\x18\x01 \x01(\t\x12\x0f\n\x07version\x18\x02 \x01(\t\x12\x0c\n\x04user\x18\x03 \x01(\t\x12\r\n\x05shell\x18\x04 \x01(\t\x12\x15\n\rallowed_tools\x18\x05 \x03(\t\x12\x14\n\x0c\x64\x65nied_tools\x18\x06 \x03(\t2\xae\x01\n\x0eRemoteExecutor\x12I\n\x07\x45xecute\x12\x1e.remote_exec.v1.ExecuteRequest\x1a\x1c.remote_exec.v1.ExecuteEvent0\x01\x12Q\n\rGetServerInfo\x12$.remote_exec.v1.GetServerInfoRequest\x1a\x1a.remote_exec.v1.ServerInfob\x06proto3')
29
+
30
+ _globals = globals()
31
+ _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
32
+ _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'remote_exec.v1.remote_exec_pb2', _globals)
33
+ if not _descriptor._USE_C_DESCRIPTORS:
34
+ DESCRIPTOR._loaded_options = None
35
+ _globals['_EXECUTEREQUEST_ENVIRONMENTENTRY']._loaded_options = None
36
+ _globals['_EXECUTEREQUEST_ENVIRONMENTENTRY']._serialized_options = b'8\001'
37
+ _globals['_EXECUTEREQUEST']._serialized_start=86
38
+ _globals['_EXECUTEREQUEST']._serialized_end=293
39
+ _globals['_EXECUTEREQUEST_ENVIRONMENTENTRY']._serialized_start=243
40
+ _globals['_EXECUTEREQUEST_ENVIRONMENTENTRY']._serialized_end=293
41
+ _globals['_EXECUTEEVENT']._serialized_start=296
42
+ _globals['_EXECUTEEVENT']._serialized_end=503
43
+ _globals['_STARTED']._serialized_start=505
44
+ _globals['_STARTED']._serialized_end=615
45
+ _globals['_OUTPUTCHUNK']._serialized_start=617
46
+ _globals['_OUTPUTCHUNK']._serialized_end=644
47
+ _globals['_FINISHED']._serialized_start=647
48
+ _globals['_FINISHED']._serialized_end=944
49
+ _globals['_FINISHED_STATUS']._serialized_start=876
50
+ _globals['_FINISHED_STATUS']._serialized_end=944
51
+ _globals['_GETSERVERINFOREQUEST']._serialized_start=946
52
+ _globals['_GETSERVERINFOREQUEST']._serialized_end=968
53
+ _globals['_SERVERINFO']._serialized_start=970
54
+ _globals['_SERVERINFO']._serialized_end=1091
55
+ _globals['_REMOTEEXECUTOR']._serialized_start=1094
56
+ _globals['_REMOTEEXECUTOR']._serialized_end=1268
57
+ # @@protoc_insertion_point(module_scope)