shell-next 0.1.0__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.
- shell_next/__init__.py +71 -0
- shell_next/backends/__init__.py +1 -0
- shell_next/backends/bash/__init__.py +1 -0
- shell_next/backends/bash/authentication.py +64 -0
- shell_next/backends/bash/containment.py +34 -0
- shell_next/backends/bash/password_channel.py +43 -0
- shell_next/backends/bash/syntax.py +30 -0
- shell_next/backends/cmd/__init__.py +1 -0
- shell_next/backends/cmd/syntax.py +32 -0
- shell_next/backends/mock/__init__.py +1 -0
- shell_next/backends/mock/driver.py +198 -0
- shell_next/backends/mock/scenario.py +131 -0
- shell_next/backends/mock/session.py +151 -0
- shell_next/backends/mock/state.py +25 -0
- shell_next/backends/native/__init__.py +1 -0
- shell_next/backends/native/bridge.py +65 -0
- shell_next/backends/native/channels.py +116 -0
- shell_next/backends/native/containment.py +17 -0
- shell_next/backends/native/driver.py +215 -0
- shell_next/backends/native/preparation.py +85 -0
- shell_next/backends/native/process.py +184 -0
- shell_next/backends/native/syntax.py +66 -0
- shell_next/backends/native/termination.py +42 -0
- shell_next/backends/powershell/__init__.py +1 -0
- shell_next/backends/powershell/driver.ps1 +22 -0
- shell_next/backends/powershell/syntax.py +86 -0
- shell_next/backends/protocol.py +69 -0
- shell_next/backends/windows/__init__.py +1 -0
- shell_next/backends/windows/containment.py +83 -0
- shell_next/backends/windows/limits.py +41 -0
- shell_next/errors.py +142 -0
- shell_next/frontend/__init__.py +1 -0
- shell_next/frontend/capture.py +115 -0
- shell_next/frontend/execution.py +199 -0
- shell_next/frontend/finalization.py +51 -0
- shell_next/frontend/handle.py +228 -0
- shell_next/frontend/lease.py +40 -0
- shell_next/frontend/observation.py +35 -0
- shell_next/frontend/operations.py +78 -0
- shell_next/frontend/output.py +123 -0
- shell_next/frontend/session.py +313 -0
- shell_next/models/__init__.py +1 -0
- shell_next/models/capabilities.py +74 -0
- shell_next/models/commands.py +48 -0
- shell_next/models/config.py +174 -0
- shell_next/models/input.py +91 -0
- shell_next/models/privilege.py +80 -0
- shell_next/models/results.py +167 -0
- shell_next/models/state.py +49 -0
- shell_next/py.typed +0 -0
- shell_next-0.1.0.dist-info/METADATA +124 -0
- shell_next-0.1.0.dist-info/RECORD +54 -0
- shell_next-0.1.0.dist-info/WHEEL +4 -0
- shell_next-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""First-class session test double using the production ownership frontend."""
|
|
2
|
+
|
|
3
|
+
import ntpath
|
|
4
|
+
import posixpath
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import replace
|
|
7
|
+
|
|
8
|
+
from shell_next.backends.mock.driver import MockDriver
|
|
9
|
+
from shell_next.backends.mock.scenario import MockScenario
|
|
10
|
+
from shell_next.backends.mock.state import state_access
|
|
11
|
+
from shell_next.errors import ConfigurationError
|
|
12
|
+
from shell_next.frontend.session import ShellSession
|
|
13
|
+
from shell_next.models.config import SessionConfig
|
|
14
|
+
from shell_next.models.state import Backend
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MockShellSession(ShellSession):
|
|
18
|
+
"""Side-effect-free session with virtual state and strict command expectations.
|
|
19
|
+
|
|
20
|
+
:param config: Normal application session configuration.
|
|
21
|
+
:param scenario: Optional predetermined commands and interactions.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, config: SessionConfig, scenario: MockScenario | None = None) -> None:
|
|
25
|
+
"""Replace native transport before session entry and disable implicit files.
|
|
26
|
+
|
|
27
|
+
:param config: Normal session configuration.
|
|
28
|
+
:param scenario: Strict scenario, or a new empty strict scenario.
|
|
29
|
+
"""
|
|
30
|
+
super().__init__(replace(config, capture=replace(config.capture, directory=None)))
|
|
31
|
+
self.scenario = scenario if scenario is not None else MockScenario()
|
|
32
|
+
self.driver = MockDriver(self.scenario, self)
|
|
33
|
+
self.cwd = config.cwd or ("C:\\" if config.backend != Backend.BASH else "/")
|
|
34
|
+
self.session_id = "mock-session"
|
|
35
|
+
self.virtual_time = True
|
|
36
|
+
self.command_count = 0
|
|
37
|
+
|
|
38
|
+
def next_command_id(self) -> str:
|
|
39
|
+
"""Allocate deterministic scenario-local identifiers.
|
|
40
|
+
|
|
41
|
+
:returns: Sequential readable mock command identifier.
|
|
42
|
+
"""
|
|
43
|
+
self.command_count += 1
|
|
44
|
+
return f"mock-command-{self.command_count}"
|
|
45
|
+
|
|
46
|
+
@classmethod
|
|
47
|
+
def configured(cls, scenario: MockScenario) -> type[MockShellSession]:
|
|
48
|
+
"""Bind a scenario to the official config._session_cls injection point.
|
|
49
|
+
|
|
50
|
+
:param scenario: Predetermined application behavior to assert.
|
|
51
|
+
:returns: Session implementation class bound to this scenario.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
class ScenarioSession(MockShellSession):
|
|
55
|
+
"""Session implementation bound to one explicitly supplied test scenario."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, config: SessionConfig) -> None:
|
|
58
|
+
"""Attach the bound scenario without affecting application configuration.
|
|
59
|
+
|
|
60
|
+
:param config: Application's normal configuration.
|
|
61
|
+
"""
|
|
62
|
+
super().__init__(config, scenario)
|
|
63
|
+
|
|
64
|
+
return ScenarioSession
|
|
65
|
+
|
|
66
|
+
def clock(self) -> float:
|
|
67
|
+
"""Read virtual time without consulting the real monotonic clock.
|
|
68
|
+
|
|
69
|
+
:returns: Scenario time in simulated seconds.
|
|
70
|
+
"""
|
|
71
|
+
return self.scenario.elapsed
|
|
72
|
+
|
|
73
|
+
def record(self, operation: str, *args: object) -> None:
|
|
74
|
+
"""Record ordered observable operations with pre-redacted secret payloads.
|
|
75
|
+
|
|
76
|
+
:param operation: Operation name.
|
|
77
|
+
:param args: Safe arguments; input secrets have already been redacted.
|
|
78
|
+
"""
|
|
79
|
+
self.scenario.calls.append((operation, *args))
|
|
80
|
+
|
|
81
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
82
|
+
"""Close and validate strict expectations without masking a body exception.
|
|
83
|
+
|
|
84
|
+
:param exc: Python context exception information.
|
|
85
|
+
:raises MockExpectationNotConsumedError: Required expectations remain unused.
|
|
86
|
+
"""
|
|
87
|
+
await self.aclose()
|
|
88
|
+
if not exc or exc[0] is None:
|
|
89
|
+
self.scenario.assert_consumed()
|
|
90
|
+
|
|
91
|
+
async def chdir(self, path: str) -> None:
|
|
92
|
+
"""Change only virtual working-directory state.
|
|
93
|
+
|
|
94
|
+
:param path: Virtual path; POSIX relative paths resolve against virtual cwd.
|
|
95
|
+
"""
|
|
96
|
+
async with state_access(self):
|
|
97
|
+
paths = posixpath if self.config.backend == Backend.BASH else ntpath
|
|
98
|
+
self.cwd = paths.normpath(paths.join(self.cwd or "/", path))
|
|
99
|
+
self.record("chdir", path)
|
|
100
|
+
|
|
101
|
+
async def set_env(self, name: str, value: str) -> None:
|
|
102
|
+
"""Update only the virtual exported environment.
|
|
103
|
+
|
|
104
|
+
:param name: Nonempty environment name without equals or NUL.
|
|
105
|
+
:param value: NUL-free environment value.
|
|
106
|
+
:raises ConfigurationError: The name or value is invalid.
|
|
107
|
+
"""
|
|
108
|
+
async with state_access(self):
|
|
109
|
+
if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name) is None or "\0" in value:
|
|
110
|
+
raise ConfigurationError("Invalid portable environment name or value")
|
|
111
|
+
self.environment[name] = value
|
|
112
|
+
self.record("set_env", name)
|
|
113
|
+
|
|
114
|
+
async def unset_env(self, name: str) -> None:
|
|
115
|
+
"""Remove an exported variable from the virtual environment.
|
|
116
|
+
|
|
117
|
+
:param name: Virtual variable name.
|
|
118
|
+
"""
|
|
119
|
+
async with state_access(self):
|
|
120
|
+
if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name) is None:
|
|
121
|
+
raise ConfigurationError("Invalid portable environment name")
|
|
122
|
+
self.environment.pop(name, None)
|
|
123
|
+
self.record("unset_env", name)
|
|
124
|
+
|
|
125
|
+
async def get_cwd(self) -> str:
|
|
126
|
+
"""Read the virtual working directory without calling os.getcwd.
|
|
127
|
+
|
|
128
|
+
:returns: Virtual directory string.
|
|
129
|
+
"""
|
|
130
|
+
async with state_access(self):
|
|
131
|
+
self.record("get_cwd")
|
|
132
|
+
return self.cwd or "/"
|
|
133
|
+
|
|
134
|
+
async def get_env(self, name: str | None = None) -> str | dict[str, str] | None:
|
|
135
|
+
"""Read virtual exported state without consulting os.environ.
|
|
136
|
+
|
|
137
|
+
:param name: Specific variable or None for a copied environment view.
|
|
138
|
+
:returns: Value, missing marker, or copied environment dictionary.
|
|
139
|
+
"""
|
|
140
|
+
async with state_access(self):
|
|
141
|
+
self.record("get_env", name)
|
|
142
|
+
return self.environment.get(name) if name is not None else dict(self.environment)
|
|
143
|
+
|
|
144
|
+
async def ping(self) -> bool:
|
|
145
|
+
"""Observe virtual session health without consuming a user expectation.
|
|
146
|
+
|
|
147
|
+
:returns: Whether the mock remains usable.
|
|
148
|
+
"""
|
|
149
|
+
async with state_access(self):
|
|
150
|
+
self.record("ping")
|
|
151
|
+
return self.is_usable
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Virtual state access through the common lifecycle and execution lease."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncIterator
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from shell_next.frontend.observation import checkpoint
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from shell_next.frontend.session import ShellSession
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@asynccontextmanager
|
|
14
|
+
async def state_access(session: ShellSession) -> AsyncIterator[None]:
|
|
15
|
+
"""Serialize a virtual state operation after previously submitted command work.
|
|
16
|
+
|
|
17
|
+
:param session: Owning mock session with normal concurrency configuration.
|
|
18
|
+
:returns: Scope holding the session's execution lease.
|
|
19
|
+
:raises SessionError: Lifecycle, busy, or reentrancy validation fails.
|
|
20
|
+
"""
|
|
21
|
+
session.validate_submission()
|
|
22
|
+
await checkpoint()
|
|
23
|
+
async with session.lease:
|
|
24
|
+
session.validate_submission()
|
|
25
|
+
yield
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Native domain for shell-next."""
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Structural subprocess execution inside the current shell's exported state."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from contextlib import ExitStack
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from shell_next.backends.bash.authentication import authenticate
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main(path: str) -> int:
|
|
13
|
+
"""Run a JSON-described executable without interpreting its arguments as shell text.
|
|
14
|
+
|
|
15
|
+
:param path: Private command manifest containing argv and pipe endpoints.
|
|
16
|
+
:returns: Native process exit code, or 127 if the executable cannot start.
|
|
17
|
+
"""
|
|
18
|
+
manifest = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
19
|
+
with (
|
|
20
|
+
open(manifest["stdout"], "wb", buffering=0) as stdout,
|
|
21
|
+
open(manifest["stderr"], "wb", buffering=0) as stderr,
|
|
22
|
+
open(manifest["stdin"], "rb", buffering=0) as stdin,
|
|
23
|
+
):
|
|
24
|
+
argv = manifest["argv"]
|
|
25
|
+
try:
|
|
26
|
+
if manifest.get("elevated"):
|
|
27
|
+
with ExitStack() as stack:
|
|
28
|
+
requests = responses = None
|
|
29
|
+
if manifest["interactive"]:
|
|
30
|
+
requests = stack.enter_context(
|
|
31
|
+
open(manifest["auth_out"], "wb", buffering=0)
|
|
32
|
+
)
|
|
33
|
+
responses = stack.enter_context(
|
|
34
|
+
open(manifest["auth_in"], "rb", buffering=0)
|
|
35
|
+
)
|
|
36
|
+
print(manifest["token"] + ":auth_ready", flush=True)
|
|
37
|
+
authenticated, attempts = authenticate(
|
|
38
|
+
manifest["target"],
|
|
39
|
+
manifest["attempts"],
|
|
40
|
+
(manifest["token"] + "-password:").encode("ascii"),
|
|
41
|
+
requests,
|
|
42
|
+
responses,
|
|
43
|
+
)
|
|
44
|
+
print(f"{manifest['token']}:auth:{int(authenticated)}:{attempts}", flush=True)
|
|
45
|
+
if not authenticated:
|
|
46
|
+
print(manifest["token"] + ":ready", flush=True)
|
|
47
|
+
return 126
|
|
48
|
+
argv = [
|
|
49
|
+
"sudo",
|
|
50
|
+
"-n",
|
|
51
|
+
*(["-u", manifest["target"]] if manifest["target"] else []),
|
|
52
|
+
"--",
|
|
53
|
+
*argv,
|
|
54
|
+
]
|
|
55
|
+
print(manifest["token"] + ":ready", flush=True)
|
|
56
|
+
return subprocess.run(argv, stdin=stdin, stdout=stdout, stderr=stderr).returncode
|
|
57
|
+
except OSError:
|
|
58
|
+
print(manifest["token"] + ":ready", flush=True)
|
|
59
|
+
print(manifest["token"] + ":startup_failure", flush=True)
|
|
60
|
+
stderr.write(b"shell-next: executable could not start\n")
|
|
61
|
+
return 127
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
if __name__ == "__main__":
|
|
65
|
+
sys.exit(main(sys.argv[1]))
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
"""Command-specific byte pipes; no shell control bytes share business input."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class CommandChannel:
|
|
11
|
+
"""One named pipe endpoint, using Windows IOCP or a POSIX FIFO.
|
|
12
|
+
|
|
13
|
+
:param path: Private pipe name or FIFO path.
|
|
14
|
+
:param input_channel: True when the package writes business input.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, path: str, input_channel: bool = False) -> None:
|
|
18
|
+
"""Initialize endpoint ownership without creating operating-system resources.
|
|
19
|
+
|
|
20
|
+
:param path: Private endpoint name.
|
|
21
|
+
:param input_channel: Whether this endpoint supplies stdin.
|
|
22
|
+
"""
|
|
23
|
+
self.path = path
|
|
24
|
+
self.input_channel = input_channel
|
|
25
|
+
self.reader = asyncio.StreamReader()
|
|
26
|
+
self.writer: asyncio.StreamWriter | None = None
|
|
27
|
+
self.connected = asyncio.Event()
|
|
28
|
+
self.transport: asyncio.BaseTransport | None = None
|
|
29
|
+
self.servers: list[Any] = []
|
|
30
|
+
self.keeper: int | None = None
|
|
31
|
+
|
|
32
|
+
def accept(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
33
|
+
"""Adopt the first Windows pipe client and reject additional connections.
|
|
34
|
+
|
|
35
|
+
:param reader: Client input stream.
|
|
36
|
+
:param writer: Client output stream.
|
|
37
|
+
"""
|
|
38
|
+
if self.connected.is_set():
|
|
39
|
+
writer.close()
|
|
40
|
+
return
|
|
41
|
+
self.reader = reader
|
|
42
|
+
self.writer = writer
|
|
43
|
+
self.connected.set()
|
|
44
|
+
|
|
45
|
+
async def open(self) -> None:
|
|
46
|
+
"""Create a private endpoint with bounded asyncio transport buffering.
|
|
47
|
+
|
|
48
|
+
:raises OSError: The operating system cannot create the endpoint.
|
|
49
|
+
"""
|
|
50
|
+
loop = asyncio.get_running_loop()
|
|
51
|
+
if sys.platform == "win32":
|
|
52
|
+
|
|
53
|
+
def protocol_factory() -> asyncio.StreamReaderProtocol:
|
|
54
|
+
"""Build the Windows pipe server's stream adapter.
|
|
55
|
+
|
|
56
|
+
:returns: Protocol accepting one client into this channel.
|
|
57
|
+
"""
|
|
58
|
+
return asyncio.StreamReaderProtocol(asyncio.StreamReader(), self.accept)
|
|
59
|
+
|
|
60
|
+
self.servers = await cast(Any, loop).start_serving_pipe(protocol_factory, self.path)
|
|
61
|
+
else:
|
|
62
|
+
os.mkfifo(self.path, 0o600)
|
|
63
|
+
read_fd = os.open(self.path, os.O_RDONLY | os.O_NONBLOCK)
|
|
64
|
+
self.keeper = os.open(self.path, os.O_WRONLY | os.O_NONBLOCK)
|
|
65
|
+
if self.input_channel:
|
|
66
|
+
protocol = asyncio.streams.FlowControlMixin(loop=loop)
|
|
67
|
+
pipe = os.fdopen(self.keeper, "wb", buffering=0)
|
|
68
|
+
self.keeper = read_fd
|
|
69
|
+
transport, _ = await loop.connect_write_pipe(lambda: protocol, pipe)
|
|
70
|
+
self.transport = transport
|
|
71
|
+
self.writer = asyncio.StreamWriter(transport, protocol, None, loop)
|
|
72
|
+
else:
|
|
73
|
+
pipe = os.fdopen(read_fd, "rb", buffering=0)
|
|
74
|
+
read_transport, _ = await loop.connect_read_pipe(
|
|
75
|
+
lambda: asyncio.StreamReaderProtocol(self.reader), pipe
|
|
76
|
+
)
|
|
77
|
+
self.transport = read_transport
|
|
78
|
+
self.connected.set()
|
|
79
|
+
|
|
80
|
+
def release_keeper(self) -> None:
|
|
81
|
+
"""Release the POSIX bootstrap descriptor after native redirection opens."""
|
|
82
|
+
if self.keeper is not None:
|
|
83
|
+
os.close(self.keeper)
|
|
84
|
+
self.keeper = None
|
|
85
|
+
|
|
86
|
+
async def read(self, size: int) -> bytes:
|
|
87
|
+
"""Read command bytes after the shell connects the endpoint.
|
|
88
|
+
|
|
89
|
+
:param size: Maximum bytes returned in one transport chunk.
|
|
90
|
+
:returns: Bytes, or empty bytes at EOF.
|
|
91
|
+
"""
|
|
92
|
+
await self.connected.wait()
|
|
93
|
+
return await self.reader.read(size)
|
|
94
|
+
|
|
95
|
+
async def send(self, data: bytes) -> None:
|
|
96
|
+
"""Submit bytes through the endpoint with transport backpressure.
|
|
97
|
+
|
|
98
|
+
:param data: Business input bytes.
|
|
99
|
+
:raises ConnectionError: The native command closed its input.
|
|
100
|
+
"""
|
|
101
|
+
await self.connected.wait()
|
|
102
|
+
assert self.writer is not None
|
|
103
|
+
self.writer.write(data)
|
|
104
|
+
await self.writer.drain()
|
|
105
|
+
|
|
106
|
+
def close(self) -> None:
|
|
107
|
+
"""Idempotently close the endpoint and its bootstrap descriptors."""
|
|
108
|
+
self.release_keeper()
|
|
109
|
+
if self.writer is not None:
|
|
110
|
+
self.writer.close()
|
|
111
|
+
if self.transport is not None:
|
|
112
|
+
self.transport.close()
|
|
113
|
+
for server in self.servers:
|
|
114
|
+
server.close()
|
|
115
|
+
if sys.platform != "win32":
|
|
116
|
+
Path(self.path).unlink(missing_ok=True)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Dispatch containment to the selected operating-system domain."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from shell_next.backends.bash.containment import PosixGroup
|
|
7
|
+
from shell_next.backends.windows.containment import WindowsJob
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def create_containment(process: asyncio.subprocess.Process) -> PosixGroup | WindowsJob:
|
|
11
|
+
"""Contain an idle shell before any user-controlled command executes.
|
|
12
|
+
|
|
13
|
+
:param process: Newly created persistent interpreter.
|
|
14
|
+
:returns: Process-group or Job Object containment owner.
|
|
15
|
+
:raises OSError: The operating system rejects containment assignment.
|
|
16
|
+
"""
|
|
17
|
+
return WindowsJob(process) if sys.platform == "win32" else PosixGroup(process.pid)
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Command preparation and stream collection for persistent native interpreters."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from shell_next.backends.bash.password_channel import supply_passwords
|
|
10
|
+
from shell_next.backends.native.channels import CommandChannel
|
|
11
|
+
from shell_next.backends.native.preparation import prepare_command
|
|
12
|
+
from shell_next.backends.native.process import NativeProcess
|
|
13
|
+
from shell_next.backends.native.syntax import quote, source_script
|
|
14
|
+
from shell_next.backends.native.termination import stop_native
|
|
15
|
+
from shell_next.errors import CaptureError, SessionProtocolError
|
|
16
|
+
from shell_next.models.commands import Command
|
|
17
|
+
from shell_next.models.config import SessionConfig
|
|
18
|
+
from shell_next.models.privilege import PrivilegeReport
|
|
19
|
+
from shell_next.models.results import BackendStatus, CleanupReport
|
|
20
|
+
from shell_next.models.state import Backend
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from shell_next.frontend.handle import CommandHandle
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class NativeDriver:
|
|
27
|
+
"""Transport implementation shared by the three native shell languages.
|
|
28
|
+
|
|
29
|
+
:param config: Session configuration.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, config: SessionConfig) -> None:
|
|
33
|
+
"""Create resource bookkeeping without starting the interpreter.
|
|
34
|
+
|
|
35
|
+
:param config: Validated session setup.
|
|
36
|
+
"""
|
|
37
|
+
self.config = config
|
|
38
|
+
self.native = NativeProcess(config)
|
|
39
|
+
self.channels: dict[str, CommandChannel] = {}
|
|
40
|
+
self.files: list[Path] = []
|
|
41
|
+
self.pumps: list[asyncio.Task[None]] = []
|
|
42
|
+
self.activation: asyncio.Task[None] | None = None
|
|
43
|
+
self.ready = asyncio.Event()
|
|
44
|
+
self.timeouts = config.defaults.timeouts
|
|
45
|
+
self.authentication: asyncio.Task[None] | None = None
|
|
46
|
+
|
|
47
|
+
async def start(self) -> None:
|
|
48
|
+
"""Start the interpreter and its containment unit.
|
|
49
|
+
|
|
50
|
+
:raises SessionProtocolError: The configured interpreter cannot start.
|
|
51
|
+
"""
|
|
52
|
+
await self.native.start()
|
|
53
|
+
|
|
54
|
+
def reserve(self, command: Command) -> None:
|
|
55
|
+
"""Accept a validated command; native preparation occurs after lease acquisition.
|
|
56
|
+
|
|
57
|
+
:param command: Submitted command description.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
async def prepare(self, handle: CommandHandle) -> None:
|
|
61
|
+
"""Create private command pipes and native wrapper files.
|
|
62
|
+
|
|
63
|
+
:param handle: Command owning these resources.
|
|
64
|
+
:raises OSError: Pipe or private file creation fails.
|
|
65
|
+
"""
|
|
66
|
+
self.timeouts = handle.options.timeouts
|
|
67
|
+
await prepare_command(self, handle)
|
|
68
|
+
|
|
69
|
+
async def activate(self, handle: CommandHandle) -> None:
|
|
70
|
+
"""Open caller input only after native command-specific channels exist.
|
|
71
|
+
|
|
72
|
+
:param handle: Command receiving the readiness notification.
|
|
73
|
+
"""
|
|
74
|
+
if os.name == "nt":
|
|
75
|
+
await self.channels["stdin"].connected.wait()
|
|
76
|
+
else:
|
|
77
|
+
await self.ready.wait()
|
|
78
|
+
for channel in self.channels.values():
|
|
79
|
+
channel.release_keeper()
|
|
80
|
+
handle.ready.set()
|
|
81
|
+
|
|
82
|
+
async def pump(self, handle: CommandHandle, name: str) -> None:
|
|
83
|
+
"""Continuously drain one command stream independently of subscribers.
|
|
84
|
+
|
|
85
|
+
:param handle: Owning command capture.
|
|
86
|
+
:param name: stdout or stderr endpoint.
|
|
87
|
+
"""
|
|
88
|
+
channel = self.channels[name]
|
|
89
|
+
while data := await channel.read(65536):
|
|
90
|
+
await handle.emit("stdout" if name == "stdout" else "stderr", data)
|
|
91
|
+
|
|
92
|
+
async def execute(self, handle: CommandHandle) -> BackendStatus:
|
|
93
|
+
"""Execute the prepared wrapper and await status plus bounded stream EOF.
|
|
94
|
+
|
|
95
|
+
:param handle: Command owning the current execution lease.
|
|
96
|
+
:returns: Backend-native completion status.
|
|
97
|
+
:raises SessionProtocolError: The shell loses its control channel.
|
|
98
|
+
:raises TimeoutError: Descendant output exceeds the drain deadline.
|
|
99
|
+
"""
|
|
100
|
+
self.pumps = [asyncio.create_task(self.pump(handle, name)) for name in ("stdout", "stderr")]
|
|
101
|
+
self.activation = asyncio.create_task(self.activate(handle))
|
|
102
|
+
if "auth_out" in self.channels:
|
|
103
|
+
self.authentication = asyncio.create_task(
|
|
104
|
+
supply_passwords(handle, self.channels["auth_out"], self.channels["auth_in"])
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
def authentication_finished(task: asyncio.Task[None]) -> None:
|
|
108
|
+
"""Stop execution if the secret provider fails.
|
|
109
|
+
|
|
110
|
+
:param task: Completed private authentication task.
|
|
111
|
+
"""
|
|
112
|
+
if not task.cancelled() and task.exception() is not None:
|
|
113
|
+
handle.startup_failed = True
|
|
114
|
+
handle.privilege = PrivilegeReport(True, False)
|
|
115
|
+
handle.stop_requested.set()
|
|
116
|
+
|
|
117
|
+
self.authentication.add_done_callback(authentication_finished)
|
|
118
|
+
wrapper = self.files[-1]
|
|
119
|
+
line = (
|
|
120
|
+
quote(str(wrapper), self.config.backend)
|
|
121
|
+
if self.config.backend == Backend.POWERSHELL
|
|
122
|
+
else source_script(wrapper, self.config.backend)
|
|
123
|
+
)
|
|
124
|
+
# PowerShell's driver accepts literal paths rather than expressions.
|
|
125
|
+
if self.config.backend == Backend.POWERSHELL:
|
|
126
|
+
line = str(wrapper)
|
|
127
|
+
await self.native.write(line + "\n")
|
|
128
|
+
process = self.native.process
|
|
129
|
+
assert process is not None and process.stdout is not None
|
|
130
|
+
token = (handle.command_id + ":").encode()
|
|
131
|
+
while True:
|
|
132
|
+
data = await self.native.read_control()
|
|
133
|
+
if not data:
|
|
134
|
+
raise SessionProtocolError(
|
|
135
|
+
"Persistent shell exited before reporting command status"
|
|
136
|
+
)
|
|
137
|
+
index = data.find(token)
|
|
138
|
+
if index < 0:
|
|
139
|
+
continue
|
|
140
|
+
payload = data[index + len(token) :].strip()
|
|
141
|
+
if payload == b"auth_ready":
|
|
142
|
+
self.channels["auth_out"].release_keeper()
|
|
143
|
+
self.channels["auth_in"].release_keeper()
|
|
144
|
+
continue
|
|
145
|
+
if payload.startswith(b"auth:"):
|
|
146
|
+
_, authenticated, attempts = payload.split(b":")
|
|
147
|
+
handle.privilege = PrivilegeReport(True, authenticated == b"1", int(attempts))
|
|
148
|
+
continue
|
|
149
|
+
if payload == b"ready":
|
|
150
|
+
self.ready.set()
|
|
151
|
+
continue
|
|
152
|
+
if payload == b"startup_failure":
|
|
153
|
+
handle.startup_failed = True
|
|
154
|
+
continue
|
|
155
|
+
if payload.startswith(b"{"):
|
|
156
|
+
status = json.loads(payload)
|
|
157
|
+
result = BackendStatus(
|
|
158
|
+
None, status["success"], status["native"], status["terminating"]
|
|
159
|
+
)
|
|
160
|
+
else:
|
|
161
|
+
result = BackendStatus(int(payload))
|
|
162
|
+
break
|
|
163
|
+
handle.backend_status = result
|
|
164
|
+
try:
|
|
165
|
+
async with asyncio.timeout(handle.options.timeouts.drain):
|
|
166
|
+
await asyncio.gather(*self.pumps)
|
|
167
|
+
except TimeoutError as exc:
|
|
168
|
+
raise CaptureError("Output drain deadline expired") from exc
|
|
169
|
+
return result
|
|
170
|
+
|
|
171
|
+
async def send(self, data: bytes) -> None:
|
|
172
|
+
"""Send business input on the active command's dedicated pipe.
|
|
173
|
+
|
|
174
|
+
:param data: Input bytes, never shell control text.
|
|
175
|
+
"""
|
|
176
|
+
await self.channels["stdin"].send(data)
|
|
177
|
+
|
|
178
|
+
async def close_stdin(self) -> None:
|
|
179
|
+
"""Close only the active command's business input pipe."""
|
|
180
|
+
self.channels["stdin"].close()
|
|
181
|
+
|
|
182
|
+
async def finish(self) -> None:
|
|
183
|
+
"""Release per-command pipes, pump tasks, and private wrapper files."""
|
|
184
|
+
tasks = [*self.pumps]
|
|
185
|
+
if self.authentication is not None:
|
|
186
|
+
tasks.append(self.authentication)
|
|
187
|
+
if self.activation is not None:
|
|
188
|
+
tasks.append(self.activation)
|
|
189
|
+
for task in tasks:
|
|
190
|
+
task.cancel()
|
|
191
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
192
|
+
for channel in self.channels.values():
|
|
193
|
+
channel.close()
|
|
194
|
+
self.channels.clear()
|
|
195
|
+
for path in self.files:
|
|
196
|
+
path.unlink(missing_ok=True)
|
|
197
|
+
self.files.clear()
|
|
198
|
+
self.pumps.clear()
|
|
199
|
+
self.activation = None
|
|
200
|
+
self.authentication = None
|
|
201
|
+
|
|
202
|
+
async def stop(self) -> CleanupReport:
|
|
203
|
+
"""Invalidate and terminate the persistent shell's entire containment unit.
|
|
204
|
+
|
|
205
|
+
:returns: Process cleanup report.
|
|
206
|
+
"""
|
|
207
|
+
return await stop_native(self)
|
|
208
|
+
|
|
209
|
+
async def close(self) -> CleanupReport:
|
|
210
|
+
"""Close all session resources idempotently.
|
|
211
|
+
|
|
212
|
+
:returns: Process containment cleanup report.
|
|
213
|
+
"""
|
|
214
|
+
await self.finish()
|
|
215
|
+
return await self.native.close()
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Private pipe, manifest, and wrapper preparation for native commands."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from shell_next.backends.native.channels import CommandChannel
|
|
10
|
+
from shell_next.backends.native.syntax import command_wrapper, invocation, source_script
|
|
11
|
+
from shell_next.models.commands import ProcessCommand
|
|
12
|
+
from shell_next.models.state import Backend
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from shell_next.backends.native.driver import NativeDriver
|
|
16
|
+
from shell_next.frontend.handle import CommandHandle
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def prepare_command(driver: NativeDriver, handle: CommandHandle) -> None:
|
|
20
|
+
"""Allocate only the current command's native transport resources.
|
|
21
|
+
|
|
22
|
+
:param driver: Resource-owning native driver.
|
|
23
|
+
:param handle: Command with a validated identity request.
|
|
24
|
+
:raises OSError: Pipe or private manifest creation fails.
|
|
25
|
+
"""
|
|
26
|
+
driver.ready.clear()
|
|
27
|
+
backend = driver.config.backend
|
|
28
|
+
token = handle.command_id
|
|
29
|
+
privilege = handle.options.privilege
|
|
30
|
+
elevated = privilege.requirement == "elevated"
|
|
31
|
+
driver.native.privileged_used |= elevated
|
|
32
|
+
names = ["stdout", "stderr", "stdin"]
|
|
33
|
+
if elevated and privilege.interactive:
|
|
34
|
+
names.extend(("auth_out", "auth_in"))
|
|
35
|
+
for name in names:
|
|
36
|
+
path = (
|
|
37
|
+
rf"\\.\pipe\shell-next-{token}-{name}"
|
|
38
|
+
if os.name == "nt"
|
|
39
|
+
else str(driver.native.path / f"{token}.{name}")
|
|
40
|
+
)
|
|
41
|
+
driver.channels[name] = CommandChannel(path, name in ("stdin", "auth_in"))
|
|
42
|
+
await driver.channels[name].open()
|
|
43
|
+
paths = {name: channel.path for name, channel in driver.channels.items()}
|
|
44
|
+
suffix = {Backend.BASH: ".sh", Backend.POWERSHELL: ".ps1", Backend.CMD: ".cmd"}[backend]
|
|
45
|
+
native_script = not isinstance(handle.command, ProcessCommand)
|
|
46
|
+
if isinstance(handle.command, ProcessCommand):
|
|
47
|
+
argv = [handle.command.executable, *handle.command.args]
|
|
48
|
+
else:
|
|
49
|
+
script = driver.native.path / f"{token}.user{suffix}"
|
|
50
|
+
driver.files.append(script)
|
|
51
|
+
script.write_text(handle.command.text + "\n", encoding="utf-8")
|
|
52
|
+
text = source_script(script, backend)
|
|
53
|
+
argv = [
|
|
54
|
+
driver.config.executable or "bash",
|
|
55
|
+
"--noprofile",
|
|
56
|
+
"--norc",
|
|
57
|
+
"-c",
|
|
58
|
+
handle.command.text,
|
|
59
|
+
]
|
|
60
|
+
if not native_script or elevated:
|
|
61
|
+
manifest = driver.native.path / f"{token}.json"
|
|
62
|
+
driver.files.append(manifest)
|
|
63
|
+
manifest.write_text(
|
|
64
|
+
json.dumps(
|
|
65
|
+
{
|
|
66
|
+
**paths,
|
|
67
|
+
"argv": argv,
|
|
68
|
+
"token": token,
|
|
69
|
+
"elevated": elevated,
|
|
70
|
+
"interactive": privilege.interactive,
|
|
71
|
+
"target": privilege.target_identity,
|
|
72
|
+
"attempts": privilege.attempts,
|
|
73
|
+
}
|
|
74
|
+
),
|
|
75
|
+
encoding="utf-8",
|
|
76
|
+
)
|
|
77
|
+
text = invocation(
|
|
78
|
+
[sys.executable, str(Path(__file__).with_name("bridge.py")), str(manifest)], backend
|
|
79
|
+
)
|
|
80
|
+
wrapper = driver.native.path / f"{token}{suffix}"
|
|
81
|
+
driver.files.append(wrapper)
|
|
82
|
+
wrapper.write_text(
|
|
83
|
+
command_wrapper(backend, text, token, native_script and not elevated, paths),
|
|
84
|
+
encoding="utf-8",
|
|
85
|
+
)
|