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.
Files changed (54) hide show
  1. shell_next/__init__.py +71 -0
  2. shell_next/backends/__init__.py +1 -0
  3. shell_next/backends/bash/__init__.py +1 -0
  4. shell_next/backends/bash/authentication.py +64 -0
  5. shell_next/backends/bash/containment.py +34 -0
  6. shell_next/backends/bash/password_channel.py +43 -0
  7. shell_next/backends/bash/syntax.py +30 -0
  8. shell_next/backends/cmd/__init__.py +1 -0
  9. shell_next/backends/cmd/syntax.py +32 -0
  10. shell_next/backends/mock/__init__.py +1 -0
  11. shell_next/backends/mock/driver.py +198 -0
  12. shell_next/backends/mock/scenario.py +131 -0
  13. shell_next/backends/mock/session.py +151 -0
  14. shell_next/backends/mock/state.py +25 -0
  15. shell_next/backends/native/__init__.py +1 -0
  16. shell_next/backends/native/bridge.py +65 -0
  17. shell_next/backends/native/channels.py +116 -0
  18. shell_next/backends/native/containment.py +17 -0
  19. shell_next/backends/native/driver.py +215 -0
  20. shell_next/backends/native/preparation.py +85 -0
  21. shell_next/backends/native/process.py +184 -0
  22. shell_next/backends/native/syntax.py +66 -0
  23. shell_next/backends/native/termination.py +42 -0
  24. shell_next/backends/powershell/__init__.py +1 -0
  25. shell_next/backends/powershell/driver.ps1 +22 -0
  26. shell_next/backends/powershell/syntax.py +86 -0
  27. shell_next/backends/protocol.py +69 -0
  28. shell_next/backends/windows/__init__.py +1 -0
  29. shell_next/backends/windows/containment.py +83 -0
  30. shell_next/backends/windows/limits.py +41 -0
  31. shell_next/errors.py +142 -0
  32. shell_next/frontend/__init__.py +1 -0
  33. shell_next/frontend/capture.py +115 -0
  34. shell_next/frontend/execution.py +199 -0
  35. shell_next/frontend/finalization.py +51 -0
  36. shell_next/frontend/handle.py +228 -0
  37. shell_next/frontend/lease.py +40 -0
  38. shell_next/frontend/observation.py +35 -0
  39. shell_next/frontend/operations.py +78 -0
  40. shell_next/frontend/output.py +123 -0
  41. shell_next/frontend/session.py +313 -0
  42. shell_next/models/__init__.py +1 -0
  43. shell_next/models/capabilities.py +74 -0
  44. shell_next/models/commands.py +48 -0
  45. shell_next/models/config.py +174 -0
  46. shell_next/models/input.py +91 -0
  47. shell_next/models/privilege.py +80 -0
  48. shell_next/models/results.py +167 -0
  49. shell_next/models/state.py +49 -0
  50. shell_next/py.typed +0 -0
  51. shell_next-0.1.0.dist-info/METADATA +124 -0
  52. shell_next-0.1.0.dist-info/RECORD +54 -0
  53. shell_next-0.1.0.dist-info/WHEEL +4 -0
  54. 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
+ )