dsh-wsl-desktop 0.2.0
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.
- package/LICENSE +21 -0
- package/README.md +222 -0
- package/cordis.patch.yml +9 -0
- package/lib/client.js +709 -0
- package/lib/http-admission.js +102 -0
- package/lib/index.js +1067 -0
- package/lib/wsl/confinement.js +424 -0
- package/lib/wsl/dsh-wsl-confine.sh +80 -0
- package/lib/wsl/fence.js +121 -0
- package/lib/wsl/fs.js +324 -0
- package/lib/wsl/host-refs.js +38 -0
- package/lib/wsl/paths.js +95 -0
- package/lib/wsl/preset.js +456 -0
- package/lib/wsl/pty.js +366 -0
- package/lib/wsl/shell.js +462 -0
- package/lib/wsl/subprocess.js +195 -0
- package/lib/wsl/terminal-bridge.py +278 -0
- package/lib/wsl/world.js +458 -0
- package/package.json +35 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""PTY bridge for the WSL execution world.
|
|
3
|
+
|
|
4
|
+
`wsl.exe` hands out three pipes, never a terminal, so a real PTY has to be
|
|
5
|
+
allocated on the Linux side and bridged. This script allocates one with the
|
|
6
|
+
`pty` module, runs the requested program on the slave, and pumps bytes between
|
|
7
|
+
the master and the host's pipes:
|
|
8
|
+
|
|
9
|
+
stdin <- host keystrokes, forwarded to the PTY
|
|
10
|
+
stdout -> PTY output, forwarded to the host
|
|
11
|
+
stderr <- one JSON control reply per line, prefixed for the host to demux
|
|
12
|
+
|
|
13
|
+
Control arrives through a FIFO whose path is the first argument, written by a
|
|
14
|
+
second `wsl.exe` process the host keeps open. Only stdlib is used, so nothing
|
|
15
|
+
has to be installed inside the distribution.
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
python3 bridge.py <fifo> <cols> <rows> <argv...>
|
|
19
|
+
|
|
20
|
+
Exits with the child's own exit status (128 + signal when it was killed).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import errno
|
|
24
|
+
import fcntl
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import pty
|
|
28
|
+
import select
|
|
29
|
+
import signal
|
|
30
|
+
import struct
|
|
31
|
+
import sys
|
|
32
|
+
import termios
|
|
33
|
+
import time
|
|
34
|
+
|
|
35
|
+
REPLY_PREFIX = b"#dsh-pty "
|
|
36
|
+
|
|
37
|
+
SIGNALS = {
|
|
38
|
+
"SIGINT": signal.SIGINT,
|
|
39
|
+
"SIGTERM": signal.SIGTERM,
|
|
40
|
+
"SIGKILL": signal.SIGKILL,
|
|
41
|
+
"SIGHUP": signal.SIGHUP,
|
|
42
|
+
"SIGTSTP": signal.SIGTSTP,
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
GRACE_SECONDS = 3.0
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def reply(payload):
|
|
49
|
+
"""Write one control reply line for the host to demux off stderr."""
|
|
50
|
+
try:
|
|
51
|
+
os.write(2, REPLY_PREFIX + json.dumps(payload).encode("utf-8") + b"\n")
|
|
52
|
+
except OSError:
|
|
53
|
+
# The host is gone; nothing left to report to.
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def exit_code_of(status):
|
|
58
|
+
"""Map a waitpid status onto the shell convention the host reports."""
|
|
59
|
+
if os.WIFEXITED(status):
|
|
60
|
+
return os.WEXITSTATUS(status)
|
|
61
|
+
if os.WIFSIGNALED(status):
|
|
62
|
+
return 128 + os.WTERMSIG(status)
|
|
63
|
+
return 0
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Bridge:
|
|
67
|
+
"""One PTY session plus its host-facing pipes."""
|
|
68
|
+
|
|
69
|
+
def __init__(self, fifo, cols, rows, argv):
|
|
70
|
+
self.argv = argv
|
|
71
|
+
self.stdin_fd = 0
|
|
72
|
+
self.stdout_fd = 1
|
|
73
|
+
self.master = None
|
|
74
|
+
self.pid = None
|
|
75
|
+
self.shell_pgrp = None
|
|
76
|
+
self.stdin_open = True
|
|
77
|
+
self.control_buffer = b""
|
|
78
|
+
self.revision = 0
|
|
79
|
+
self.last_state = None
|
|
80
|
+
self.control_fd = self._open_control(fifo)
|
|
81
|
+
self._spawn()
|
|
82
|
+
self.resize(cols, rows)
|
|
83
|
+
|
|
84
|
+
def _open_control(self, fifo):
|
|
85
|
+
"""Create the control FIFO and hold its read end open."""
|
|
86
|
+
try:
|
|
87
|
+
os.unlink(fifo)
|
|
88
|
+
except FileNotFoundError:
|
|
89
|
+
pass
|
|
90
|
+
os.mkfifo(fifo, 0o600)
|
|
91
|
+
return os.open(fifo, os.O_RDONLY | os.O_NONBLOCK)
|
|
92
|
+
|
|
93
|
+
def _spawn(self):
|
|
94
|
+
"""Fork the program onto a new PTY."""
|
|
95
|
+
self.pid, self.master = pty.fork()
|
|
96
|
+
if self.pid == 0:
|
|
97
|
+
try:
|
|
98
|
+
os.execvp(self.argv[0], self.argv)
|
|
99
|
+
except OSError:
|
|
100
|
+
pass
|
|
101
|
+
os._exit(127)
|
|
102
|
+
self.shell_pgrp = os.getpgid(self.pid)
|
|
103
|
+
reply({"event": "started", "pid": self.pid, "pgrp": self.shell_pgrp})
|
|
104
|
+
|
|
105
|
+
def resize(self, cols, rows):
|
|
106
|
+
"""Apply a window size to the PTY."""
|
|
107
|
+
fcntl.ioctl(self.master, termios.TIOCSWINSZ, struct.pack("HHHH", int(rows), int(cols), 0, 0))
|
|
108
|
+
|
|
109
|
+
def foreground_pgrp(self):
|
|
110
|
+
"""The PTY's current foreground process group."""
|
|
111
|
+
try:
|
|
112
|
+
return os.tcgetpgrp(self.master)
|
|
113
|
+
except OSError:
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
def activity(self):
|
|
117
|
+
"""Report busy when the foreground group is not the shell itself."""
|
|
118
|
+
pgrp = self.foreground_pgrp()
|
|
119
|
+
state = "unknown" if pgrp is None else ("idle" if pgrp == self.shell_pgrp else "busy")
|
|
120
|
+
if state != self.last_state:
|
|
121
|
+
self.last_state = state
|
|
122
|
+
self.revision += 1
|
|
123
|
+
return {"state": state, "revision": self.revision}
|
|
124
|
+
|
|
125
|
+
def drain(self):
|
|
126
|
+
"""Read every immediately available PTY byte, stopping at the EIO that marks a closed slave."""
|
|
127
|
+
while True:
|
|
128
|
+
try:
|
|
129
|
+
ready, _, _ = select.select([self.master], [], [], 0)
|
|
130
|
+
except InterruptedError:
|
|
131
|
+
return
|
|
132
|
+
if not ready:
|
|
133
|
+
return
|
|
134
|
+
try:
|
|
135
|
+
data = os.read(self.master, 65536)
|
|
136
|
+
except OSError as error:
|
|
137
|
+
# EIO is how Linux reports that the slave side is gone.
|
|
138
|
+
if error.errno == errno.EIO:
|
|
139
|
+
return
|
|
140
|
+
raise
|
|
141
|
+
if not data:
|
|
142
|
+
return
|
|
143
|
+
os.write(self.stdout_fd, data)
|
|
144
|
+
|
|
145
|
+
def handle_control(self, line):
|
|
146
|
+
"""Run one control request and answer it."""
|
|
147
|
+
try:
|
|
148
|
+
request = json.loads(line.decode("utf-8"))
|
|
149
|
+
except (ValueError, UnicodeDecodeError):
|
|
150
|
+
reply({"ok": False, "error": "control line is not JSON"})
|
|
151
|
+
return
|
|
152
|
+
request_id = request.get("id")
|
|
153
|
+
|
|
154
|
+
def ans(payload):
|
|
155
|
+
"""Answer the request, echoing its id so the host can pair the reply."""
|
|
156
|
+
if request_id is not None:
|
|
157
|
+
payload = {"id": request_id, **payload}
|
|
158
|
+
reply(payload)
|
|
159
|
+
|
|
160
|
+
op = request.get("op")
|
|
161
|
+
if op == "resize":
|
|
162
|
+
self.resize(request.get("cols", 80), request.get("rows", 24))
|
|
163
|
+
ans({"ok": True})
|
|
164
|
+
elif op == "foreground":
|
|
165
|
+
pgrp = self.foreground_pgrp()
|
|
166
|
+
ans({"ok": True, "pgrp": pgrp, "shellPgrp": self.shell_pgrp})
|
|
167
|
+
elif op == "activity":
|
|
168
|
+
ans({"ok": True, **self.activity()})
|
|
169
|
+
elif op == "signal":
|
|
170
|
+
name = request.get("signal")
|
|
171
|
+
number = SIGNALS.get(name)
|
|
172
|
+
if number is None:
|
|
173
|
+
ans({"ok": False, "error": "unsupported signal %s" % name})
|
|
174
|
+
return
|
|
175
|
+
pgrp = self.foreground_pgrp()
|
|
176
|
+
if pgrp is None:
|
|
177
|
+
ans({"ok": False, "error": "no foreground process group"})
|
|
178
|
+
return
|
|
179
|
+
try:
|
|
180
|
+
os.killpg(pgrp, number)
|
|
181
|
+
ans({"ok": True, "pgrp": pgrp})
|
|
182
|
+
except OSError as error:
|
|
183
|
+
ans({"ok": False, "error": str(error)})
|
|
184
|
+
elif op == "terminate":
|
|
185
|
+
ans({"ok": True, "terminated": self.terminate()})
|
|
186
|
+
else:
|
|
187
|
+
ans({"ok": False, "error": "unknown op"})
|
|
188
|
+
|
|
189
|
+
def terminate(self):
|
|
190
|
+
"""Signal the whole session and report the group that received it."""
|
|
191
|
+
pgrp = self.foreground_pgrp() or self.shell_pgrp
|
|
192
|
+
for number, wait in ((signal.SIGHUP, 0.2), (signal.SIGTERM, GRACE_SECONDS), (signal.SIGKILL, 0.2)):
|
|
193
|
+
try:
|
|
194
|
+
os.killpg(pgrp, number)
|
|
195
|
+
except OSError:
|
|
196
|
+
break
|
|
197
|
+
deadline = time.monotonic() + wait
|
|
198
|
+
while time.monotonic() < deadline:
|
|
199
|
+
done, _ = os.waitpid(self.pid, os.WNOHANG)
|
|
200
|
+
if done == self.pid:
|
|
201
|
+
return True
|
|
202
|
+
time.sleep(0.05)
|
|
203
|
+
return False
|
|
204
|
+
|
|
205
|
+
def run(self):
|
|
206
|
+
"""Pump bytes until the child settles, then exit with its status."""
|
|
207
|
+
status = 0
|
|
208
|
+
while True:
|
|
209
|
+
watched = [self.master, self.control_fd]
|
|
210
|
+
if self.stdin_open:
|
|
211
|
+
watched.append(self.stdin_fd)
|
|
212
|
+
try:
|
|
213
|
+
ready, _, _ = select.select(watched, [], [], 0.2)
|
|
214
|
+
except InterruptedError:
|
|
215
|
+
continue
|
|
216
|
+
|
|
217
|
+
if self.master in ready:
|
|
218
|
+
try:
|
|
219
|
+
data = os.read(self.master, 65536)
|
|
220
|
+
except OSError as error:
|
|
221
|
+
data = b""
|
|
222
|
+
if error.errno != errno.EIO:
|
|
223
|
+
raise
|
|
224
|
+
if data:
|
|
225
|
+
os.write(self.stdout_fd, data)
|
|
226
|
+
|
|
227
|
+
if self.stdin_open and self.stdin_fd in ready:
|
|
228
|
+
try:
|
|
229
|
+
data = os.read(self.stdin_fd, 65536)
|
|
230
|
+
except OSError:
|
|
231
|
+
data = b""
|
|
232
|
+
if data:
|
|
233
|
+
try:
|
|
234
|
+
os.write(self.master, data)
|
|
235
|
+
except OSError:
|
|
236
|
+
# The session ended between the select and the write.
|
|
237
|
+
pass
|
|
238
|
+
else:
|
|
239
|
+
# The host closed its side; keep the session but stop polling it.
|
|
240
|
+
self.stdin_open = False
|
|
241
|
+
|
|
242
|
+
if self.control_fd in ready:
|
|
243
|
+
chunk = os.read(self.control_fd, 65536)
|
|
244
|
+
self.control_buffer += chunk
|
|
245
|
+
while b"\n" in self.control_buffer:
|
|
246
|
+
line, self.control_buffer = self.control_buffer.split(b"\n", 1)
|
|
247
|
+
if line.strip():
|
|
248
|
+
self.handle_control(line)
|
|
249
|
+
|
|
250
|
+
try:
|
|
251
|
+
done, status = os.waitpid(self.pid, os.WNOHANG)
|
|
252
|
+
except ChildProcessError:
|
|
253
|
+
done, status = self.pid, 0
|
|
254
|
+
if done == self.pid:
|
|
255
|
+
self.drain()
|
|
256
|
+
return exit_code_of(status)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def main():
|
|
260
|
+
if len(sys.argv) < 5:
|
|
261
|
+
sys.stderr.write("usage: bridge.py <fifo> <cols> <rows> <argv...>\n")
|
|
262
|
+
return 2
|
|
263
|
+
fifo = sys.argv[1]
|
|
264
|
+
cols, rows = sys.argv[2], sys.argv[3]
|
|
265
|
+
argv = sys.argv[4:]
|
|
266
|
+
bridge = Bridge(fifo, cols, rows, argv)
|
|
267
|
+
try:
|
|
268
|
+
code = bridge.run()
|
|
269
|
+
finally:
|
|
270
|
+
try:
|
|
271
|
+
os.unlink(fifo)
|
|
272
|
+
except OSError:
|
|
273
|
+
pass
|
|
274
|
+
return code
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
if __name__ == "__main__":
|
|
278
|
+
sys.exit(main())
|