dais-shell 0.1.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.
- dais_shell/__init__.py +62 -0
- dais_shell/constants.py +6 -0
- dais_shell/env_builder.py +103 -0
- dais_shell/iostream_reader.py +171 -0
- dais_shell/py.typed +0 -0
- dais_shell/runtimes/BaseShellRuntime.py +18 -0
- dais_shell/runtimes/BashRuntime.py +90 -0
- dais_shell/runtimes/PowershellRuntime.py +114 -0
- dais_shell/runtimes/__init__.py +3 -0
- dais_shell/types/__init__.py +26 -0
- dais_shell/types/exceptions.py +16 -0
- dais_shell-0.1.1.dist-info/METADATA +12 -0
- dais_shell-0.1.1.dist-info/RECORD +14 -0
- dais_shell-0.1.1.dist-info/WHEEL +4 -0
dais_shell/__init__.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import platform
|
|
2
|
+
from typing import TypeAlias
|
|
3
|
+
from .env_builder import EnvBuilder
|
|
4
|
+
from .iostream_reader import IOStreamReaderResult, IOStreamReaderStatus
|
|
5
|
+
from .runtimes import BaseShellRuntime, BashRuntime, PowerShellRuntime
|
|
6
|
+
from .types import CommandStep
|
|
7
|
+
from .types.exceptions import ShellError, CommandNotFoundError, ShellRuntimeNotFoundError, ForbiddenShellTargetError
|
|
8
|
+
from .constants import DEFAULT_COMMAND_BLACKLIST
|
|
9
|
+
|
|
10
|
+
ShellResult: TypeAlias = IOStreamReaderResult
|
|
11
|
+
ShellResultStatus: TypeAlias = IOStreamReaderStatus
|
|
12
|
+
|
|
13
|
+
class AgentShell:
|
|
14
|
+
def __init__(self,
|
|
15
|
+
command_blacklist: set[str] | None = None,
|
|
16
|
+
env_extra: dict[str, str] | None = None,
|
|
17
|
+
max_lines: int = 10000,
|
|
18
|
+
):
|
|
19
|
+
self._runtime = self._create_runtime(max_lines)
|
|
20
|
+
self._command_blacklist = command_blacklist or DEFAULT_COMMAND_BLACKLIST
|
|
21
|
+
self._env_builder = EnvBuilder(blacklist=None, extra=env_extra)
|
|
22
|
+
|
|
23
|
+
@staticmethod
|
|
24
|
+
def _create_runtime(max_lines: int) -> BaseShellRuntime:
|
|
25
|
+
if platform.system() == "Windows":
|
|
26
|
+
return PowerShellRuntime(max_lines)
|
|
27
|
+
else:
|
|
28
|
+
return BashRuntime(max_lines)
|
|
29
|
+
|
|
30
|
+
def run_sync(self,
|
|
31
|
+
step: CommandStep,
|
|
32
|
+
on_stdout=None,
|
|
33
|
+
on_stderr=None
|
|
34
|
+
) -> ShellResult:
|
|
35
|
+
step.validate_command(self._command_blacklist)
|
|
36
|
+
step.env = (self._env_builder
|
|
37
|
+
.with_extra(step.env or {})
|
|
38
|
+
.build())
|
|
39
|
+
return self._runtime.run_sync(step, on_stdout, on_stderr)
|
|
40
|
+
|
|
41
|
+
async def run(self,
|
|
42
|
+
step: CommandStep,
|
|
43
|
+
on_stdout=None,
|
|
44
|
+
on_stderr=None
|
|
45
|
+
) -> ShellResult:
|
|
46
|
+
step.validate_command(self._command_blacklist)
|
|
47
|
+
step.env = (self._env_builder
|
|
48
|
+
.with_extra(step.env or {})
|
|
49
|
+
.build())
|
|
50
|
+
return await self._runtime.run(step, on_stdout, on_stderr)
|
|
51
|
+
|
|
52
|
+
__all__ = [
|
|
53
|
+
"AgentShell",
|
|
54
|
+
"CommandStep",
|
|
55
|
+
"ShellResult",
|
|
56
|
+
"ShellResultStatus",
|
|
57
|
+
|
|
58
|
+
"ShellError",
|
|
59
|
+
"CommandNotFoundError",
|
|
60
|
+
"ShellRuntimeNotFoundError",
|
|
61
|
+
"ForbiddenShellTargetError",
|
|
62
|
+
]
|
dais_shell/constants.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import platform
|
|
3
|
+
|
|
4
|
+
WINDOWS_ESSENTIAL_VARS = {
|
|
5
|
+
"SYSTEMROOT",
|
|
6
|
+
"SYSTEMDRIVE",
|
|
7
|
+
"PATHEXT",
|
|
8
|
+
"COMSPEC",
|
|
9
|
+
"WINDIR",
|
|
10
|
+
|
|
11
|
+
"USERPROFILE",
|
|
12
|
+
"USERNAME",
|
|
13
|
+
"COMPUTERNAME",
|
|
14
|
+
"APPDATA",
|
|
15
|
+
"LOCALAPPDATA",
|
|
16
|
+
"PUBLIC",
|
|
17
|
+
|
|
18
|
+
"PROCESSOR_ARCHITECTURE"
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
UNIX_ESSENTIAL_VARS = {
|
|
22
|
+
"HOME",
|
|
23
|
+
"USER",
|
|
24
|
+
"LOGNAME",
|
|
25
|
+
"SHELL",
|
|
26
|
+
"TERM",
|
|
27
|
+
"LINES", "COLUMNS",
|
|
28
|
+
"PWD",
|
|
29
|
+
"XDG_RUNTIME_DIR", "XDG_CONFIG_HOME"
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
MACOS_ESSENTIAL_VARS = UNIX_ESSENTIAL_VARS | {
|
|
33
|
+
"DYLD_LIBRARY_PATH",
|
|
34
|
+
"DYLD_FRAMEWORK_PATH",
|
|
35
|
+
"DYLD_FALLBACK_LIBRARY_PATH",
|
|
36
|
+
|
|
37
|
+
"SSH_AUTH_SOCK",
|
|
38
|
+
"SECURITYSESSIONID",
|
|
39
|
+
|
|
40
|
+
"__CF_USER_TEXT_ENCODING",
|
|
41
|
+
"DEVELOPER_DIR",
|
|
42
|
+
|
|
43
|
+
"TERM_PROGRAM",
|
|
44
|
+
"TERM_PROGRAM_VERSION",
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
UNIVERSAL_ESSENTIAL_VARS = {
|
|
48
|
+
"PATH",
|
|
49
|
+
"TEMP", "TMP", "TMPDIR",
|
|
50
|
+
"LANG", "LC_ALL", "LC_CTYPE",
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
PROGRAM_ESSENTIAL_VARS = {
|
|
54
|
+
"PYTHONIOENCODING",
|
|
55
|
+
"GOPATH",
|
|
56
|
+
"LUA_PATH",
|
|
57
|
+
"JAVA_HOME",
|
|
58
|
+
"RUSTUP_HOME",
|
|
59
|
+
"CARGO_HOME",
|
|
60
|
+
"PNPM_HOME",
|
|
61
|
+
"ANDROID_HOME"
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
SECURITY_ADDITIONS = {
|
|
65
|
+
"SSL_CERT_FILE",
|
|
66
|
+
"SSL_CERT_DIR",
|
|
67
|
+
"REQUESTS_CA_BUNDLE"
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
ESSENTIAL_VARS = UNIVERSAL_ESSENTIAL_VARS | PROGRAM_ESSENTIAL_VARS | SECURITY_ADDITIONS
|
|
71
|
+
if platform.system() == "Windows":
|
|
72
|
+
ESSENTIAL_VARS |= WINDOWS_ESSENTIAL_VARS
|
|
73
|
+
elif platform.system() == "Darwin":
|
|
74
|
+
ESSENTIAL_VARS |= MACOS_ESSENTIAL_VARS
|
|
75
|
+
else:
|
|
76
|
+
ESSENTIAL_VARS |= UNIX_ESSENTIAL_VARS
|
|
77
|
+
|
|
78
|
+
class EnvBuilder:
|
|
79
|
+
def __init__(self,
|
|
80
|
+
blacklist: set[str] | None = None,
|
|
81
|
+
extra: dict[str, str] | None = None,
|
|
82
|
+
):
|
|
83
|
+
self._blacklist = blacklist
|
|
84
|
+
self._extra = extra
|
|
85
|
+
|
|
86
|
+
def with_extra(self, extra: dict[str, str]) -> "EnvBuilder":
|
|
87
|
+
final_extra = (self._extra or {}).copy()
|
|
88
|
+
final_extra.update(extra)
|
|
89
|
+
return EnvBuilder(self._blacklist, final_extra)
|
|
90
|
+
|
|
91
|
+
def build(self) -> dict[str, str]:
|
|
92
|
+
base_env = os.environ.copy()
|
|
93
|
+
final_env = {}
|
|
94
|
+
|
|
95
|
+
# insert essential vars
|
|
96
|
+
for key, var in base_env.items():
|
|
97
|
+
if key.upper() in ESSENTIAL_VARS:
|
|
98
|
+
final_env[key] = var
|
|
99
|
+
|
|
100
|
+
# insert extra vars
|
|
101
|
+
if self._extra:
|
|
102
|
+
final_env.update(self._extra)
|
|
103
|
+
return final_env
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import subprocess
|
|
3
|
+
import threading
|
|
4
|
+
import time
|
|
5
|
+
from enum import Enum
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from collections import deque
|
|
8
|
+
from typing import Callable, TextIO
|
|
9
|
+
|
|
10
|
+
IOStreamCallback = Callable[[str], None]
|
|
11
|
+
IOStreamBuffer = deque[str]
|
|
12
|
+
|
|
13
|
+
class IOStreamReaderStatus(str, Enum):
|
|
14
|
+
SUCCESS = "success"
|
|
15
|
+
TIMEOUT = "timeout"
|
|
16
|
+
CANCELED = "canceled"
|
|
17
|
+
ERROR = "error"
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class IOStreamReaderResult:
|
|
21
|
+
returncode: int
|
|
22
|
+
status: IOStreamReaderStatus
|
|
23
|
+
error: Exception | None
|
|
24
|
+
stdout_buf: IOStreamBuffer
|
|
25
|
+
stderr_buf: IOStreamBuffer
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def stdout(self) -> str:
|
|
29
|
+
"""Get the full text of stdout"""
|
|
30
|
+
return "\n".join(self.stdout_buf)
|
|
31
|
+
|
|
32
|
+
@property
|
|
33
|
+
def stderr(self) -> str:
|
|
34
|
+
"""Get the full text of stderr"""
|
|
35
|
+
return "\n".join(self.stderr_buf)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class IOStreamReaderSync:
|
|
39
|
+
def __init__(self,
|
|
40
|
+
proc: subprocess.Popen,
|
|
41
|
+
on_stdout: IOStreamCallback | None = None,
|
|
42
|
+
on_stderr: IOStreamCallback | None = None,
|
|
43
|
+
max_lines: int = 10000):
|
|
44
|
+
self._proc = proc
|
|
45
|
+
self._max_lines = max_lines
|
|
46
|
+
self._on_stdout = on_stdout
|
|
47
|
+
self._on_stderr = on_stderr
|
|
48
|
+
self._cancel_event = threading.Event()
|
|
49
|
+
|
|
50
|
+
def cancel(self):
|
|
51
|
+
self._cancel_event.set()
|
|
52
|
+
|
|
53
|
+
@staticmethod
|
|
54
|
+
def _consumer(pipe: TextIO, callback: IOStreamCallback | None, buf: IOStreamBuffer):
|
|
55
|
+
for line in iter(pipe.readline, ""):
|
|
56
|
+
buf.append(line)
|
|
57
|
+
if callback: callback(line)
|
|
58
|
+
|
|
59
|
+
def read(self, timeout_sec: int | None = None) -> IOStreamReaderResult:
|
|
60
|
+
if (returncode := self._proc.poll()) is not None:
|
|
61
|
+
return IOStreamReaderResult(
|
|
62
|
+
returncode,
|
|
63
|
+
status=IOStreamReaderStatus.SUCCESS,
|
|
64
|
+
error=None,
|
|
65
|
+
stdout_buf=IOStreamBuffer(),
|
|
66
|
+
stderr_buf=IOStreamBuffer())
|
|
67
|
+
|
|
68
|
+
stdout_buf = IOStreamBuffer(maxlen=self._max_lines)
|
|
69
|
+
stderr_buf = IOStreamBuffer(maxlen=self._max_lines)
|
|
70
|
+
|
|
71
|
+
stdout_consumer = threading.Thread(
|
|
72
|
+
target=IOStreamReaderSync._consumer,
|
|
73
|
+
args=(self._proc.stdout, self._on_stdout, stdout_buf))
|
|
74
|
+
stderr_consumer = threading.Thread(
|
|
75
|
+
target=IOStreamReaderSync._consumer,
|
|
76
|
+
args=(self._proc.stderr, self._on_stderr, stderr_buf))
|
|
77
|
+
|
|
78
|
+
stdout_consumer.start()
|
|
79
|
+
stderr_consumer.start()
|
|
80
|
+
|
|
81
|
+
start_time = time.monotonic()
|
|
82
|
+
status = IOStreamReaderStatus.SUCCESS
|
|
83
|
+
error: Exception | None = None
|
|
84
|
+
try:
|
|
85
|
+
while self._proc.poll() is None:
|
|
86
|
+
if self._cancel_event.is_set():
|
|
87
|
+
self._proc.kill()
|
|
88
|
+
status = IOStreamReaderStatus.CANCELED
|
|
89
|
+
break
|
|
90
|
+
|
|
91
|
+
if (timeout_sec is not None and
|
|
92
|
+
(time.monotonic() - start_time) > timeout_sec):
|
|
93
|
+
self._proc.kill()
|
|
94
|
+
status = IOStreamReaderStatus.TIMEOUT
|
|
95
|
+
break
|
|
96
|
+
time.sleep(0.1)
|
|
97
|
+
except Exception as exc:
|
|
98
|
+
self._proc.kill()
|
|
99
|
+
status = IOStreamReaderStatus.ERROR
|
|
100
|
+
error = exc
|
|
101
|
+
|
|
102
|
+
stdout_consumer.join(timeout=3)
|
|
103
|
+
stderr_consumer.join(timeout=3)
|
|
104
|
+
|
|
105
|
+
returncode = self._proc.returncode
|
|
106
|
+
if returncode is None:
|
|
107
|
+
returncode = self._proc.wait()
|
|
108
|
+
return IOStreamReaderResult(returncode, status, error, stdout_buf, stderr_buf)
|
|
109
|
+
|
|
110
|
+
class IOStreamReader:
|
|
111
|
+
def __init__(self,
|
|
112
|
+
proc: asyncio.subprocess.Process,
|
|
113
|
+
max_lines: int,
|
|
114
|
+
on_stdout: IOStreamCallback | None = None,
|
|
115
|
+
on_stderr: IOStreamCallback | None = None,
|
|
116
|
+
):
|
|
117
|
+
self._proc = proc
|
|
118
|
+
self._max_lines = max_lines
|
|
119
|
+
self._on_stdout = on_stdout
|
|
120
|
+
self._on_stderr = on_stderr
|
|
121
|
+
|
|
122
|
+
@staticmethod
|
|
123
|
+
async def _consumer(stream: asyncio.StreamReader, callback: IOStreamCallback | None, buf: IOStreamBuffer):
|
|
124
|
+
while not stream.at_eof():
|
|
125
|
+
line = await stream.readline()
|
|
126
|
+
if not line: break
|
|
127
|
+
text = line.decode("utf-8", errors="replace")
|
|
128
|
+
buf.append(text)
|
|
129
|
+
if callback: callback(text)
|
|
130
|
+
|
|
131
|
+
async def read(self, timeout_sec: int | None = None) -> IOStreamReaderResult:
|
|
132
|
+
stdout_buf = IOStreamBuffer(maxlen=self._max_lines)
|
|
133
|
+
stderr_buf = IOStreamBuffer(maxlen=self._max_lines)
|
|
134
|
+
|
|
135
|
+
assert self._proc.stdout is not None
|
|
136
|
+
assert self._proc.stderr is not None
|
|
137
|
+
consumer_task = [
|
|
138
|
+
asyncio.create_task(IOStreamReader._consumer(self._proc.stdout, self._on_stdout, stdout_buf)),
|
|
139
|
+
asyncio.create_task(IOStreamReader._consumer(self._proc.stderr, self._on_stderr, stderr_buf))
|
|
140
|
+
]
|
|
141
|
+
|
|
142
|
+
status = IOStreamReaderStatus.SUCCESS
|
|
143
|
+
error: Exception | None = None
|
|
144
|
+
try:
|
|
145
|
+
if timeout_sec is not None:
|
|
146
|
+
returncode = await asyncio.wait_for(self._proc.wait(), timeout=timeout_sec)
|
|
147
|
+
else:
|
|
148
|
+
returncode = await self._proc.wait()
|
|
149
|
+
except asyncio.TimeoutError:
|
|
150
|
+
self._proc.kill()
|
|
151
|
+
returncode = await self._proc.wait()
|
|
152
|
+
status = IOStreamReaderStatus.TIMEOUT
|
|
153
|
+
except asyncio.CancelledError:
|
|
154
|
+
self._proc.kill()
|
|
155
|
+
returncode = await self._proc.wait()
|
|
156
|
+
status = IOStreamReaderStatus.CANCELED
|
|
157
|
+
except Exception as exc:
|
|
158
|
+
self._proc.kill()
|
|
159
|
+
returncode = await self._proc.wait()
|
|
160
|
+
status = IOStreamReaderStatus.ERROR
|
|
161
|
+
error = exc
|
|
162
|
+
finally:
|
|
163
|
+
try:
|
|
164
|
+
await asyncio.wait_for(
|
|
165
|
+
asyncio.gather(*consumer_task, return_exceptions=True),
|
|
166
|
+
timeout=2)
|
|
167
|
+
except asyncio.TimeoutError:
|
|
168
|
+
for task in consumer_task: task.cancel()
|
|
169
|
+
await asyncio.gather(*consumer_task, return_exceptions=True)
|
|
170
|
+
|
|
171
|
+
return IOStreamReaderResult(returncode, status, error, stdout_buf, stderr_buf)
|
dais_shell/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from abc import ABC, abstractmethod
|
|
2
|
+
from ..types import CommandStep
|
|
3
|
+
from ..iostream_reader import IOStreamReaderResult
|
|
4
|
+
|
|
5
|
+
class BaseShellRuntime(ABC):
|
|
6
|
+
@abstractmethod
|
|
7
|
+
def run_sync(self,
|
|
8
|
+
step: CommandStep,
|
|
9
|
+
on_stdout=None,
|
|
10
|
+
on_stderr=None,
|
|
11
|
+
) -> IOStreamReaderResult: ...
|
|
12
|
+
|
|
13
|
+
@abstractmethod
|
|
14
|
+
async def run(self,
|
|
15
|
+
step: CommandStep,
|
|
16
|
+
on_stdout=None,
|
|
17
|
+
on_stderr=None
|
|
18
|
+
) -> IOStreamReaderResult: ...
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import shutil
|
|
3
|
+
import subprocess
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from .BaseShellRuntime import BaseShellRuntime
|
|
6
|
+
from ..types import CommandStep
|
|
7
|
+
from ..types.exceptions import ShellRuntimeNotFoundError
|
|
8
|
+
from ..iostream_reader import IOStreamReaderResult, IOStreamReader, IOStreamReaderSync
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class BashCommandStep(CommandStep):
|
|
12
|
+
@classmethod
|
|
13
|
+
def from_command_step(cls, step: CommandStep):
|
|
14
|
+
return cls(
|
|
15
|
+
command=step.command,
|
|
16
|
+
args=step.args,
|
|
17
|
+
env=step.env,
|
|
18
|
+
cwd=step.cwd,
|
|
19
|
+
timeout=step.timeout
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
def to_wrapper_script(self):
|
|
23
|
+
return 'exec "$1" "${@:2}"'
|
|
24
|
+
|
|
25
|
+
# --- --- --- --- --- ---
|
|
26
|
+
|
|
27
|
+
class BashRuntime(BaseShellRuntime):
|
|
28
|
+
def __init__(self, max_lines: int):
|
|
29
|
+
self._shell = self._detect_shell()
|
|
30
|
+
self._max_lines = max_lines
|
|
31
|
+
|
|
32
|
+
def _detect_shell(self) -> str:
|
|
33
|
+
if bash := shutil.which("bash"):
|
|
34
|
+
return bash
|
|
35
|
+
if sh := shutil.which("sh"):
|
|
36
|
+
return sh
|
|
37
|
+
raise ShellRuntimeNotFoundError("Bash")
|
|
38
|
+
|
|
39
|
+
def _make_bash_commands(self, step: BashCommandStep):
|
|
40
|
+
return [
|
|
41
|
+
self._shell,
|
|
42
|
+
"-c",
|
|
43
|
+
step.to_wrapper_script(),
|
|
44
|
+
"--",
|
|
45
|
+
step.command,
|
|
46
|
+
*step.args
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
def _prepare_cmd(self, step: CommandStep) -> list[str]:
|
|
50
|
+
step = BashCommandStep.from_command_step(step)
|
|
51
|
+
resolved = shutil.which(step.command)
|
|
52
|
+
is_shell_command = resolved is None
|
|
53
|
+
if is_shell_command:
|
|
54
|
+
return self._make_bash_commands(step)
|
|
55
|
+
else:
|
|
56
|
+
return [resolved, *step.args]
|
|
57
|
+
|
|
58
|
+
def run_sync(self,
|
|
59
|
+
step: CommandStep,
|
|
60
|
+
on_stdout=None,
|
|
61
|
+
on_stderr=None,
|
|
62
|
+
) -> IOStreamReaderResult:
|
|
63
|
+
proc = subprocess.Popen(
|
|
64
|
+
self._prepare_cmd(step),
|
|
65
|
+
cwd=step.cwd,
|
|
66
|
+
env=step.env,
|
|
67
|
+
stdout=subprocess.PIPE,
|
|
68
|
+
stderr=subprocess.PIPE,
|
|
69
|
+
text=True,
|
|
70
|
+
bufsize=1,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
reader = IOStreamReaderSync(proc, on_stdout, on_stderr, self._max_lines)
|
|
74
|
+
return reader.read(step.timeout)
|
|
75
|
+
|
|
76
|
+
async def run(self,
|
|
77
|
+
step: CommandStep,
|
|
78
|
+
on_stdout=None,
|
|
79
|
+
on_stderr=None
|
|
80
|
+
) -> IOStreamReaderResult:
|
|
81
|
+
proc = await asyncio.create_subprocess_exec(
|
|
82
|
+
*self._prepare_cmd(step),
|
|
83
|
+
cwd=step.cwd,
|
|
84
|
+
env=step.env,
|
|
85
|
+
stdout=asyncio.subprocess.PIPE,
|
|
86
|
+
stderr=asyncio.subprocess.PIPE,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
reader = IOStreamReader(proc, self._max_lines, on_stdout, on_stderr)
|
|
90
|
+
return await reader.read(step.timeout)
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import base64
|
|
3
|
+
import os
|
|
4
|
+
import json
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from .BaseShellRuntime import BaseShellRuntime
|
|
9
|
+
from ..iostream_reader import IOStreamReaderResult, IOStreamReader, IOStreamReaderSync
|
|
10
|
+
from ..types import CommandStep
|
|
11
|
+
from ..types.exceptions import ShellRuntimeNotFoundError
|
|
12
|
+
|
|
13
|
+
CREATE_NO_WINDOW = 0x08000000 if os.name == "nt" else 0
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class PowerShellCommandStep(CommandStep):
|
|
17
|
+
@classmethod
|
|
18
|
+
def from_command_step(cls, step: CommandStep):
|
|
19
|
+
return cls(
|
|
20
|
+
command=step.command,
|
|
21
|
+
args=step.args,
|
|
22
|
+
env=step.env,
|
|
23
|
+
cwd=step.cwd,
|
|
24
|
+
timeout=step.timeout
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
def to_wrapper_script(self):
|
|
28
|
+
cmd_json = json.dumps(self.command)
|
|
29
|
+
args_json = json.dumps(self.args)
|
|
30
|
+
script = f"""
|
|
31
|
+
$ErrorActionPreference = "Stop"
|
|
32
|
+
$PSNativeCommandArgumentPassing = "Standard"
|
|
33
|
+
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
|
|
34
|
+
|
|
35
|
+
$command = ConvertFrom-Json '{cmd_json}'
|
|
36
|
+
$arguments = ,(ConvertFrom-Json '{args_json}')
|
|
37
|
+
|
|
38
|
+
& $command @arguments
|
|
39
|
+
exit $LASTEXITCODE"""
|
|
40
|
+
return script.strip()
|
|
41
|
+
|
|
42
|
+
# --- --- --- --- --- ---
|
|
43
|
+
|
|
44
|
+
class PowerShellRuntime(BaseShellRuntime):
|
|
45
|
+
def __init__(self, max_lines: int):
|
|
46
|
+
self._shell = self._detect_shell()
|
|
47
|
+
self._max_lines = max_lines
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def _detect_shell() -> str:
|
|
51
|
+
if pwsh := shutil.which("pwsh"):
|
|
52
|
+
return pwsh
|
|
53
|
+
if powershell := shutil.which("powershell"):
|
|
54
|
+
return powershell
|
|
55
|
+
raise ShellRuntimeNotFoundError("PowerShell")
|
|
56
|
+
|
|
57
|
+
@staticmethod
|
|
58
|
+
def _encode(source: str) -> str:
|
|
59
|
+
return base64.b64encode(
|
|
60
|
+
source.encode("utf-16-le")
|
|
61
|
+
).decode("ascii")
|
|
62
|
+
|
|
63
|
+
def _make_powershell_commands(self, encoded: str):
|
|
64
|
+
return [
|
|
65
|
+
self._shell,
|
|
66
|
+
"-NoProfile",
|
|
67
|
+
"-NonInteractive",
|
|
68
|
+
"-ExecutionPolicy", "Bypass",
|
|
69
|
+
"-EncodedCommand", encoded
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
def _prepare_cmd(self, step: CommandStep) -> list[str]:
|
|
73
|
+
step = PowerShellCommandStep.from_command_step(step)
|
|
74
|
+
script = step.to_wrapper_script()
|
|
75
|
+
encoded = self._encode(script)
|
|
76
|
+
return self._make_powershell_commands(encoded)
|
|
77
|
+
|
|
78
|
+
def run_sync(
|
|
79
|
+
self,
|
|
80
|
+
step: CommandStep,
|
|
81
|
+
on_stdout=None,
|
|
82
|
+
on_stderr=None,
|
|
83
|
+
) -> IOStreamReaderResult:
|
|
84
|
+
proc = subprocess.Popen(
|
|
85
|
+
self._prepare_cmd(step),
|
|
86
|
+
cwd=step.cwd,
|
|
87
|
+
env=step.env,
|
|
88
|
+
stdout=subprocess.PIPE,
|
|
89
|
+
stderr=subprocess.PIPE,
|
|
90
|
+
text=True,
|
|
91
|
+
bufsize=1,
|
|
92
|
+
creationflags=CREATE_NO_WINDOW
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
reader = IOStreamReaderSync(proc, on_stdout, on_stderr, self._max_lines)
|
|
96
|
+
return reader.read(step.timeout)
|
|
97
|
+
|
|
98
|
+
async def run(
|
|
99
|
+
self,
|
|
100
|
+
step: CommandStep,
|
|
101
|
+
on_stdout=None,
|
|
102
|
+
on_stderr=None
|
|
103
|
+
) -> IOStreamReaderResult:
|
|
104
|
+
proc = await asyncio.create_subprocess_exec(
|
|
105
|
+
*self._prepare_cmd(step),
|
|
106
|
+
cwd=step.cwd,
|
|
107
|
+
env=step.env,
|
|
108
|
+
stdout=asyncio.subprocess.PIPE,
|
|
109
|
+
stderr=asyncio.subprocess.PIPE,
|
|
110
|
+
creationflags=CREATE_NO_WINDOW
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
reader = IOStreamReader(proc, self._max_lines, on_stdout, on_stderr)
|
|
114
|
+
return await reader.read(step.timeout)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import shutil
|
|
3
|
+
from abc import abstractmethod
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from .exceptions import CommandNotFoundError, ForbiddenShellTargetError
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class CommandStep:
|
|
10
|
+
command: str
|
|
11
|
+
args: list[str]
|
|
12
|
+
cwd: str | Path
|
|
13
|
+
env: dict[str, str] | None = None
|
|
14
|
+
timeout: int | None = None
|
|
15
|
+
|
|
16
|
+
@abstractmethod
|
|
17
|
+
def to_wrapper_script(self) -> str: ...
|
|
18
|
+
|
|
19
|
+
def validate_command(self, filter: set[str] | None = None):
|
|
20
|
+
if filter is not None:
|
|
21
|
+
name = os.path.basename(self.command).lower()
|
|
22
|
+
if name in filter:
|
|
23
|
+
raise ForbiddenShellTargetError(name)
|
|
24
|
+
resolved = shutil.which(self.command)
|
|
25
|
+
if resolved is None:
|
|
26
|
+
raise CommandNotFoundError(self.command)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
class ShellError(Exception): ...
|
|
2
|
+
|
|
3
|
+
class CommandNotFoundError(ShellError):
|
|
4
|
+
def __init__(self, command: str):
|
|
5
|
+
self.command = command
|
|
6
|
+
super().__init__(f"Command not found: {command}")
|
|
7
|
+
|
|
8
|
+
class ShellRuntimeNotFoundError(ShellError):
|
|
9
|
+
def __init__(self, runtime: str):
|
|
10
|
+
self.runtime = runtime
|
|
11
|
+
super().__init__(f"Shell runtime not found: {runtime}")
|
|
12
|
+
|
|
13
|
+
class ForbiddenShellTargetError(ShellError):
|
|
14
|
+
def __init__(self, command: str):
|
|
15
|
+
self.command = command
|
|
16
|
+
super().__init__(f"Refusing to execute shell program as target: {command}")
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: dais-shell
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: The shell tool for Dais.
|
|
5
|
+
Author: BHznJNs
|
|
6
|
+
Author-email: BHznJNs <441768875@qq.com>
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
|
|
10
|
+
# Dais Shell
|
|
11
|
+
|
|
12
|
+
The shell tool for [Dais](https://github.com/Dais-Project/Dais).
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
dais_shell/__init__.py,sha256=GIEykP4yW5bC31JR1km2_fOVJGyxU_kPWgZd02fDobY,2202
|
|
2
|
+
dais_shell/constants.py,sha256=G5_Bo503U7CB9l5b7cjiQe2gNrGsn4HIQFnBokBrpQo,146
|
|
3
|
+
dais_shell/env_builder.py,sha256=ZIJlxZeOhngU1_nD8xJcvfC2CTLYkv7G8tewI7zppJk,2208
|
|
4
|
+
dais_shell/iostream_reader.py,sha256=IZEXgxHseDUyFNr8OGr4EAtUyohHuMdvkGSv_iaWcKg,6044
|
|
5
|
+
dais_shell/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
dais_shell/runtimes/BaseShellRuntime.py,sha256=997cIoDK866hbvOhDVLO2IAXUZ3TIUV552VnrQi91w0,540
|
|
7
|
+
dais_shell/runtimes/BashRuntime.py,sha256=UGQa_yl_9I9IkG_lWA1h-ApImONKWuKyFJlaEOhNPyE,2741
|
|
8
|
+
dais_shell/runtimes/PowershellRuntime.py,sha256=8G9OhGJYUu12P1H-Mibjs_14IDy_E8EsfFPxSgQXo-c,3342
|
|
9
|
+
dais_shell/runtimes/__init__.py,sha256=VfiKA1QYRYFpdFJDRzzwYitxKuoK86OadjO8wh4Zd4U,133
|
|
10
|
+
dais_shell/types/__init__.py,sha256=6GFk9q51LQfOnIWwd-lgI9w85SodNLzVXbLiwzVhB-k,775
|
|
11
|
+
dais_shell/types/exceptions.py,sha256=F4x3Q9MTK9V_6XbVRHcfzAUahXvDv6YEO1mVa7uSGhs,579
|
|
12
|
+
dais_shell-0.1.1.dist-info/WHEEL,sha256=iHtWm8nRfs0VRdCYVXocAWFW8ppjHL-uTJkAdZJKOBM,80
|
|
13
|
+
dais_shell-0.1.1.dist-info/METADATA,sha256=bNu9cxvfuPEo6NBbZZ7ZFxbumpj4sbqQS4ncEHkT4Mc,289
|
|
14
|
+
dais_shell-0.1.1.dist-info/RECORD,,
|