diff --git a/README.md b/README.md index 4b4ce93..d485548 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ LiveShell is a generic local execution substrate for Python. It provides persistent shell sessions, synchronous and asynchronous APIs, durable command records, replayable command events, and a small JSON-lines daemon protocol for process-managed integrations. -It is intentionally reusable infrastructure. LiveShell owns local sessions, commands, output events, cancellation, and backend capability discovery. It does not include agent, plan, scheduler, work-packet, evidence, policy, URL-handler, or network-server concepts. +It is intentionally reusable infrastructure. LiveShell owns local sessions, commands, output events, cancellation, and backend capability discovery. It does not include agent, plan, scheduler, work-packet, evidence, policy, URL-handler, or remote-network-server concepts. Its optional daemon transport is a loopback-only TCP socket (see [Transports](docs/PROTOCOL.md#transports)); there is no remote listener. ## What It Provides @@ -134,7 +134,7 @@ Async code can use methods such as `discover_capabilities_async`, `create_sessio ## Daemon Protocol -The daemon speaks JSON lines over stdio: +The daemon speaks JSON-line protocol frames over stdio or the loopback TCP socket transport: - Each request is one JSON object followed by a newline. - Each response is one JSON object followed by a newline. @@ -154,18 +154,31 @@ Example response: {"id":"req_1","ok":true,"result":{"protocol_version":"1.0","capabilities":[]}} ``` -Start a long-running daemon on stdio: +Start a long-running daemon on stdio (lifetime is bound to the launching process's pipes): ```powershell liveshell daemon stdio --state-dir .\.liveshell-state ``` -Process exactly one request and exit, which is useful for deterministic tests: +For stdio, process exactly one request and exit, which is useful for deterministic tests: ```powershell liveshell daemon stdio --once --state-dir .\.liveshell-state ``` +Start a long-running daemon on the loopback TCP socket transport (keeps running after clients disconnect): + +```powershell +liveshell daemon serve --state-dir .\.liveshell-state +``` + +Attach from a fresh client after the socket daemon is running: + +```python +with LiveShellClient.connect(".liveshell-state") as client: + ... +``` + Supported protocol methods: - `capability.discover` @@ -255,6 +268,9 @@ liveshell --json-pretty capability discover liveshell run --kind cmd --command "echo hello" --timeout-seconds 5 liveshell daemon stdio liveshell daemon stdio --once +liveshell daemon start --state-dir .\.liveshell-state +liveshell daemon serve --host 127.0.0.1 --port 0 --state-dir .\.liveshell-state +liveshell daemon stop --state-dir .\.liveshell-state liveshell daemon status liveshell daemon shutdown --reason maintenance liveshell session list --state-dir .\.liveshell-state @@ -267,9 +283,9 @@ liveshell command cancel --command-id cmd_... --state-dir .\.liveshell-state `liveshell run` is a one-shot convenience command. It starts a local stdio daemon, creates a session, runs one command, waits for the durable result envelope, closes the session, and exits. -Long-lived live sessions, command start, and active command cancellation require the daemon process that owns the in-memory shell session. Direct `session create`, `command start`, and active `command cancel` CLI paths fail clearly outside that daemon instead of faking success against only the SQLite store. Use `LiveShellClient` or send protocol requests to a running stdio daemon for live session control. +Long-lived live sessions, command start, and active command cancellation require the daemon process that owns the in-memory shell session. Direct `session create`, `command start`, and active `command cancel` CLI paths fail clearly outside that daemon instead of faking success against only the SQLite store. Use `LiveShellClient` or send protocol requests to a running stdio or socket daemon for live session control. `liveshell daemon start` launches a detached, persistent socket daemon (loopback TCP) that survives the launching process; attach to it from a fresh client with `LiveShellClient.connect(state_dir)` and stop it with `liveshell daemon stop`. -`daemon.status` reads local state-dir daemon metadata. `daemon.shutdown` writes a local shutdown marker; stdio daemons also support the reliable `daemon.shutdown` protocol method over their stdin. A default network server, OS URL protocol handler, and hidden command execution from links are intentionally not provided. +`daemon.status` reads local state-dir daemon metadata. `daemon.shutdown` writes a local shutdown marker; live daemons also support the reliable `daemon.shutdown` protocol method over their channel (stdin for stdio, the socket for a socket daemon). An auto-started network server, OS URL protocol handler, and hidden command execution from links are intentionally not provided; the socket daemon is opt-in and binds to a loopback IPv4 address only (default `127.0.0.1`). ## Capability Discovery @@ -354,9 +370,9 @@ Command output is durable and replayable, but it may contain secrets. LiveShell Persistent sessions own their working directory. Set `cwd` on `session.create`; per-command `cwd` is accepted only when it matches the session cwd. Create a separate session for a different working directory. -OS URL protocol handlers, deep links, network servers, and hidden command execution from URLs are intentionally not implemented. +OS URL protocol handlers, deep links, remote or auto-started network servers, and hidden command execution from URLs are intentionally not implemented. -Local named pipe or Unix socket daemon transport is not included in this slice. The stdio protocol is the supported live transport; status/shutdown CLI commands operate through state-dir metadata unless a caller sends the protocol method over an existing daemon stdio channel. +Two live transports are supported: **stdio** (the default; its lifetime is bound to the launching process's pipes) and a **loopback TCP socket** (`liveshell daemon serve`/`daemon start`, attached from a fresh client via `LiveShellClient.connect(state_dir)`) that keeps running after clients disconnect. Named-pipe and Unix-domain-socket transports are not used — the socket transport is loopback TCP. `status`/`shutdown` CLI commands operate through state-dir metadata unless a caller sends the protocol method over a live daemon channel. ## Tests diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md index 7036b3f..faeccd9 100644 --- a/docs/PROTOCOL.md +++ b/docs/PROTOCOL.md @@ -1,6 +1,6 @@ # LiveShell Protocol -LiveShell speaks newline-delimited JSON over local stdio. Each request and response is one JSON object plus `\n`. +LiveShell speaks newline-delimited JSON over a local transport. Each request and response is one JSON object plus `\n`. The same request/response protocol is served over two interchangeable transports (see [Transports](#transports)): the stdio pipes of a launched daemon, or a loopback TCP socket. Current protocol version: `1.0` @@ -54,8 +54,16 @@ Stable error codes: `command.events` returns events with stable per-command sequence numbers greater than `since_seq`. -`daemon.shutdown` is reliable over the live stdio channel. CLI shutdown writes a state-dir marker for local operators; it is not a network control plane. +`daemon.shutdown` is reliable over a live channel (stdio or socket). The `liveshell daemon shutdown`/`stop` CLI paths also write a state-dir marker for operators when no live channel is held; the marker is not a network control plane. + +## Transports + +The protocol is transport-agnostic. Two transports are supported, both local-only: + +- **stdio** — `liveshell daemon stdio` serves the protocol over the process's stdin/stdout. The daemon's lifetime is bound to the launching process's pipes. This is the default, used by `LiveShellClient.stdio(...)` and `liveshell run`. +- **socket** — `liveshell daemon serve`/`daemon start` serves the protocol over a **loopback IPv4 TCP socket** (default `127.0.0.1`, an ephemeral port unless one is given). The bound address is published to `daemon.json` in the state dir so a fresh client can attach with `LiveShellClient.connect(state_dir)`. Unlike stdio, a socket daemon keeps running — and its commands keep executing — after any client disconnects. +The host is validated as loopback-only: non-loopback and non-IPv4 hosts are rejected. There is no remote transport and no auto-started network listener; the socket daemon is opt-in. ## Security -The protocol is local-only stdio in this slice. LiveShell does not install URL handlers, expose a default network server, or execute commands from links. +The protocol is local-only. LiveShell does not install URL handlers, expose a *remote* network server, or execute commands from links. The optional socket transport binds to loopback (`127.0.0.1`) only and is never started implicitly. diff --git a/src/liveshell/capabilities.py b/src/liveshell/capabilities.py index 919f05e..11d5ff8 100644 --- a/src/liveshell/capabilities.py +++ b/src/liveshell/capabilities.py @@ -23,7 +23,15 @@ def discover_capabilities() -> list[Capability]: Capability("command.poll", True), Capability("command.timeout", True), Capability("command.exit_code.native", True), - Capability("daemon.protocol", True, {"transport": "stdio", "network": False}), + Capability( + "daemon.protocol", + True, + { + "transports": ["stdio", "socket"], + "socket_scope": "loopback", + "remote_network": False, + }, + ), Capability("command.events.replay", True), Capability("command.events.chunking", True), Capability("command.stdout.streaming", True, {"scope": "process_backed"}), diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py index b6e342b..8cd0954 100644 --- a/tests/test_capabilities.py +++ b/tests/test_capabilities.py @@ -43,6 +43,20 @@ def test_discovery_returns_json_serializable_capabilities(self) -> None: self.assertTrue(streaming["details"]["hosted_powershell_native_exit_code"]) self.assertIsInstance(encoded, str) + def test_daemon_protocol_advertises_both_transports(self) -> None: + payload = [capability.to_dict() for capability in discover_capabilities()] + daemon_protocol = next( + item for item in payload if item["name"] == "daemon.protocol" + ) + details = daemon_protocol["details"] + # The package ships both a stdio and a loopback-socket transport + # (serve_socket / daemon serve / LiveShellClient.connect), so discovery + # must advertise both rather than claiming stdio-only. + self.assertEqual(details["transports"], ["stdio", "socket"]) + self.assertEqual(details["socket_scope"], "loopback") + # There is still no remote/auto-started network server. + self.assertFalse(details["remote_network"]) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_cli.py b/tests/test_cli.py index 8c01523..2f72353 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -45,7 +45,7 @@ def test_capability_discover_succeeds_as_json(self) -> None: self.assertIn("command.poll", capability_names) def test_daemon_stdio_once_prints_protocol_response(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: output = io.StringIO() request = json.dumps( {"id": "req_1", "method": "capability.discover", "params": {}} @@ -64,7 +64,7 @@ def test_daemon_stdio_once_prints_protocol_response(self) -> None: self.assertIn("capabilities", response["result"]) def test_session_list_and_snapshot_succeed_as_json(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: store = Store.from_state_dir(temp_dir) session = store.create_session( SessionSpec(kind="cmd", cwd=temp_dir, metadata={"purpose": "cli-test"}), @@ -97,7 +97,7 @@ def test_session_list_and_snapshot_succeed_as_json(self) -> None: self.assertEqual(snapshot_payload["result"]["metadata"]["purpose"], "cli-test") def test_command_read_commands_succeed_as_json_for_terminal_records(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: store = Store.from_state_dir(temp_dir) session = store.create_session(SessionSpec(kind="cmd"), status="closed") command = store.create_command( @@ -175,7 +175,7 @@ def test_run_executes_command_through_stdio_daemon_client(self) -> None: self.skipTest("No process-backed shell is available") kind, command = shell - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: output = io.StringIO() with contextlib.redirect_stdout(output): status = main( @@ -203,7 +203,7 @@ def test_run_executes_command_through_stdio_daemon_client(self) -> None: self.assertEqual(payload["result"]["closed_session"]["status"], "closed") def test_session_create_without_live_daemon_fails_as_json(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: output = io.StringIO() with contextlib.redirect_stdout(output): status = main( @@ -223,7 +223,7 @@ def test_session_create_without_live_daemon_fails_as_json(self) -> None: self.assertEqual(payload["error"]["type"], "RuntimeError") def test_command_start_without_live_daemon_fails_as_json(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: output = io.StringIO() with contextlib.redirect_stdout(output): status = main( @@ -245,7 +245,7 @@ def test_command_start_without_live_daemon_fails_as_json(self) -> None: self.assertEqual(payload["error"]["type"], "RuntimeError") def test_command_cancel_without_live_daemon_fails_as_json(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: store = Store.from_state_dir(temp_dir) session = store.create_session(SessionSpec(kind="cmd"), status="running") command = store.create_command( @@ -273,7 +273,7 @@ def test_command_cancel_without_live_daemon_fails_as_json(self) -> None: self.assertEqual(store.get_command(command.id).status, "running") def test_command_events_for_unknown_command_fails_as_json(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: output = io.StringIO() with contextlib.redirect_stdout(output): status = main( @@ -297,7 +297,7 @@ class FailedProcess: def poll(self) -> int: return 23 - with tempfile.TemporaryDirectory(prefix="liveshell-cli-daemon-") as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True, prefix="liveshell-cli-daemon-") as temp_dir: args = mock.Mock() args.state_dir = temp_dir args.host = "127.0.0.1" diff --git a/tests/test_client.py b/tests/test_client.py index 2fdf73f..7c996ac 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -26,7 +26,7 @@ def available_process_shell() -> tuple[str, str] | None: class LiveShellClientTests(unittest.TestCase): def test_stdio_client_discovers_capabilities(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: with LiveShellClient.stdio(temp_dir) as client: capabilities = client.discover_capabilities() @@ -38,7 +38,7 @@ def test_stdio_client_session_handle_runs_command_and_closes(self) -> None: self.skipTest("No process-backed shell is available") kind, command = shell - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: with LiveShellClient.stdio(temp_dir) as client: session = client.create_session(kind) result = session.run(command, timeout_seconds=5, poll_interval=0.05) @@ -49,7 +49,7 @@ def test_stdio_client_session_handle_runs_command_and_closes(self) -> None: self.assertEqual(closed.status, "closed") def test_stdio_client_raises_response_error_for_daemon_errors(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: with LiveShellClient.stdio(temp_dir) as client: with self.assertRaises(LiveShellResponseError) as context: client.session_snapshot("sess_missing") @@ -60,7 +60,7 @@ def test_stdio_client_raises_response_error_for_daemon_errors(self) -> None: class AsyncLiveShellClientTests(unittest.IsolatedAsyncioTestCase): async def test_async_client_methods_wrap_sync_protocol_operations(self) -> None: asyncio.get_running_loop().slow_callback_duration = 2.0 - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: client = LiveShellClient.stdio(temp_dir) try: capabilities = await client.discover_capabilities_async() diff --git a/tests/test_daemon.py b/tests/test_daemon.py index 2f9b913..efbf46c 100644 --- a/tests/test_daemon.py +++ b/tests/test_daemon.py @@ -75,7 +75,7 @@ def stderr_process_shell() -> tuple[str, str] | None: class DaemonTests(unittest.TestCase): def test_protocol_handler_returns_capabilities_response(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: daemon = JsonLineDaemon(LiveShellService(Store.from_state_dir(temp_dir))) response = daemon.handle_request( @@ -89,7 +89,7 @@ def test_protocol_handler_returns_capabilities_response(self) -> None: self.assertIn("capabilities", response["result"]) def test_protocol_validation_returns_value_error(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: daemon = JsonLineDaemon(LiveShellService(Store.from_state_dir(temp_dir))) missing_method = daemon.handle_request({"id": "req_missing", "params": {}}) @@ -103,7 +103,7 @@ def test_protocol_validation_returns_value_error(self) -> None: self.assertEqual(bad_params["error"]["type"], "ValueError") def test_protocol_accepts_null_params_and_missing_id(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: daemon = JsonLineDaemon(LiveShellService(Store.from_state_dir(temp_dir))) response = daemon.handle_request( @@ -115,7 +115,7 @@ def test_protocol_accepts_null_params_and_missing_id(self) -> None: self.assertEqual(response["result"], []) def test_protocol_reports_unknown_methods_and_ids(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: daemon = JsonLineDaemon(LiveShellService(Store.from_state_dir(temp_dir))) unknown_method = daemon.handle_request( @@ -144,7 +144,7 @@ def test_protocol_reports_unknown_methods_and_ids(self) -> None: self.assertEqual(unknown_command["error"]["type"], "KeyError") def test_stdio_loop_reports_malformed_json(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: daemon = JsonLineDaemon(LiveShellService(Store.from_state_dir(temp_dir))) output_stream = io.StringIO() @@ -160,7 +160,7 @@ def test_stdio_loop_reports_malformed_json(self) -> None: self.assertEqual(response["error"]["type"], "JSONDecodeError") def test_stdio_loop_handles_multiple_requests(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: daemon = JsonLineDaemon(LiveShellService(Store.from_state_dir(temp_dir))) input_stream = io.StringIO( "\n".join( @@ -203,7 +203,7 @@ def test_session_command_result_and_event_replay(self) -> None: self.skipTest("No process-backed shell is available") kind, command, _, _ = shell - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: service = LiveShellService(Store.from_state_dir(temp_dir)) session = service.create_session(kind) try: @@ -225,7 +225,7 @@ def test_commands_preserve_session_state_across_same_daemon_session(self) -> Non self.skipTest("No process-backed shell is available") kind, set_command, read_command = shell - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: service = LiveShellService(Store.from_state_dir(temp_dir)) session = service.create_session(kind) try: @@ -254,7 +254,7 @@ def test_process_backed_commands_stream_stdout_events_before_completion(self) -> self.skipTest("No process-backed shell is available") kind, command = shell - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: service = LiveShellService(Store.from_state_dir(temp_dir)) session = service.create_session(kind) try: @@ -296,7 +296,7 @@ def test_process_backed_commands_record_stderr_separately(self) -> None: self.skipTest("No process-backed shell is available") kind, command = shell - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: service = LiveShellService(Store.from_state_dir(temp_dir)) session = service.create_session(kind) try: @@ -330,7 +330,7 @@ def test_completed_command_records_output_hash_and_tails(self) -> None: self.skipTest("No process-backed shell is available") kind, command, _, _ = shell - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: service = LiveShellService(Store.from_state_dir(temp_dir)) session = service.create_session(kind) try: @@ -353,7 +353,7 @@ def test_protocol_session_create_command_start_poll_result_events(self) -> None: self.skipTest("No process-backed shell is available") kind, command, _, _ = shell - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: daemon = JsonLineDaemon(LiveShellService(Store.from_state_dir(temp_dir))) create_response = daemon.handle_request( @@ -440,7 +440,7 @@ def test_command_start_rejects_unsupported_per_command_cwd(self) -> None: self.skipTest("No process-backed shell is available") kind, command, _, _ = shell - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: daemon = JsonLineDaemon(LiveShellService(Store.from_state_dir(temp_dir))) create_response = daemon.handle_request( { @@ -473,7 +473,7 @@ def test_command_start_rejects_invalid_timeout_before_creating_record(self) -> N self.skipTest("No process-backed shell is available") kind, command, _, _ = shell - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: daemon = JsonLineDaemon(LiveShellService(Store.from_state_dir(temp_dir))) create_response = daemon.handle_request( { @@ -507,7 +507,7 @@ def test_command_start_allows_matching_session_cwd(self) -> None: self.skipTest("No process-backed shell is available") kind, command, _, _ = shell - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: service = LiveShellService(Store.from_state_dir(temp_dir)) session = service.create_session(kind, cwd=temp_dir) try: @@ -541,7 +541,7 @@ def close(self): original_session_type = LiveShellService._session_type LiveShellService._session_type = staticmethod(lambda kind: FakeHostedSession) try: - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: service = LiveShellService(Store.from_state_dir(temp_dir)) session = service.create_session("powershell", cwd=temp_dir) @@ -573,7 +573,7 @@ def close(self): original_session_type = LiveShellService._session_type LiveShellService._session_type = staticmethod(lambda kind: FakeHostedSession) try: - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: service = LiveShellService(Store.from_state_dir(temp_dir)) session = service.create_session("powershell") try: @@ -614,7 +614,7 @@ def close(self): original_session_type = LiveShellService._session_type LiveShellService._session_type = staticmethod(lambda kind: FakeHostedSession) try: - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: service = LiveShellService(Store.from_state_dir(temp_dir)) session = service.create_session("powershell") try: @@ -661,7 +661,7 @@ def close(self): original_session_type = LiveShellService._session_type LiveShellService._session_type = staticmethod(lambda kind: FakeHostedSession) try: - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: service = LiveShellService(Store.from_state_dir(temp_dir)) session = service.create_session("powershell") try: @@ -689,7 +689,7 @@ def test_cancel_running_command_marks_terminal_canceled(self) -> None: self.skipTest("No process-backed shell is available") kind, _, long_command, _ = shell - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: service = LiveShellService(Store.from_state_dir(temp_dir)) session = service.create_session(kind) @@ -707,7 +707,7 @@ def test_immediate_cancel_does_not_move_command_back_to_running(self) -> None: self.skipTest("No process-backed shell is available") kind, _, long_command, _ = shell - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: service = LiveShellService(Store.from_state_dir(temp_dir)) session = service.create_session(kind) @@ -723,7 +723,7 @@ def test_session_close_cancels_running_commands(self) -> None: self.skipTest("No process-backed shell is available") kind, _, long_command, _ = shell - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: service = LiveShellService(Store.from_state_dir(temp_dir)) session = service.create_session(kind) handle = service.start_command(session.id, long_command, timeout_seconds=30) @@ -744,7 +744,7 @@ def test_immediate_session_close_does_not_move_command_back_to_running(self) -> self.skipTest("No process-backed shell is available") kind, _, long_command, _ = shell - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: service = LiveShellService(Store.from_state_dir(temp_dir)) session = service.create_session(kind) handle = service.start_command(session.id, long_command, timeout_seconds=30) @@ -761,7 +761,7 @@ def test_timeout_marks_command_timed_out(self) -> None: self.skipTest("No process-backed shell is available") kind, _, long_command, _ = shell - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: service = LiveShellService(Store.from_state_dir(temp_dir)) session = service.create_session(kind) @@ -777,7 +777,7 @@ def test_failed_command_preserves_stdout_without_stderr_duplication(self) -> Non self.skipTest("No process-backed shell is available") kind, _, _, failing_command = shell - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: service = LiveShellService(Store.from_state_dir(temp_dir)) session = service.create_session(kind) try: @@ -791,7 +791,7 @@ def test_failed_command_preserves_stdout_without_stderr_duplication(self) -> Non service.close_session(session.id) def test_recovery_marks_running_records_honestly(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: store = Store.from_state_dir(temp_dir) session = store.create_session(SessionSpec(kind="cmd"), status="running") command = store.create_command( diff --git a/tests/test_handles.py b/tests/test_handles.py index f3533ff..c6bc319 100644 --- a/tests/test_handles.py +++ b/tests/test_handles.py @@ -18,14 +18,14 @@ class CommandHandleTests(unittest.TestCase): def test_events_for_unknown_command_raises_key_error(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: handle = CommandHandle("cmd_missing", Store.from_state_dir(temp_dir)) with self.assertRaises(KeyError): handle.events() def test_sync_handle_returns_terminal_result_and_cancel_snapshot(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: store = Store.from_state_dir(temp_dir) session = store.create_session(SessionSpec(kind="cmd"), status="closed") command = store.create_command( @@ -64,7 +64,7 @@ def start_command(self, session_id, command, **kwargs): ) return CommandHandle(created.id, store) - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: store = Store.from_state_dir(temp_dir) session = store.create_session(SessionSpec(kind="cmd"), status="running") service = FakeService() @@ -81,7 +81,7 @@ def start_command(self, session_id, command, **kwargs): class AsyncCommandHandleTests(unittest.IsolatedAsyncioTestCase): async def test_async_methods_wrap_sync_handle_operations(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: store = Store.from_state_dir(temp_dir) session = store.create_session(SessionSpec(kind="cmd"), status="closed") command = store.create_command( diff --git a/tests/test_production_readiness.py b/tests/test_production_readiness.py index c1c27a9..68638bc 100644 --- a/tests/test_production_readiness.py +++ b/tests/test_production_readiness.py @@ -62,7 +62,7 @@ def async_stderr_shell() -> tuple[type, str] | None: class StoreProductionTests(unittest.TestCase): def test_schema_version_metadata_and_active_command_helpers(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: store = Store.from_state_dir(temp_dir) session = store.create_session( SessionSpec(kind="cmd", metadata={"owner": "owner-1"}), @@ -88,7 +88,7 @@ def test_schema_version_metadata_and_active_command_helpers(self) -> None: self.assertTrue(store.session_owned_by(session.id, "owner-1")) def test_batched_events_preserve_order_and_replay_from_arbitrary_seq(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: store = Store.from_state_dir(temp_dir) session = store.create_session(SessionSpec(kind="cmd"), status="running") command = store.create_command( @@ -112,7 +112,7 @@ def test_batched_events_preserve_order_and_replay_from_arbitrary_seq(self) -> No class ProtocolProductionTests(unittest.TestCase): def test_capability_discovery_includes_protocol_version_and_error_codes(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: daemon = JsonLineDaemon(LiveShellService(Store.from_state_dir(temp_dir))) capabilities = daemon.handle_request( @@ -132,7 +132,7 @@ def test_capability_discovery_includes_protocol_version_and_error_codes(self) -> self.assertEqual(Store.from_state_dir(temp_dir).list_sessions(), []) def test_daemon_status_and_shutdown_protocol_methods(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: daemon = JsonLineDaemon(LiveShellService(Store.from_state_dir(temp_dir))) status = daemon.handle_request( @@ -173,7 +173,7 @@ def close(self): original_session_type = LiveShellService._session_type LiveShellService._session_type = staticmethod(lambda kind: FakeHostedSession) try: - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: store = Store.from_state_dir(temp_dir) daemon = JsonLineDaemon(LiveShellService(store)) create = daemon.handle_request( @@ -212,7 +212,7 @@ def close(self): original_session_type = LiveShellService._session_type LiveShellService._session_type = staticmethod(lambda kind: FakeHostedSession) try: - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: store = Store.from_state_dir(temp_dir) service = LiveShellService(store) session = service.create_session("powershell") @@ -229,7 +229,7 @@ def close(self): LiveShellService._session_type = staticmethod(original_session_type) def test_service_start_clears_stale_shutdown_marker(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: request_daemon_shutdown_marker(temp_dir, reason="old marker") service = LiveShellService(Store.from_state_dir(temp_dir)) @@ -267,7 +267,7 @@ def close(self): original_session_type = LiveShellService._session_type LiveShellService._session_type = staticmethod(lambda kind: SlowHostedSession) try: - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: service = LiveShellService(Store.from_state_dir(temp_dir)) session = service.create_session("powershell") try: @@ -310,7 +310,7 @@ def close(self): original_session_type = LiveShellService._session_type LiveShellService._session_type = staticmethod(lambda kind: LargeHostedSession) try: - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: service = LiveShellService( Store.from_state_dir(temp_dir), event_chunk_size=10, @@ -340,7 +340,7 @@ def test_timeout_preserves_partial_output_in_result_and_tail(self) -> None: self.skipTest("No process-backed shell is available") kind, command = shell - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: service = LiveShellService(Store.from_state_dir(temp_dir)) session = service.create_session(kind) result = service.start_command( @@ -363,7 +363,7 @@ def test_client_close_requests_daemon_shutdown_and_closes_sessions(self) -> None else: self.skipTest("No process-backed shell is available") - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: client = LiveShellClient.stdio(temp_dir) session = client.create_session(kind) @@ -408,7 +408,7 @@ def readline(self, *args, **kwargs): class CliProductionTests(unittest.TestCase): def test_daemon_status_shutdown_and_pretty_json(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: status_output = io.StringIO() with contextlib.redirect_stdout(status_output): status_code = main( diff --git a/tests/test_store.py b/tests/test_store.py index 655a840..4529d0c 100644 --- a/tests/test_store.py +++ b/tests/test_store.py @@ -17,7 +17,7 @@ class StoreTests(unittest.TestCase): def test_store_uses_wal_and_busy_timeout(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: store = Store.from_state_dir(temp_dir) with store._connect() as connection: @@ -28,7 +28,7 @@ def test_store_uses_wal_and_busy_timeout(self) -> None: self.assertEqual(busy_timeout, BUSY_TIMEOUT_MS) def test_create_list_update_sessions(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: store = Store.from_state_dir(temp_dir) session = store.create_session( @@ -44,7 +44,7 @@ def test_create_list_update_sessions(self) -> None: self.assertEqual([item.id for item in sessions], [session.id]) def test_command_events_are_ordered_and_replayable(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: store = Store.from_state_dir(temp_dir) session = store.create_session(SessionSpec(kind="cmd"), status="running") command = store.create_command( @@ -61,7 +61,7 @@ def test_command_events_are_ordered_and_replayable(self) -> None: self.assertEqual(store.command_result(command.id).stdout, "hello") def test_command_tail_fields_are_truncated(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: store = Store.from_state_dir(temp_dir) session = store.create_session(SessionSpec(kind="cmd"), status="running") command = store.create_command( @@ -75,7 +75,7 @@ def test_command_tail_fields_are_truncated(self) -> None: self.assertEqual(updated.stderr_tail, output[-TAIL_LIMIT:]) def test_session_and_command_filters(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: store = Store.from_state_dir(temp_dir) running = store.create_session(SessionSpec(kind="cmd"), status="running") closed = store.create_session(SessionSpec(kind="cmd"), status="closed") @@ -102,7 +102,7 @@ def test_session_and_command_filters(self) -> None: ) def test_missing_records_and_invalid_updates_are_explicit(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: store = Store.from_state_dir(temp_dir) self.assertIsNone(store.command_result("cmd_missing")) @@ -124,7 +124,7 @@ def test_missing_records_and_invalid_updates_are_explicit(self) -> None: store.update_command(command.id, unsupported=True) def test_metadata_round_trips_on_sessions_commands_and_events(self) -> None: - with tempfile.TemporaryDirectory() as temp_dir: + with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as temp_dir: store = Store.from_state_dir(temp_dir) session = store.create_session( SessionSpec(kind="cmd", metadata={"owner": "models"}),